@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
@@ -2,185 +2,51 @@
2
2
  <MODULE_CONTRACT>
3
3
  <purpose>spec.live.merge handler — extracts deltas from an RFC's ## Design section,
4
4
  classifies them as ADDED/MODIFIED/REMOVED, applies to a living spec with RFC-namespaced
5
- headings, and writes atomically (RFC-0711).</purpose>
5
+ headings, and writes atomically (RFC-0711). Idempotent since RFC-1230: an RFC already
6
+ present in spec history[] is skipped as already-merged unless --force re-merges it.</purpose>
6
7
  <non-goals>
7
8
  <item>Do not handle docs.archive integration — that is in core.module.ts.</item>
8
9
  <item>Do not validate living specs — that is spec.live.validate.</item>
10
+ <item>Do not repair corrupted specs — that is spec.live.rebuild.</item>
9
11
  </non-goals>
10
- </MODULE_CONTRACT>
11
12
  <CHANGE_SUMMARY>
12
13
  <item>RFC-0711: initial spec.live.merge handler with delta extraction, classification, and atomic writes.</item>
13
14
  <item>RFC-0957: namespace headings by RFC ID, remove conflict detection (structurally impossible with namespacing).</item>
15
+ <item>RFC-1230: idempotent merge — already-merged gate keyed on spec history[], --force surgical re-merge via removeNamespacedSections, fail-fast on spec file with unparseable frontmatter; pure helpers moved to live-spec-shared.ts.</item>
16
+ <item>RFC-1230: review findings — scoped droppedSections to namespaced headings, warn on unreadable spec, fail-fast merge on corrupt frontmatter, CHANGE_SUMMARY dedupe</item>
14
17
  </CHANGE_SUMMARY>
15
18
  */
16
19
 
17
20
  import { ambientIo as fs } from "../../src/utils/io.ts";
18
21
  import { existsSync } from "../../src/utils/sync-fs.ts";
19
22
  import path from "node:path";
20
- import YAML from "yaml";
21
23
  import type {
22
24
  ForgeCommandInput,
23
25
  ForgeCommandResult,
24
26
  ForgeRuntimeContext,
25
27
  } from "../../src/types.ts";
26
28
  import { writeFileIfChanged, buildGeneratedHeader } from "../../src/utils/index.ts";
27
- import { parseRfcFile, listRfcFiles } from "../rfc/frontmatter-io.ts";
29
+ import { parseRfcFile } from "../rfc/frontmatter-io.ts";
28
30
  import { RFC_DIR } from "../rfc/types.ts";
29
31
  import type {
30
32
  LivingSpec,
31
- LivingSpecHistoryEntry,
32
33
  DeltaOperation,
33
34
  SpecLiveMergeResult,
34
35
  } from "./live-spec-types.ts";
35
-
36
- const LIVE_SPECS_DIR = "docs/specs/live";
37
-
38
- interface ParsedHeading {
39
- level: number;
40
- text: string;
41
- body: string;
42
- }
43
-
44
- function extractDesignSection(rfcBody: string): string {
45
- const designMatch = rfcBody.match(/^##\s+Design\s*$/m);
46
- if (!designMatch) return "";
47
- const startIndex = designMatch.index! + designMatch[0].length;
48
- const nextH2Match = rfcBody.slice(startIndex).match(/^##\s+/m);
49
- if (!nextH2Match) {
50
- return rfcBody.slice(startIndex).trim();
51
- }
52
- return rfcBody.slice(startIndex, startIndex + nextH2Match.index!).trim();
53
- }
54
-
55
- function parseHeadings(content: string): ParsedHeading[] {
56
- const lines = content.split("\n");
57
- const headings: ParsedHeading[] = [];
58
- let currentHeading: ParsedHeading | null = null;
59
- let currentBody: string[] = [];
60
-
61
- for (const line of lines) {
62
- const headingMatch = line.match(/^(#{3,})\s+(.+)$/);
63
- if (headingMatch) {
64
- if (currentHeading) {
65
- currentHeading.body = currentBody.join("\n").trim();
66
- headings.push(currentHeading);
67
- }
68
- currentHeading = {
69
- level: headingMatch[1].length,
70
- text: headingMatch[2].trim(),
71
- body: "",
72
- };
73
- currentBody = [];
74
- } else if (currentHeading) {
75
- currentBody.push(line);
76
- }
77
- }
78
- if (currentHeading) {
79
- currentHeading.body = currentBody.join("\n").trim();
80
- headings.push(currentHeading);
81
- }
82
- return headings;
83
- }
84
-
85
- function deriveDomain(frontmatter: Record<string, unknown>): string | null {
86
- const liveSpec = frontmatter["liveSpec"];
87
- if (typeof liveSpec === "string" && liveSpec.length > 0) {
88
- return liveSpec;
89
- }
90
- if (liveSpec === true) {
91
- const packagesImpacted = frontmatter["packagesImpacted"];
92
- if (Array.isArray(packagesImpacted) && packagesImpacted.length > 0) {
93
- const firstPkg = String(packagesImpacted[0]);
94
- return firstPkg.replace(/^packages\//, "").replace(/^@[^/]+\//, "");
95
- }
96
- return null;
97
- }
98
- return null;
99
- }
100
-
101
- function parseLivingSpec(content: string): LivingSpec | null {
102
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
103
- if (!match) return null;
104
- const fm = (YAML.parse(match[1]!) ?? {}) as Record<string, unknown>;
105
- return {
106
- domain: String(fm["domain"] ?? ""),
107
- title: String(fm["title"] ?? ""),
108
- lastMergedRfc: String(fm["lastMergedRfc"] ?? ""),
109
- updatedAt: String(fm["updatedAt"] ?? ""),
110
- createdAt: String(fm["createdAt"] ?? ""),
111
- history: (Array.isArray(fm["history"]) ? fm["history"] : []) as LivingSpecHistoryEntry[],
112
- body: match[2] ?? "",
113
- };
114
- }
115
-
116
- function serializeLivingSpec(spec: LivingSpec): string {
117
- const fm: Record<string, unknown> = {
118
- domain: spec.domain,
119
- title: spec.title,
120
- lastMergedRfc: spec.lastMergedRfc,
121
- updatedAt: spec.updatedAt,
122
- createdAt: spec.createdAt,
123
- history: spec.history,
124
- };
125
- const fmStr = YAML.stringify(fm).trimEnd();
126
- return `---\n${fmStr}\n---\n\n${spec.body}`;
127
- }
128
-
129
- function findHeadingInSpec(spec: LivingSpec, headingText: string): ParsedHeading | null {
130
- const headings = parseHeadings(spec.body);
131
- return headings.find((h) => h.text === headingText) ?? null;
132
- }
133
-
134
- function applyDeltasToSpecBody(
135
- specBody: string,
136
- deltas: ParsedHeading[],
137
- operations: DeltaOperation[],
138
- ): string {
139
- let body = specBody;
140
- for (const delta of deltas) {
141
- const op = operations.find((o) => o.heading === delta.text);
142
- if (!op) continue;
143
-
144
- if (op.type === "added") {
145
- body = `${body}\n\n${"#".repeat(delta.level)} ${delta.text}\n\n${delta.body}`;
146
- } else if (op.type === "modified") {
147
- const headingRegex = new RegExp(
148
- `(${"#".repeat(delta.level)}\\s+${escapeRegex(delta.text)}\\s*\\n)([\\s\\S]*?)(?=#{3,}|$)`,
149
- );
150
- body = body.replace(headingRegex, `$1\n${delta.body}\n`);
151
- } else if (op.type === "removed") {
152
- const headingRegex = new RegExp(
153
- `\\n*${"#".repeat(delta.level)}\\s+${escapeRegex(delta.text)}\\s*\\n[\\s\\S]*?(?=#{3,}|$)`,
154
- );
155
- body = body.replace(headingRegex, "\n");
156
- }
157
- }
158
- return body.trim();
159
- }
160
-
161
- function escapeRegex(str: string): string {
162
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
163
- }
164
-
165
- function namespaceHeadings(content: string, rfcId: string): string {
166
- return content.replace(/^(#{3,})\s+(.+)$/gm, (_match, hashes: string, text: string) => {
167
- if (/\(RFC-\d{4}\)$/.test(text)) return `${hashes} ${text}`;
168
- return `${hashes} ${text} (${rfcId})`;
169
- });
170
- }
171
-
172
- function findRfcFile(rfcDir: string, rfcId: string): Promise<string | null> {
173
- return (async () => {
174
- const files = await listRfcFiles(rfcDir);
175
- for (const file of files) {
176
- const basename = path.basename(file);
177
- if (basename.toLowerCase().startsWith(rfcId.toLowerCase())) {
178
- return file;
179
- }
180
- }
181
- return null;
182
- })();
183
- }
36
+ import {
37
+ LIVE_SPECS_DIR,
38
+ extractDesignSection,
39
+ parseHeadings,
40
+ deriveDomain,
41
+ parseLivingSpec,
42
+ serializeLivingSpec,
43
+ findHeadingInSpec,
44
+ applyDeltasToSpecBody,
45
+ namespaceHeadings,
46
+ removeNamespacedSections,
47
+ findRfcFile,
48
+ seedSpecPrefix,
49
+ } from "./live-spec-shared.ts";
184
50
 
185
51
  export async function runSpecLiveMerge(
186
52
  input: ForgeCommandInput,
@@ -188,6 +54,7 @@ export async function runSpecLiveMerge(
188
54
  ): Promise<ForgeCommandResult<SpecLiveMergeResult>> {
189
55
  const { workspaceRoot, logger, outputFormat } = context;
190
56
  const rfcId = String(input.flags["id"] ?? "");
57
+ const force = input.flags["force"] === true;
191
58
  const dryRun = context.dryRun || input.flags["dry-run"] === true;
192
59
 
193
60
  if (!rfcId) {
@@ -284,6 +151,55 @@ export async function runSpecLiveMerge(
284
151
  if (existsSync(specFilePath)) {
285
152
  const specContent = await fs.readFile(specFilePath);
286
153
  existingSpec = parseLivingSpec(specContent);
154
+ // A spec file whose frontmatter cannot be parsed must not be silently
155
+ // overwritten by the creation path — that would discard its history[].
156
+ if (!existingSpec) {
157
+ return {
158
+ data: {
159
+ command: "spec.live.merge",
160
+ domain,
161
+ operation: "modified",
162
+ deltas: [],
163
+ conflicts: [],
164
+ dryRun,
165
+ },
166
+ exitCode: 1,
167
+ summary: `spec.live.merge: ${domain}.md exists but has no valid frontmatter — inspect or repair via spec.live.rebuild`,
168
+ };
169
+ }
170
+ }
171
+
172
+ // RFC-1230: idempotency gate — an RFC already in history[] is a no-op unless
173
+ // --force surgically re-merges (strips its namespaced sections + old entries).
174
+ if (existingSpec) {
175
+ const alreadyMerged = existingSpec.history.some((h) => h.rfc === rfcId);
176
+ if (alreadyMerged && !force) {
177
+ const result: SpecLiveMergeResult = {
178
+ command: "spec.live.merge",
179
+ domain,
180
+ operation: "already-merged",
181
+ deltas: [],
182
+ conflicts: [],
183
+ dryRun,
184
+ };
185
+ if (outputFormat === "pretty") {
186
+ logger.info(
187
+ `spec.live.merge: ${rfcId} already merged into "${domain}" — skipping (use --force to re-merge)`,
188
+ );
189
+ }
190
+ return {
191
+ data: result,
192
+ exitCode: 0,
193
+ summary: `spec.live.merge: ${rfcId} already merged into ${domain}`,
194
+ };
195
+ }
196
+ if (force) {
197
+ existingSpec = {
198
+ ...existingSpec,
199
+ body: removeNamespacedSections(existingSpec.body, rfcId),
200
+ history: existingSpec.history.filter((h) => h.rfc !== rfcId),
201
+ };
202
+ }
287
203
  }
288
204
 
289
205
  const operations: DeltaOperation[] = [];
@@ -307,7 +223,7 @@ export async function runSpecLiveMerge(
307
223
  updatedAt: today,
308
224
  createdAt: today,
309
225
  history: [{ rfc: rfcId, mergedAt: today, operation: "created" }],
310
- body: `${header}# Living Spec: ${domain}\n\n## Overview\n\n${designSection}`,
226
+ body: `${seedSpecPrefix(domain, header)}${designSection}`,
311
227
  };
312
228
 
313
229
  if (!dryRun) {
@@ -0,0 +1,317 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for spec.live.rebuild handler — dedup-replay of history[],
4
+ unreadable-RFC tolerance, unchanged/skipped operations, dry-run (RFC-1230).</purpose>
5
+ <non-goals>
6
+ <item>Do not test spec.live.merge — covered by live-spec-merge.test.ts.</item>
7
+ </non-goals>
8
+ </MODULE_CONTRACT>
9
+ <CHANGE_SUMMARY>
10
+ <item>RFC-1230: initial unit tests for spec.live.rebuild.</item>
11
+ <item>RFC-1230: review findings — scoped droppedSections to namespaced headings, warn on unreadable spec, fail-fast merge on corrupt frontmatter, CHANGE_SUMMARY dedupe</item>
12
+ <item>RFC-1231: step 1 — rename supportsAllSites to acceptsAllFlag
13
+
14
+ Mechanical sweep: the field only ever gated --all argv acceptance; fan-out
15
+ follows the parsed selector. Guard renamed assertAllSitesAllowed ->
16
+ assertAllFlagAccepted, message updated. 417 declaration sites + type
17
+ surfaces (KernelCommandMetadata, ForgeCommandMetadata) in one atomic pass.</item>
18
+ </CHANGE_SUMMARY>
19
+ */
20
+
21
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
22
+ import fs from "node:fs/promises";
23
+ import path from "node:path";
24
+ import os from "node:os";
25
+ import { runSpecLiveMerge } from "./live-spec-merge.ts";
26
+ import { runSpecLiveRebuild } from "./live-spec-rebuild.ts";
27
+ import { removeNamespacedSections } from "./live-spec-shared.ts";
28
+ import type { ForgeRuntimeContext } from "../../src/types.ts";
29
+
30
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
31
+ return {
32
+ workspaceRoot,
33
+ logger: {
34
+ section: () => {},
35
+ info: () => {},
36
+ warn: () => {},
37
+ error: () => {},
38
+ success: () => {},
39
+ },
40
+ dryRun: false,
41
+ outputFormat: "json",
42
+ };
43
+ }
44
+
45
+ function rfcFixture(id: string, extraFrontmatter = "", designBody = "### Heading A\n\nBody.") {
46
+ return `---
47
+ id: ${id}
48
+ title: "Test RFC ${id}"
49
+ status: implemented
50
+ kind: architecture
51
+ scope: workspace
52
+ owners:
53
+ - architecture
54
+ reviewers:
55
+ - human:test
56
+ createdAt: 2026-08-06
57
+ updatedAt: 2026-08-06
58
+ implementedAt: 2026-08-06
59
+ versionBump: patch
60
+ liveSpec: true
61
+ packagesImpacted:
62
+ - packages/forge${extraFrontmatter}
63
+ commands:
64
+ proposed: []
65
+ added: []
66
+ changed: []
67
+ removed: []
68
+ appsImpacted: []
69
+ successSignals: []
70
+ nonGoals: []
71
+ ---
72
+
73
+ # ${id}: Test RFC
74
+
75
+ ## Design
76
+
77
+ ${designBody}
78
+
79
+ ## Rollout
80
+
81
+ Done.
82
+ `;
83
+ }
84
+
85
+ async function setupWorkspace(): Promise<string> {
86
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "spec-live-rebuild-"));
87
+ const rfcDir = path.join(tmpDir, "docs/rfcs");
88
+ await fs.mkdir(rfcDir, { recursive: true });
89
+ await fs.writeFile(
90
+ path.join(rfcDir, "rfc-9001-first.md"),
91
+ rfcFixture("RFC-9001", "", "### CLI surface\n\ncli body.\n\n### Types\n\ntypes body."),
92
+ );
93
+ await fs.writeFile(
94
+ path.join(rfcDir, "rfc-9004-second.md"),
95
+ rfcFixture("RFC-9004", "", "### Other\n\nother body."),
96
+ );
97
+ // RFC-9006 exists but lacks ## Design — exercises the unreadable/skip path.
98
+ await fs.writeFile(
99
+ path.join(rfcDir, "rfc-9006-no-design.md"),
100
+ rfcFixture("RFC-9006").replace("## Design\n\n### Heading A\n\nBody.", "## Context\n\nNo design here."),
101
+ );
102
+ return tmpDir;
103
+ }
104
+
105
+ async function cleanupWorkspace(tmpDir: string): Promise<void> {
106
+ await fs.rm(tmpDir, { recursive: true, force: true });
107
+ }
108
+
109
+ const SPEC_FILE = "docs/specs/live/forge.md";
110
+
111
+ async function corruptSpec(tmpDir: string): Promise<void> {
112
+ const specFile = path.join(tmpDir, SPEC_FILE);
113
+ const corrupted = (await fs.readFile(specFile, "utf-8"))
114
+ .replace(
115
+ "### CLI surface (RFC-9001)\n\ncli body.",
116
+ "### CLI surface (RFC-9001)\n\ncli body.\n\n### CLI surface (RFC-9001)\n\nstale duplicate.",
117
+ )
118
+ .replace(
119
+ " - rfc: RFC-9004",
120
+ " - rfc: RFC-9001\n mergedAt: 2026-08-07\n operation: modified\n - rfc: RFC-9004",
121
+ );
122
+ await fs.writeFile(specFile, corrupted);
123
+ }
124
+
125
+ describe("spec.live.rebuild", () => {
126
+ let tmpDir: string;
127
+
128
+ beforeEach(async () => {
129
+ tmpDir = await setupWorkspace();
130
+ // Seed a clean spec via the real merge path.
131
+ await runSpecLiveMerge({ argv: [], flags: { id: "RFC-9001" } }, makeContext(tmpDir));
132
+ await runSpecLiveMerge({ argv: [], flags: { id: "RFC-9004" } }, makeContext(tmpDir));
133
+ });
134
+
135
+ afterEach(async () => {
136
+ await cleanupWorkspace(tmpDir);
137
+ });
138
+
139
+ it("repairs duplicated sections and history entries by replay (AC-4)", async () => {
140
+ await corruptSpec(tmpDir);
141
+
142
+ const result = await runSpecLiveRebuild(
143
+ { argv: [], flags: { domain: "forge" } },
144
+ makeContext(tmpDir),
145
+ );
146
+
147
+ expect(result.exitCode).toBe(0);
148
+ expect(result.data).toMatchObject({
149
+ command: "spec.live.rebuild",
150
+ domain: "forge",
151
+ operation: "rebuilt",
152
+ uniqueRfcs: 2,
153
+ droppedHistoryEntries: 1,
154
+ droppedSections: 1,
155
+ unreadableRfcs: [],
156
+ });
157
+
158
+ const content = await fs.readFile(path.join(tmpDir, SPEC_FILE), "utf-8");
159
+ expect(content).not.toContain("stale duplicate");
160
+ expect(content.match(/\(RFC-9001\)/g)?.length).toBe(2);
161
+ expect(content.match(/\(RFC-9004\)/g)?.length).toBe(1);
162
+ expect(content.match(/rfc: RFC-9001/g)?.length).toBe(1);
163
+ expect(content.match(/rfc: RFC-9004/g)?.length).toBe(1);
164
+ expect(content).toContain("lastMergedRfc: RFC-9004");
165
+ });
166
+
167
+ it("reports unchanged when a clean spec regenerates identically", async () => {
168
+ const result = await runSpecLiveRebuild(
169
+ { argv: [], flags: { domain: "forge" } },
170
+ makeContext(tmpDir),
171
+ );
172
+
173
+ expect(result.exitCode).toBe(0);
174
+ expect(result.data).toMatchObject({ operation: "unchanged", uniqueRfcs: 2 });
175
+ });
176
+
177
+ it("rebuild is idempotent — a second run after repair reports unchanged", async () => {
178
+ await corruptSpec(tmpDir);
179
+ await runSpecLiveRebuild({ argv: [], flags: { domain: "forge" } }, makeContext(tmpDir));
180
+ const second = await runSpecLiveRebuild(
181
+ { argv: [], flags: { domain: "forge" } },
182
+ makeContext(tmpDir),
183
+ );
184
+ expect(second.exitCode).toBe(0);
185
+ expect(second.data).toMatchObject({ operation: "unchanged" });
186
+ });
187
+
188
+ it("warns and continues on unreadable/missing RFCs, listing unreadableRfcs (AC-8)", async () => {
189
+ const specFile = path.join(tmpDir, SPEC_FILE);
190
+ const content = await fs.readFile(specFile, "utf-8");
191
+ await fs.writeFile(
192
+ specFile,
193
+ content
194
+ .replace(
195
+ "### CLI surface (RFC-9001)\n\ncli body.",
196
+ "### CLI surface (RFC-9001)\n\ncli body.\n\n### CLI surface (RFC-9001)\n\nstale duplicate.",
197
+ )
198
+ .replace(
199
+ " - rfc: RFC-9004",
200
+ " - rfc: RFC-9006\n mergedAt: 2026-08-07\n operation: modified\n - rfc: RFC-9999\n mergedAt: 2026-08-08\n operation: modified\n - rfc: RFC-9004",
201
+ ),
202
+ );
203
+
204
+ const result = await runSpecLiveRebuild(
205
+ { argv: [], flags: { domain: "forge" } },
206
+ makeContext(tmpDir),
207
+ );
208
+
209
+ expect(result.exitCode).toBe(0);
210
+ expect(result.data).toMatchObject({
211
+ operation: "rebuilt",
212
+ unreadableRfcs: ["RFC-9006", "RFC-9999"],
213
+ });
214
+
215
+ const after = await fs.readFile(specFile, "utf-8");
216
+ expect(after).toContain("rfc: RFC-9006");
217
+ expect(after).toContain("rfc: RFC-9999");
218
+ expect(after).not.toContain("(RFC-9006)");
219
+ expect(after).toContain("lastMergedRfc: RFC-9004");
220
+ });
221
+
222
+ it("writes nothing under --dry-run (AC-9)", async () => {
223
+ await corruptSpec(tmpDir);
224
+ const specFile = path.join(tmpDir, SPEC_FILE);
225
+ const before = await fs.readFile(specFile, "utf-8");
226
+
227
+ const ctx = makeContext(tmpDir);
228
+ ctx.dryRun = true;
229
+ const result = await runSpecLiveRebuild(
230
+ { argv: [], flags: { domain: "forge", "dry-run": true } },
231
+ ctx,
232
+ );
233
+
234
+ expect(result.data).toMatchObject({ operation: "rebuilt", dryRun: true });
235
+ expect(await fs.readFile(specFile, "utf-8")).toBe(before);
236
+ });
237
+
238
+ it("defaults to all specs when no selector is given; rejects --domain + --all", async () => {
239
+ // Under `werkstatt run` the kernel consumes --all as its site selector —
240
+ // "no --domain" is therefore the all-specs signal there (acceptsAllFlag).
241
+ const bare = await runSpecLiveRebuild({ argv: [], flags: {} }, makeContext(tmpDir));
242
+ expect(bare.exitCode).toBe(0);
243
+ expect((bare.data as { domains: unknown[] }).domains.length).toBe(1);
244
+
245
+ const both = await runSpecLiveRebuild(
246
+ { argv: [], flags: { domain: "forge", all: true } },
247
+ makeContext(tmpDir),
248
+ );
249
+ expect(both.exitCode).toBe(1);
250
+ });
251
+
252
+ it("removeNamespacedSections removes nothing when only ### matches are fence-interior (RFC-1232 AC-3)", () => {
253
+ const body = [
254
+ "## Overview",
255
+ "",
256
+ "Some prose.",
257
+ "",
258
+ "```md",
259
+ "### Looks like a heading (RFC-9004)",
260
+ "### Also looks like one (RFC-9004)",
261
+ "```",
262
+ ].join("\n");
263
+ expect(removeNamespacedSections(body, "RFC-9004")).toBe(body);
264
+ });
265
+
266
+ it("treats content after an unterminated fence as interior (RFC-1232)", () => {
267
+ const body = "## Overview\n\n```\n### Hidden (RFC-9004)\n### Also hidden (RFC-9004)\n";
268
+ expect(removeNamespacedSections(body, "RFC-9004")).toBe(body.trim());
269
+ });
270
+
271
+ it("replays RFC Design containing fenced ### lines without corrupting them (RFC-1232)", async () => {
272
+ await fs.writeFile(
273
+ path.join(tmpDir, "docs/rfcs/rfc-9007-fenced-design.md"),
274
+ rfcFixture(
275
+ "RFC-9007",
276
+ "",
277
+ "### CLI surface\n\n```sh\n### a comment inside the fence\nwerkstatt run spec.live.rebuild\n```\n",
278
+ ),
279
+ );
280
+ await runSpecLiveMerge({ argv: [], flags: { id: "RFC-9007" } }, makeContext(tmpDir));
281
+
282
+ const result = await runSpecLiveRebuild(
283
+ { argv: [], flags: { domain: "forge" } },
284
+ makeContext(tmpDir),
285
+ );
286
+
287
+ expect(result.exitCode).toBe(0);
288
+ const content = await fs.readFile(path.join(tmpDir, SPEC_FILE), "utf-8");
289
+ expect(content).toContain("### a comment inside the fence\n");
290
+ expect(content).not.toContain("a comment inside the fence (RFC-9007)");
291
+ expect(content).toContain("### CLI surface (RFC-9007)");
292
+ });
293
+
294
+ it("exits 1 when --domain names a nonexistent spec", async () => {
295
+ const result = await runSpecLiveRebuild(
296
+ { argv: [], flags: { domain: "nonexistent" } },
297
+ makeContext(tmpDir),
298
+ );
299
+ expect(result.exitCode).toBe(1);
300
+ expect(result.summary).toContain("not found");
301
+ });
302
+
303
+ it("reports skipped for a spec with empty history", async () => {
304
+ await fs.writeFile(
305
+ path.join(tmpDir, "docs/specs/live/empty.md"),
306
+ `---\ndomain: empty\ntitle: "Living Spec: empty"\nlastMergedRfc: ""\nupdatedAt: 2026-08-06\ncreatedAt: 2026-08-06\nhistory: []\n---\n\n# Living Spec: empty\n\n## Overview\n`,
307
+ );
308
+ const result = await runSpecLiveRebuild(
309
+ { argv: [], flags: { all: true } },
310
+ makeContext(tmpDir),
311
+ );
312
+ expect(result.exitCode).toBe(0);
313
+ const domains = (result.data as { domains: Array<{ domain: string; operation: string }> }).domains;
314
+ expect(domains.find((d) => d.domain === "empty")?.operation).toBe("skipped");
315
+ expect(domains.find((d) => d.domain === "forge")?.operation).toBe("unchanged");
316
+ });
317
+ });