@warpgogol/forge 5.3.4 → 6.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.
Files changed (253) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/bin/cli.ts +7 -2
  3. package/dist/bin/cli.js +7 -2
  4. package/dist/bin/cli.js.map +1 -1
  5. package/dist/os/adr/adr.module.d.ts.map +1 -1
  6. package/dist/os/adr/adr.module.js +2 -0
  7. package/dist/os/adr/adr.module.js.map +1 -1
  8. package/dist/os/audit/audit.module.d.ts.map +1 -1
  9. package/dist/os/audit/audit.module.js +2 -0
  10. package/dist/os/audit/audit.module.js.map +1 -1
  11. package/dist/os/compass/compass.module.d.ts.map +1 -1
  12. package/dist/os/compass/compass.module.js +47 -15
  13. package/dist/os/compass/compass.module.js.map +1 -1
  14. package/dist/os/compass/handlers/compass-audit-handler.d.ts +2 -0
  15. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  16. package/dist/os/compass/handlers/compass-audit-handler.js +40 -10
  17. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  18. package/dist/os/compass/handlers/compass-docs-validate.d.ts +13 -0
  19. package/dist/os/compass/handlers/compass-docs-validate.d.ts.map +1 -0
  20. package/dist/os/compass/handlers/compass-docs-validate.js +328 -0
  21. package/dist/os/compass/handlers/compass-docs-validate.js.map +1 -0
  22. package/dist/os/compass/handlers/summary-record.d.ts +11 -2
  23. package/dist/os/compass/handlers/summary-record.d.ts.map +1 -1
  24. package/dist/os/compass/handlers/summary-record.js +95 -15
  25. package/dist/os/compass/handlers/summary-record.js.map +1 -1
  26. package/dist/os/core/core.module.d.ts.map +1 -1
  27. package/dist/os/core/core.module.js +124 -73
  28. package/dist/os/core/core.module.js.map +1 -1
  29. package/dist/os/core/handlers/file-size-lint.d.ts.map +1 -1
  30. package/dist/os/core/handlers/file-size-lint.js +2 -0
  31. package/dist/os/core/handlers/file-size-lint.js.map +1 -1
  32. package/dist/os/exploration/exploration.module.d.ts.map +1 -1
  33. package/dist/os/exploration/exploration.module.js +3 -0
  34. package/dist/os/exploration/exploration.module.js.map +1 -1
  35. package/dist/os/naming/naming-convention.d.ts.map +1 -1
  36. package/dist/os/naming/naming-convention.js +11 -1
  37. package/dist/os/naming/naming-convention.js.map +1 -1
  38. package/dist/os/naming/naming.module.d.ts.map +1 -1
  39. package/dist/os/naming/naming.module.js +9 -2
  40. package/dist/os/naming/naming.module.js.map +1 -1
  41. package/dist/os/notes/notes.module.d.ts.map +1 -1
  42. package/dist/os/notes/notes.module.js +17 -6
  43. package/dist/os/notes/notes.module.js.map +1 -1
  44. package/dist/os/plan/plan.module.d.ts.map +1 -1
  45. package/dist/os/plan/plan.module.js +2 -0
  46. package/dist/os/plan/plan.module.js.map +1 -1
  47. package/dist/os/plugin/plugin.module.d.ts.map +1 -1
  48. package/dist/os/plugin/plugin.module.js +9 -2
  49. package/dist/os/plugin/plugin.module.js.map +1 -1
  50. package/dist/os/program/program.module.d.ts.map +1 -1
  51. package/dist/os/program/program.module.js +19 -4
  52. package/dist/os/program/program.module.js.map +1 -1
  53. package/dist/os/queue/handlers/queue-validate.d.ts.map +1 -1
  54. package/dist/os/queue/handlers/queue-validate.js +154 -7
  55. package/dist/os/queue/handlers/queue-validate.js.map +1 -1
  56. package/dist/os/queue/manifest.d.ts +17 -1
  57. package/dist/os/queue/manifest.d.ts.map +1 -1
  58. package/dist/os/queue/manifest.js +127 -6
  59. package/dist/os/queue/manifest.js.map +1 -1
  60. package/dist/os/queue/queue.module.d.ts.map +1 -1
  61. package/dist/os/queue/queue.module.js +32 -4
  62. package/dist/os/queue/queue.module.js.map +1 -1
  63. package/dist/os/queue/types.d.ts +100 -1
  64. package/dist/os/queue/types.d.ts.map +1 -1
  65. package/dist/os/queue/types.js +44 -0
  66. package/dist/os/queue/types.js.map +1 -1
  67. package/dist/os/rfc/handlers/implement-stamp.d.ts.map +1 -1
  68. package/dist/os/rfc/handlers/implement-stamp.js +30 -8
  69. package/dist/os/rfc/handlers/implement-stamp.js.map +1 -1
  70. package/dist/os/rfc/rfc.module.d.ts.map +1 -1
  71. package/dist/os/rfc/rfc.module.js +25 -6
  72. package/dist/os/rfc/rfc.module.js.map +1 -1
  73. package/dist/os/rfc/types.d.ts +12 -2
  74. package/dist/os/rfc/types.d.ts.map +1 -1
  75. package/dist/os/rfc/types.js +2 -2
  76. package/dist/os/rfc/types.js.map +1 -1
  77. package/dist/os/rfc/verification-evidence.d.ts +21 -0
  78. package/dist/os/rfc/verification-evidence.d.ts.map +1 -1
  79. package/dist/os/rfc/verification-evidence.js +134 -23
  80. package/dist/os/rfc/verification-evidence.js.map +1 -1
  81. package/dist/os/rfc/verification-refresh.d.ts.map +1 -1
  82. package/dist/os/rfc/verification-refresh.js +66 -37
  83. package/dist/os/rfc/verification-refresh.js.map +1 -1
  84. package/dist/os/session/handlers/save.d.ts.map +1 -1
  85. package/dist/os/session/handlers/save.js +42 -5
  86. package/dist/os/session/handlers/save.js.map +1 -1
  87. package/dist/os/session/session.module.d.ts.map +1 -1
  88. package/dist/os/session/session.module.js +5 -0
  89. package/dist/os/session/session.module.js.map +1 -1
  90. package/dist/os/spec/live-spec-merge.d.ts.map +1 -1
  91. package/dist/os/spec/live-spec-merge.js +55 -134
  92. package/dist/os/spec/live-spec-merge.js.map +1 -1
  93. package/dist/os/spec/live-spec-rebuild.d.ts +32 -0
  94. package/dist/os/spec/live-spec-rebuild.d.ts.map +1 -0
  95. package/dist/os/spec/live-spec-rebuild.js +272 -0
  96. package/dist/os/spec/live-spec-rebuild.js.map +1 -0
  97. package/dist/os/spec/live-spec-shared.d.ts +62 -0
  98. package/dist/os/spec/live-spec-shared.d.ts.map +1 -0
  99. package/dist/os/spec/live-spec-shared.js +315 -0
  100. package/dist/os/spec/live-spec-shared.js.map +1 -0
  101. package/dist/os/spec/live-spec-types.d.ts +17 -1
  102. package/dist/os/spec/live-spec-types.d.ts.map +1 -1
  103. package/dist/os/spec/live-spec-types.js +3 -0
  104. package/dist/os/spec/live-spec-types.js.map +1 -1
  105. package/dist/os/spec/live-spec-validate.d.ts.map +1 -1
  106. package/dist/os/spec/live-spec-validate.js +105 -2
  107. package/dist/os/spec/live-spec-validate.js.map +1 -1
  108. package/dist/os/spec/spec-materialize.d.ts +2 -0
  109. package/dist/os/spec/spec-materialize.d.ts.map +1 -1
  110. package/dist/os/spec/spec-materialize.js +45 -5
  111. package/dist/os/spec/spec-materialize.js.map +1 -1
  112. package/dist/os/spec/spec-schema.d.ts +19 -0
  113. package/dist/os/spec/spec-schema.d.ts.map +1 -1
  114. package/dist/os/spec/spec-schema.js +30 -0
  115. package/dist/os/spec/spec-schema.js.map +1 -1
  116. package/dist/os/spec/spec-status.d.ts +4 -0
  117. package/dist/os/spec/spec-status.d.ts.map +1 -1
  118. package/dist/os/spec/spec-status.js +19 -1
  119. package/dist/os/spec/spec-status.js.map +1 -1
  120. package/dist/os/spec/spec-validate.d.ts.map +1 -1
  121. package/dist/os/spec/spec-validate.js +55 -8
  122. package/dist/os/spec/spec-validate.js.map +1 -1
  123. package/dist/os/spec/spec.module.d.ts.map +1 -1
  124. package/dist/os/spec/spec.module.js +84 -8
  125. package/dist/os/spec/spec.module.js.map +1 -1
  126. package/dist/os/werkstatt/handlers/werkstatt-operation-validate.d.ts.map +1 -1
  127. package/dist/os/werkstatt/handlers/werkstatt-operation-validate.js +10 -4
  128. package/dist/os/werkstatt/handlers/werkstatt-operation-validate.js.map +1 -1
  129. package/dist/os/werkstatt/werkstatt.module.d.ts.map +1 -1
  130. package/dist/os/werkstatt/werkstatt.module.js +13 -4
  131. package/dist/os/werkstatt/werkstatt.module.js.map +1 -1
  132. package/dist/os/workflow/workflow.module.d.ts.map +1 -1
  133. package/dist/os/workflow/workflow.module.js +13 -5
  134. package/dist/os/workflow/workflow.module.js.map +1 -1
  135. package/dist/src/cli-flags.d.ts.map +1 -1
  136. package/dist/src/cli-flags.js +25 -5
  137. package/dist/src/cli-flags.js.map +1 -1
  138. package/dist/src/config/forge-config.d.ts +3 -0
  139. package/dist/src/config/forge-config.d.ts.map +1 -1
  140. package/dist/src/config/forge-config.js +2 -0
  141. package/dist/src/config/forge-config.js.map +1 -1
  142. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  143. package/dist/src/onboarding/doctor.js +2 -3
  144. package/dist/src/onboarding/doctor.js.map +1 -1
  145. package/dist/src/onboarding/init.d.ts.map +1 -1
  146. package/dist/src/onboarding/init.js +8 -3
  147. package/dist/src/onboarding/init.js.map +1 -1
  148. package/dist/src/pipeline-status.d.ts +14 -1
  149. package/dist/src/pipeline-status.d.ts.map +1 -1
  150. package/dist/src/pipeline-status.js +28 -8
  151. package/dist/src/pipeline-status.js.map +1 -1
  152. package/dist/src/types.d.ts +7 -1
  153. package/dist/src/types.d.ts.map +1 -1
  154. package/dist/src/types.js +6 -0
  155. package/dist/src/types.js.map +1 -1
  156. package/dist/src/utils/generated-marker.d.ts +8 -0
  157. package/dist/src/utils/generated-marker.d.ts.map +1 -1
  158. package/dist/src/utils/generated-marker.js +25 -0
  159. package/dist/src/utils/generated-marker.js.map +1 -1
  160. package/dist/src/validators/skill-validate.d.ts.map +1 -1
  161. package/dist/src/validators/skill-validate.js +2 -4
  162. package/dist/src/validators/skill-validate.js.map +1 -1
  163. package/os/adr/adr.module.ts +2 -0
  164. package/os/audit/audit.module.ts +2 -0
  165. package/os/compass/compass.module.ts +49 -15
  166. package/os/compass/handlers/compass-audit-handler.ts +51 -10
  167. package/os/compass/handlers/compass-docs-validate.ts +361 -0
  168. package/os/compass/handlers/summary-record.ts +106 -15
  169. package/os/compass/handlers/tests/compass-audit-record.test.ts +2 -1
  170. package/os/compass/handlers/tests/compass-audit-validate.test.ts +92 -3
  171. package/os/compass/handlers/tests/compass-docs-validate.test.ts +203 -0
  172. package/os/compass/handlers/tests/compass-ledger-scope.test.ts +2 -1
  173. package/os/compass/handlers/tests/compass-policy.test.ts +7 -1
  174. package/os/compass/handlers/tests/summary-record.test.ts +127 -0
  175. package/os/core/core.module.ts +147 -75
  176. package/os/core/docs-archive.test.ts +136 -0
  177. package/os/core/handlers/file-size-lint.ts +2 -0
  178. package/os/exploration/exploration.module.ts +3 -0
  179. package/os/naming/naming-convention.ts +11 -1
  180. package/os/naming/naming.module.ts +9 -2
  181. package/os/notes/notes.module.ts +17 -6
  182. package/os/plan/plan-0000-template.md +150 -0
  183. package/os/plan/plan.module.ts +2 -0
  184. package/os/plugin/plugin.module.ts +9 -2
  185. package/os/program/program.module.ts +19 -4
  186. package/os/queue/decision-ledger.test.ts +163 -0
  187. package/os/queue/handlers/queue-validate.ts +190 -8
  188. package/os/queue/manifest.ts +201 -7
  189. package/os/queue/queue-validate.test.ts +303 -0
  190. package/os/queue/queue.module.ts +32 -4
  191. package/os/queue/types.ts +56 -1
  192. package/os/rfc/handlers/implement-stamp.ts +37 -8
  193. package/os/rfc/rfc-read-only-no-side-effects.test.ts +1 -0
  194. package/os/rfc/rfc.module.ts +27 -6
  195. package/os/rfc/types.ts +18 -3
  196. package/os/rfc/verification-emit.test.ts +305 -0
  197. package/os/rfc/verification-evidence.ts +149 -24
  198. package/os/rfc/verification-refresh.test.ts +222 -1
  199. package/os/rfc/verification-refresh.ts +74 -38
  200. package/os/session/handlers/save.ts +37 -5
  201. package/os/session/session.module.ts +5 -0
  202. package/os/spec/live-spec-idempotency.pbt.test.ts +222 -0
  203. package/os/spec/live-spec-list-show-validate.test.ts +10 -6
  204. package/os/spec/live-spec-merge.test.ts +187 -7
  205. package/os/spec/live-spec-merge.ts +71 -155
  206. package/os/spec/live-spec-rebuild.test.ts +317 -0
  207. package/os/spec/live-spec-rebuild.ts +356 -0
  208. package/os/spec/live-spec-shared.ts +378 -0
  209. package/os/spec/live-spec-types.ts +21 -1
  210. package/os/spec/live-spec-validate.test.ts +317 -0
  211. package/os/spec/live-spec-validate.ts +106 -2
  212. package/os/spec/spec-materialize.test.ts +192 -0
  213. package/os/spec/spec-materialize.ts +48 -4
  214. package/os/spec/spec-schema.ts +49 -0
  215. package/os/spec/spec-status.ts +24 -0
  216. package/os/spec/spec-validate.test.ts +66 -0
  217. package/os/spec/spec-validate.ts +62 -8
  218. package/os/spec/spec.module.ts +85 -8
  219. package/os/werkstatt/handlers/werkstatt-operation-validate.ts +10 -4
  220. package/os/werkstatt/werkstatt.module.ts +13 -4
  221. package/os/workflow/workflow.module.ts +13 -5
  222. package/package.json +1 -1
  223. package/skills/_shared/fo-pipeline-conventions.md +126 -1
  224. package/skills/fo/fo-doc-audit/SKILL.md +4 -4
  225. package/skills/fo/fo-explore/SKILL.md +2 -1
  226. package/skills/fo/fo-fix/SKILL.md +11 -3
  227. package/skills/fo/fo-handoff/SKILL.md +4 -6
  228. package/skills/fo/fo-idea-create-rfc/SKILL.md +2 -1
  229. package/skills/fo/fo-idea-enhance/SKILL.md +23 -4
  230. package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +86 -22
  231. package/skills/fo/fo-idea-implement/SKILL.md +53 -10
  232. package/skills/fo/fo-idea-plan/SKILL.md +28 -6
  233. package/skills/fo/fo-review/SKILL.md +69 -8
  234. package/skills/fo/fo-session-retro/SKILL.md +3 -2
  235. package/skills/fo/fo-step-commit/SKILL.md +2 -0
  236. package/skills/shared/grilling/SKILL.md +12 -0
  237. package/skills/shared/writing-great-skills/GLOSSARY.md +2 -0
  238. package/skills/shared/writing-great-skills/SKILL.md +2 -1
  239. package/src/cli-flags.ts +23 -5
  240. package/src/config/forge-config.ts +4 -0
  241. package/src/onboarding/doctor.ts +2 -3
  242. package/src/onboarding/init.ts +8 -3
  243. package/src/pipeline-status.ts +32 -8
  244. package/src/tests/cli-flags.test.ts +20 -0
  245. package/src/tests/cli-output.test.ts +13 -7
  246. package/src/tests/fixtures/agents-generate-business-before.txt +7 -7
  247. package/src/tests/implement-stamp.test.ts +38 -0
  248. package/src/tests/session-handlers.test.ts +33 -0
  249. package/src/types.ts +13 -1
  250. package/src/utils/generated-marker.ts +28 -0
  251. package/src/validators/skill-validate.ts +2 -4
  252. package/skills/fo/fo-idea-implement/ADR-FLOW.md +0 -179
  253. package/skills/fo/fo-review/AXES.md +0 -70
@@ -105,15 +105,44 @@ async function setupWorkspace(opts: {
105
105
  };
106
106
  }
107
107
 
108
- function makeContext(workspaceRoot: string) {
108
+ function makeContext(workspaceRoot: string, overrides: Record<string, unknown> = {}) {
109
109
  return {
110
110
  workspaceRoot,
111
111
  logger: { info: () => {} },
112
112
  outputFormat: "json" as const,
113
113
  commandRegistry: undefined,
114
+ dryRun: false,
115
+ ...overrides,
114
116
  };
115
117
  }
116
118
 
119
+ /**
120
+ * RFC-1237: a minimal recording-style WorkspaceIO — writes land in `writes`,
121
+ * never on disk. Lets tests assert which writes the handler attempted through
122
+ * the port without touching fs.
123
+ */
124
+ function makeSpyIo() {
125
+ const writes: string[] = [];
126
+ const io = {
127
+ readFile: (p: string) => fs.readFile(p, "utf8"),
128
+ writeFile: async (p: string) => {
129
+ writes.push(p);
130
+ },
131
+ mkdir: async (p: string) => {
132
+ writes.push(`mkdir:${p}`);
133
+ },
134
+ exists: async (p: string) => {
135
+ try {
136
+ await fs.stat(p);
137
+ return true;
138
+ } catch {
139
+ return false;
140
+ }
141
+ },
142
+ };
143
+ return { io, writes };
144
+ }
145
+
117
146
  beforeEach(() => {
118
147
  mockRunProbe.mockReset();
119
148
  mockCaptureGitContext.mockReset();
@@ -124,6 +153,53 @@ afterEach(() => {
124
153
  vi.restoreAllMocks();
125
154
  });
126
155
 
156
+ describe("rfc.verification.refresh — sweep delta report", () => {
157
+ test("reports recovered (fail→pass) and regressed (pass→fail) transitions", async () => {
158
+ const { workspaceRoot, cleanup } = await setupWorkspace({
159
+ rfcs: [
160
+ { id: "RFC-0100", status: "implemented" },
161
+ { id: "RFC-0101", status: "implemented" },
162
+ { id: "RFC-0102", status: "implemented" },
163
+ ],
164
+ envelopes: [
165
+ { id: "RFC-0100", overall: "fail" },
166
+ { id: "RFC-0101", overall: "pass" },
167
+ { id: "RFC-0102", overall: "fail" },
168
+ ],
169
+ });
170
+
171
+ try {
172
+ // RFC-0100: fail → pass (recovered); RFC-0101: pass → fail (regressed);
173
+ // RFC-0102: fail → fail (still failing — not a regression).
174
+ mockRunProbe
175
+ .mockResolvedValueOnce({ probe: PROBE, ok: true, detail: "exists" })
176
+ .mockResolvedValueOnce({ probe: PROBE, ok: false, detail: "missing" })
177
+ .mockResolvedValueOnce({ probe: PROBE, ok: false, detail: "missing" });
178
+
179
+ const result = await runRfcVerificationRefresh(
180
+ { flags: { all: true } } as unknown as ForgeCommandInput,
181
+ makeContext(workspaceRoot) as unknown as ForgeRuntimeContext,
182
+ );
183
+
184
+ expect(result.data!.delta).toEqual({
185
+ recovered: ["RFC-0100"],
186
+ regressed: ["RFC-0101"],
187
+ });
188
+ const entries = Object.fromEntries(result.data!.refreshed.map((r) => [r.rfcId, r]));
189
+ expect(entries["RFC-0100"]?.previousOverall).toBe("fail");
190
+ expect(entries["RFC-0101"]?.previousOverall).toBe("pass");
191
+ expect(entries["RFC-0102"]?.previousOverall).toBe("fail");
192
+ const r101Diag = result.data!.diagnostics.find((d) => d.file?.includes("rfc-0101"));
193
+ expect(r101Diag?.message).toContain("regressed: was pass");
194
+ const r102Diag = result.data!.diagnostics.find((d) => d.file?.includes("rfc-0102"));
195
+ expect(r102Diag?.message).not.toContain("regressed");
196
+ expect(result.summary).toContain("1 regressed, 1 recovered");
197
+ } finally {
198
+ await cleanup();
199
+ }
200
+ });
201
+ });
202
+
127
203
  describe("rfc.verification.refresh — AC-1: single-RFC refresh", () => {
128
204
  test("updates lastRefreshedAt, replaces probes[], preserves emittedAt", async () => {
129
205
  const { workspaceRoot, cleanup } = await setupWorkspace({
@@ -260,6 +336,151 @@ describe("rfc.verification.refresh — AC-4: --dry-run does not write files", ()
260
336
  });
261
337
  });
262
338
 
339
+ describe("rfc.verification.refresh — RFC-1237 AC-1: context.dryRun (the real CLI path)", () => {
340
+ test("werkstatt run --dry-run consumes --dry-run into context.dryRun — zero writes", async () => {
341
+ const { workspaceRoot, cleanup } = await setupWorkspace({
342
+ rfcs: [{ id: "RFC-0100", status: "implemented" }],
343
+ envelopes: [{ id: "RFC-0100", emittedAt: "2026-01-01T00:00:00.000Z" }],
344
+ });
345
+
346
+ try {
347
+ mockRunProbe.mockResolvedValue({ probe: PROBE, ok: true, detail: "exists" });
348
+ const envelopePath = path.join(
349
+ workspaceRoot,
350
+ "docs",
351
+ "rfcs",
352
+ "verification",
353
+ "rfc-0100.generated.yaml",
354
+ );
355
+ const before = await fs.readFile(envelopePath, "utf-8");
356
+
357
+ // The real CLI path: consumeCommonFlags strips --dry-run from argv into
358
+ // context.dryRun — input.flags carries no "dry-run" key at all.
359
+ const result = await runRfcVerificationRefresh(
360
+ { flags: { id: "RFC-0100" } } as unknown as ForgeCommandInput,
361
+ makeContext(workspaceRoot, { dryRun: true }) as unknown as ForgeRuntimeContext,
362
+ );
363
+
364
+ expect(result.data!.refreshed).toHaveLength(1);
365
+ expect(await fs.readFile(envelopePath, "utf-8")).toBe(before);
366
+ } finally {
367
+ await cleanup();
368
+ }
369
+ });
370
+
371
+ test("recording-IO port sees zero write intents under context.dryRun", async () => {
372
+ const { workspaceRoot, cleanup } = await setupWorkspace({
373
+ rfcs: [{ id: "RFC-0100", status: "implemented" }],
374
+ envelopes: [{ id: "RFC-0100" }],
375
+ });
376
+
377
+ try {
378
+ mockRunProbe.mockResolvedValue({ probe: PROBE, ok: true, detail: "exists" });
379
+ const { io, writes } = makeSpyIo();
380
+
381
+ await runRfcVerificationRefresh(
382
+ { flags: { id: "RFC-0100" } } as unknown as ForgeCommandInput,
383
+ makeContext(workspaceRoot, { dryRun: true, io }) as unknown as ForgeRuntimeContext,
384
+ );
385
+
386
+ expect(
387
+ writes,
388
+ "dry-run refresh must issue zero io writes — check the dryRun guard in runRfcVerificationRefresh",
389
+ ).toEqual([]);
390
+ } finally {
391
+ await cleanup();
392
+ }
393
+ });
394
+ });
395
+
396
+ describe("rfc.verification.refresh — RFC-1237 AC-2: emit-canonical header", () => {
397
+ test("writes the emit-owned structured marker header, not a refresh-owned one", async () => {
398
+ const { workspaceRoot, cleanup } = await setupWorkspace({
399
+ rfcs: [{ id: "RFC-0100", status: "implemented" }],
400
+ envelopes: [{ id: "RFC-0100" }],
401
+ });
402
+
403
+ try {
404
+ mockRunProbe.mockResolvedValue({ probe: PROBE, ok: true, detail: "exists" });
405
+
406
+ await runRfcVerificationRefresh(
407
+ { flags: { id: "RFC-0100" } } as unknown as ForgeCommandInput,
408
+ makeContext(workspaceRoot) as unknown as ForgeRuntimeContext,
409
+ );
410
+
411
+ const raw = await fs.readFile(
412
+ path.join(workspaceRoot, "docs", "rfcs", "verification", "rfc-0100.generated.yaml"),
413
+ "utf-8",
414
+ );
415
+ // Structured marker keys — identical-by-construction to what emit writes.
416
+ expect(raw).toMatch(/^generatedMarker: GENERATED\./);
417
+ expect(raw).toContain("ownerCommand: rfc.verification.emit");
418
+ expect(raw).toContain("regenerateCommand: pnpm exec werkstatt run rfc.verification.emit");
419
+ // Refresh never stamps itself as owner — it is a conditional secondary writer.
420
+ expect(raw).not.toContain("rfc.verification.refresh");
421
+ } finally {
422
+ await cleanup();
423
+ }
424
+ });
425
+
426
+ test("legacy '#'-comment input header normalizes to the canonical key format", async () => {
427
+ const { workspaceRoot, cleanup } = await setupWorkspace({
428
+ rfcs: [{ id: "RFC-0100", status: "implemented" }],
429
+ envelopes: [{ id: "RFC-0100" }],
430
+ });
431
+
432
+ try {
433
+ mockRunProbe.mockResolvedValue({ probe: PROBE, ok: true, detail: "exists" });
434
+ await runRfcVerificationRefresh(
435
+ { flags: { id: "RFC-0100" } } as unknown as ForgeCommandInput,
436
+ makeContext(workspaceRoot) as unknown as ForgeRuntimeContext,
437
+ );
438
+
439
+ const raw = await fs.readFile(
440
+ path.join(workspaceRoot, "docs", "rfcs", "verification", "rfc-0100.generated.yaml"),
441
+ "utf-8",
442
+ );
443
+ // setupWorkspace wrote the legacy `# Generated by ...` comment header —
444
+ // refresh output must carry no comment lines at all.
445
+ expect(raw.split("\n").every((line) => !line.startsWith("#"))).toBe(true);
446
+ expect(raw).toMatch(/^generatedMarker: GENERATED\./);
447
+ } finally {
448
+ await cleanup();
449
+ }
450
+ });
451
+
452
+ test("writes route through the resolved io port, not ambient fs", async () => {
453
+ const { workspaceRoot, cleanup } = await setupWorkspace({
454
+ rfcs: [{ id: "RFC-0100", status: "implemented" }],
455
+ envelopes: [{ id: "RFC-0100", emittedAt: "2026-01-01T00:00:00.000Z" }],
456
+ });
457
+
458
+ try {
459
+ mockRunProbe.mockResolvedValue({ probe: PROBE, ok: true, detail: "exists" });
460
+ const { io, writes } = makeSpyIo();
461
+ const envelopePath = path.join(
462
+ workspaceRoot,
463
+ "docs",
464
+ "rfcs",
465
+ "verification",
466
+ "rfc-0100.generated.yaml",
467
+ );
468
+
469
+ await runRfcVerificationRefresh(
470
+ { flags: { id: "RFC-0100" } } as unknown as ForgeCommandInput,
471
+ makeContext(workspaceRoot, { io }) as unknown as ForgeRuntimeContext,
472
+ );
473
+
474
+ expect(writes).toEqual([envelopePath]);
475
+ // The spy io never touches disk — the on-disk file keeps its old content.
476
+ const raw = await fs.readFile(envelopePath, "utf-8");
477
+ expect(raw).not.toContain("lastRefreshedAt");
478
+ } finally {
479
+ await cleanup();
480
+ }
481
+ });
482
+ });
483
+
263
484
  describe("rfc.verification.refresh — AC-5: no evidence envelope", () => {
264
485
  test("skips RFC with reason 'no evidence envelope' and exits non-zero", async () => {
265
486
  const { workspaceRoot, cleanup } = await setupWorkspace({
@@ -14,6 +14,11 @@ lastRefreshedAt, replaces probes[] with fresh results. Supports --id,
14
14
  </MODULE_CONTRACT>
15
15
  <CHANGE_SUMMARY>
16
16
  <item>RFC-0999: initial implementation.</item>
17
+ <item>RFC-1237: dual-read dry-run (context.dryRun || flags["dry-run"]),
18
+ io.writeFile port routing, emit-canonical generated header — refresh owns
19
+ content freshness, never envelope identity (ownerCommand stays
20
+ rfc.verification.emit).</item>
21
+ <item>RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt</item>
17
22
  </CHANGE_SUMMARY>
18
23
  */
19
24
 
@@ -24,14 +29,14 @@ import { parse as yamlParse } from "yaml";
24
29
 
25
30
  import { runProbe } from "./acceptance.ts";
26
31
  import { listRfcFiles, readAndParseRfc } from "./frontmatter-io.ts";
27
- import { writeFileAtomic } from "../../src/utils/fs-atomic.ts";
28
- import { buildGeneratedHeader } from "../../src/utils/generated-marker.ts";
29
- import { stringify as yamlStringify } from "yaml";
30
32
  import {
31
33
  captureGitContext,
32
34
  getKernelVersion,
35
+ mapWithConcurrency,
36
+ resolveConcurrency,
33
37
  VERIFICATION_DIR,
34
38
  buildEvidenceEnvelope,
39
+ serializeEvidenceEnvelope,
35
40
  } from "./verification-evidence.ts";
36
41
  import { RFC_DIR } from "./types.ts";
37
42
  import type {
@@ -57,7 +62,10 @@ export async function runRfcVerificationRefresh(
57
62
  const rfcDirPath = join(workspaceRoot, RFC_DIR);
58
63
  const targetId = input.flags["id"] as string | undefined;
59
64
  const allMode = input.flags["all"] === true;
60
- const dryRun = input.flags["dry-run"] === true;
65
+ // RFC-1237: consumeCommonFlags strips --dry-run into context.dryRun before
66
+ // dispatch — the input.flags read stays for direct/programmatic callers.
67
+ const dryRun = context.dryRun === true || input.flags["dry-run"] === true;
68
+ const concurrency = resolveConcurrency(input.flags["concurrency"], (m) => logger.warn(m));
61
69
 
62
70
  if (!targetId && !allMode) {
63
71
  return {
@@ -82,19 +90,24 @@ export async function runRfcVerificationRefresh(
82
90
  const gitContext = await captureGitContext(workspaceRoot);
83
91
  const kernelVersion = await getKernelVersion(workspaceRoot);
84
92
 
85
- for (const fileName of allFiles) {
93
+ const processFile = async (
94
+ fileName: string,
95
+ ): Promise<{
96
+ refreshedEntry?: NonNullable<RfcVerificationRefreshResult["refreshed"]>[number];
97
+ skippedEntry?: NonNullable<RfcVerificationRefreshResult["skipped"]>[number];
98
+ diagnostic?: Diagnostic;
99
+ }> => {
86
100
  const parsedFile = await readAndParseRfc(rfcDirPath, fileName);
87
- if (!parsedFile) continue;
88
- if ("error" in parsedFile) continue;
101
+ if (!parsedFile) return {};
102
+ if ("error" in parsedFile) return {};
89
103
  const fm = parsedFile.parsed.frontmatter;
90
104
  const rfcId = String(fm["id"] ?? "");
91
105
  const status = String(fm["status"] ?? "");
92
106
 
93
- if (targetId && rfcId.toLowerCase() !== targetId.toLowerCase()) continue;
107
+ if (targetId && rfcId.toLowerCase() !== targetId.toLowerCase()) return {};
94
108
 
95
109
  if (status !== "implemented") {
96
- skipped.push({ rfcId, reason: "not implemented" });
97
- continue;
110
+ return { skippedEntry: { rfcId, reason: "not implemented" } };
98
111
  }
99
112
 
100
113
  const slug = rfcId.toLowerCase();
@@ -113,15 +126,9 @@ export async function runRfcVerificationRefresh(
113
126
  // file doesn't exist or can't be parsed
114
127
  }
115
128
 
116
- if (!existingEnvelope) {
117
- skipped.push({ rfcId, reason: "no evidence envelope" });
118
- continue;
119
- }
120
-
121
129
  const acceptance = fm["acceptance"];
122
- if (!Array.isArray(acceptance) || acceptance.length === 0) {
123
- skipped.push({ rfcId, reason: "no evidence envelope" });
124
- continue;
130
+ if (!existingEnvelope || !Array.isArray(acceptance) || acceptance.length === 0) {
131
+ return { skippedEntry: { rfcId, reason: "no evidence envelope" } };
125
132
  }
126
133
 
127
134
  const probes = acceptance as AcceptanceProbe[];
@@ -156,33 +163,49 @@ export async function runRfcVerificationRefresh(
156
163
  envelope.lastRefreshedAt = now;
157
164
 
158
165
  if (!dryRun) {
159
- const yamlContent = `${buildGeneratedHeader({ filePath: evidenceRelPath, ownerCommand: "rfc.verification.refresh" })}${yamlStringify(envelope)}\n`;
160
- await writeFileAtomic(evidenceAbsPath, yamlContent);
166
+ // RFC-1237: emit-canonical serialization — emit owns these envelopes;
167
+ // refresh owns content freshness, never file identity (OWN-DUP-01).
168
+ // Port write routes through recording IO under kernel --dry-run.
169
+ await io.writeFile(evidenceAbsPath, serializeEvidenceEnvelope(envelope));
161
170
  }
162
171
 
163
172
  const probesFailed = probeRecords.filter((r) => !r.ok).length;
164
- refreshed.push({
165
- rfcId,
166
- file: evidenceRelPath,
167
- overall: envelope.overall,
168
- probesTotal: probeRecords.length,
169
- probesFailed,
170
- });
171
-
172
- if (envelope.overall === "fail") {
173
- diagnostics.push({
174
- ruleId: "RFC-REFRESH-01",
175
- severity: "error",
176
- file: evidenceRelPath,
177
- message: `${rfcId}: refresh overall is "fail" — ${probesFailed}/${probeRecords.length} probe(s) failed.`,
178
- });
179
- }
173
+ const previousOverall = existingEnvelope.overall;
180
174
 
181
175
  if (outputFormat === "pretty") {
176
+ const drift = previousOverall !== envelope.overall ? `, was ${previousOverall}` : "";
182
177
  logger.info(
183
- `[refresh] ${rfcId} → ${evidenceRelPath} (${envelope.overall}, ${probeRecords.length} probes${dryRun ? ", dry-run" : ""})`,
178
+ `[refresh] ${rfcId} → ${evidenceRelPath} (${envelope.overall}, ${probeRecords.length} probes${drift}${dryRun ? ", dry-run" : ""})`,
184
179
  );
185
180
  }
181
+
182
+ return {
183
+ refreshedEntry: {
184
+ rfcId,
185
+ file: evidenceRelPath,
186
+ overall: envelope.overall,
187
+ previousOverall,
188
+ probesTotal: probeRecords.length,
189
+ probesFailed,
190
+ },
191
+ ...(envelope.overall === "fail"
192
+ ? {
193
+ diagnostic: {
194
+ ruleId: "RFC-REFRESH-01",
195
+ severity: "error",
196
+ file: evidenceRelPath,
197
+ message: `${rfcId}: refresh overall is "fail" — ${probesFailed}/${probeRecords.length} probe(s) failed${previousOverall === "pass" ? " (regressed: was pass)" : ""}.`,
198
+ } satisfies Diagnostic,
199
+ }
200
+ : {}),
201
+ };
202
+ };
203
+
204
+ const results = await mapWithConcurrency(allFiles, concurrency, processFile);
205
+ for (const r of results) {
206
+ if (r.refreshedEntry) refreshed.push(r.refreshedEntry);
207
+ if (r.skippedEntry) skipped.push(r.skippedEntry);
208
+ if (r.diagnostic) diagnostics.push(r.diagnostic);
186
209
  }
187
210
 
188
211
  const passed = refreshed.filter((r) => r.overall === "pass").length;
@@ -190,6 +213,18 @@ export async function runRfcVerificationRefresh(
190
213
  const hasFailures = failed > 0;
191
214
  const hasSkippedNoEnvelope = skipped.some((s) => s.reason === "no evidence envelope");
192
215
  const status: RfcVerificationRefreshResult["status"] = hasFailures ? "fail" : "pass";
216
+ const delta = {
217
+ recovered: refreshed
218
+ .filter((r) => r.previousOverall === "fail" && r.overall === "pass")
219
+ .map((r) => r.rfcId),
220
+ regressed: refreshed
221
+ .filter((r) => r.previousOverall === "pass" && r.overall === "fail")
222
+ .map((r) => r.rfcId),
223
+ };
224
+ const deltaSummary =
225
+ delta.recovered.length + delta.regressed.length > 0
226
+ ? `, ${delta.regressed.length} regressed, ${delta.recovered.length} recovered`
227
+ : "";
193
228
 
194
229
  return {
195
230
  data: {
@@ -198,6 +233,7 @@ export async function runRfcVerificationRefresh(
198
233
  refreshed,
199
234
  skipped,
200
235
  diagnostics,
236
+ delta,
201
237
  summary: {
202
238
  total: refreshed.length,
203
239
  passed,
@@ -206,6 +242,6 @@ export async function runRfcVerificationRefresh(
206
242
  },
207
243
  },
208
244
  exitCode: hasFailures || hasSkippedNoEnvelope ? 1 : 0,
209
- summary: `rfc.verification.refresh: ${refreshed.length} refreshed, ${skipped.length} skipped`,
245
+ summary: `rfc.verification.refresh: ${refreshed.length} refreshed, ${skipped.length} skipped${deltaSummary}`,
210
246
  };
211
247
  }
@@ -19,6 +19,7 @@ Mechanical v1 to v2 header migration across the workspace: 942 files rewritten
19
19
  <item>RFC-1097: sweep — werkstatt-engine clean
20
20
 
21
21
  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>
22
+ <item>RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt</item>
22
23
  </CHANGE_SUMMARY>
23
24
  */
24
25
 
@@ -196,7 +197,9 @@ export async function runSessionSave(
196
197
  } else {
197
198
  try {
198
199
  const entries = await fs.readdir(rawDirPath);
199
- rawFiles = entries.filter((e) => e.isFile).map((e) => e.name);
200
+ // Only *.atif files are processed — claimed intermediates
201
+ // (*.atif.claim-<pid>) and in-progress writer temp files are skipped.
202
+ rawFiles = entries.filter((e) => e.isFile && e.name.endsWith(".atif")).map((e) => e.name);
200
203
  } catch {
201
204
  rawFiles = [];
202
205
  }
@@ -234,9 +237,25 @@ export async function runSessionSave(
234
237
 
235
238
  for (const rawFileName of rawFiles) {
236
239
  const rawFilePath = path.join(rawDirPath, rawFileName);
240
+ // Atomic claim: rename before reading so two concurrent session.save runs
241
+ // (parallel agent sessions) can't process the same file — the losing
242
+ // rename sees ENOENT and skips. Dry-run stays read-only by design.
243
+ let claimedPath = rawFilePath;
244
+ if (!dryRun) {
245
+ claimedPath = `${rawFilePath}.claim-${process.pid}`;
246
+ try {
247
+ await fs.rename(rawFilePath, claimedPath);
248
+ } catch (err) {
249
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
250
+ skipped.push({ rawFile: rawFileName, reason: "claimed by another process" });
251
+ continue;
252
+ }
253
+ throw err;
254
+ }
255
+ }
237
256
  let rawContent: string;
238
257
  try {
239
- rawContent = await fs.readFile(rawFilePath);
258
+ rawContent = await fs.readFile(claimedPath);
240
259
  } catch (err) {
241
260
  if ((err as NodeJS.ErrnoException).code === "ENOENT") {
242
261
  skipped.push({ rawFile: rawFileName, reason: "already processed by another process" });
@@ -279,12 +298,18 @@ export async function runSessionSave(
279
298
  // Delete raw file even when skipping — otherwise .atif files accumulate
280
299
  if (!dryRun && !keepRaw) {
281
300
  try {
282
- await trashPath(rawFilePath);
301
+ await trashPath(claimedPath);
283
302
  } catch (err) {
284
303
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
285
304
  throw err;
286
305
  }
287
306
  }
307
+ } else if (!dryRun && claimedPath !== rawFilePath) {
308
+ try {
309
+ await fs.rename(claimedPath, rawFilePath);
310
+ } catch {
311
+ // best-effort restore
312
+ }
288
313
  }
289
314
  continue;
290
315
  } catch {
@@ -320,15 +345,22 @@ export async function runSessionSave(
320
345
  );
321
346
  }
322
347
 
323
- // Delete raw file unless --keep-raw
348
+ // Delete raw file unless --keep-raw (then restore the original name —
349
+ // the claim suffix is an internal detail, not part of the file's name)
324
350
  if (!keepRaw) {
325
351
  try {
326
- await trashPath(rawFilePath);
352
+ await trashPath(claimedPath);
327
353
  } catch (err) {
328
354
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
329
355
  throw err;
330
356
  }
331
357
  }
358
+ } else if (claimedPath !== rawFilePath) {
359
+ try {
360
+ await fs.rename(claimedPath, rawFilePath);
361
+ } catch {
362
+ // best-effort restore — the claimed name remains, still recoverable
363
+ }
332
364
  }
333
365
  }
334
366
 
@@ -57,6 +57,7 @@ export async function createForgeSessionModule(): Promise<ForgeModule> {
57
57
  },
58
58
  {
59
59
  name: "session.archive",
60
+ coverage: "operator" as const,
60
61
  description:
61
62
  "Move session files older than --max-age-days (default 7) from " +
62
63
  "docs/sessions/ to docs/sessions/archive/. Bidirectional: files in " +
@@ -84,6 +85,8 @@ export async function createForgeSessionModule(): Promise<ForgeModule> {
84
85
  },
85
86
  {
86
87
  name: "session.validate",
88
+ coverage: "operator" as const,
89
+ coverageNote: "retrospective session-doc audit — pre-existing frontmatter debt",
87
90
  mutatesState: false,
88
91
  contract: "session",
89
92
  rules: [],
@@ -102,6 +105,7 @@ export async function createForgeSessionModule(): Promise<ForgeModule> {
102
105
  },
103
106
  {
104
107
  name: "session.list",
108
+ coverage: "operator" as const,
105
109
  mutatesState: false,
106
110
  description:
107
111
  "List all sessions. Filter with --date-from, --date-to, --rfc, --type flags. " +
@@ -132,6 +136,7 @@ export async function createForgeSessionModule(): Promise<ForgeModule> {
132
136
  },
133
137
  {
134
138
  name: "metrics.aggregate",
139
+ coverage: "operator" as const,
135
140
  mutatesState: true,
136
141
  description:
137
142
  "Query and aggregate skill effectiveness metrics from docs/metrics/ files. " +