@warpgogol/forge 5.1.3 → 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 +1 -0
- package/bin/cli.ts +4 -0
- package/dist/bin/cli.js +4 -0
- package/dist/bin/cli.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/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/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/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/handlers/pipeline-status.ts +24 -67
- package/package.json +17 -1
- package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +46 -1
- package/src/pipeline-status.test.ts +192 -0
- package/src/pipeline-status.ts +319 -0
|
@@ -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
|
+
}
|