Skip to content

Commit c4039df

Browse files
committed
feat(miner-plan): add plan-artifact Markdown and JSON export renderers
1 parent 7d1014e commit c4039df

3 files changed

Lines changed: 218 additions & 0 deletions

File tree

packages/gittensory-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export {
1010
type OpportunityRankInput,
1111
} from "./opportunity-ranker.js";
1212
export * from "./governor/rate-limit.js";
13+
export * from "./plan-export.js";
1314
export * from "./portfolio/queue.js";
1415
export {
1516
resolveAiPolicyVerdict,
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Plan DAG rendering (pure).
2+
//
3+
// Deterministic, side-effect-free renderers over an already-validated plan DAG (the `planDagSchema` shape used by
4+
// the MCP `gittensory_plan_status` surface in src/mcp/server.ts). No IO and no new logic: given a plan, produce
5+
// either a human-readable Markdown checklist ordered by dependency, or a stable, key-ordered JSON string that is
6+
// byte-identical across runs of the same plan (useful for diffing). The types below mirror the `planDagSchema`
7+
// shape so the engine package stays standalone and does not import the app's Zod schema.
8+
9+
export type PlanStepStatus = "pending" | "running" | "completed" | "failed" | "skipped";
10+
11+
export type PlanStep = {
12+
id: string;
13+
title: string;
14+
actionClass?: string | undefined;
15+
dependsOn: string[];
16+
status: PlanStepStatus;
17+
attempts: number;
18+
maxAttempts: number;
19+
lastError?: string | null | undefined;
20+
};
21+
22+
export type PlanDag = { steps: PlanStep[] };
23+
24+
// Stable topological order: emit steps whose in-plan dependencies are already emitted, ties broken by the plan's
25+
// original order. A dependency id not present in the plan is treated as satisfied. Any steps left in a cycle are
26+
// appended in original order so nothing is dropped and the function always terminates.
27+
function orderByDependency(steps: PlanStep[]): PlanStep[] {
28+
const present = new Set(steps.map((step) => step.id));
29+
const emitted = new Set<string>();
30+
const ordered: PlanStep[] = [];
31+
const remaining = [...steps];
32+
let progressed = true;
33+
while (remaining.length > 0 && progressed) {
34+
progressed = false;
35+
for (let i = 0; i < remaining.length; ) {
36+
const step = remaining[i]!;
37+
const ready = step.dependsOn.every((dep) => !present.has(dep) || emitted.has(dep));
38+
if (ready) {
39+
ordered.push(step);
40+
emitted.add(step.id);
41+
remaining.splice(i, 1);
42+
progressed = true;
43+
} else {
44+
i += 1;
45+
}
46+
}
47+
}
48+
ordered.push(...remaining);
49+
return ordered;
50+
}
51+
52+
// Make an untrusted title/error safe to drop into a Markdown checklist line: collapse any CR/LF run to a single
53+
// space (both fields allow newlines in the plan schema) so a step cannot spill onto extra rows, and backslash-escape
54+
// the Markdown control characters that would otherwise re-style the line (emphasis, code, links, html, tables,
55+
// strikethrough) when the artifact is pasted into a review surface. Backslash is escaped by the same class, so the
56+
// single pass is idempotent per character.
57+
function displaySafe(text: string): string {
58+
return text.replace(/[\r\n]+/g, " ").replace(/[\\`*_[\]<>|~]/g, "\\$&");
59+
}
60+
61+
/**
62+
* Render a plan DAG as a Markdown checklist ordered by dependency: one `- [x]`/`- [ ]` line per step (checked when
63+
* the step is completed), annotated with its status, its attempt count when it has run, and its last error when
64+
* present. Display fields are collapsed to a single line and Markdown control characters are escaped so each step
65+
* stays on one row and an untrusted title/error cannot re-style it. Pure — it reads the plan and returns a string.
66+
*/
67+
export function renderPlanAsMarkdown(plan: PlanDag): string {
68+
const ordered = orderByDependency(plan.steps);
69+
if (ordered.length === 0) return "_No steps in this plan._";
70+
return ordered
71+
.map((step) => {
72+
const box = step.status === "completed" ? "[x]" : "[ ]";
73+
let line = `- ${box} ${displaySafe(step.title)}${step.status}`;
74+
if (step.attempts > 0) line += ` (attempt ${step.attempts}/${step.maxAttempts})`;
75+
if (step.lastError) line += `: ${displaySafe(step.lastError)}`;
76+
return line;
77+
})
78+
.join("\n");
79+
}
80+
81+
// Sort object keys at every level so the output is deterministic; arrays (e.g. `steps`) keep their order.
82+
function sortedKeysReplacer(_key: string, value: unknown): unknown {
83+
if (value && typeof value === "object" && !Array.isArray(value)) {
84+
const source = value as Record<string, unknown>;
85+
return Object.keys(source)
86+
.sort()
87+
.reduce<Record<string, unknown>>((acc, key) => {
88+
acc[key] = source[key];
89+
return acc;
90+
}, {});
91+
}
92+
return value;
93+
}
94+
95+
/**
96+
* Render a plan DAG as a stable, deterministically key-ordered JSON string. Two renders of the identical plan are
97+
* byte-identical (object keys are sorted at every level; array order is preserved), which makes plan snapshots
98+
* diffable across runs. Pure.
99+
*/
100+
export function renderPlanAsJson(plan: PlanDag): string {
101+
return JSON.stringify(plan, sortedKeysReplacer, 2);
102+
}

test/unit/plan-export.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
renderPlanAsJson,
4+
renderPlanAsMarkdown,
5+
type PlanDag,
6+
type PlanStep,
7+
} from "../../packages/gittensory-engine/src/plan-export";
8+
9+
function step(over: Partial<PlanStep> & { id: string; title: string }): PlanStep {
10+
return {
11+
actionClass: undefined,
12+
dependsOn: [],
13+
status: "pending",
14+
attempts: 0,
15+
maxAttempts: 3,
16+
lastError: null,
17+
...over,
18+
};
19+
}
20+
21+
describe("renderPlanAsMarkdown", () => {
22+
it("renders an empty plan with a placeholder", () => {
23+
expect(renderPlanAsMarkdown({ steps: [] })).toBe("_No steps in this plan._");
24+
});
25+
26+
it("checks completed steps, leaves others unchecked, and shows status", () => {
27+
const md = renderPlanAsMarkdown({
28+
steps: [
29+
step({ id: "a", title: "Build", status: "completed" }),
30+
step({ id: "b", title: "Test", status: "pending", dependsOn: ["a"] }),
31+
],
32+
});
33+
expect(md).toBe("- [x] Build — completed\n- [ ] Test — pending");
34+
});
35+
36+
it("annotates attempts only after a step has run and appends the last error", () => {
37+
const md = renderPlanAsMarkdown({
38+
steps: [
39+
step({ id: "a", title: "Deploy", status: "failed", attempts: 2, maxAttempts: 2, lastError: "boom" }),
40+
step({ id: "b", title: "Wait", status: "pending", attempts: 0 }),
41+
],
42+
});
43+
expect(md).toBe("- [ ] Deploy — failed (attempt 2/2): boom\n- [ ] Wait — pending");
44+
});
45+
46+
it("orders steps by dependency so a dependency precedes its dependents", () => {
47+
const md = renderPlanAsMarkdown({
48+
steps: [
49+
step({ id: "b", title: "B", dependsOn: ["a"] }),
50+
step({ id: "a", title: "A" }),
51+
],
52+
});
53+
expect(md).toBe("- [ ] A — pending\n- [ ] B — pending");
54+
});
55+
56+
it("treats an unknown dependency id as already satisfied", () => {
57+
const md = renderPlanAsMarkdown({ steps: [step({ id: "a", title: "A", dependsOn: ["ghost"] })] });
58+
expect(md).toBe("- [ ] A — pending");
59+
});
60+
61+
it("appends steps caught in a dependency cycle in original order instead of dropping or looping", () => {
62+
const md = renderPlanAsMarkdown({
63+
steps: [
64+
step({ id: "a", title: "A", dependsOn: ["b"] }),
65+
step({ id: "b", title: "B", dependsOn: ["a"] }),
66+
],
67+
});
68+
expect(md.split("\n")).toEqual(["- [ ] A — pending", "- [ ] B — pending"]);
69+
});
70+
71+
it("keeps each step on one line when a title or lastError contains newlines", () => {
72+
const md = renderPlanAsMarkdown({
73+
steps: [step({ id: "a", title: "Line one\nLine two", status: "failed", attempts: 1, maxAttempts: 3, lastError: "err one\r\nerr two" })],
74+
});
75+
expect(md.split("\n")).toHaveLength(1);
76+
expect(md).toBe("- [ ] Line one Line two — failed (attempt 1/3): err one err two");
77+
});
78+
79+
it("escapes Markdown control characters in a title or lastError so they cannot re-style the line", () => {
80+
const md = renderPlanAsMarkdown({
81+
steps: [step({ id: "a", title: "drop `table` [x] *now*", status: "failed", attempts: 1, maxAttempts: 2, lastError: "path a\\b <tag> | ~x~" })],
82+
});
83+
expect(md.split("\n")).toHaveLength(1);
84+
expect(md).toBe("- [ ] drop \\`table\\` \\[x\\] \\*now\\* — failed (attempt 1/2): path a\\\\b \\<tag\\> \\| \\~x\\~");
85+
});
86+
});
87+
88+
describe("renderPlanAsJson", () => {
89+
it("produces stable, key-sorted JSON that is byte-identical across renders of the same plan", () => {
90+
const plan: PlanDag = { steps: [step({ id: "z", title: "Z", status: "running", attempts: 1 })] };
91+
const first = renderPlanAsJson(plan);
92+
expect(renderPlanAsJson(plan)).toBe(first);
93+
const parsedStep = JSON.parse(first).steps[0];
94+
expect(Object.keys(parsedStep)).toEqual([...Object.keys(parsedStep)].sort());
95+
});
96+
97+
it("sorts keys regardless of input insertion order but preserves the step array order", () => {
98+
const forward = renderPlanAsJson({
99+
steps: [step({ id: "1", title: "One" }), step({ id: "2", title: "Two" })],
100+
});
101+
const reorderedKeys = {
102+
title: "One",
103+
id: "1",
104+
maxAttempts: 3,
105+
attempts: 0,
106+
status: "pending",
107+
dependsOn: [],
108+
actionClass: undefined,
109+
lastError: null,
110+
} as PlanStep;
111+
const reordered = renderPlanAsJson({ steps: [reorderedKeys, step({ id: "2", title: "Two" })] });
112+
expect(reordered).toBe(forward);
113+
expect(forward.indexOf('"id": "1"')).toBeLessThan(forward.indexOf('"id": "2"'));
114+
});
115+
});

0 commit comments

Comments
 (0)