@warpgogol/forge 5.2.0 → 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 (78) hide show
  1. package/AGENTS.md +2 -1
  2. package/dist/os/adr/adr.module.js +1 -1
  3. package/dist/os/adr/adr.module.js.map +1 -1
  4. package/dist/os/compass/compass.module.js +2 -2
  5. package/dist/os/compass/compass.module.js.map +1 -1
  6. package/dist/os/compass/handlers/compass-audit-handler.d.ts +3 -0
  7. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  8. package/dist/os/compass/handlers/compass-audit-handler.js +87 -8
  9. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  10. package/dist/os/core/core.module.js +1 -1
  11. package/dist/os/core/core.module.js.map +1 -1
  12. package/dist/os/mission/handlers/archive.d.ts.map +1 -1
  13. package/dist/os/mission/handlers/archive.js +22 -6
  14. package/dist/os/mission/handlers/archive.js.map +1 -1
  15. package/dist/os/program/lease.js +1 -1
  16. package/dist/os/program/lease.js.map +1 -1
  17. package/dist/os/queue/queue.module.js +1 -1
  18. package/dist/os/queue/queue.module.js.map +1 -1
  19. package/dist/os/rfc/handlers/list-create.d.ts.map +1 -1
  20. package/dist/os/rfc/handlers/list-create.js +79 -32
  21. package/dist/os/rfc/handlers/list-create.js.map +1 -1
  22. package/dist/os/rfc/rfc.module.js +1 -1
  23. package/dist/os/rfc/rfc.module.js.map +1 -1
  24. package/dist/os/spec/spec.module.d.ts.map +1 -1
  25. package/dist/os/spec/spec.module.js +2 -0
  26. package/dist/os/spec/spec.module.js.map +1 -1
  27. package/dist/src/knowledge/index.d.ts +1 -0
  28. package/dist/src/knowledge/index.d.ts.map +1 -1
  29. package/dist/src/knowledge/index.js +1 -0
  30. package/dist/src/knowledge/index.js.map +1 -1
  31. package/dist/src/knowledge/sync.d.ts +24 -0
  32. package/dist/src/knowledge/sync.d.ts.map +1 -0
  33. package/dist/src/knowledge/sync.js +127 -0
  34. package/dist/src/knowledge/sync.js.map +1 -0
  35. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  36. package/dist/src/onboarding/doctor.js +7 -6
  37. package/dist/src/onboarding/doctor.js.map +1 -1
  38. package/dist/src/onboarding/init.d.ts.map +1 -1
  39. package/dist/src/onboarding/init.js +15 -8
  40. package/dist/src/onboarding/init.js.map +1 -1
  41. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  42. package/dist/src/onboarding/upgrade.js +42 -13
  43. package/dist/src/onboarding/upgrade.js.map +1 -1
  44. package/dist/src/types.d.ts +1 -2
  45. package/dist/src/types.d.ts.map +1 -1
  46. package/dist/src/types.js +1 -0
  47. package/dist/src/types.js.map +1 -1
  48. package/dist/src/utils/fs-atomic.js +1 -1
  49. package/dist/src/utils/fs-atomic.js.map +1 -1
  50. package/os/adr/adr.module.ts +1 -1
  51. package/os/compass/compass.module.ts +2 -2
  52. package/os/compass/handlers/compass-audit-handler.ts +116 -8
  53. package/os/compass/handlers/tests/compass-audit-plan.test.ts +98 -0
  54. package/os/compass/handlers/tests/compass-audit-record.test.ts +105 -0
  55. package/os/compass/handlers/tests/compass-audit-validate.test.ts +133 -0
  56. package/os/compass/handlers/tests/compass-ledger-scope.test.ts +124 -0
  57. package/os/core/core.module.ts +1 -1
  58. package/os/mission/handlers/archive.test.ts +49 -0
  59. package/os/mission/handlers/archive.ts +21 -6
  60. package/os/program/lease.ts +1 -1
  61. package/os/queue/queue.module.ts +1 -1
  62. package/os/rfc/handlers/list-create.ts +83 -35
  63. package/os/rfc/handlers/validate-rules.test.ts +50 -0
  64. package/os/rfc/rfc-create-concurrent.test.ts +118 -0
  65. package/os/rfc/rfc-create-hint.test.ts +90 -0
  66. package/os/rfc/rfc-read-only-no-side-effects.test.ts +83 -0
  67. package/os/rfc/rfc.module.ts +1 -1
  68. package/os/spec/spec.module.ts +2 -0
  69. package/package.json +1 -1
  70. package/src/knowledge/__tests__/sync.test.ts +227 -0
  71. package/src/knowledge/index.ts +7 -0
  72. package/src/knowledge/sync.ts +161 -0
  73. package/src/onboarding/doctor.ts +7 -6
  74. package/src/onboarding/init.ts +15 -8
  75. package/src/onboarding/upgrade.ts +47 -16
  76. package/src/tests/upgrade.test.ts +53 -0
  77. package/src/types.ts +2 -2
  78. package/src/utils/fs-atomic.ts +1 -1
@@ -0,0 +1,124 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-1139 AC-5: compass.audit.baseline must not seed ledger entries for
5
+ paths under missions/ or gitignored roots — the tracked ledger only
6
+ accumulates entries for paths that can be audited long-term.
7
+ </purpose>
8
+ </MODULE_CONTRACT>
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
12
+ import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { execFile } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+ import { parse as yamlParse } from "yaml";
18
+ import { runCompassAuditBaseline } from "../compass-audit-handler.ts";
19
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../../../../src/types.ts";
20
+
21
+ const execFileAsync = promisify(execFile);
22
+
23
+ const logger = {
24
+ section() {},
25
+ info() {},
26
+ warn() {},
27
+ error() {},
28
+ success() {},
29
+ getEvents() {
30
+ return [];
31
+ },
32
+ };
33
+
34
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
35
+ return {
36
+ workspaceRoot,
37
+ site: undefined,
38
+ siteExplicit: false,
39
+ logger: logger as never,
40
+ dryRun: false,
41
+ outputFormat: "json",
42
+ io: {} as never,
43
+ actualState: undefined as never,
44
+ fileIntents: [],
45
+ } as unknown as ForgeRuntimeContext;
46
+ }
47
+
48
+ function makeInput(flags: Record<string, unknown> = {}): ForgeCommandInput {
49
+ return { argv: [], flags } as unknown as ForgeCommandInput;
50
+ }
51
+
52
+ interface LedgerEntry {
53
+ path: string;
54
+ }
55
+
56
+ describe("compass.audit.baseline — RFC-1139 ledger scope", () => {
57
+ let root: string;
58
+
59
+ beforeEach(async () => {
60
+ root = await mkdtemp(join(tmpdir(), "compass-ledger-"));
61
+ // Authored source file (plain .ts, no generated marker → authored entry)
62
+ await mkdir(join(root, "packages", "foo"), { recursive: true });
63
+ await writeFile(join(root, "packages", "foo", "index.ts"), "export const x = 1;\n", "utf8");
64
+ // Mission workpiece file — would be authored but must be excluded
65
+ await mkdir(join(root, "missions", "acme-m000001", "workpiece", "src"), {
66
+ recursive: true,
67
+ });
68
+ await writeFile(
69
+ join(root, "missions", "acme-m000001", "workpiece", "src", "local.ts"),
70
+ "export const y = 2;\n",
71
+ "utf8",
72
+ );
73
+ // Gitignored generated dir — excluded via check-ignore
74
+ await mkdir(join(root, "dist"), { recursive: true });
75
+ await writeFile(join(root, "dist", "bundle.ts"), "export const z = 3;\n", "utf8");
76
+ await writeFile(join(root, ".gitignore"), "dist/\nmissions/\n", "utf8");
77
+ // Git repo for getRevisionByPath + check-ignore
78
+ await execFileAsync("git", ["init"], { cwd: root });
79
+ await execFileAsync("git", ["config", "user.email", "test@test.com"], { cwd: root });
80
+ await execFileAsync("git", ["config", "user.name", "Test"], { cwd: root });
81
+ await execFileAsync("git", ["add", "."], { cwd: root });
82
+ await execFileAsync("git", ["commit", "-m", "init"], { cwd: root });
83
+ });
84
+
85
+ afterEach(async () => {
86
+ await rm(root, { recursive: true, force: true });
87
+ });
88
+
89
+ it("AC-5: baseline seeds no missions/ or gitignored ledger entries", async () => {
90
+ const result = await runCompassAuditBaseline(makeInput(), makeContext(root));
91
+ expect(result.exitCode).toBe(0);
92
+
93
+ const ledgerRaw = await readFile(
94
+ join(root, "docs", "compass-audit-ledger.generated.yaml"),
95
+ "utf8",
96
+ );
97
+ const ledger = yamlParse(ledgerRaw) as { entries: LedgerEntry[] };
98
+ const paths = ledger.entries.map((e) => e.path);
99
+
100
+ expect(paths.some((p) => p.startsWith("missions/"))).toBe(false);
101
+ expect(paths.some((p) => p.startsWith("dist/"))).toBe(false);
102
+ expect(paths).toContain("packages/foo/index.ts");
103
+ });
104
+
105
+ it("AC-5: pre-existing missions/ entries are pruned on next baseline", async () => {
106
+ // Seed a ledger with a stale missions/ entry
107
+ await mkdir(join(root, "docs"), { recursive: true });
108
+ await writeFile(
109
+ join(root, "docs", "compass-audit-ledger.generated.yaml"),
110
+ `entries:\n - path: missions/acme-m000001/workpiece/src/local.ts\n entityId: ""\n auditedRevision: abc\n auditedHash: def\n auditedAt: "2026-01-01T00:00:00Z"\n verdict: baseline\n agent: system:baseline\n`,
111
+ "utf8",
112
+ );
113
+
114
+ const result = await runCompassAuditBaseline(makeInput(), makeContext(root));
115
+ expect(result.exitCode).toBe(0);
116
+
117
+ const ledgerRaw = await readFile(
118
+ join(root, "docs", "compass-audit-ledger.generated.yaml"),
119
+ "utf8",
120
+ );
121
+ const ledger = yamlParse(ledgerRaw) as { entries: LedgerEntry[] };
122
+ expect(ledger.entries.some((e) => e.path.startsWith("missions/"))).toBe(false);
123
+ });
124
+ });
@@ -305,7 +305,7 @@ export async function createForgeCoreModule(): Promise<ForgeModule> {
305
305
  {
306
306
  name: "forge.scaffold",
307
307
  description:
308
- "Create a working pnpm + Turborepo monorepo from a stack profile in an empty directory.",
308
+ "Create a working pnpm + Turborepo monorepo from a stack profile in an empty directory. Required flags: `--profile`, `--name`.",
309
309
  scope: "workspace",
310
310
  supportsAllSites: false,
311
311
  flags: {
@@ -572,4 +572,53 @@ describe("mission.archive", () => {
572
572
  expect(data.skipped).toHaveLength(1);
573
573
  expect(data.skipped[0].reason).toContain("use --clean-orphans");
574
574
  });
575
+
576
+ test("RFC-1138 AC-2: git add -A skipped when archive target is gitignored", async () => {
577
+ execSyncMock.mockReset();
578
+ execSyncMock.mockImplementation((cmd: string) => {
579
+ if (cmd.startsWith("pnpm install")) return "";
580
+ if (cmd.startsWith("git status --porcelain pnpm-lock.yaml")) return "M pnpm-lock.yaml";
581
+ // git check-ignore exits 0 → path is ignored (missions/ is gitignored)
582
+ if (cmd.startsWith("git check-ignore")) return "";
583
+ return "";
584
+ });
585
+ await writeMissionManifest(missionsDir, "test-r1138-01", "closed");
586
+
587
+ await runMissionArchive(makeInput(), makeContext(tmpDir));
588
+
589
+ const checkIgnoreCall = execSyncMock.mock.calls.find(
590
+ (c) => typeof c[0] === "string" && c[0].startsWith("git check-ignore"),
591
+ );
592
+ expect(checkIgnoreCall).toBeDefined();
593
+
594
+ const addArchiveCall = execSyncMock.mock.calls.find(
595
+ (c) => typeof c[0] === "string" && c[0].startsWith("git add -A"),
596
+ );
597
+ expect(addArchiveCall).toBeUndefined();
598
+
599
+ // Lockfile itself is still staged + committed
600
+ const lockfileAdd = execSyncMock.mock.calls.find(
601
+ (c) => typeof c[0] === "string" && c[0] === "git add pnpm-lock.yaml",
602
+ );
603
+ expect(lockfileAdd).toBeDefined();
604
+ });
605
+
606
+ test("RFC-1138 AC-2: git add -A still runs when archive target is NOT gitignored", async () => {
607
+ execSyncMock.mockReset();
608
+ execSyncMock.mockImplementation((cmd: string) => {
609
+ if (cmd.startsWith("pnpm install")) return "";
610
+ if (cmd.startsWith("git status --porcelain pnpm-lock.yaml")) return "M pnpm-lock.yaml";
611
+ // git check-ignore exits 1 → not ignored → execSync throws
612
+ if (cmd.startsWith("git check-ignore")) throw new Error("exit 1");
613
+ return "";
614
+ });
615
+ await writeMissionManifest(missionsDir, "test-r1138-02", "closed");
616
+
617
+ await runMissionArchive(makeInput(), makeContext(tmpDir));
618
+
619
+ const addArchiveCall = execSyncMock.mock.calls.find(
620
+ (c) => typeof c[0] === "string" && c[0].startsWith("git add -A"),
621
+ );
622
+ expect(addArchiveCall).toBeDefined();
623
+ });
575
624
  });
@@ -13,7 +13,6 @@ archive subdirectories back to missions/.
13
13
  </non-goals>
14
14
  </MODULE_CONTRACT>
15
15
  <CHANGE_SUMMARY>
16
- <item>RFC-0733: add pinned-files pre-check — skip pinned mission directories with warning instead of moving them.</item>
17
16
  <item>RFC-0804: auto-refresh pnpm-lock.yaml after directory moves.</item>
18
17
  <item>RFC-0982: fallback state detection for orphaned workpiece dirs (no mission.yaml) via .closed marker and cache-only heuristic; --clean-orphans flag; improved skip reasons.</item>
19
18
  <item>RFC-1097: step 6 — compass.migrate codemod run
@@ -22,7 +21,8 @@ Mechanical v1 to v2 header migration across the workspace: 942 files rewritten
22
21
  <item>RFC-1097: sweep — packages/forge + services clean
23
22
 
24
23
  Sweep batch 2: real KEY_DECISIONS on 10 files, expanded purposes (CONTRACT-02/PURPOSE-02), headers on mission/index + gen-upstreams, sanitizeItemText in summary.record (literal Compass tags corrupted history), excludedPaths for wrangler types, test-fixtures testPattern. forge+services now 0 diagnostics under --mode error.</item>
25
- <history>RFC-0573, RFC-0801</history>
24
+ <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>
25
+ <history>RFC-0573, RFC-0733, RFC-0801</history>
26
26
  </CHANGE_SUMMARY>
27
27
  */
28
28
 
@@ -520,10 +520,25 @@ export async function runMissionArchive(
520
520
  stdio: ["pipe", "pipe", "pipe"],
521
521
  });
522
522
  for (const m of moved) {
523
- execSync(`git add -A ${JSON.stringify(m.to)}`, {
524
- cwd: workspaceRoot,
525
- stdio: ["pipe", "pipe", "pipe"],
526
- });
523
+ // RFC-1138: missions/ is gitignored — `git add` on the archive path is
524
+ // structurally guaranteed to fail. Skip staging when the target is
525
+ // ignored (git check-ignore exits 0 = ignored, 1 = not ignored).
526
+ let targetIgnored = false;
527
+ try {
528
+ execSync(`git check-ignore -q ${JSON.stringify(m.to)}`, {
529
+ cwd: workspaceRoot,
530
+ stdio: ["pipe", "pipe", "pipe"],
531
+ });
532
+ targetIgnored = true;
533
+ } catch {
534
+ targetIgnored = false;
535
+ }
536
+ if (!targetIgnored) {
537
+ execSync(`git add -A ${JSON.stringify(m.to)}`, {
538
+ cwd: workspaceRoot,
539
+ stdio: ["pipe", "pipe", "pipe"],
540
+ });
541
+ }
527
542
  if (existsSync(m.from)) continue;
528
543
  execSync(`git rm -r --cached --ignore-unmatch ${JSON.stringify(m.from)}`, {
529
544
  cwd: workspaceRoot,
@@ -38,7 +38,7 @@ export function leasePath(workspaceRoot: string, program: string, packetId: stri
38
38
  * Generate a random opaque lease token (32 bytes hex).
39
39
  */
40
40
  export function generateLeaseToken(): string {
41
- return randomBytes(32).toString("hex");
41
+ return Array.from(randomBytes(32), (b) => b.toString(16).padStart(2, "0")).join("");
42
42
  }
43
43
 
44
44
  /**
@@ -31,7 +31,7 @@ export async function createForgeQueueModule(): Promise<ForgeModule> {
31
31
  description:
32
32
  "Validate a queue manifest (docs/queues/*.yaml) and report derived " +
33
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).",
34
+ "the pipeline orchestrator runs this before starting a queued batch (RFC-1140). Required flags: `--file`.",
35
35
  scope: "workspace",
36
36
  contract: "queue",
37
37
  rules: ["QUEUE-01", "QUEUE-02", "QUEUE-03", "QUEUE-04", "QUEUE-05", "QUEUE-06"],
@@ -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
  ],
@@ -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" },
@@ -0,0 +1,118 @@
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
+ import { runRfcCreate } from "./handlers/list-create.ts";
6
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../../src/types.ts";
7
+
8
+ /*
9
+ <MODULE_CONTRACT>
10
+ <purpose>
11
+ RFC-1138 AC-5: concurrent rfc.create invocations must produce distinct
12
+ RFC ids — the wx (exclusive-create) write + bounded retry on EEXIST
13
+ replaces the old scan-then-write race.
14
+ </purpose>
15
+ </MODULE_CONTRACT>
16
+ */
17
+
18
+ const RFC_TEMPLATE = `---
19
+ id: RFC-0000
20
+ title: "TEMPLATE"
21
+ kind: command
22
+ scope: workspace
23
+ createdAt: YYYY-MM-DD
24
+ updatedAt: YYYY-MM-DD
25
+ satisfies: []
26
+ ---
27
+
28
+ # RFC-0000: TEMPLATE
29
+ `;
30
+
31
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
32
+ return {
33
+ workspaceRoot,
34
+ logger: {
35
+ info: () => {},
36
+ success: () => {},
37
+ warn: () => {},
38
+ error: () => {},
39
+ debug: () => {},
40
+ },
41
+ outputFormat: "json",
42
+ dryRun: false,
43
+ } as unknown as ForgeRuntimeContext;
44
+ }
45
+
46
+ function makeInput(title: string): ForgeCommandInput {
47
+ return {
48
+ commandName: "rfc.create",
49
+ // kind "command" avoids the post-cutoff --satisfies requirement
50
+ // (only architecture|contract require DNA ids).
51
+ flags: { title, kind: "command", scope: "workspace" },
52
+ } as unknown as ForgeCommandInput;
53
+ }
54
+
55
+ describe("rfc.create — RFC-1138 concurrent id allocation", () => {
56
+ let tmpDir: string;
57
+ let rfcDir: string;
58
+
59
+ beforeEach(async () => {
60
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-create-"));
61
+ rfcDir = path.join(tmpDir, "docs", "rfcs");
62
+ await fs.mkdir(rfcDir, { recursive: true });
63
+ await fs.writeFile(path.join(rfcDir, "rfc-0000-template.md"), RFC_TEMPLATE, "utf-8");
64
+ });
65
+
66
+ afterEach(async () => {
67
+ await fs.rm(tmpDir, { recursive: true, force: true });
68
+ });
69
+
70
+ test("AC-5: five concurrent creates produce five distinct ids", async () => {
71
+ const results = await Promise.all(
72
+ [1, 2, 3, 4, 5].map((n) =>
73
+ runRfcCreate(makeInput(`Concurrent RFC ${n}`), makeContext(tmpDir)),
74
+ ),
75
+ );
76
+
77
+ const ids = results.map((r) => (r.data as { id: string }).id);
78
+ expect(new Set(ids).size).toBe(5);
79
+
80
+ const files = (await fs.readdir(rfcDir)).filter((f) => f.startsWith("rfc-") && f !== "rfc-0000-template.md");
81
+ expect(files).toHaveLength(5);
82
+ });
83
+
84
+ test("AC-5: sequential creates still allocate monotonically", async () => {
85
+ const first = await runRfcCreate(makeInput("First"), makeContext(tmpDir));
86
+ const second = await runRfcCreate(makeInput("Second"), makeContext(tmpDir));
87
+
88
+ const firstId = (first.data as { id: string }).id;
89
+ const secondId = (second.data as { id: string }).id;
90
+ expect(firstId).not.toBe(secondId);
91
+ expect(secondId > firstId).toBe(true);
92
+ });
93
+
94
+ test("AC-5: pre-existing highest id is respected after a collision", async () => {
95
+ // Seed rfc-0001 so the first create takes 0002; a file racing in at 0002
96
+ // forces the retry path to land on 0003.
97
+ await fs.writeFile(path.join(rfcDir, "rfc-0001-seeded.md"), "---\nid: RFC-0001\n---\n", "utf-8");
98
+ const winner = runRfcCreate(makeInput("Winner"), makeContext(tmpDir));
99
+ // Interleave a manual claim at the id the scan will compute.
100
+ const claim = (async () => {
101
+ // Small delay so the create's scan completes first, then we steal 0002.
102
+ await new Promise((r) => setTimeout(r, 5));
103
+ await fs
104
+ .writeFile(path.join(rfcDir, "rfc-0002-interloper.md"), "---\nid: RFC-0002\n---\n", {
105
+ flag: "wx",
106
+ })
107
+ .catch(() => {});
108
+ })();
109
+ const [result] = await Promise.all([winner, claim]);
110
+
111
+ const id = (result.data as { id: string }).id;
112
+ // Either the create won 0002 (interloper lost the wx race) or it retried to 0003.
113
+ expect(["RFC-0002", "RFC-0003"]).toContain(id);
114
+ const files = (await fs.readdir(rfcDir)).filter((f) => f.startsWith("rfc-") && f !== "rfc-0000-template.md");
115
+ // Seeded + interloper (if it won) + create output — never a silent overwrite.
116
+ expect(files.length).toBeGreaterThanOrEqual(2);
117
+ });
118
+ });