@warpgogol/forge 5.3.4 → 6.0.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 (235) 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 +39 -9
  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 +326 -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 +93 -5
  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/exploration/exploration.module.d.ts.map +1 -1
  30. package/dist/os/exploration/exploration.module.js +3 -0
  31. package/dist/os/exploration/exploration.module.js.map +1 -1
  32. package/dist/os/naming/naming.module.d.ts.map +1 -1
  33. package/dist/os/naming/naming.module.js +9 -2
  34. package/dist/os/naming/naming.module.js.map +1 -1
  35. package/dist/os/notes/notes.module.d.ts.map +1 -1
  36. package/dist/os/notes/notes.module.js +17 -6
  37. package/dist/os/notes/notes.module.js.map +1 -1
  38. package/dist/os/plan/plan.module.d.ts.map +1 -1
  39. package/dist/os/plan/plan.module.js +2 -0
  40. package/dist/os/plan/plan.module.js.map +1 -1
  41. package/dist/os/plugin/plugin.module.d.ts.map +1 -1
  42. package/dist/os/plugin/plugin.module.js +9 -2
  43. package/dist/os/plugin/plugin.module.js.map +1 -1
  44. package/dist/os/program/program.module.d.ts.map +1 -1
  45. package/dist/os/program/program.module.js +19 -4
  46. package/dist/os/program/program.module.js.map +1 -1
  47. package/dist/os/queue/handlers/queue-validate.d.ts.map +1 -1
  48. package/dist/os/queue/handlers/queue-validate.js +154 -7
  49. package/dist/os/queue/handlers/queue-validate.js.map +1 -1
  50. package/dist/os/queue/manifest.d.ts +17 -1
  51. package/dist/os/queue/manifest.d.ts.map +1 -1
  52. package/dist/os/queue/manifest.js +127 -6
  53. package/dist/os/queue/manifest.js.map +1 -1
  54. package/dist/os/queue/queue.module.d.ts.map +1 -1
  55. package/dist/os/queue/queue.module.js +32 -4
  56. package/dist/os/queue/queue.module.js.map +1 -1
  57. package/dist/os/queue/types.d.ts +100 -1
  58. package/dist/os/queue/types.d.ts.map +1 -1
  59. package/dist/os/queue/types.js +44 -0
  60. package/dist/os/queue/types.js.map +1 -1
  61. package/dist/os/rfc/handlers/implement-stamp.d.ts.map +1 -1
  62. package/dist/os/rfc/handlers/implement-stamp.js +28 -5
  63. package/dist/os/rfc/handlers/implement-stamp.js.map +1 -1
  64. package/dist/os/rfc/rfc.module.d.ts.map +1 -1
  65. package/dist/os/rfc/rfc.module.js +27 -3
  66. package/dist/os/rfc/rfc.module.js.map +1 -1
  67. package/dist/os/rfc/types.d.ts +11 -0
  68. package/dist/os/rfc/types.d.ts.map +1 -1
  69. package/dist/os/rfc/types.js +1 -0
  70. package/dist/os/rfc/types.js.map +1 -1
  71. package/dist/os/rfc/verification-evidence.d.ts +21 -0
  72. package/dist/os/rfc/verification-evidence.d.ts.map +1 -1
  73. package/dist/os/rfc/verification-evidence.js +140 -23
  74. package/dist/os/rfc/verification-evidence.js.map +1 -1
  75. package/dist/os/rfc/verification-refresh.d.ts.map +1 -1
  76. package/dist/os/rfc/verification-refresh.js +72 -37
  77. package/dist/os/rfc/verification-refresh.js.map +1 -1
  78. package/dist/os/session/handlers/save.d.ts.map +1 -1
  79. package/dist/os/session/handlers/save.js +46 -5
  80. package/dist/os/session/handlers/save.js.map +1 -1
  81. package/dist/os/session/session.module.d.ts.map +1 -1
  82. package/dist/os/session/session.module.js +5 -0
  83. package/dist/os/session/session.module.js.map +1 -1
  84. package/dist/os/spec/live-spec-merge.d.ts.map +1 -1
  85. package/dist/os/spec/live-spec-merge.js +55 -134
  86. package/dist/os/spec/live-spec-merge.js.map +1 -1
  87. package/dist/os/spec/live-spec-rebuild.d.ts +32 -0
  88. package/dist/os/spec/live-spec-rebuild.d.ts.map +1 -0
  89. package/dist/os/spec/live-spec-rebuild.js +272 -0
  90. package/dist/os/spec/live-spec-rebuild.js.map +1 -0
  91. package/dist/os/spec/live-spec-shared.d.ts +62 -0
  92. package/dist/os/spec/live-spec-shared.d.ts.map +1 -0
  93. package/dist/os/spec/live-spec-shared.js +315 -0
  94. package/dist/os/spec/live-spec-shared.js.map +1 -0
  95. package/dist/os/spec/live-spec-types.d.ts +17 -1
  96. package/dist/os/spec/live-spec-types.d.ts.map +1 -1
  97. package/dist/os/spec/live-spec-types.js +3 -0
  98. package/dist/os/spec/live-spec-types.js.map +1 -1
  99. package/dist/os/spec/live-spec-validate.d.ts.map +1 -1
  100. package/dist/os/spec/live-spec-validate.js +105 -2
  101. package/dist/os/spec/live-spec-validate.js.map +1 -1
  102. package/dist/os/spec/spec-materialize.d.ts +2 -0
  103. package/dist/os/spec/spec-materialize.d.ts.map +1 -1
  104. package/dist/os/spec/spec-materialize.js +45 -5
  105. package/dist/os/spec/spec-materialize.js.map +1 -1
  106. package/dist/os/spec/spec-schema.d.ts +19 -0
  107. package/dist/os/spec/spec-schema.d.ts.map +1 -1
  108. package/dist/os/spec/spec-schema.js +30 -0
  109. package/dist/os/spec/spec-schema.js.map +1 -1
  110. package/dist/os/spec/spec-status.d.ts +4 -0
  111. package/dist/os/spec/spec-status.d.ts.map +1 -1
  112. package/dist/os/spec/spec-status.js +19 -1
  113. package/dist/os/spec/spec-status.js.map +1 -1
  114. package/dist/os/spec/spec-validate.d.ts.map +1 -1
  115. package/dist/os/spec/spec-validate.js +55 -8
  116. package/dist/os/spec/spec-validate.js.map +1 -1
  117. package/dist/os/spec/spec.module.d.ts.map +1 -1
  118. package/dist/os/spec/spec.module.js +84 -8
  119. package/dist/os/spec/spec.module.js.map +1 -1
  120. package/dist/os/werkstatt/handlers/werkstatt-operation-validate.d.ts.map +1 -1
  121. package/dist/os/werkstatt/handlers/werkstatt-operation-validate.js +10 -4
  122. package/dist/os/werkstatt/handlers/werkstatt-operation-validate.js.map +1 -1
  123. package/dist/os/werkstatt/werkstatt.module.d.ts.map +1 -1
  124. package/dist/os/werkstatt/werkstatt.module.js +13 -4
  125. package/dist/os/werkstatt/werkstatt.module.js.map +1 -1
  126. package/dist/os/workflow/workflow.module.d.ts.map +1 -1
  127. package/dist/os/workflow/workflow.module.js +13 -5
  128. package/dist/os/workflow/workflow.module.js.map +1 -1
  129. package/dist/src/cli-flags.d.ts.map +1 -1
  130. package/dist/src/cli-flags.js +25 -5
  131. package/dist/src/cli-flags.js.map +1 -1
  132. package/dist/src/config/forge-config.d.ts +3 -0
  133. package/dist/src/config/forge-config.d.ts.map +1 -1
  134. package/dist/src/config/forge-config.js +2 -0
  135. package/dist/src/config/forge-config.js.map +1 -1
  136. package/dist/src/onboarding/init.d.ts.map +1 -1
  137. package/dist/src/onboarding/init.js +8 -3
  138. package/dist/src/onboarding/init.js.map +1 -1
  139. package/dist/src/pipeline-status.d.ts +14 -1
  140. package/dist/src/pipeline-status.d.ts.map +1 -1
  141. package/dist/src/pipeline-status.js +28 -8
  142. package/dist/src/pipeline-status.js.map +1 -1
  143. package/dist/src/types.d.ts +7 -1
  144. package/dist/src/types.d.ts.map +1 -1
  145. package/dist/src/types.js +6 -0
  146. package/dist/src/types.js.map +1 -1
  147. package/dist/src/utils/generated-marker.d.ts +8 -0
  148. package/dist/src/utils/generated-marker.d.ts.map +1 -1
  149. package/dist/src/utils/generated-marker.js +25 -0
  150. package/dist/src/utils/generated-marker.js.map +1 -1
  151. package/os/adr/adr.module.ts +2 -0
  152. package/os/audit/audit.module.ts +2 -0
  153. package/os/compass/compass.module.ts +49 -15
  154. package/os/compass/handlers/compass-audit-handler.ts +50 -9
  155. package/os/compass/handlers/compass-docs-validate.ts +359 -0
  156. package/os/compass/handlers/summary-record.ts +104 -5
  157. package/os/compass/handlers/tests/compass-audit-record.test.ts +2 -1
  158. package/os/compass/handlers/tests/compass-audit-validate.test.ts +92 -3
  159. package/os/compass/handlers/tests/compass-docs-validate.test.ts +203 -0
  160. package/os/compass/handlers/tests/compass-ledger-scope.test.ts +2 -1
  161. package/os/compass/handlers/tests/summary-record.test.ts +127 -0
  162. package/os/core/core.module.ts +147 -75
  163. package/os/core/docs-archive.test.ts +136 -0
  164. package/os/exploration/exploration.module.ts +3 -0
  165. package/os/naming/naming.module.ts +9 -2
  166. package/os/notes/notes.module.ts +17 -6
  167. package/os/plan/plan-0000-template.md +150 -0
  168. package/os/plan/plan.module.ts +2 -0
  169. package/os/plugin/plugin.module.ts +9 -2
  170. package/os/program/program.module.ts +19 -4
  171. package/os/queue/decision-ledger.test.ts +163 -0
  172. package/os/queue/handlers/queue-validate.ts +190 -8
  173. package/os/queue/manifest.ts +201 -7
  174. package/os/queue/queue-validate.test.ts +303 -0
  175. package/os/queue/queue.module.ts +32 -4
  176. package/os/queue/types.ts +56 -1
  177. package/os/rfc/handlers/implement-stamp.ts +35 -5
  178. package/os/rfc/rfc.module.ts +29 -3
  179. package/os/rfc/types.ts +17 -1
  180. package/os/rfc/verification-emit.test.ts +305 -0
  181. package/os/rfc/verification-evidence.ts +155 -24
  182. package/os/rfc/verification-refresh.test.ts +222 -1
  183. package/os/rfc/verification-refresh.ts +80 -38
  184. package/os/session/handlers/save.ts +41 -5
  185. package/os/session/session.module.ts +5 -0
  186. package/os/spec/live-spec-idempotency.pbt.test.ts +222 -0
  187. package/os/spec/live-spec-list-show-validate.test.ts +10 -6
  188. package/os/spec/live-spec-merge.test.ts +187 -7
  189. package/os/spec/live-spec-merge.ts +71 -155
  190. package/os/spec/live-spec-rebuild.test.ts +317 -0
  191. package/os/spec/live-spec-rebuild.ts +356 -0
  192. package/os/spec/live-spec-shared.ts +378 -0
  193. package/os/spec/live-spec-types.ts +21 -1
  194. package/os/spec/live-spec-validate.test.ts +317 -0
  195. package/os/spec/live-spec-validate.ts +106 -2
  196. package/os/spec/spec-materialize.test.ts +192 -0
  197. package/os/spec/spec-materialize.ts +48 -4
  198. package/os/spec/spec-schema.ts +49 -0
  199. package/os/spec/spec-status.ts +24 -0
  200. package/os/spec/spec-validate.test.ts +66 -0
  201. package/os/spec/spec-validate.ts +62 -8
  202. package/os/spec/spec.module.ts +85 -8
  203. package/os/werkstatt/handlers/werkstatt-operation-validate.ts +10 -4
  204. package/os/werkstatt/werkstatt.module.ts +13 -4
  205. package/os/workflow/workflow.module.ts +13 -5
  206. package/package.json +1 -1
  207. package/skills/_shared/fo-pipeline-conventions.md +126 -1
  208. package/skills/fo/fo-doc-audit/SKILL.md +4 -4
  209. package/skills/fo/fo-explore/SKILL.md +2 -1
  210. package/skills/fo/fo-fix/SKILL.md +11 -3
  211. package/skills/fo/fo-handoff/SKILL.md +4 -6
  212. package/skills/fo/fo-idea-create-rfc/SKILL.md +2 -1
  213. package/skills/fo/fo-idea-enhance/SKILL.md +23 -4
  214. package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +86 -22
  215. package/skills/fo/fo-idea-implement/SKILL.md +53 -10
  216. package/skills/fo/fo-idea-plan/SKILL.md +28 -6
  217. package/skills/fo/fo-review/SKILL.md +69 -8
  218. package/skills/fo/fo-session-retro/SKILL.md +3 -2
  219. package/skills/fo/fo-step-commit/SKILL.md +2 -0
  220. package/skills/shared/grilling/SKILL.md +12 -0
  221. package/skills/shared/writing-great-skills/GLOSSARY.md +2 -0
  222. package/skills/shared/writing-great-skills/SKILL.md +2 -1
  223. package/src/cli-flags.ts +23 -5
  224. package/src/config/forge-config.ts +4 -0
  225. package/src/onboarding/init.ts +8 -3
  226. package/src/pipeline-status.ts +32 -8
  227. package/src/tests/cli-flags.test.ts +20 -0
  228. package/src/tests/cli-output.test.ts +13 -7
  229. package/src/tests/fixtures/agents-generate-business-before.txt +7 -7
  230. package/src/tests/implement-stamp.test.ts +38 -0
  231. package/src/tests/session-handlers.test.ts +33 -0
  232. package/src/types.ts +13 -1
  233. package/src/utils/generated-marker.ts +28 -0
  234. package/skills/fo/fo-idea-implement/ADR-FLOW.md +0 -179
  235. package/skills/fo/fo-review/AXES.md +0 -70
@@ -0,0 +1,305 @@
1
+ // @vitest-environment node
2
+ import { test, expect, describe, vi, beforeEach, afterEach } from "vitest";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { stringify as yamlStringify } from "yaml";
7
+
8
+ import {
9
+ runRfcVerificationEmit,
10
+ mapWithConcurrency,
11
+ resolveConcurrency,
12
+ } from "./verification-evidence.ts";
13
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../../src/types.ts";
14
+
15
+ const { mockRunProbe } = vi.hoisted(() => ({
16
+ mockRunProbe: vi.fn(),
17
+ }));
18
+
19
+ vi.mock("./acceptance.ts", () => ({
20
+ runProbe: mockRunProbe,
21
+ }));
22
+
23
+ const RFC_FRONTMATTER = (id: string, status: string, probes: Record<string, unknown>[] = []) => {
24
+ const probeYaml = probes
25
+ .map(
26
+ (p) =>
27
+ ` - probe: ${p.probe}\n path: ${p.path ?? ""}\n criterion: ${p.criterion ?? ""}`,
28
+ )
29
+ .join("\n");
30
+ return `---\nid: ${id}\ntitle: "Test ${id}"\nstatus: ${status}\nkind: command\nscope: workspace\nowners:\n - architecture\ncreatedAt: 2026-01-01\nupdatedAt: 2026-01-01\nacceptance:\n${probeYaml}\n---\n\n# ${id}\n## Context\nTest.\n`;
31
+ };
32
+
33
+ const PROBE = { probe: "file-exists", path: "src/index.ts", criterion: "AC-1" };
34
+
35
+ function makeEnvelope(rfcId: string, overall: "pass" | "fail"): Record<string, unknown> {
36
+ return {
37
+ rfcId,
38
+ title: `Test ${rfcId}`,
39
+ rfcStatus: "implemented",
40
+ emittedAt: "2026-01-01T00:00:00.000Z",
41
+ commit: "abc123",
42
+ workingTreeDirty: false,
43
+ kernelVersion: "1.0.0",
44
+ rfcFileHash: "hash123",
45
+ acceptanceHash: "accHash123",
46
+ probes: [{ probe: PROBE, ok: overall === "pass", detail: "", durationMs: 5 }],
47
+ overall,
48
+ };
49
+ }
50
+
51
+ async function setupWorkspace(opts: {
52
+ rfcs: Array<{ id: string; status: string; probes?: unknown[] }>;
53
+ envelopes?: Array<{ id: string; overall: "pass" | "fail" }>;
54
+ }): Promise<{ workspaceRoot: string; cleanup: () => Promise<void> }> {
55
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-emit-test-"));
56
+ const rfcDir = path.join(tmpDir, "docs", "rfcs");
57
+ const verificationDir = path.join(rfcDir, "verification");
58
+ await fs.mkdir(rfcDir, { recursive: true });
59
+
60
+ for (const rfc of opts.rfcs) {
61
+ const slug = rfc.id.toLowerCase();
62
+ const probes = rfc.probes ?? [PROBE];
63
+ await fs.writeFile(
64
+ path.join(rfcDir, `${slug}-test.md`),
65
+ RFC_FRONTMATTER(rfc.id, rfc.status, probes),
66
+ );
67
+ }
68
+
69
+ for (const env of opts.envelopes ?? []) {
70
+ await fs.mkdir(verificationDir, { recursive: true });
71
+ await fs.writeFile(
72
+ path.join(verificationDir, `${env.id.toLowerCase()}.generated.yaml`),
73
+ yamlStringify(makeEnvelope(env.id, env.overall)) + "\n",
74
+ );
75
+ }
76
+
77
+ return {
78
+ workspaceRoot: tmpDir,
79
+ cleanup: async () => {
80
+ await fs.rm(tmpDir, { recursive: true, force: true });
81
+ },
82
+ };
83
+ }
84
+
85
+ function makeContext(workspaceRoot: string, overrides: Record<string, unknown> = {}) {
86
+ return {
87
+ workspaceRoot,
88
+ logger: { info: () => {} },
89
+ outputFormat: "json" as const,
90
+ commandRegistry: undefined,
91
+ dryRun: false,
92
+ ...overrides,
93
+ };
94
+ }
95
+
96
+ const envelopePath = (root: string, rfcId: string) =>
97
+ path.join(root, "docs", "rfcs", "verification", `${rfcId.toLowerCase()}.generated.yaml`);
98
+
99
+ beforeEach(() => {
100
+ mockRunProbe.mockReset();
101
+ });
102
+
103
+ afterEach(() => {
104
+ vi.restoreAllMocks();
105
+ });
106
+
107
+ describe("rfc.verification.emit — baseline emission", () => {
108
+ test("writes the emit-canonical structured marker header + envelope", async () => {
109
+ const { workspaceRoot, cleanup } = await setupWorkspace({
110
+ rfcs: [{ id: "RFC-0100", status: "implemented" }],
111
+ });
112
+
113
+ try {
114
+ mockRunProbe.mockResolvedValue({ probe: PROBE, ok: true, detail: "exists" });
115
+
116
+ const result = await runRfcVerificationEmit(
117
+ { flags: { id: "RFC-0100" } } as unknown as ForgeCommandInput,
118
+ makeContext(workspaceRoot) as unknown as ForgeRuntimeContext,
119
+ );
120
+
121
+ expect(result.data!.emitted).toHaveLength(1);
122
+ expect(result.exitCode).toBe(0);
123
+
124
+ const raw = await fs.readFile(envelopePath(workspaceRoot, "RFC-0100"), "utf-8");
125
+ // RFC-1237 canonical format: structured marker keys, not '#' comments.
126
+ expect(raw).toMatch(/^generatedMarker: GENERATED\./);
127
+ expect(raw).toContain("doNotEdit: DO NOT EDIT THIS FILE.");
128
+ expect(raw).toContain("ownerCommand: rfc.verification.emit");
129
+ expect(raw).toContain("regenerateCommand: pnpm exec werkstatt run rfc.verification.emit");
130
+ expect(raw).toContain("rfcId: RFC-0100");
131
+ expect(raw).toContain("overall: pass");
132
+ expect(raw.split("\n").every((line) => !line.startsWith("#"))).toBe(true);
133
+ } finally {
134
+ await cleanup();
135
+ }
136
+ });
137
+ });
138
+
139
+ describe("rfc.verification.emit — sweep delta report", () => {
140
+ test("reports recovered (fail→pass) and regressed (pass→fail) transitions", async () => {
141
+ const { workspaceRoot, cleanup } = await setupWorkspace({
142
+ rfcs: [
143
+ { id: "RFC-0100", status: "implemented" },
144
+ { id: "RFC-0101", status: "implemented" },
145
+ { id: "RFC-0102", status: "implemented" },
146
+ ],
147
+ envelopes: [
148
+ { id: "RFC-0100", overall: "fail" },
149
+ { id: "RFC-0101", overall: "pass" },
150
+ { id: "RFC-0102", overall: "fail" },
151
+ ],
152
+ });
153
+
154
+ try {
155
+ mockRunProbe
156
+ .mockResolvedValueOnce({ probe: PROBE, ok: true, detail: "exists" })
157
+ .mockResolvedValueOnce({ probe: PROBE, ok: false, detail: "missing" })
158
+ .mockResolvedValueOnce({ probe: PROBE, ok: false, detail: "missing" });
159
+
160
+ const result = await runRfcVerificationEmit(
161
+ { flags: { status: "implemented" } } as unknown as ForgeCommandInput,
162
+ makeContext(workspaceRoot) as unknown as ForgeRuntimeContext,
163
+ );
164
+
165
+ expect(result.data!.delta).toEqual({
166
+ recovered: ["RFC-0100"],
167
+ regressed: ["RFC-0101"],
168
+ });
169
+ const entries = Object.fromEntries(result.data!.emitted.map((e) => [e.rfcId, e]));
170
+ expect(entries["RFC-0100"]?.previousOverall).toBe("fail");
171
+ expect(entries["RFC-0101"]?.previousOverall).toBe("pass");
172
+ expect(entries["RFC-0102"]?.previousOverall).toBe("fail");
173
+ const r101Diag = result.data!.diagnostics.find((d) => d.file?.includes("rfc-0101"));
174
+ expect(r101Diag?.message).toContain("regressed: was pass");
175
+ expect(result.summary).toContain("1 regressed, 1 recovered");
176
+ } finally {
177
+ await cleanup();
178
+ }
179
+ });
180
+ });
181
+
182
+ describe("rfc.verification.emit — RFC-1237 AC-6: dry-run", () => {
183
+ test("context.dryRun (the real werkstatt run --dry-run path) writes zero envelopes", async () => {
184
+ const { workspaceRoot, cleanup } = await setupWorkspace({
185
+ rfcs: [{ id: "RFC-0100", status: "implemented" }],
186
+ });
187
+
188
+ try {
189
+ mockRunProbe.mockResolvedValue({ probe: PROBE, ok: true, detail: "exists" });
190
+
191
+ // The real CLI path: consumeCommonFlags strips --dry-run from argv into
192
+ // context.dryRun — input.flags carries no "dry-run" key at all.
193
+ const result = await runRfcVerificationEmit(
194
+ { flags: { id: "RFC-0100" } } as unknown as ForgeCommandInput,
195
+ makeContext(workspaceRoot, { dryRun: true }) as unknown as ForgeRuntimeContext,
196
+ );
197
+
198
+ expect(result.data!.emitted).toHaveLength(1);
199
+ expect(result.summary).toContain("dry-run");
200
+ await expect(fs.stat(envelopePath(workspaceRoot, "RFC-0100"))).rejects.toThrow();
201
+ // The verification dir itself is also not created under dry-run.
202
+ await expect(
203
+ fs.stat(path.join(workspaceRoot, "docs", "rfcs", "verification")),
204
+ ).rejects.toThrow();
205
+ } finally {
206
+ await cleanup();
207
+ }
208
+ });
209
+
210
+ test('input.flags["dry-run"] (direct/programmatic callers) writes zero envelopes', async () => {
211
+ const { workspaceRoot, cleanup } = await setupWorkspace({
212
+ rfcs: [{ id: "RFC-0100", status: "implemented" }],
213
+ });
214
+
215
+ try {
216
+ mockRunProbe.mockResolvedValue({ probe: PROBE, ok: true, detail: "exists" });
217
+
218
+ const result = await runRfcVerificationEmit(
219
+ { flags: { id: "RFC-0100", "dry-run": true } } as unknown as ForgeCommandInput,
220
+ makeContext(workspaceRoot) as unknown as ForgeRuntimeContext,
221
+ );
222
+
223
+ expect(result.summary).toContain("dry-run");
224
+ await expect(fs.stat(envelopePath(workspaceRoot, "RFC-0100"))).rejects.toThrow();
225
+ } finally {
226
+ await cleanup();
227
+ }
228
+ });
229
+
230
+ test("dry-run still runs probes and reports overall: fail", async () => {
231
+ const { workspaceRoot, cleanup } = await setupWorkspace({
232
+ rfcs: [{ id: "RFC-0100", status: "implemented" }],
233
+ });
234
+
235
+ try {
236
+ mockRunProbe.mockResolvedValue({ probe: PROBE, ok: false, detail: "does not exist" });
237
+
238
+ const result = await runRfcVerificationEmit(
239
+ { flags: { id: "RFC-0100" } } as unknown as ForgeCommandInput,
240
+ makeContext(workspaceRoot, { dryRun: true }) as unknown as ForgeRuntimeContext,
241
+ );
242
+
243
+ expect(mockRunProbe).toHaveBeenCalledOnce();
244
+ expect(result.data!.emitted[0]?.overall).toBe("fail");
245
+ expect(result.exitCode).toBe(1);
246
+ await expect(fs.stat(envelopePath(workspaceRoot, "RFC-0100"))).rejects.toThrow();
247
+ } finally {
248
+ await cleanup();
249
+ }
250
+ });
251
+ });
252
+
253
+ describe("verification sweep — --concurrency", () => {
254
+ test("mapWithConcurrency preserves input order and bounds parallelism", async () => {
255
+ let inFlight = 0;
256
+ let maxInFlight = 0;
257
+ const items = Array.from({ length: 10 }, (_, i) => i);
258
+ const results = await mapWithConcurrency(items, 3, async (n) => {
259
+ inFlight += 1;
260
+ maxInFlight = Math.max(maxInFlight, inFlight);
261
+ await new Promise((r) => setTimeout(r, Math.random() * 5));
262
+ inFlight -= 1;
263
+ return n * 2;
264
+ });
265
+ expect(results).toEqual(items.map((n) => n * 2));
266
+ expect(maxInFlight).toBeLessThanOrEqual(3);
267
+ expect(maxInFlight).toBeGreaterThan(1);
268
+ });
269
+
270
+ test("resolveConcurrency: absent→1, garbage→1+warn, >16→clamped", () => {
271
+ const warnings: string[] = [];
272
+ const warn = (m: string) => warnings.push(m);
273
+ expect(resolveConcurrency(undefined)).toBe(1);
274
+ expect(resolveConcurrency("abc", warn)).toBe(1);
275
+ expect(resolveConcurrency("0", warn)).toBe(1);
276
+ expect(resolveConcurrency("4", warn)).toBe(4);
277
+ expect(resolveConcurrency("99", warn)).toBe(16);
278
+ expect(warnings).toHaveLength(3);
279
+ });
280
+
281
+ test("--concurrency 3 emits identical results to sequential", async () => {
282
+ const ids = ["RFC-0201", "RFC-0202", "RFC-0203", "RFC-0204"];
283
+ const { workspaceRoot, cleanup } = await setupWorkspace({
284
+ rfcs: ids.map((id) => ({ id, status: "implemented" })),
285
+ });
286
+
287
+ try {
288
+ mockRunProbe.mockResolvedValue({ probe: PROBE, ok: true, detail: "exists" });
289
+
290
+ const result = await runRfcVerificationEmit(
291
+ { flags: { status: "implemented", concurrency: "3" } } as unknown as ForgeCommandInput,
292
+ makeContext(workspaceRoot) as unknown as ForgeRuntimeContext,
293
+ );
294
+
295
+ expect(result.data!.emitted.map((e) => e.rfcId)).toEqual(ids);
296
+ expect(result.data!.emitted.every((e) => e.overall === "pass")).toBe(true);
297
+ expect(result.exitCode).toBe(0);
298
+ for (const id of ids) {
299
+ await expect(fs.stat(envelopePath(workspaceRoot, id))).resolves.toBeDefined();
300
+ }
301
+ } finally {
302
+ await cleanup();
303
+ }
304
+ });
305
+ });
@@ -14,6 +14,18 @@ a JSON evidence envelope to docs/rfcs/verification/<slug>.generated.yaml.
14
14
  <CHANGE_SUMMARY>
15
15
  <item>RFC-0330: initial implementation.</item>
16
16
  <item>RFC-0999: exported captureGitContext, getKernelVersion, byteHashHex, VERIFICATION_DIR for reuse by verification-refresh.ts.</item>
17
+ <item>RFC-1237: dual-read dry-run (context.dryRun || flags["dry-run"]),
18
+ io.writeFile port routing, dry-run summary marker; serializeEvidenceEnvelope —
19
+ shared emit-canonical serializer restoring the structured YAML marker-key
20
+ header (generatedMarker/doNotEdit/ownerCommand/editInstead/regenerateCommand
21
+ with "pnpm exec werkstatt run" prefix), also consumed by refresh.</item>
22
+ <item>Session-retro 2026-10-08: sweep delta report — reads the committed
23
+ envelope baseline (previousOverall) and reports recovered/regressed
24
+ transitions so a fail-heavy sweep needs no manual pass/fail triage.</item>
25
+ <item>Session-retro 2026-10-08: --concurrency flag (default 1, garbage values
26
+ warn + fall back to 1, >16 clamps) fans emit out across RFC units via
27
+ mapWithConcurrency while probes inside one RFC stay sequential — envelope
28
+ ordering stays deterministic.</item>
17
29
  </CHANGE_SUMMARY>
18
30
  */
19
31
 
@@ -26,9 +38,8 @@ import { byteHash } from "../../src/utils/hash.ts";
26
38
 
27
39
  import { runProbe } from "./acceptance.ts";
28
40
  import { listRfcFiles, readAndParseRfc } from "./frontmatter-io.ts";
29
- import { writeFileAtomic } from "../../src/utils/fs-atomic.ts";
30
- import { buildGeneratedHeader } from "../../src/utils/generated-marker.ts";
31
- import { stringify as yamlStringify } from "yaml";
41
+ import { generatedHeaderFields } from "../../src/utils/generated-marker.ts";
42
+ import { parse as yamlParse, stringify as yamlStringify } from "yaml";
32
43
  import { RFC_DIR } from "./types.ts";
33
44
 
34
45
  // Ambient default for helper fns without a context param — handlers override
@@ -90,6 +101,71 @@ export function byteHashHex(content: string): string {
90
101
  return byteHash(content).slice(HASH_PREFIX.length);
91
102
  }
92
103
 
104
+ /**
105
+ * Bounded-parallel map preserving input order — one in-flight worker per slot,
106
+ * results land in the same positions as their inputs. Used by the emit/refresh
107
+ * sweeps so `--concurrency N` overlaps probe subprocesses across RFCs.
108
+ */
109
+ export async function mapWithConcurrency<T, R>(
110
+ items: readonly T[],
111
+ concurrency: number,
112
+ worker: (item: T, index: number) => Promise<R>,
113
+ ): Promise<R[]> {
114
+ const results = new Array<R>(items.length);
115
+ let cursor = 0;
116
+ const lanes = Array.from({ length: Math.min(Math.max(concurrency, 1), items.length) }, () =>
117
+ (async () => {
118
+ while (cursor < items.length) {
119
+ const index = cursor;
120
+ cursor += 1;
121
+ results[index] = await worker(items[index]!, index);
122
+ }
123
+ })(),
124
+ );
125
+ await Promise.all(lanes);
126
+ return results;
127
+ }
128
+
129
+ /**
130
+ * Resolve the `--concurrency` flag (string flag; the kernel schema has no
131
+ * number kind). Garbage/NaN/non-positive degrades to 1 with a warning;
132
+ * clamped to 16 — probe workers spawn vitest/site-kernel children and wider
133
+ * fan-out starves CPU rather than speeding the sweep.
134
+ */
135
+ export function resolveConcurrency(flag: unknown, warn?: (message: string) => void): number {
136
+ if (flag === undefined) return 1;
137
+ const raw = Array.isArray(flag) ? flag[0] : flag;
138
+ const parsed = Number.parseInt(String(raw), 10);
139
+ if (!Number.isFinite(parsed) || parsed < 1) {
140
+ warn?.(`--concurrency "${String(raw)}" is not a positive integer — falling back to 1.`);
141
+ return 1;
142
+ }
143
+ if (parsed > 16) {
144
+ warn?.(
145
+ `--concurrency ${parsed} exceeds the 16 cap — clamped (probe workers spawn subprocesses).`,
146
+ );
147
+ return 16;
148
+ }
149
+ return parsed;
150
+ }
151
+
152
+ /**
153
+ * RFC-1237: the emit-canonical envelope serialization — structured marker keys
154
+ * (generatedMarker/doNotEdit/ownerCommand/editInstead/regenerateCommand) as
155
+ * leading YAML fields, then the envelope body. emit and refresh MUST share
156
+ * this serializer: refresh owns content freshness, never envelope identity
157
+ * (OWN-DUP-01), so refresh output is identical-by-construction.
158
+ */
159
+ export function serializeEvidenceEnvelope(envelope: VerificationEvidence): string {
160
+ return `${yamlStringify({
161
+ ...generatedHeaderFields({
162
+ ownerCommand: "rfc.verification.emit",
163
+ commandPrefix: "pnpm exec werkstatt run",
164
+ }),
165
+ ...envelope,
166
+ })}\n`;
167
+ }
168
+
93
169
  function normalizeProbes(probes: AcceptanceProbe[]): string {
94
170
  return JSON.stringify(probes, Object.keys(probes[0] ?? {}).sort());
95
171
  }
@@ -130,6 +206,10 @@ export async function runRfcVerificationEmit(
130
206
  const rfcDirPath = join(workspaceRoot, RFC_DIR);
131
207
  const targetId = input.flags["id"] as string | undefined;
132
208
  const targetStatus = input.flags["status"] as string | undefined;
209
+ // RFC-1237: consumeCommonFlags strips --dry-run into context.dryRun before
210
+ // dispatch — the input.flags read stays for direct/programmatic callers.
211
+ const dryRun = context.dryRun === true || input.flags["dry-run"] === true;
212
+ const concurrency = resolveConcurrency(input.flags["concurrency"], (m) => logger.warn(m));
133
213
 
134
214
  if (!targetId && !targetStatus) {
135
215
  return {
@@ -161,22 +241,27 @@ export async function runRfcVerificationEmit(
161
241
  }
162
242
  const kernelVersion = await getKernelVersion(workspaceRoot);
163
243
  const verificationDirAbs = join(workspaceRoot, VERIFICATION_DIR);
164
- await io.mkdir(verificationDirAbs);
244
+ if (!dryRun) await io.mkdir(verificationDirAbs);
165
245
 
166
- for (const fileName of allFiles) {
246
+ const processFile = async (
247
+ fileName: string,
248
+ ): Promise<{
249
+ emittedEntry?: NonNullable<RfcVerificationEmitResult["emitted"]>[number];
250
+ skippedEntry?: NonNullable<RfcVerificationEmitResult["skipped"]>[number];
251
+ diagnostic?: Diagnostic;
252
+ }> => {
167
253
  const parsedFile = await readAndParseRfc(rfcDirPath, fileName);
168
- if (!parsedFile) continue;
169
- if ("error" in parsedFile) continue;
254
+ if (!parsedFile) return {};
255
+ if ("error" in parsedFile) return {};
170
256
  const fm = parsedFile.parsed.frontmatter;
171
257
  const rfcId = String(fm["id"] ?? "");
172
258
 
173
- if (targetId && rfcId.toLowerCase() !== targetId.toLowerCase()) continue;
174
- if (targetStatus && String(fm["status"] ?? "") !== targetStatus) continue;
259
+ if (targetId && rfcId.toLowerCase() !== targetId.toLowerCase()) return {};
260
+ if (targetStatus && String(fm["status"] ?? "") !== targetStatus) return {};
175
261
 
176
262
  const acceptance = fm["acceptance"];
177
263
  if (!Array.isArray(acceptance) || acceptance.length === 0) {
178
- skipped.push({ rfcId, reason: "no-probes" });
179
- continue;
264
+ return { skippedEntry: { rfcId, reason: "no-probes" } };
180
265
  }
181
266
 
182
267
  const probes = acceptance as AcceptanceProbe[];
@@ -213,29 +298,74 @@ export async function runRfcVerificationEmit(
213
298
  const evidenceFileName = `${slug}.generated.yaml`;
214
299
  const evidenceRelPath = join(VERIFICATION_DIR, evidenceFileName);
215
300
  const evidenceAbsPath = join(workspaceRoot, evidenceRelPath);
216
- const jsonContent = `${buildGeneratedHeader({ filePath: evidenceRelPath, ownerCommand: "rfc.verification.emit" })}${yamlStringify(envelope)}\n`;
217
- await writeFileAtomic(evidenceAbsPath, jsonContent);
218
-
219
- emitted.push({ rfcId, file: evidenceRelPath, overall: envelope.overall });
220
301
 
221
- if (envelope.overall === "fail") {
222
- diagnostics.push({
223
- ruleId: "RFC-EVID-02",
224
- severity: "error",
225
- file: evidenceRelPath,
226
- message: `${rfcId}: evidence overall is "fail" — ${probeRecords.filter((r) => !r.ok).length} probe(s) failed.`,
227
- });
302
+ // Committed-envelope baseline for the sweep delta — lets a fail-heavy run
303
+ // separate still-failing drift from fresh regressions without git diff.
304
+ let previousOverall: "pass" | "fail" | undefined;
305
+ try {
306
+ const existing = yamlParse(await io.readFile(evidenceAbsPath)) as VerificationEvidence | null;
307
+ if (existing && (existing.overall === "pass" || existing.overall === "fail")) {
308
+ previousOverall = existing.overall;
309
+ }
310
+ } catch {
311
+ // No committed envelope — first emission for this RFC.
228
312
  }
229
313
 
314
+ // RFC-1237: port write — the recording IO adapter intercepts under kernel
315
+ // --dry-run even if the explicit guard regresses.
316
+ if (!dryRun) await io.writeFile(evidenceAbsPath, serializeEvidenceEnvelope(envelope));
317
+
230
318
  if (outputFormat === "pretty") {
319
+ const drift =
320
+ previousOverall !== undefined && previousOverall !== envelope.overall
321
+ ? `, was ${previousOverall}`
322
+ : "";
231
323
  logger.info(
232
- `[evidence] ${rfcId} → ${evidenceRelPath} (${envelope.overall}, ${probeRecords.length} probes)`,
324
+ `[evidence] ${rfcId} → ${evidenceRelPath} (${envelope.overall}, ${probeRecords.length} probes${drift}${dryRun ? ", dry-run" : ""})`,
233
325
  );
234
326
  }
327
+
328
+ return {
329
+ emittedEntry: {
330
+ rfcId,
331
+ file: evidenceRelPath,
332
+ overall: envelope.overall,
333
+ ...(previousOverall !== undefined ? { previousOverall } : {}),
334
+ },
335
+ ...(envelope.overall === "fail"
336
+ ? {
337
+ diagnostic: {
338
+ ruleId: "RFC-EVID-02",
339
+ severity: "error",
340
+ file: evidenceRelPath,
341
+ message: `${rfcId}: evidence overall is "fail" — ${probeRecords.filter((r) => !r.ok).length} probe(s) failed${previousOverall === "pass" ? " (regressed: was pass)" : ""}.`,
342
+ } satisfies Diagnostic,
343
+ }
344
+ : {}),
345
+ };
346
+ };
347
+
348
+ const results = await mapWithConcurrency(allFiles, concurrency, processFile);
349
+ for (const r of results) {
350
+ if (r.emittedEntry) emitted.push(r.emittedEntry);
351
+ if (r.skippedEntry) skipped.push(r.skippedEntry);
352
+ if (r.diagnostic) diagnostics.push(r.diagnostic);
235
353
  }
236
354
 
237
355
  const hasFailures = emitted.some((e) => e.overall === "fail");
238
356
  const status: RfcVerificationEmitResult["status"] = hasFailures ? "fail" : "pass";
357
+ const delta = {
358
+ recovered: emitted
359
+ .filter((e) => e.previousOverall === "fail" && e.overall === "pass")
360
+ .map((e) => e.rfcId),
361
+ regressed: emitted
362
+ .filter((e) => e.previousOverall === "pass" && e.overall === "fail")
363
+ .map((e) => e.rfcId),
364
+ };
365
+ const deltaSummary =
366
+ delta.recovered.length + delta.regressed.length > 0
367
+ ? `, ${delta.regressed.length} regressed, ${delta.recovered.length} recovered`
368
+ : "";
239
369
 
240
370
  return {
241
371
  data: {
@@ -244,8 +374,9 @@ export async function runRfcVerificationEmit(
244
374
  emitted,
245
375
  skipped,
246
376
  diagnostics,
377
+ delta,
247
378
  },
248
379
  exitCode: hasFailures ? 1 : 0,
249
- summary: `rfc.verification.emit: ${emitted.length} emitted, ${skipped.length} skipped`,
380
+ summary: `rfc.verification.emit: ${emitted.length} emitted, ${skipped.length} skipped${deltaSummary}${dryRun ? ", dry-run" : ""}`,
250
381
  };
251
382
  }