@warpgogol/forge 5.1.2 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -2
- package/bin/cli.ts +4 -0
- package/dist/bin/cli.js +4 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/os/adr/handlers/implement-stamp.d.ts.map +1 -1
- package/dist/os/adr/handlers/implement-stamp.js +32 -2
- package/dist/os/adr/handlers/implement-stamp.js.map +1 -1
- package/dist/os/adr/types.d.ts +2 -1
- package/dist/os/adr/types.d.ts.map +1 -1
- package/dist/os/naming/naming-convention.d.ts.map +1 -1
- package/dist/os/naming/naming-convention.js +2 -0
- package/dist/os/naming/naming-convention.js.map +1 -1
- package/dist/os/queue/handlers/queue-validate.d.ts +4 -0
- package/dist/os/queue/handlers/queue-validate.d.ts.map +1 -0
- package/dist/os/queue/handlers/queue-validate.js +62 -0
- package/dist/os/queue/handlers/queue-validate.js.map +1 -0
- package/dist/os/queue/index.d.ts +5 -0
- package/dist/os/queue/index.d.ts.map +1 -0
- package/dist/os/queue/index.js +18 -0
- package/dist/os/queue/index.js.map +1 -0
- package/dist/os/queue/manifest.d.ts +14 -0
- package/dist/os/queue/manifest.d.ts.map +1 -0
- package/dist/os/queue/manifest.js +110 -0
- package/dist/os/queue/manifest.js.map +1 -0
- package/dist/os/queue/queue.module.d.ts +3 -0
- package/dist/os/queue/queue.module.d.ts.map +1 -0
- package/dist/os/queue/queue.module.js +56 -0
- package/dist/os/queue/queue.module.js.map +1 -0
- package/dist/os/queue/types.d.ts +25 -0
- package/dist/os/queue/types.d.ts.map +1 -0
- package/dist/os/queue/types.js +28 -0
- package/dist/os/queue/types.js.map +1 -0
- package/dist/os/rfc/frontmatter-io.d.ts.map +1 -1
- package/dist/os/rfc/frontmatter-io.js +1 -0
- package/dist/os/rfc/frontmatter-io.js.map +1 -1
- package/dist/os/rfc/handlers/pipeline-status.d.ts +2 -7
- package/dist/os/rfc/handlers/pipeline-status.d.ts.map +1 -1
- package/dist/os/rfc/handlers/pipeline-status.js +15 -57
- package/dist/os/rfc/handlers/pipeline-status.js.map +1 -1
- package/dist/os/rfc/handlers/validate-rules.d.ts.map +1 -1
- package/dist/os/rfc/handlers/validate-rules.js +6 -1
- package/dist/os/rfc/handlers/validate-rules.js.map +1 -1
- package/dist/os/werkstatt/handlers/workspace-deps.d.ts.map +1 -1
- package/dist/os/werkstatt/handlers/workspace-deps.js +4 -3
- package/dist/os/werkstatt/handlers/workspace-deps.js.map +1 -1
- package/dist/src/onboarding/create.d.ts.map +1 -1
- package/dist/src/onboarding/create.js +5 -2
- package/dist/src/onboarding/create.js.map +1 -1
- package/dist/src/onboarding/doctor.d.ts.map +1 -1
- package/dist/src/onboarding/doctor.js +15 -0
- package/dist/src/onboarding/doctor.js.map +1 -1
- package/dist/src/onboarding/init.d.ts +1 -0
- package/dist/src/onboarding/init.d.ts.map +1 -1
- package/dist/src/onboarding/init.js.map +1 -1
- package/dist/src/onboarding/npm-token-check.d.ts +40 -0
- package/dist/src/onboarding/npm-token-check.d.ts.map +1 -0
- package/dist/src/onboarding/npm-token-check.js +104 -0
- package/dist/src/onboarding/npm-token-check.js.map +1 -0
- package/dist/src/onboarding/scaffold-project.d.ts.map +1 -1
- package/dist/src/onboarding/scaffold-project.js +10 -0
- package/dist/src/onboarding/scaffold-project.js.map +1 -1
- package/dist/src/pipeline-status.d.ts +62 -0
- package/dist/src/pipeline-status.d.ts.map +1 -0
- package/dist/src/pipeline-status.js +220 -0
- package/dist/src/pipeline-status.js.map +1 -0
- package/dist/src/profiles/profile-schema.d.ts +2 -0
- package/dist/src/profiles/profile-schema.d.ts.map +1 -1
- package/dist/src/profiles/profile-schema.js +1 -0
- package/dist/src/profiles/profile-schema.js.map +1 -1
- package/dist/src/profiles/stack-profile.d.ts +1 -0
- package/dist/src/profiles/stack-profile.d.ts.map +1 -1
- package/dist/src/profiles/stack-profile.js +2 -0
- package/dist/src/profiles/stack-profile.js.map +1 -1
- package/os/adr/handlers/implement-stamp.ts +34 -2
- package/os/adr/types.ts +3 -1
- package/os/naming/naming-convention.ts +2 -0
- package/os/queue/handlers/queue-validate.ts +83 -0
- package/os/queue/index.ts +19 -0
- package/os/queue/manifest.ts +183 -0
- package/os/queue/queue-validate.test.ts +177 -0
- package/os/queue/queue.module.ts +59 -0
- package/os/queue/types.ts +49 -0
- package/os/rfc/frontmatter-io.ts +1 -0
- package/os/rfc/handlers/pipeline-status.ts +24 -67
- package/os/rfc/handlers/validate-rules.ts +8 -1
- package/os/werkstatt/handlers/workspace-deps.ts +4 -3
- package/package.json +17 -1
- package/profiles/site-workshop.yaml +654 -0
- package/skills/fo/fo-idea-create-rfc/acceptance-criteria-standard.md +1 -1
- package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +46 -1
- package/src/onboarding/create.ts +5 -2
- package/src/onboarding/doctor-token-check.test.ts +124 -0
- package/src/onboarding/doctor.ts +17 -0
- package/src/onboarding/init.ts +1 -0
- package/src/onboarding/npm-token-check.ts +140 -0
- package/src/onboarding/scaffold-project.ts +11 -0
- package/src/onboarding/site-workshop-profile.test.ts +84 -0
- package/src/pipeline-status.test.ts +192 -0
- package/src/pipeline-status.ts +319 -0
- package/src/profiles/profile-schema.ts +2 -0
- package/src/profiles/stack-profile.ts +2 -0
- package/src/tests/adr-implement-stamp.test.ts +150 -0
- package/src/tests/profile-schema.test.ts +1 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
deriveQueueItemStatus,
|
|
8
|
+
deriveQueueReport,
|
|
9
|
+
resolveDocument,
|
|
10
|
+
computeRfcPipelineStages,
|
|
11
|
+
nextPipelineStep,
|
|
12
|
+
} from "./pipeline-status.ts";
|
|
13
|
+
|
|
14
|
+
let tmpDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "pipeline-status-test-"));
|
|
18
|
+
await fs.mkdir(path.join(tmpDir, "docs/rfcs"), { recursive: true });
|
|
19
|
+
await fs.mkdir(path.join(tmpDir, "docs/adrs"), { recursive: true });
|
|
20
|
+
await fs.mkdir(path.join(tmpDir, "docs/audits"), { recursive: true });
|
|
21
|
+
await fs.mkdir(path.join(tmpDir, "docs/plans"), { recursive: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
async function writeRfc(id: string, frontmatter: string): Promise<void> {
|
|
29
|
+
await fs.writeFile(
|
|
30
|
+
path.join(tmpDir, `docs/rfcs/${id.toLowerCase()}-test.md`),
|
|
31
|
+
`---\nid: ${id}\n${frontmatter}---\n# ${id}\n`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function writeAdr(id: string, frontmatter: string): Promise<void> {
|
|
36
|
+
await fs.writeFile(
|
|
37
|
+
path.join(tmpDir, `docs/adrs/${id.toLowerCase()}-test.md`),
|
|
38
|
+
`---\nid: ${id}\n${frontmatter}---\n# ${id}\n`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("resolveDocument", () => {
|
|
43
|
+
test("resolves an active RFC file", async () => {
|
|
44
|
+
await writeRfc("RFC-1001", "status: draft\n");
|
|
45
|
+
const doc = await resolveDocument(tmpDir, "RFC-1001");
|
|
46
|
+
expect(doc?.kind).toBe("rfc");
|
|
47
|
+
expect(doc?.frontmatter["status"]).toBe("draft");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("resolves an archived RFC file", async () => {
|
|
51
|
+
await fs.mkdir(path.join(tmpDir, "docs/rfcs/archive/implemented"), { recursive: true });
|
|
52
|
+
await fs.writeFile(
|
|
53
|
+
path.join(tmpDir, "docs/rfcs/archive/implemented/rfc-1002-old.md"),
|
|
54
|
+
"---\nid: RFC-1002\nstatus: implemented\n---\n# RFC-1002\n",
|
|
55
|
+
);
|
|
56
|
+
const doc = await resolveDocument(tmpDir, "RFC-1002");
|
|
57
|
+
expect(doc?.file).toContain("archive/implemented");
|
|
58
|
+
expect(doc?.frontmatter["status"]).toBe("implemented");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns undefined for malformed ids", async () => {
|
|
62
|
+
expect(await resolveDocument(tmpDir, "NOT-A-DOC")).toBeUndefined();
|
|
63
|
+
expect(await resolveDocument(tmpDir, "RFC-999")).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("deriveQueueItemStatus — derivation matrix (RFC-1140)", () => {
|
|
68
|
+
test("implemented status → implemented", async () => {
|
|
69
|
+
await writeRfc("RFC-1100", "status: implemented\n");
|
|
70
|
+
const r = await deriveQueueItemStatus(tmpDir, "RFC-1100");
|
|
71
|
+
expect(r?.status).toBe("implemented");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("implementedAt set → implemented", async () => {
|
|
75
|
+
await writeRfc("RFC-1101", "status: accepted\nimplementedAt: 2026-09-01\n");
|
|
76
|
+
const r = await deriveQueueItemStatus(tmpDir, "RFC-1101");
|
|
77
|
+
expect(r?.status).toBe("implemented");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("rejected → skipped", async () => {
|
|
81
|
+
await writeRfc("RFC-1102", "status: rejected\n");
|
|
82
|
+
const r = await deriveQueueItemStatus(tmpDir, "RFC-1102");
|
|
83
|
+
expect(r?.status).toBe("skipped");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("superseded → skipped", async () => {
|
|
87
|
+
await writeRfc("RFC-1103", "status: superseded\n");
|
|
88
|
+
const r = await deriveQueueItemStatus(tmpDir, "RFC-1103");
|
|
89
|
+
expect(r?.status).toBe("skipped");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("accepted RFC → in-progress/implement", async () => {
|
|
93
|
+
await writeRfc("RFC-1104", "status: accepted\n");
|
|
94
|
+
const r = await deriveQueueItemStatus(tmpDir, "RFC-1104");
|
|
95
|
+
expect(r?.status).toBe("in-progress");
|
|
96
|
+
expect(r?.pipelineStep).toBe("implement");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("plan file exists → in-progress/implement", async () => {
|
|
100
|
+
await writeRfc("RFC-1105", "status: draft\nenhancedAt: 2026-09-01\n");
|
|
101
|
+
await fs.writeFile(
|
|
102
|
+
path.join(tmpDir, "docs/plans/plan-rfc-1105-test.md"),
|
|
103
|
+
"---\nrfcId: RFC-1105\n---\n",
|
|
104
|
+
);
|
|
105
|
+
const r = await deriveQueueItemStatus(tmpDir, "RFC-1105");
|
|
106
|
+
expect(r?.status).toBe("in-progress");
|
|
107
|
+
expect(r?.pipelineStep).toBe("implement");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("enhancedAt without plan → in-progress/plan", async () => {
|
|
111
|
+
await writeRfc("RFC-1106", "status: draft\nenhancedAt: 2026-09-01\n");
|
|
112
|
+
const r = await deriveQueueItemStatus(tmpDir, "RFC-1106");
|
|
113
|
+
expect(r?.status).toBe("in-progress");
|
|
114
|
+
expect(r?.pipelineStep).toBe("plan");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("audit file only → in-progress/enhance", async () => {
|
|
118
|
+
await writeRfc("RFC-1107", "status: draft\n");
|
|
119
|
+
await fs.writeFile(
|
|
120
|
+
path.join(tmpDir, "docs/audits/audit-rfc-1107-test.md"),
|
|
121
|
+
"# audit\n",
|
|
122
|
+
);
|
|
123
|
+
const r = await deriveQueueItemStatus(tmpDir, "RFC-1107");
|
|
124
|
+
expect(r?.status).toBe("in-progress");
|
|
125
|
+
expect(r?.pipelineStep).toBe("enhance");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("no artifacts → pending", async () => {
|
|
129
|
+
await writeRfc("RFC-1108", "status: draft\n");
|
|
130
|
+
const r = await deriveQueueItemStatus(tmpDir, "RFC-1108");
|
|
131
|
+
expect(r?.status).toBe("pending");
|
|
132
|
+
expect(r?.pipelineStep).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("non-terminal ADR → pending", async () => {
|
|
136
|
+
await writeAdr("ADR-0100", "status: accepted\n");
|
|
137
|
+
const r = await deriveQueueItemStatus(tmpDir, "ADR-0100");
|
|
138
|
+
expect(r?.status).toBe("pending");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("unresolvable id → undefined", async () => {
|
|
142
|
+
expect(await deriveQueueItemStatus(tmpDir, "RFC-9999")).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("deriveQueueReport", () => {
|
|
147
|
+
test("preserves manifest order and picks the first actionable item as next", async () => {
|
|
148
|
+
await writeRfc("RFC-1200", "status: implemented\n");
|
|
149
|
+
await writeRfc("RFC-1201", "status: draft\n");
|
|
150
|
+
await writeRfc("RFC-1202", "status: draft\n");
|
|
151
|
+
|
|
152
|
+
const report = await deriveQueueReport(tmpDir, ["RFC-1200", "RFC-1201", "RFC-1202"]);
|
|
153
|
+
|
|
154
|
+
expect(report.items.map((i) => i.id)).toEqual(["RFC-1200", "RFC-1201", "RFC-1202"]);
|
|
155
|
+
expect(report.next).toBe("RFC-1201");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("next is null when every item is terminal", async () => {
|
|
159
|
+
await writeRfc("RFC-1210", "status: implemented\n");
|
|
160
|
+
await writeRfc("RFC-1211", "status: rejected\n");
|
|
161
|
+
|
|
162
|
+
const report = await deriveQueueReport(tmpDir, ["RFC-1210", "RFC-1211"]);
|
|
163
|
+
|
|
164
|
+
expect(report.next).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("in-progress items are actionable for next", async () => {
|
|
168
|
+
await writeRfc("RFC-1220", "status: draft\nenhancedAt: 2026-09-01\n");
|
|
169
|
+
await writeRfc("RFC-1221", "status: draft\n");
|
|
170
|
+
|
|
171
|
+
const report = await deriveQueueReport(tmpDir, ["RFC-1220", "RFC-1221"]);
|
|
172
|
+
|
|
173
|
+
expect(report.next).toBe("RFC-1220");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("computeRfcPipelineStages + nextPipelineStep", () => {
|
|
178
|
+
test("stages reflect artifact completion", () => {
|
|
179
|
+
const stages = computeRfcPipelineStages({
|
|
180
|
+
status: "draft",
|
|
181
|
+
enhancedAt: "2026-09-01",
|
|
182
|
+
auditFile: "docs/audits/audit-rfc-1.md",
|
|
183
|
+
});
|
|
184
|
+
expect(stages.map((s) => s.done)).toEqual([true, true, false, false]);
|
|
185
|
+
expect(nextPipelineStep("draft", stages)).toBe("plan");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("terminal status yields null next step", () => {
|
|
189
|
+
const stages = computeRfcPipelineStages({ status: "implemented" });
|
|
190
|
+
expect(nextPipelineStep("implemented", stages)).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Shared pipeline-status resolver (RFC-1140) — derives per-document
|
|
4
|
+
pipeline progress (audit, enhance, plan, implement) from document frontmatter
|
|
5
|
+
and pipeline artifacts. Single source of truth consumed by rfc.pipeline.status,
|
|
6
|
+
queue.validate, and the orchestrator's resume detection. Portable layer: no
|
|
7
|
+
os/ or kernel imports.</purpose>
|
|
8
|
+
<non-goals>
|
|
9
|
+
<item>Do not validate RFC/ADR content — use rfc.validate / adr.validate.</item>
|
|
10
|
+
<item>Do not mutate any files — derivation is read-only.</item>
|
|
11
|
+
<item>Do not store progress state — status is derived at read time, never persisted.</item>
|
|
12
|
+
</non-goals>
|
|
13
|
+
</MODULE_CONTRACT>
|
|
14
|
+
<CHANGE_SUMMARY>
|
|
15
|
+
<item>RFC-1140: initial shared resolver — artifact conventions (audit-*.md,
|
|
16
|
+
plan-*.md, enhancedAt, implementedAt) extracted from
|
|
17
|
+
os/rfc/handlers/pipeline-status.ts; QueueItemStatus derivation matrix added
|
|
18
|
+
for queue.validate.</item>
|
|
19
|
+
<item>RFC-1140: steps 1-4 — shared resolver, queue module, registration
|
|
20
|
+
|
|
21
|
+
Extract pipeline-status derivation into packages/forge/src/pipeline-status.ts, refactor rfc.pipeline.status onto it, add os/queue module with queue.validate command, register in WORKSHOP_MODULE_MAP.forge + bin/cli.ts + package.json exports.</item>
|
|
22
|
+
<item>RFC-1140: step 6 — cover shared resolver and queue.validate
|
|
23
|
+
|
|
24
|
+
Add pipeline-status.test.ts (derivation matrix, order, next selection, ADR, skipped) and queue-validate.test.ts (schema errors, unknown/dup ids, id-filename mismatch, empty items, dependsOn warning, JSON shape). Fix queue derivation: pipelineStep is the stage after the last completed stage, not first incomplete — handles non-contiguous artifacts.</item>
|
|
25
|
+
</CHANGE_SUMMARY>
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import fs from "node:fs/promises";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import YAML from "yaml";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Pipeline stages (shared with rfc.pipeline.status)
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export type PipelineStage = "audit" | "enhance" | "plan" | "implement";
|
|
37
|
+
|
|
38
|
+
export interface RfcPipelineStageInfo {
|
|
39
|
+
stage: PipelineStage;
|
|
40
|
+
done: boolean;
|
|
41
|
+
/** File or marker that confirms completion, when applicable. */
|
|
42
|
+
evidence?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const TERMINAL_STATUSES: ReadonlySet<string> = new Set([
|
|
46
|
+
"implemented",
|
|
47
|
+
"rejected",
|
|
48
|
+
"superseded",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Minimal frontmatter parsing (self-contained — src/ must not import os/)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export interface ParsedDocument {
|
|
56
|
+
frontmatter: Record<string, unknown>;
|
|
57
|
+
body: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseFrontmatter(source: string): ParsedDocument {
|
|
61
|
+
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
62
|
+
if (!match) {
|
|
63
|
+
return { frontmatter: {}, body: source };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
frontmatter: (YAML.parse(match[1]!) ?? {}) as Record<string, unknown>,
|
|
67
|
+
body: match[2] ?? "",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Artifact finders — the canonical conventions
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export async function findAuditFile(
|
|
76
|
+
workspaceRoot: string,
|
|
77
|
+
docId: string,
|
|
78
|
+
): Promise<string | undefined> {
|
|
79
|
+
const auditsDir = path.join(workspaceRoot, "docs/audits");
|
|
80
|
+
try {
|
|
81
|
+
const entries = await fs.readdir(auditsDir);
|
|
82
|
+
const prefix = `audit-${docId.toLowerCase()}`;
|
|
83
|
+
const match = entries.find((e) => e.toLowerCase().startsWith(prefix) && e.endsWith(".md"));
|
|
84
|
+
return match ? path.join("docs/audits", match) : undefined;
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function findPlanFile(
|
|
91
|
+
workspaceRoot: string,
|
|
92
|
+
docId: string,
|
|
93
|
+
): Promise<string | undefined> {
|
|
94
|
+
const plansDir = path.join(workspaceRoot, "docs/plans");
|
|
95
|
+
try {
|
|
96
|
+
const entries = await fs.readdir(plansDir);
|
|
97
|
+
const prefix = `plan-${docId.toLowerCase()}`;
|
|
98
|
+
const match = entries.find((e) => e.toLowerCase().startsWith(prefix) && e.endsWith(".md"));
|
|
99
|
+
return match ? path.join("docs/plans", match) : undefined;
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Document resolution — active dir + archive (implemented docs may be archived)
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export type QueueDocumentKind = "rfc" | "adr";
|
|
110
|
+
|
|
111
|
+
export interface ResolvedDocument {
|
|
112
|
+
id: string;
|
|
113
|
+
kind: QueueDocumentKind;
|
|
114
|
+
/** Workspace-relative path to the document file. */
|
|
115
|
+
file: string;
|
|
116
|
+
frontmatter: Record<string, unknown>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const DOC_ID_PATTERN = /^(RFC|ADR)-\d{4}$/i;
|
|
120
|
+
|
|
121
|
+
async function scanForDocument(
|
|
122
|
+
dirPath: string,
|
|
123
|
+
relativePrefix: string,
|
|
124
|
+
idLower: string,
|
|
125
|
+
): Promise<string | undefined> {
|
|
126
|
+
let entries;
|
|
127
|
+
try {
|
|
128
|
+
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
129
|
+
} catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
const relativePath = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
|
|
134
|
+
if (entry.isDirectory()) {
|
|
135
|
+
const found = await scanForDocument(path.join(dirPath, entry.name), relativePath, idLower);
|
|
136
|
+
if (found) return found;
|
|
137
|
+
} else if (
|
|
138
|
+
entry.isFile() &&
|
|
139
|
+
entry.name.endsWith(".md") &&
|
|
140
|
+
entry.name.toLowerCase().startsWith(idLower)
|
|
141
|
+
) {
|
|
142
|
+
return relativePath;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve an RFC-XXXX or ADR-XXXX id to its document file and parsed
|
|
150
|
+
* frontmatter. Scans the active dir and archive subdirectories recursively.
|
|
151
|
+
* Returns undefined when the id is malformed or no file matches.
|
|
152
|
+
*/
|
|
153
|
+
export async function resolveDocument(
|
|
154
|
+
workspaceRoot: string,
|
|
155
|
+
docId: string,
|
|
156
|
+
): Promise<ResolvedDocument | undefined> {
|
|
157
|
+
const idMatch = DOC_ID_PATTERN.exec(docId.trim());
|
|
158
|
+
if (!idMatch) return undefined;
|
|
159
|
+
const kind: QueueDocumentKind = idMatch[1]!.toUpperCase() === "ADR" ? "adr" : "rfc";
|
|
160
|
+
const baseDir = kind === "adr" ? "docs/adrs" : "docs/rfcs";
|
|
161
|
+
const idLower = docId.toLowerCase();
|
|
162
|
+
|
|
163
|
+
const found = await scanForDocument(path.join(workspaceRoot, baseDir), "", idLower);
|
|
164
|
+
if (!found) return undefined;
|
|
165
|
+
|
|
166
|
+
let source: string;
|
|
167
|
+
try {
|
|
168
|
+
source = await fs.readFile(path.join(workspaceRoot, baseDir, found), "utf-8");
|
|
169
|
+
} catch {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
const parsed = parseFrontmatter(source);
|
|
173
|
+
return {
|
|
174
|
+
id: docId.toUpperCase(),
|
|
175
|
+
kind,
|
|
176
|
+
file: path.join(baseDir, found),
|
|
177
|
+
frontmatter: parsed.frontmatter,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// RFC stage derivation — shared by rfc.pipeline.status and queue.validate
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
export interface RfcStageInput {
|
|
186
|
+
status: string;
|
|
187
|
+
enhancedAt?: string;
|
|
188
|
+
implementedAt?: string;
|
|
189
|
+
auditFile?: string;
|
|
190
|
+
planFile?: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Compute the four pipeline stages for an RFC from frontmatter + artifacts. */
|
|
194
|
+
export function computeRfcPipelineStages(input: RfcStageInput): RfcPipelineStageInfo[] {
|
|
195
|
+
return [
|
|
196
|
+
{ stage: "audit", done: !!input.auditFile, evidence: input.auditFile },
|
|
197
|
+
{
|
|
198
|
+
stage: "enhance",
|
|
199
|
+
done: !!input.enhancedAt,
|
|
200
|
+
evidence: input.enhancedAt ? `enhancedAt: ${input.enhancedAt}` : undefined,
|
|
201
|
+
},
|
|
202
|
+
{ stage: "plan", done: !!input.planFile, evidence: input.planFile },
|
|
203
|
+
{
|
|
204
|
+
stage: "implement",
|
|
205
|
+
done: input.status === "implemented" || !!input.implementedAt,
|
|
206
|
+
evidence: input.implementedAt ? `implementedAt: ${input.implementedAt}` : undefined,
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** First incomplete stage for a non-terminal RFC, or null when terminal. */
|
|
212
|
+
export function nextPipelineStep(
|
|
213
|
+
status: string,
|
|
214
|
+
stages: RfcPipelineStageInfo[],
|
|
215
|
+
): PipelineStage | null {
|
|
216
|
+
if (TERMINAL_STATUSES.has(status)) return null;
|
|
217
|
+
for (const s of stages) {
|
|
218
|
+
if (!s.done) return s.stage;
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Queue item derivation (RFC-1140)
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
export type QueueItemStatus = "pending" | "in-progress" | "implemented" | "skipped";
|
|
228
|
+
|
|
229
|
+
export interface QueueItemReport {
|
|
230
|
+
id: string;
|
|
231
|
+
status: QueueItemStatus;
|
|
232
|
+
/** First incomplete pipeline stage — present only when in-progress. */
|
|
233
|
+
pipelineStep?: PipelineStage;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Derive the queue status of a single document per the RFC-1140 matrix:
|
|
238
|
+
* implemented → implemented; rejected/superseded → skipped; RFC accepted or
|
|
239
|
+
* plan file → in-progress/implement; enhancedAt → in-progress/plan; audit
|
|
240
|
+
* file → in-progress/enhance; otherwise pending. ADR non-terminal → pending.
|
|
241
|
+
*/
|
|
242
|
+
export async function deriveQueueItemStatus(
|
|
243
|
+
workspaceRoot: string,
|
|
244
|
+
docId: string,
|
|
245
|
+
): Promise<QueueItemReport | undefined> {
|
|
246
|
+
const doc = await resolveDocument(workspaceRoot, docId);
|
|
247
|
+
if (!doc) return undefined;
|
|
248
|
+
|
|
249
|
+
const fm = doc.frontmatter;
|
|
250
|
+
const status = String(fm["status"] ?? "");
|
|
251
|
+
const implementedAt = fm["implementedAt"] ? String(fm["implementedAt"]) : undefined;
|
|
252
|
+
|
|
253
|
+
if (status === "implemented" || implementedAt) {
|
|
254
|
+
return { id: doc.id, status: "implemented" };
|
|
255
|
+
}
|
|
256
|
+
if (status === "rejected" || status === "superseded") {
|
|
257
|
+
return { id: doc.id, status: "skipped" };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ADR pipeline is create → implement — no intermediate artifacts exist.
|
|
261
|
+
if (doc.kind === "adr") {
|
|
262
|
+
return { id: doc.id, status: "pending" };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// RFC accepted means the plan step is done by definition.
|
|
266
|
+
if (status === "accepted") {
|
|
267
|
+
return { id: doc.id, status: "in-progress", pipelineStep: "implement" };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const enhancedAt = fm["enhancedAt"] ? String(fm["enhancedAt"]) : undefined;
|
|
271
|
+
const auditFile = await findAuditFile(workspaceRoot, doc.id);
|
|
272
|
+
const planFile = await findPlanFile(workspaceRoot, doc.id);
|
|
273
|
+
|
|
274
|
+
const stages = computeRfcPipelineStages({
|
|
275
|
+
status,
|
|
276
|
+
enhancedAt,
|
|
277
|
+
implementedAt,
|
|
278
|
+
auditFile,
|
|
279
|
+
planFile,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Queue semantics: the actionable step is the stage AFTER the last completed
|
|
283
|
+
// one — artifacts may be non-contiguous (e.g. enhancedAt without an audit
|
|
284
|
+
// file), where "first incomplete stage" would wrongly report audit.
|
|
285
|
+
let lastDone = -1;
|
|
286
|
+
for (let i = 0; i < stages.length; i++) {
|
|
287
|
+
if (stages[i]!.done) lastDone = i;
|
|
288
|
+
}
|
|
289
|
+
if (lastDone === -1) {
|
|
290
|
+
return { id: doc.id, status: "pending" };
|
|
291
|
+
}
|
|
292
|
+
const nextStage = stages[lastDone + 1];
|
|
293
|
+
if (!nextStage) {
|
|
294
|
+
return { id: doc.id, status: "pending" };
|
|
295
|
+
}
|
|
296
|
+
return { id: doc.id, status: "in-progress", pipelineStep: nextStage.stage };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface QueueReport {
|
|
300
|
+
items: QueueItemReport[];
|
|
301
|
+
/** First item whose derived status is pending or in-progress; null when fully terminal. */
|
|
302
|
+
next: string | null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Derive the full queue report, preserving manifest order. */
|
|
306
|
+
export async function deriveQueueReport(
|
|
307
|
+
workspaceRoot: string,
|
|
308
|
+
itemIds: string[],
|
|
309
|
+
): Promise<QueueReport> {
|
|
310
|
+
const items: QueueItemReport[] = [];
|
|
311
|
+
for (const id of itemIds) {
|
|
312
|
+
const report = await deriveQueueItemStatus(workspaceRoot, id);
|
|
313
|
+
// Unresolvable ids are reported by the manifest validator; the resolver
|
|
314
|
+
// skips them here so a partial report stays usable.
|
|
315
|
+
if (report) items.push(report);
|
|
316
|
+
}
|
|
317
|
+
const nextItem = items.find((i) => i.status === "pending" || i.status === "in-progress");
|
|
318
|
+
return { items, next: nextItem?.id ?? null };
|
|
319
|
+
}
|
|
@@ -363,6 +363,7 @@ export const stackProfileDomainFieldsSchema = z.object({
|
|
|
363
363
|
templates: z.array(profileTemplateSchema).optional(),
|
|
364
364
|
scriptDir: z.string().min(1).optional(),
|
|
365
365
|
rootAgentsMdTemplate: z.string().optional(),
|
|
366
|
+
nextSteps: z.string().optional(),
|
|
366
367
|
compass: compassPolicyOverridesSchema.optional(),
|
|
367
368
|
});
|
|
368
369
|
|
|
@@ -380,5 +381,6 @@ export interface StackProfileDomainFields {
|
|
|
380
381
|
templates?: ProfileTemplate[];
|
|
381
382
|
scriptDir?: string;
|
|
382
383
|
rootAgentsMdTemplate?: string;
|
|
384
|
+
nextSteps?: string;
|
|
383
385
|
compass?: CompassPolicyOverrides;
|
|
384
386
|
}
|
|
@@ -80,6 +80,8 @@ export const stackProfileSchema = z.object({
|
|
|
80
80
|
scriptDir: stackProfileDomainFieldsSchema.shape.scriptDir,
|
|
81
81
|
// Profile-driven root AGENTS.md template
|
|
82
82
|
rootAgentsMdTemplate: stackProfileDomainFieldsSchema.shape.rootAgentsMdTemplate,
|
|
83
|
+
// RFC-1125: profile-driven NEXT_STEPS.md content override
|
|
84
|
+
nextSteps: stackProfileDomainFieldsSchema.shape.nextSteps,
|
|
83
85
|
// Profile-driven Compass file extensions
|
|
84
86
|
compass: stackProfileDomainFieldsSchema.shape.compass,
|
|
85
87
|
});
|
|
@@ -70,6 +70,48 @@ Test consequences.
|
|
|
70
70
|
Test evolution.
|
|
71
71
|
`;
|
|
72
72
|
|
|
73
|
+
const ADR_BODY_WITH_AC = (
|
|
74
|
+
id: string,
|
|
75
|
+
status: string,
|
|
76
|
+
createdAt: string,
|
|
77
|
+
acBlock: string,
|
|
78
|
+
): string => `---
|
|
79
|
+
id: ${id}
|
|
80
|
+
title: "Test ADR"
|
|
81
|
+
status: ${status}
|
|
82
|
+
scope: workspace
|
|
83
|
+
decider: human:test
|
|
84
|
+
createdAt: ${createdAt}
|
|
85
|
+
updatedAt: ${createdAt}
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
# ${id}: Test ADR
|
|
89
|
+
|
|
90
|
+
## Context
|
|
91
|
+
|
|
92
|
+
Test context.
|
|
93
|
+
|
|
94
|
+
## Decision
|
|
95
|
+
|
|
96
|
+
Test decision.
|
|
97
|
+
|
|
98
|
+
## Justification
|
|
99
|
+
|
|
100
|
+
Test justification.
|
|
101
|
+
|
|
102
|
+
## Consequences
|
|
103
|
+
|
|
104
|
+
Test consequences.
|
|
105
|
+
|
|
106
|
+
## Evolution
|
|
107
|
+
|
|
108
|
+
Test evolution.
|
|
109
|
+
|
|
110
|
+
## Acceptance criteria
|
|
111
|
+
|
|
112
|
+
${acBlock}
|
|
113
|
+
`;
|
|
114
|
+
|
|
73
115
|
async function makeGitRepo(dir: string): Promise<void> {
|
|
74
116
|
execFileSync("git", ["init"], { cwd: dir, timeout: 5000 });
|
|
75
117
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, timeout: 5000 });
|
|
@@ -295,6 +337,114 @@ describe("adr.implement.stamp", () => {
|
|
|
295
337
|
expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-05")).toBe(true);
|
|
296
338
|
});
|
|
297
339
|
|
|
340
|
+
// ── ADR-IMP-02: acceptance criteria completeness (post-cutoff, mirrors AV-17) ──
|
|
341
|
+
|
|
342
|
+
it("rejects post-cutoff ADR with unchecked acceptance criteria", async () => {
|
|
343
|
+
await writeFile(
|
|
344
|
+
join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
|
|
345
|
+
ADR_BODY_WITH_AC(
|
|
346
|
+
"ADR-0001",
|
|
347
|
+
"accepted",
|
|
348
|
+
"2026-09-10",
|
|
349
|
+
"- [ ] AC-1: THE artifact SHALL exist (evidence: file: x.ts:1)\n- [x] AC-2: THE other SHALL pass (evidence: file: y.ts:2)",
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
353
|
+
|
|
354
|
+
const result = await runAdrImplementStamp(
|
|
355
|
+
makeInput("ADR-0001", sha),
|
|
356
|
+
makeContext(workspaceRoot),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
expect(result.data?.status).toBe("fail");
|
|
360
|
+
expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-02")).toBe(true);
|
|
361
|
+
expect(result.exitCode).toBe(1);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("rejects post-cutoff ADR with checked criteria lacking evidence annotation", async () => {
|
|
365
|
+
await writeFile(
|
|
366
|
+
join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
|
|
367
|
+
ADR_BODY_WITH_AC(
|
|
368
|
+
"ADR-0001",
|
|
369
|
+
"accepted",
|
|
370
|
+
"2026-09-10",
|
|
371
|
+
"- [x] AC-1: THE artifact SHALL exist",
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
375
|
+
|
|
376
|
+
const result = await runAdrImplementStamp(
|
|
377
|
+
makeInput("ADR-0001", sha),
|
|
378
|
+
makeContext(workspaceRoot),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
expect(result.data?.status).toBe("fail");
|
|
382
|
+
expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-02")).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("stamps post-cutoff ADR when all criteria are checked with evidence", async () => {
|
|
386
|
+
const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0001-test.md");
|
|
387
|
+
await writeFile(
|
|
388
|
+
adrPath,
|
|
389
|
+
ADR_BODY_WITH_AC(
|
|
390
|
+
"ADR-0001",
|
|
391
|
+
"accepted",
|
|
392
|
+
"2026-09-10",
|
|
393
|
+
"- [x] AC-1: THE artifact SHALL exist (evidence: file: x.ts:1)\n- [x] AC-2: THE other SHALL pass (evidence: file: y.ts:2)",
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
397
|
+
|
|
398
|
+
const result = await runAdrImplementStamp(
|
|
399
|
+
makeInput("ADR-0001", sha),
|
|
400
|
+
makeContext(workspaceRoot),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
expect(result.data?.status).toBe("pass");
|
|
404
|
+
expect(result.data?.data?.criteriaChecked).toBe(2);
|
|
405
|
+
|
|
406
|
+
const afterContent = await readFile(adrPath, "utf-8");
|
|
407
|
+
expect(afterContent).toContain("status: implemented");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("stamps pre-cutoff ADR with unchecked criteria (AV-17 exempt)", async () => {
|
|
411
|
+
await writeFile(
|
|
412
|
+
join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
|
|
413
|
+
ADR_BODY_WITH_AC(
|
|
414
|
+
"ADR-0001",
|
|
415
|
+
"accepted",
|
|
416
|
+
"2026-08-01",
|
|
417
|
+
"- [ ] AC-1: THE artifact SHALL exist (evidence: file: x.ts:1)",
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
421
|
+
|
|
422
|
+
const result = await runAdrImplementStamp(
|
|
423
|
+
makeInput("ADR-0001", sha),
|
|
424
|
+
makeContext(workspaceRoot),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
expect(result.data?.status).toBe("pass");
|
|
428
|
+
expect(result.data?.data?.criteriaChecked).toBeUndefined();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("stamps post-cutoff ADR without an acceptance criteria section", async () => {
|
|
432
|
+
const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0001-test.md");
|
|
433
|
+
const body = ADR_BODY("ADR-0001", "accepted").replace(
|
|
434
|
+
"createdAt: 2026-08-01",
|
|
435
|
+
"createdAt: 2026-09-10",
|
|
436
|
+
);
|
|
437
|
+
await writeFile(adrPath, body);
|
|
438
|
+
const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
|
|
439
|
+
|
|
440
|
+
const result = await runAdrImplementStamp(
|
|
441
|
+
makeInput("ADR-0001", sha),
|
|
442
|
+
makeContext(workspaceRoot),
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
expect(result.data?.status).toBe("pass");
|
|
446
|
+
});
|
|
447
|
+
|
|
298
448
|
// ── Dry-run ──────────────────────────────────────────────────────────────────
|
|
299
449
|
|
|
300
450
|
it("dry-run checks preconditions without mutating file", async () => {
|