@warpgogol/forge 5.1.3 → 5.2.3

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.
Files changed (116) hide show
  1. package/AGENTS.md +3 -1
  2. package/bin/cli.ts +4 -0
  3. package/dist/bin/cli.js +4 -0
  4. package/dist/bin/cli.js.map +1 -1
  5. package/dist/os/adr/adr.module.js +1 -1
  6. package/dist/os/adr/adr.module.js.map +1 -1
  7. package/dist/os/compass/compass.module.js +2 -2
  8. package/dist/os/compass/compass.module.js.map +1 -1
  9. package/dist/os/compass/handlers/compass-audit-handler.d.ts +3 -0
  10. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  11. package/dist/os/compass/handlers/compass-audit-handler.js +87 -8
  12. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  13. package/dist/os/core/core.module.js +1 -1
  14. package/dist/os/core/core.module.js.map +1 -1
  15. package/dist/os/mission/handlers/archive.d.ts.map +1 -1
  16. package/dist/os/mission/handlers/archive.js +22 -6
  17. package/dist/os/mission/handlers/archive.js.map +1 -1
  18. package/dist/os/program/lease.js +1 -1
  19. package/dist/os/program/lease.js.map +1 -1
  20. package/dist/os/queue/handlers/queue-validate.d.ts +4 -0
  21. package/dist/os/queue/handlers/queue-validate.d.ts.map +1 -0
  22. package/dist/os/queue/handlers/queue-validate.js +62 -0
  23. package/dist/os/queue/handlers/queue-validate.js.map +1 -0
  24. package/dist/os/queue/index.d.ts +5 -0
  25. package/dist/os/queue/index.d.ts.map +1 -0
  26. package/dist/os/queue/index.js +18 -0
  27. package/dist/os/queue/index.js.map +1 -0
  28. package/dist/os/queue/manifest.d.ts +14 -0
  29. package/dist/os/queue/manifest.d.ts.map +1 -0
  30. package/dist/os/queue/manifest.js +110 -0
  31. package/dist/os/queue/manifest.js.map +1 -0
  32. package/dist/os/queue/queue.module.d.ts +3 -0
  33. package/dist/os/queue/queue.module.d.ts.map +1 -0
  34. package/dist/os/queue/queue.module.js +56 -0
  35. package/dist/os/queue/queue.module.js.map +1 -0
  36. package/dist/os/queue/types.d.ts +25 -0
  37. package/dist/os/queue/types.d.ts.map +1 -0
  38. package/dist/os/queue/types.js +28 -0
  39. package/dist/os/queue/types.js.map +1 -0
  40. package/dist/os/rfc/handlers/list-create.d.ts.map +1 -1
  41. package/dist/os/rfc/handlers/list-create.js +79 -32
  42. package/dist/os/rfc/handlers/list-create.js.map +1 -1
  43. package/dist/os/rfc/handlers/pipeline-status.d.ts +2 -7
  44. package/dist/os/rfc/handlers/pipeline-status.d.ts.map +1 -1
  45. package/dist/os/rfc/handlers/pipeline-status.js +15 -57
  46. package/dist/os/rfc/handlers/pipeline-status.js.map +1 -1
  47. package/dist/os/rfc/rfc.module.js +1 -1
  48. package/dist/os/rfc/rfc.module.js.map +1 -1
  49. package/dist/os/spec/spec.module.d.ts.map +1 -1
  50. package/dist/os/spec/spec.module.js +2 -0
  51. package/dist/os/spec/spec.module.js.map +1 -1
  52. package/dist/src/knowledge/index.d.ts +1 -0
  53. package/dist/src/knowledge/index.d.ts.map +1 -1
  54. package/dist/src/knowledge/index.js +1 -0
  55. package/dist/src/knowledge/index.js.map +1 -1
  56. package/dist/src/knowledge/sync.d.ts +24 -0
  57. package/dist/src/knowledge/sync.d.ts.map +1 -0
  58. package/dist/src/knowledge/sync.js +127 -0
  59. package/dist/src/knowledge/sync.js.map +1 -0
  60. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  61. package/dist/src/onboarding/doctor.js +7 -6
  62. package/dist/src/onboarding/doctor.js.map +1 -1
  63. package/dist/src/onboarding/init.d.ts.map +1 -1
  64. package/dist/src/onboarding/init.js +15 -8
  65. package/dist/src/onboarding/init.js.map +1 -1
  66. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  67. package/dist/src/onboarding/upgrade.js +42 -13
  68. package/dist/src/onboarding/upgrade.js.map +1 -1
  69. package/dist/src/pipeline-status.d.ts +62 -0
  70. package/dist/src/pipeline-status.d.ts.map +1 -0
  71. package/dist/src/pipeline-status.js +220 -0
  72. package/dist/src/pipeline-status.js.map +1 -0
  73. package/dist/src/types.d.ts +1 -2
  74. package/dist/src/types.d.ts.map +1 -1
  75. package/dist/src/types.js +1 -0
  76. package/dist/src/types.js.map +1 -1
  77. package/dist/src/utils/fs-atomic.js +1 -1
  78. package/dist/src/utils/fs-atomic.js.map +1 -1
  79. package/os/adr/adr.module.ts +1 -1
  80. package/os/compass/compass.module.ts +2 -2
  81. package/os/compass/handlers/compass-audit-handler.ts +116 -8
  82. package/os/compass/handlers/tests/compass-audit-plan.test.ts +98 -0
  83. package/os/compass/handlers/tests/compass-audit-record.test.ts +105 -0
  84. package/os/compass/handlers/tests/compass-audit-validate.test.ts +133 -0
  85. package/os/compass/handlers/tests/compass-ledger-scope.test.ts +124 -0
  86. package/os/core/core.module.ts +1 -1
  87. package/os/mission/handlers/archive.test.ts +49 -0
  88. package/os/mission/handlers/archive.ts +21 -6
  89. package/os/program/lease.ts +1 -1
  90. package/os/queue/handlers/queue-validate.ts +83 -0
  91. package/os/queue/index.ts +19 -0
  92. package/os/queue/manifest.ts +183 -0
  93. package/os/queue/queue-validate.test.ts +177 -0
  94. package/os/queue/queue.module.ts +59 -0
  95. package/os/queue/types.ts +49 -0
  96. package/os/rfc/handlers/list-create.ts +83 -35
  97. package/os/rfc/handlers/pipeline-status.ts +24 -67
  98. package/os/rfc/handlers/validate-rules.test.ts +50 -0
  99. package/os/rfc/rfc-create-concurrent.test.ts +118 -0
  100. package/os/rfc/rfc-create-hint.test.ts +90 -0
  101. package/os/rfc/rfc-read-only-no-side-effects.test.ts +83 -0
  102. package/os/rfc/rfc.module.ts +1 -1
  103. package/os/spec/spec.module.ts +2 -0
  104. package/package.json +17 -1
  105. package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +46 -1
  106. package/src/knowledge/__tests__/sync.test.ts +227 -0
  107. package/src/knowledge/index.ts +7 -0
  108. package/src/knowledge/sync.ts +161 -0
  109. package/src/onboarding/doctor.ts +7 -6
  110. package/src/onboarding/init.ts +15 -8
  111. package/src/onboarding/upgrade.ts +47 -16
  112. package/src/pipeline-status.test.ts +192 -0
  113. package/src/pipeline-status.ts +319 -0
  114. package/src/tests/upgrade.test.ts +53 -0
  115. package/src/types.ts +2 -2
  116. package/src/utils/fs-atomic.ts +1 -1
@@ -0,0 +1,177 @@
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 type { ForgeRuntimeContext } from "../../src/types.ts";
7
+ import { runQueueValidate } from "./handlers/queue-validate.ts";
8
+ import { loadQueueManifest } from "./manifest.ts";
9
+
10
+ let tmpDir: string;
11
+
12
+ beforeEach(async () => {
13
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "queue-validate-test-"));
14
+ await fs.mkdir(path.join(tmpDir, "docs/rfcs"), { recursive: true });
15
+ await fs.mkdir(path.join(tmpDir, "docs/adrs"), { recursive: true });
16
+ await fs.mkdir(path.join(tmpDir, "docs/queues"), { recursive: true });
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await fs.rm(tmpDir, { recursive: true, force: true });
21
+ });
22
+
23
+ function testContext(): ForgeRuntimeContext {
24
+ const logs: string[] = [];
25
+ return {
26
+ workspaceRoot: tmpDir,
27
+ dryRun: false,
28
+ outputFormat: "json",
29
+ logger: {
30
+ section: (m: string) => logs.push(m),
31
+ info: (m: string) => logs.push(m),
32
+ warn: (m: string) => logs.push(m),
33
+ error: (m: string) => logs.push(m),
34
+ success: (m: string) => logs.push(m),
35
+ },
36
+ };
37
+ }
38
+
39
+ async function writeRfc(id: string, frontmatter = "status: draft\n"): Promise<void> {
40
+ await fs.writeFile(
41
+ path.join(tmpDir, `docs/rfcs/${id.toLowerCase()}-test.md`),
42
+ `---\nid: ${id}\n${frontmatter}---\n# ${id}\n`,
43
+ );
44
+ }
45
+
46
+ async function writeManifest(name: string, body: string): Promise<string> {
47
+ const rel = `docs/queues/${name}.yaml`;
48
+ await fs.writeFile(path.join(tmpDir, rel), body);
49
+ return rel;
50
+ }
51
+
52
+ describe("loadQueueManifest — validation rules (RFC-1140)", () => {
53
+ test("valid manifest loads with no errors", async () => {
54
+ await writeRfc("RFC-1300");
55
+ const file = await writeManifest(
56
+ "block-a",
57
+ "id: block-a\ncreatedAt: 2026-09-23\nitems:\n - id: RFC-1300\n",
58
+ );
59
+ const { errors } = await loadQueueManifest(tmpDir, file);
60
+ expect(errors).toEqual([]);
61
+ });
62
+
63
+ test("id↔filename mismatch → QUEUE-02 blocking error", async () => {
64
+ await writeRfc("RFC-1301");
65
+ const file = await writeManifest(
66
+ "block-b",
67
+ "id: different-id\ncreatedAt: 2026-09-23\nitems:\n - id: RFC-1301\n",
68
+ );
69
+ const { errors } = await loadQueueManifest(tmpDir, file);
70
+ expect(errors.some((e) => e.ruleId === "QUEUE-02")).toBe(true);
71
+ });
72
+
73
+ test("malformed item id → QUEUE-03", async () => {
74
+ const file = await writeManifest(
75
+ "block-c",
76
+ "id: block-c\ncreatedAt: 2026-09-23\nitems:\n - id: RFC-13\n",
77
+ );
78
+ const { errors } = await loadQueueManifest(tmpDir, file);
79
+ expect(errors.some((e) => e.ruleId === "QUEUE-03")).toBe(true);
80
+ });
81
+
82
+ test("unresolvable item id → QUEUE-04", async () => {
83
+ const file = await writeManifest(
84
+ "block-d",
85
+ "id: block-d\ncreatedAt: 2026-09-23\nitems:\n - id: RFC-9999\n",
86
+ );
87
+ const { errors } = await loadQueueManifest(tmpDir, file);
88
+ expect(errors.some((e) => e.ruleId === "QUEUE-04")).toBe(true);
89
+ });
90
+
91
+ test("duplicate item id → QUEUE-05", async () => {
92
+ await writeRfc("RFC-1305");
93
+ const file = await writeManifest(
94
+ "block-e",
95
+ "id: block-e\ncreatedAt: 2026-09-23\nitems:\n - id: RFC-1305\n - id: RFC-1305\n",
96
+ );
97
+ const { errors } = await loadQueueManifest(tmpDir, file);
98
+ expect(errors.some((e) => e.ruleId === "QUEUE-05")).toBe(true);
99
+ });
100
+
101
+ test("dependsOn order violation → QUEUE-06 warning, not blocking", async () => {
102
+ await writeRfc("RFC-1310");
103
+ await writeRfc("RFC-1311", "status: draft\ndependsOn:\n - RFC-1310\n");
104
+ const file = await writeManifest(
105
+ "block-f",
106
+ "id: block-f\ncreatedAt: 2026-09-23\nitems:\n - id: RFC-1311\n - id: RFC-1310\n",
107
+ );
108
+ const { errors, warnings } = await loadQueueManifest(tmpDir, file);
109
+ expect(errors).toEqual([]);
110
+ expect(warnings.some((w) => w.ruleId === "QUEUE-06")).toBe(true);
111
+ });
112
+
113
+ test("empty items is valid", async () => {
114
+ const file = await writeManifest("block-g", "id: block-g\ncreatedAt: 2026-09-23\nitems: []\n");
115
+ const { errors } = await loadQueueManifest(tmpDir, file);
116
+ expect(errors).toEqual([]);
117
+ });
118
+ });
119
+
120
+ describe("runQueueValidate — command contract (RFC-1140)", () => {
121
+ test("throws without --file", async () => {
122
+ await expect(
123
+ runQueueValidate({ argv: [], flags: {} }, testContext()),
124
+ ).rejects.toThrow("--file");
125
+ });
126
+
127
+ test("valid manifest → pass, items + next in JSON output", async () => {
128
+ await writeRfc("RFC-1400", "status: implemented\n");
129
+ await writeRfc("RFC-1401");
130
+ const file = await writeManifest(
131
+ "block-h",
132
+ "id: block-h\ncreatedAt: 2026-09-23\nitems:\n - id: RFC-1400\n - id: RFC-1401\n",
133
+ );
134
+
135
+ const result = await runQueueValidate(
136
+ { argv: [], flags: { file } },
137
+ testContext(),
138
+ );
139
+
140
+ expect(result?.data?.status).toBe("pass");
141
+ expect(result?.data?.queue).toBe("block-h");
142
+ expect(result?.data?.items).toHaveLength(2);
143
+ expect(result?.data?.items[0]?.status).toBe("implemented");
144
+ expect(result?.data?.items[1]?.status).toBe("pending");
145
+ expect(result?.data?.next).toBe("RFC-1401");
146
+ expect(result?.exitCode).toBe(0);
147
+ });
148
+
149
+ test("invalid manifest → fail + exitCode 1", async () => {
150
+ const file = await writeManifest(
151
+ "block-i",
152
+ "id: block-i\ncreatedAt: 2026-09-23\nitems:\n - id: RFC-9999\n",
153
+ );
154
+
155
+ const result = await runQueueValidate(
156
+ { argv: [], flags: { file } },
157
+ testContext(),
158
+ );
159
+
160
+ expect(result?.data?.status).toBe("fail");
161
+ expect(result?.exitCode).toBe(1);
162
+ expect(result?.data?.errors.length).toBeGreaterThan(0);
163
+ });
164
+
165
+ test("empty items → pass with next null", async () => {
166
+ const file = await writeManifest("block-j", "id: block-j\ncreatedAt: 2026-09-23\nitems: []\n");
167
+
168
+ const result = await runQueueValidate(
169
+ { argv: [], flags: { file } },
170
+ testContext(),
171
+ );
172
+
173
+ expect(result?.data?.status).toBe("pass");
174
+ expect(result?.data?.items).toEqual([]);
175
+ expect(result?.data?.next).toBeNull();
176
+ });
177
+ });
@@ -0,0 +1,59 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Register the queue.validate command with the forge kernel registry (RFC-1140).</purpose>
4
+ <non-goals>
5
+ <item>Do not implement handler logic here — delegate to handlers/queue-validate.ts.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-1140: initial forgeQueueModule registering queue.validate.</item>
10
+ <item>RFC-1140: steps 1-4 — shared resolver, queue module, registration
11
+
12
+ 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>
13
+ <item>RFC-1140: queue.validate missing contract/rules metadata (DNA-91)
14
+
15
+ fo-review REVIEW-CODE-2026-09-23-01 finding: declare contract queue and emitted ruleIds QUEUE-01..06 so validator.inventory.generate does not fail closed. Manifest regenerated.</item>
16
+ </CHANGE_SUMMARY>
17
+ */
18
+
19
+ import type { ForgeModule } from "../../src/forge-module.ts";
20
+
21
+ export async function createForgeQueueModule(): Promise<ForgeModule> {
22
+ const { runQueueValidate } = await import("./handlers/queue-validate.ts");
23
+ return {
24
+ name: "forge-queue",
25
+ version: "0.1.0",
26
+ runtime: "autonomous",
27
+ declarations: [],
28
+ commands: [
29
+ {
30
+ name: "queue.validate",
31
+ description:
32
+ "Validate a queue manifest (docs/queues/*.yaml) and report derived " +
33
+ "per-item pipeline status plus the next actionable item. Read-only — " +
34
+ "the pipeline orchestrator runs this before starting a queued batch (RFC-1140). Required flags: `--file`.",
35
+ scope: "workspace",
36
+ contract: "queue",
37
+ rules: ["QUEUE-01", "QUEUE-02", "QUEUE-03", "QUEUE-04", "QUEUE-05", "QUEUE-06"],
38
+ mutatesState: false,
39
+ reads: [
40
+ "docs/queues/*.yaml",
41
+ "docs/rfcs/**/*.md",
42
+ "docs/adrs/**/*.md",
43
+ "docs/audits/*.md",
44
+ "docs/plans/*.md",
45
+ ],
46
+ cacheable: false,
47
+ flags: {
48
+ file: {
49
+ kind: "string",
50
+ required: true,
51
+ description: "Path to the queue manifest YAML file.",
52
+ },
53
+ },
54
+ execute: runQueueValidate,
55
+ },
56
+ ],
57
+ pipelines: [],
58
+ };
59
+ }
@@ -0,0 +1,49 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Queue manifest contract (RFC-1140) — the durable ordered document
4
+ block consumed by queue.validate and the pipeline orchestrator's queue mode.</purpose>
5
+ <non-goals>
6
+ <item>Do not store per-item progress state — status is derived, never persisted.</item>
7
+ </non-goals>
8
+ </MODULE_CONTRACT>
9
+ <CHANGE_SUMMARY>
10
+ <item>RFC-1140: initial QueueManifest schema and queue.validate result types.</item>
11
+ <item>RFC-1140: steps 1-4 — shared resolver, queue module, registration
12
+
13
+ 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>
14
+ </CHANGE_SUMMARY>
15
+ */
16
+
17
+ import { z } from "zod";
18
+
19
+ import type { Diagnostic } from "../../src/types.ts";
20
+ import type { QueueItemReport } from "../../src/pipeline-status.ts";
21
+
22
+ export const QUEUE_ITEM_ID_PATTERN = /^(RFC|ADR)-\d{4}$/;
23
+
24
+ export const queueManifestSchema = z.object({
25
+ id: z.string().min(1),
26
+ createdAt: z.string().min(1),
27
+ items: z
28
+ .array(
29
+ z.object({
30
+ id: z.string().min(1),
31
+ note: z.string().optional(),
32
+ }),
33
+ )
34
+ .default([]),
35
+ });
36
+
37
+ export type QueueManifest = z.infer<typeof queueManifestSchema>;
38
+
39
+ export interface QueueValidateResult {
40
+ command: "queue.validate";
41
+ status: "pass" | "fail";
42
+ queue: string;
43
+ file: string;
44
+ items: QueueItemReport[];
45
+ /** First item whose derived status is pending or in-progress; null when fully terminal. */
46
+ next: string | null;
47
+ errors: Diagnostic[];
48
+ warnings: Diagnostic[];
49
+ }
@@ -6,13 +6,16 @@
6
6
  </non-goals>
7
7
  </MODULE_CONTRACT>
8
8
  <CHANGE_SUMMARY>
9
- <item>RFC-0303: extracted list and create handlers from handlers.ts into handlers/list-create.ts.</item>
10
9
  <item>RFC-1097: step 6 — compass.migrate codemod run
11
10
 
12
11
  Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.</item>
13
12
  <item>RFC-1097: sweep — werkstatt-engine clean
14
13
 
15
14
  Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.</item>
15
+ <item>RFC-1138: pipeline hygiene — module-scoped exempt entries, archive gitignore guard, dns upsert to deploy phases, promote auto-sync, rfc.create claim protocol, siteHasRuntime filter</item>
16
+ <item>RFC-1139: CLI hint accuracy and agent-safety hygiene — rfc.create hint, EC-14-PARTIAL, amend delegation, ledger scope, sync footer, mission.open remnants</item>
17
+ <item>RFC-1139: second hint fix (rfc.list empty-state) + read-only test fix</item>
18
+ <history>RFC-0303</history>
16
19
  </CHANGE_SUMMARY>
17
20
  */
18
21
 
@@ -140,7 +143,7 @@ export async function runRfcList(
140
143
  entries.length === 0
141
144
  ? [
142
145
  {
143
- action: `Create a new RFC: pnpm exec forge run rfc.create --title "..."`,
146
+ action: `Create a new RFC: pnpm exec werkstatt run rfc.create --title "..."`,
144
147
  kind: "optional",
145
148
  },
146
149
  ]
@@ -184,21 +187,6 @@ export async function runRfcCreate(
184
187
  }
185
188
  await assertSatisfiesIdsExist(workspaceRoot, satisfies);
186
189
 
187
- const files = await listRfcFiles(rfcDirPath);
188
- let maxId = 0;
189
- for (const f of files) {
190
- const basename = path.basename(f);
191
- const match = basename.match(/^rfc-(\d{4})/);
192
- if (match) {
193
- const num = parseInt(match[1]!, 10);
194
- if (num > maxId) maxId = num;
195
- }
196
- }
197
- const nextNum = maxId + 1;
198
- const paddedNum = String(nextNum).padStart(4, "0");
199
- const nextSlug = `rfc-${paddedNum}`;
200
- const nextId = `RFC-${paddedNum}`;
201
-
202
190
  const templatePath = resolveRfcTemplate(workspaceRoot);
203
191
  let templateContent: string;
204
192
  try {
@@ -208,7 +196,6 @@ export async function runRfcCreate(
208
196
  }
209
197
 
210
198
  const kebabTitle = toKebabCase(title);
211
- const fileName = `${nextSlug}-${kebabTitle}.md`;
212
199
 
213
200
  // RFC-0329: consult decision log before scaffolding
214
201
  let consultedDecisions: ConsultedDecision[] = [];
@@ -230,25 +217,86 @@ export async function runRfcCreate(
230
217
  }
231
218
  }
232
219
 
233
- let content = templateContent;
234
- content = content.replace(/^id: RFC-0000$/m, `id: ${nextId}`);
235
- content = content.replace(/^title: ".*"$/m, `title: "${title}"`);
236
- content = content.replace(/^kind: \w+$/m, `kind: ${kind}`);
237
- content = content.replace(/^scope: \w+$/m, `scope: ${scope}`);
238
- content = content.replace(/^createdAt: YYYY-MM-DD$/m, `createdAt: ${today}`);
239
- content = content.replace(/^updatedAt: YYYY-MM-DD$/m, `updatedAt: ${today}`);
240
- if (satisfies.length > 0) {
241
- content = content.replace(
242
- /^satisfies: \[\]$/m,
243
- `satisfies:\n${satisfies.map((id) => ` - ${id}`).join("\n")}`,
244
- );
220
+ // RFC-1138: atomic id allocation — scan → claim the id via wx on a
221
+ // title-independent `rfc-NNNN.claim` file → write the real file → release.
222
+ // The claim must be on the id, not the filename: two creates with different
223
+ // titles produce different filenames, so a wx write on the target path can
224
+ // never collide. `.claim` files are invisible to listRfcFiles (.md-only),
225
+ // so they don't pollute rfc.list or the maxId scan. Stale claims (crashed
226
+ // create) are reclaimed after CLAIM_STALE_MS.
227
+ const MAX_CREATE_ATTEMPTS = 10;
228
+ const CLAIM_STALE_MS = 30_000;
229
+ let nextId = "";
230
+ let relativeFile = "";
231
+ let allocated = false;
232
+
233
+ const files = await listRfcFiles(rfcDirPath);
234
+ let maxId = 0;
235
+ for (const f of files) {
236
+ const basename = path.basename(f);
237
+ const match = basename.match(/^rfc-(\d{4})/);
238
+ if (match) {
239
+ const num = parseInt(match[1]!, 10);
240
+ if (num > maxId) maxId = num;
241
+ }
245
242
  }
246
- content = content.replace(/^# RFC-0000: .+$/m, `# ${nextId}: ${title}`);
243
+ let candidate = maxId + 1;
244
+
245
+ for (let attempt = 1; attempt <= MAX_CREATE_ATTEMPTS; attempt++) {
246
+ const paddedNum = String(candidate).padStart(4, "0");
247
+ const nextSlug = `rfc-${paddedNum}`;
248
+ nextId = `RFC-${paddedNum}`;
249
+ const fileName = `${nextSlug}-${kebabTitle}.md`;
250
+ const claimPath = path.join(rfcDirPath, `${nextSlug}.claim`);
251
+
252
+ try {
253
+ await fs.writeFile(claimPath, `${process.pid}\n`, { flag: "wx" });
254
+ } catch (err) {
255
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
256
+ // Stale claim (crashed create) → reclaim and retry the same id.
257
+ // Live claim → another create holds this id — move to the next.
258
+ const st = await fs.stat(claimPath).catch(() => null);
259
+ if (st && Date.now() - st.mtimeMs > CLAIM_STALE_MS) {
260
+ await fs.rm(claimPath, { force: true });
261
+ } else {
262
+ candidate++;
263
+ }
264
+ continue;
265
+ }
266
+ throw err;
267
+ }
247
268
 
248
- const targetPath = path.join(rfcDirPath, fileName);
249
- await fs.writeFile(targetPath, content, "utf-8");
269
+ try {
270
+ let content = templateContent;
271
+ content = content.replace(/^id: RFC-0000$/m, `id: ${nextId}`);
272
+ content = content.replace(/^title: ".*"$/m, `title: "${title}"`);
273
+ content = content.replace(/^kind: \w+$/m, `kind: ${kind}`);
274
+ content = content.replace(/^scope: \w+$/m, `scope: ${scope}`);
275
+ content = content.replace(/^createdAt: YYYY-MM-DD$/m, `createdAt: ${today}`);
276
+ content = content.replace(/^updatedAt: YYYY-MM-DD$/m, `updatedAt: ${today}`);
277
+ if (satisfies.length > 0) {
278
+ content = content.replace(
279
+ /^satisfies: \[\]$/m,
280
+ `satisfies:\n${satisfies.map((id) => ` - ${id}`).join("\n")}`,
281
+ );
282
+ }
283
+ content = content.replace(/^# RFC-0000: .+$/m, `# ${nextId}: ${title}`);
284
+
285
+ const targetPath = path.join(rfcDirPath, fileName);
286
+ await fs.writeFile(targetPath, content, { flag: "wx" });
287
+ relativeFile = path.join(RFC_DIR, fileName);
288
+ allocated = true;
289
+ } finally {
290
+ await fs.rm(claimPath, { force: true });
291
+ }
292
+ if (allocated) break;
293
+ }
250
294
 
251
- const relativeFile = path.join(RFC_DIR, fileName);
295
+ if (!allocated) {
296
+ throw new Error(
297
+ `rfc.create: id allocation failed after ${MAX_CREATE_ATTEMPTS} attempts — concurrent creates keep claiming the next id`,
298
+ );
299
+ }
252
300
 
253
301
  if (outputFormat === "pretty") {
254
302
  logger.success(`Created ${nextId}: ${title}`);
@@ -269,7 +317,7 @@ export async function runRfcCreate(
269
317
  summary: `Created ${nextId}: ${title}`,
270
318
  nextSteps: [
271
319
  {
272
- action: `Validate the RFC: pnpm exec forge run rfc.validate --file ${relativeFile}`,
320
+ action: `Validate the RFC: pnpm exec werkstatt run rfc.validate --id ${nextId}`,
273
321
  kind: "optional",
274
322
  },
275
323
  ],
@@ -8,16 +8,21 @@
8
8
  </non-goals>
9
9
  </MODULE_CONTRACT>
10
10
  <CHANGE_SUMMARY>
11
+ <item>RFC-1140: refactored onto the shared resolver in src/pipeline-status.ts —
12
+ artifact finders, TERMINAL_STATUSES, and stage computation now imported from
13
+ the portable layer so queue.validate and the orchestrator share one derivation.</item>
11
14
  <item>RFC-1097: step 6 — compass.migrate codemod run
12
15
 
13
16
  Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.</item>
14
17
  <item>RFC-1097: sweep — werkstatt-engine clean
15
18
 
16
19
  Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.</item>
20
+ <item>RFC-1140: steps 1-4 — shared resolver, queue module, registration
21
+
22
+ 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>
17
23
  </CHANGE_SUMMARY>
18
24
  */
19
25
 
20
- import fs from "node:fs/promises";
21
26
  import path from "node:path";
22
27
 
23
28
  import { listRfcFiles, readAndParseRfc } from "../frontmatter-io.ts";
@@ -26,17 +31,18 @@ import type {
26
31
  ForgeCommandResult,
27
32
  ForgeRuntimeContext,
28
33
  } from "../../../src/types.ts";
34
+ import {
35
+ TERMINAL_STATUSES,
36
+ computeRfcPipelineStages,
37
+ findAuditFile,
38
+ findPlanFile,
39
+ nextPipelineStep,
40
+ } from "../../../src/pipeline-status.ts";
41
+ import type { PipelineStage, RfcPipelineStageInfo } from "../../../src/pipeline-status.ts";
29
42
  import type { RfcStatus } from "../types.ts";
30
43
  import { RFC_DIR } from "../types.ts";
31
44
 
32
- export type PipelineStage = "audit" | "enhance" | "plan" | "implement";
33
-
34
- export interface RfcPipelineStageInfo {
35
- stage: PipelineStage;
36
- done: boolean;
37
- /** File or marker that confirms completion, when applicable. */
38
- evidence?: string;
39
- }
45
+ export type { PipelineStage, RfcPipelineStageInfo };
40
46
 
41
47
  export interface RfcPipelineEntry {
42
48
  id: string;
@@ -55,32 +61,6 @@ export interface RfcPipelineStatusResult {
55
61
  entries: RfcPipelineEntry[];
56
62
  }
57
63
 
58
- const TERMINAL_STATUSES: ReadonlySet<string> = new Set(["implemented", "rejected", "superseded"]);
59
-
60
- async function findAuditFile(workspaceRoot: string, rfcId: string): Promise<string | undefined> {
61
- const auditsDir = path.join(workspaceRoot, "docs/audits");
62
- try {
63
- const entries = await fs.readdir(auditsDir);
64
- const prefix = `audit-${rfcId.toLowerCase()}`;
65
- const match = entries.find((e) => e.toLowerCase().startsWith(prefix) && e.endsWith(".md"));
66
- return match ? path.join("docs/audits", match) : undefined;
67
- } catch {
68
- return undefined;
69
- }
70
- }
71
-
72
- async function findPlanFile(workspaceRoot: string, rfcId: string): Promise<string | undefined> {
73
- const plansDir = path.join(workspaceRoot, "docs/plans");
74
- try {
75
- const entries = await fs.readdir(plansDir);
76
- const prefix = `plan-${rfcId.toLowerCase()}`;
77
- const match = entries.find((e) => e.toLowerCase().startsWith(prefix) && e.endsWith(".md"));
78
- return match ? path.join("docs/plans", match) : undefined;
79
- } catch {
80
- return undefined;
81
- }
82
- }
83
-
84
64
  export async function runRfcPipelineStatus(
85
65
  input: ForgeCommandInput,
86
66
  context: ForgeRuntimeContext,
@@ -116,38 +96,15 @@ export async function runRfcPipelineStatus(
116
96
  const auditFile = await findAuditFile(workspaceRoot, id);
117
97
  const planFile = await findPlanFile(workspaceRoot, id);
118
98
 
119
- const stages: RfcPipelineStageInfo[] = [
120
- {
121
- stage: "audit",
122
- done: !!auditFile,
123
- evidence: auditFile,
124
- },
125
- {
126
- stage: "enhance",
127
- done: !!enhancedAt,
128
- evidence: enhancedAt ? `enhancedAt: ${enhancedAt}` : undefined,
129
- },
130
- {
131
- stage: "plan",
132
- done: !!planFile,
133
- evidence: planFile,
134
- },
135
- {
136
- stage: "implement",
137
- done: status === "implemented" || !!implementedAt,
138
- evidence: implementedAt ? `implementedAt: ${implementedAt}` : undefined,
139
- },
140
- ];
141
-
142
- let nextStep: PipelineStage | null = null;
143
- if (!isTerminal) {
144
- for (const s of stages) {
145
- if (!s.done) {
146
- nextStep = s.stage;
147
- break;
148
- }
149
- }
150
- }
99
+ const stages: RfcPipelineStageInfo[] = computeRfcPipelineStages({
100
+ status,
101
+ enhancedAt,
102
+ implementedAt,
103
+ auditFile,
104
+ planFile,
105
+ });
106
+
107
+ const nextStep: PipelineStage | null = isTerminal ? null : nextPipelineStep(status, stages);
151
108
 
152
109
  entries.push({
153
110
  id,
@@ -385,6 +385,56 @@ describe("V-32: implementation commit drift detection", () => {
385
385
  }
386
386
  });
387
387
 
388
+ test("no V-32 or V-17 when implemented RFC is later superseded", async () => {
389
+ const dir = createGitRepoWithCommits([
390
+ { message: "implement: RFC-9999 — step 1", date: "2026-01-02T10:00:00" },
391
+ ]);
392
+ try {
393
+ const parsed = makeParsed("superseded", BASE_BODY, {
394
+ supersededBy: "RFC-0001",
395
+ });
396
+ const { add, violations } = makeViolationsCollector();
397
+ const allParsed = new Map([
398
+ [
399
+ "RFC-0001",
400
+ {
401
+ fileName: "rfc-0001-other.md",
402
+ parsed: makeParsed("accepted", BASE_BODY, { supersedes: ["RFC-9999"] }),
403
+ },
404
+ ],
405
+ ]);
406
+ await validateSingleRfc(
407
+ "rfc-9999-test.md",
408
+ parsed,
409
+ allParsed,
410
+ new Map(),
411
+ new Set(),
412
+ new Set(),
413
+ new Set(Object.keys(parsed.frontmatter)),
414
+ dir,
415
+ add,
416
+ );
417
+ expect(filterRule(violations, "V-32")).toHaveLength(0);
418
+ expect(filterRule(violations, "V-17")).toHaveLength(0);
419
+ } finally {
420
+ rmSync(dir, { recursive: true, force: true });
421
+ }
422
+ });
423
+
424
+ test("no V-32 when status is rejected", async () => {
425
+ const dir = createGitRepoWithCommits([
426
+ { message: "implement: RFC-9999 — step 1", date: "2026-01-02T10:00:00" },
427
+ ]);
428
+ try {
429
+ const parsed = makeParsed("rejected", BASE_BODY);
430
+ const violations = await runValidateInDir(parsed, dir);
431
+ const v32 = filterRule(violations, "V-32");
432
+ expect(v32).toHaveLength(0);
433
+ } finally {
434
+ rmSync(dir, { recursive: true, force: true });
435
+ }
436
+ });
437
+
388
438
  test("no V-32 when no implement: commits exist", async () => {
389
439
  const dir = createGitRepoWithCommits([
390
440
  { message: "feat: add some feature", date: "2026-01-02T10:00:00" },