@warpgogol/forge 5.3.3 → 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 (282) 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 +48 -16
  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/compass-inventory.d.ts.map +1 -1
  23. package/dist/os/compass/handlers/compass-inventory.js +5 -2
  24. package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
  25. package/dist/os/compass/handlers/summary-record.d.ts +17 -3
  26. package/dist/os/compass/handlers/summary-record.d.ts.map +1 -1
  27. package/dist/os/compass/handlers/summary-record.js +107 -10
  28. package/dist/os/compass/handlers/summary-record.js.map +1 -1
  29. package/dist/os/compass/handlers/tests/forge-yaml-fixture.d.ts +18 -0
  30. package/dist/os/compass/handlers/tests/forge-yaml-fixture.d.ts.map +1 -0
  31. package/dist/os/compass/handlers/tests/forge-yaml-fixture.js +64 -0
  32. package/dist/os/compass/handlers/tests/forge-yaml-fixture.js.map +1 -0
  33. package/dist/os/compass/policy.d.ts +6 -1
  34. package/dist/os/compass/policy.d.ts.map +1 -1
  35. package/dist/os/compass/policy.js +8 -2
  36. package/dist/os/compass/policy.js.map +1 -1
  37. package/dist/os/core/core.module.d.ts.map +1 -1
  38. package/dist/os/core/core.module.js +124 -73
  39. package/dist/os/core/core.module.js.map +1 -1
  40. package/dist/os/exploration/exploration.module.d.ts.map +1 -1
  41. package/dist/os/exploration/exploration.module.js +3 -0
  42. package/dist/os/exploration/exploration.module.js.map +1 -1
  43. package/dist/os/mission/handlers/archive.d.ts.map +1 -1
  44. package/dist/os/mission/handlers/archive.js +1 -2
  45. package/dist/os/mission/handlers/archive.js.map +1 -1
  46. package/dist/os/naming/naming.module.d.ts.map +1 -1
  47. package/dist/os/naming/naming.module.js +9 -2
  48. package/dist/os/naming/naming.module.js.map +1 -1
  49. package/dist/os/notes/notes.module.d.ts.map +1 -1
  50. package/dist/os/notes/notes.module.js +17 -6
  51. package/dist/os/notes/notes.module.js.map +1 -1
  52. package/dist/os/plan/plan.module.d.ts.map +1 -1
  53. package/dist/os/plan/plan.module.js +2 -0
  54. package/dist/os/plan/plan.module.js.map +1 -1
  55. package/dist/os/plugin/plugin.module.d.ts.map +1 -1
  56. package/dist/os/plugin/plugin.module.js +9 -2
  57. package/dist/os/plugin/plugin.module.js.map +1 -1
  58. package/dist/os/program/program.module.d.ts.map +1 -1
  59. package/dist/os/program/program.module.js +19 -4
  60. package/dist/os/program/program.module.js.map +1 -1
  61. package/dist/os/queue/handlers/queue-validate.d.ts.map +1 -1
  62. package/dist/os/queue/handlers/queue-validate.js +154 -7
  63. package/dist/os/queue/handlers/queue-validate.js.map +1 -1
  64. package/dist/os/queue/manifest.d.ts +17 -1
  65. package/dist/os/queue/manifest.d.ts.map +1 -1
  66. package/dist/os/queue/manifest.js +127 -6
  67. package/dist/os/queue/manifest.js.map +1 -1
  68. package/dist/os/queue/queue.module.d.ts.map +1 -1
  69. package/dist/os/queue/queue.module.js +32 -4
  70. package/dist/os/queue/queue.module.js.map +1 -1
  71. package/dist/os/queue/types.d.ts +100 -1
  72. package/dist/os/queue/types.d.ts.map +1 -1
  73. package/dist/os/queue/types.js +44 -0
  74. package/dist/os/queue/types.js.map +1 -1
  75. package/dist/os/rfc/handlers/implement-stamp.d.ts.map +1 -1
  76. package/dist/os/rfc/handlers/implement-stamp.js +28 -5
  77. package/dist/os/rfc/handlers/implement-stamp.js.map +1 -1
  78. package/dist/os/rfc/handlers/validate.js +1 -1
  79. package/dist/os/rfc/rfc.module.d.ts.map +1 -1
  80. package/dist/os/rfc/rfc.module.js +27 -3
  81. package/dist/os/rfc/rfc.module.js.map +1 -1
  82. package/dist/os/rfc/types.d.ts +11 -0
  83. package/dist/os/rfc/types.d.ts.map +1 -1
  84. package/dist/os/rfc/types.js +1 -0
  85. package/dist/os/rfc/types.js.map +1 -1
  86. package/dist/os/rfc/verification-evidence.d.ts +21 -0
  87. package/dist/os/rfc/verification-evidence.d.ts.map +1 -1
  88. package/dist/os/rfc/verification-evidence.js +140 -23
  89. package/dist/os/rfc/verification-evidence.js.map +1 -1
  90. package/dist/os/rfc/verification-refresh.d.ts.map +1 -1
  91. package/dist/os/rfc/verification-refresh.js +72 -37
  92. package/dist/os/rfc/verification-refresh.js.map +1 -1
  93. package/dist/os/session/handlers/save.d.ts.map +1 -1
  94. package/dist/os/session/handlers/save.js +46 -5
  95. package/dist/os/session/handlers/save.js.map +1 -1
  96. package/dist/os/session/session.module.d.ts.map +1 -1
  97. package/dist/os/session/session.module.js +5 -0
  98. package/dist/os/session/session.module.js.map +1 -1
  99. package/dist/os/spec/live-spec-merge.d.ts.map +1 -1
  100. package/dist/os/spec/live-spec-merge.js +55 -134
  101. package/dist/os/spec/live-spec-merge.js.map +1 -1
  102. package/dist/os/spec/live-spec-rebuild.d.ts +32 -0
  103. package/dist/os/spec/live-spec-rebuild.d.ts.map +1 -0
  104. package/dist/os/spec/live-spec-rebuild.js +272 -0
  105. package/dist/os/spec/live-spec-rebuild.js.map +1 -0
  106. package/dist/os/spec/live-spec-shared.d.ts +62 -0
  107. package/dist/os/spec/live-spec-shared.d.ts.map +1 -0
  108. package/dist/os/spec/live-spec-shared.js +315 -0
  109. package/dist/os/spec/live-spec-shared.js.map +1 -0
  110. package/dist/os/spec/live-spec-types.d.ts +17 -1
  111. package/dist/os/spec/live-spec-types.d.ts.map +1 -1
  112. package/dist/os/spec/live-spec-types.js +3 -0
  113. package/dist/os/spec/live-spec-types.js.map +1 -1
  114. package/dist/os/spec/live-spec-validate.d.ts.map +1 -1
  115. package/dist/os/spec/live-spec-validate.js +105 -2
  116. package/dist/os/spec/live-spec-validate.js.map +1 -1
  117. package/dist/os/spec/spec-materialize.d.ts +2 -0
  118. package/dist/os/spec/spec-materialize.d.ts.map +1 -1
  119. package/dist/os/spec/spec-materialize.js +45 -5
  120. package/dist/os/spec/spec-materialize.js.map +1 -1
  121. package/dist/os/spec/spec-schema.d.ts +19 -0
  122. package/dist/os/spec/spec-schema.d.ts.map +1 -1
  123. package/dist/os/spec/spec-schema.js +30 -0
  124. package/dist/os/spec/spec-schema.js.map +1 -1
  125. package/dist/os/spec/spec-status.d.ts +4 -0
  126. package/dist/os/spec/spec-status.d.ts.map +1 -1
  127. package/dist/os/spec/spec-status.js +19 -1
  128. package/dist/os/spec/spec-status.js.map +1 -1
  129. package/dist/os/spec/spec-validate.d.ts.map +1 -1
  130. package/dist/os/spec/spec-validate.js +55 -8
  131. package/dist/os/spec/spec-validate.js.map +1 -1
  132. package/dist/os/spec/spec.module.d.ts.map +1 -1
  133. package/dist/os/spec/spec.module.js +84 -8
  134. package/dist/os/spec/spec.module.js.map +1 -1
  135. package/dist/os/werkstatt/handlers/werkstatt-operation-validate.d.ts.map +1 -1
  136. package/dist/os/werkstatt/handlers/werkstatt-operation-validate.js +10 -4
  137. package/dist/os/werkstatt/handlers/werkstatt-operation-validate.js.map +1 -1
  138. package/dist/os/werkstatt/werkstatt.module.d.ts.map +1 -1
  139. package/dist/os/werkstatt/werkstatt.module.js +13 -4
  140. package/dist/os/werkstatt/werkstatt.module.js.map +1 -1
  141. package/dist/os/workflow/workflow.module.d.ts.map +1 -1
  142. package/dist/os/workflow/workflow.module.js +13 -5
  143. package/dist/os/workflow/workflow.module.js.map +1 -1
  144. package/dist/src/cli-flags.d.ts.map +1 -1
  145. package/dist/src/cli-flags.js +25 -5
  146. package/dist/src/cli-flags.js.map +1 -1
  147. package/dist/src/config/forge-config.d.ts +24 -3
  148. package/dist/src/config/forge-config.d.ts.map +1 -1
  149. package/dist/src/config/forge-config.js +38 -7
  150. package/dist/src/config/forge-config.js.map +1 -1
  151. package/dist/src/onboarding/create.d.ts.map +1 -1
  152. package/dist/src/onboarding/create.js +26 -10
  153. package/dist/src/onboarding/create.js.map +1 -1
  154. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  155. package/dist/src/onboarding/doctor.js +49 -2
  156. package/dist/src/onboarding/doctor.js.map +1 -1
  157. package/dist/src/onboarding/init.d.ts.map +1 -1
  158. package/dist/src/onboarding/init.js +8 -3
  159. package/dist/src/onboarding/init.js.map +1 -1
  160. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  161. package/dist/src/onboarding/upgrade.js +24 -8
  162. package/dist/src/onboarding/upgrade.js.map +1 -1
  163. package/dist/src/pipeline-status.d.ts +14 -1
  164. package/dist/src/pipeline-status.d.ts.map +1 -1
  165. package/dist/src/pipeline-status.js +28 -8
  166. package/dist/src/pipeline-status.js.map +1 -1
  167. package/dist/src/types.d.ts +7 -1
  168. package/dist/src/types.d.ts.map +1 -1
  169. package/dist/src/types.js +6 -0
  170. package/dist/src/types.js.map +1 -1
  171. package/dist/src/utils/generated-marker.d.ts +8 -0
  172. package/dist/src/utils/generated-marker.d.ts.map +1 -1
  173. package/dist/src/utils/generated-marker.js +25 -0
  174. package/dist/src/utils/generated-marker.js.map +1 -1
  175. package/dist/src/validators/skill-validate.d.ts.map +1 -1
  176. package/dist/src/validators/skill-validate.js +42 -15
  177. package/dist/src/validators/skill-validate.js.map +1 -1
  178. package/os/adr/adr.module.ts +2 -0
  179. package/os/audit/audit.module.ts +2 -0
  180. package/os/compass/compass.module.ts +50 -16
  181. package/os/compass/handlers/compass-audit-handler.ts +50 -9
  182. package/os/compass/handlers/compass-docs-validate.ts +359 -0
  183. package/os/compass/handlers/compass-inventory.ts +6 -3
  184. package/os/compass/handlers/summary-record.ts +118 -10
  185. package/os/compass/handlers/tests/compass-audit-record.test.ts +2 -1
  186. package/os/compass/handlers/tests/compass-audit-validate.test.ts +92 -3
  187. package/os/compass/handlers/tests/compass-docs-validate.test.ts +203 -0
  188. package/os/compass/handlers/tests/compass-ledger-scope.test.ts +2 -1
  189. package/os/compass/handlers/tests/compass-policy.test.ts +15 -18
  190. package/os/compass/handlers/tests/compass-v2-contract.test.ts +38 -0
  191. package/os/compass/handlers/tests/forge-yaml-fixture.ts +69 -0
  192. package/os/compass/handlers/tests/summary-record.test.ts +152 -0
  193. package/os/compass/policy.ts +8 -2
  194. package/os/core/core.module.ts +147 -75
  195. package/os/core/docs-archive.test.ts +136 -0
  196. package/os/exploration/exploration.module.ts +3 -0
  197. package/os/mission/handlers/archive.ts +1 -2
  198. package/os/naming/naming.module.ts +9 -2
  199. package/os/notes/notes.module.ts +17 -6
  200. package/os/plan/plan-0000-template.md +150 -0
  201. package/os/plan/plan.module.ts +2 -0
  202. package/os/plugin/plugin.module.ts +9 -2
  203. package/os/program/program.module.ts +19 -4
  204. package/os/queue/decision-ledger.test.ts +163 -0
  205. package/os/queue/handlers/queue-validate.ts +190 -8
  206. package/os/queue/manifest.ts +201 -7
  207. package/os/queue/queue-validate.test.ts +303 -0
  208. package/os/queue/queue.module.ts +32 -4
  209. package/os/queue/types.ts +56 -1
  210. package/os/rfc/handlers/implement-stamp.ts +35 -5
  211. package/os/rfc/handlers/validate.ts +1 -1
  212. package/os/rfc/rfc.module.ts +29 -3
  213. package/os/rfc/types.ts +17 -1
  214. package/os/rfc/verification-emit.test.ts +305 -0
  215. package/os/rfc/verification-evidence.ts +155 -24
  216. package/os/rfc/verification-refresh.test.ts +222 -1
  217. package/os/rfc/verification-refresh.ts +80 -38
  218. package/os/session/handlers/save.ts +41 -5
  219. package/os/session/session.module.ts +5 -0
  220. package/os/spec/live-spec-idempotency.pbt.test.ts +222 -0
  221. package/os/spec/live-spec-list-show-validate.test.ts +10 -6
  222. package/os/spec/live-spec-merge.test.ts +187 -7
  223. package/os/spec/live-spec-merge.ts +71 -155
  224. package/os/spec/live-spec-rebuild.test.ts +317 -0
  225. package/os/spec/live-spec-rebuild.ts +356 -0
  226. package/os/spec/live-spec-shared.ts +378 -0
  227. package/os/spec/live-spec-types.ts +21 -1
  228. package/os/spec/live-spec-validate.test.ts +317 -0
  229. package/os/spec/live-spec-validate.ts +106 -2
  230. package/os/spec/spec-materialize.test.ts +192 -0
  231. package/os/spec/spec-materialize.ts +48 -4
  232. package/os/spec/spec-schema.ts +49 -0
  233. package/os/spec/spec-status.ts +24 -0
  234. package/os/spec/spec-validate.test.ts +66 -0
  235. package/os/spec/spec-validate.ts +62 -8
  236. package/os/spec/spec.module.ts +85 -8
  237. package/os/werkstatt/handlers/werkstatt-operation-validate.ts +10 -4
  238. package/os/werkstatt/werkstatt.module.ts +13 -4
  239. package/os/workflow/workflow.module.ts +13 -5
  240. package/package.json +1 -1
  241. package/skills/_shared/fo-pipeline-conventions.md +126 -1
  242. package/skills/fo/fo-compass-annotate/SKILL.md +1 -1
  243. package/skills/fo/fo-doc-audit/SKILL.md +4 -4
  244. package/skills/fo/fo-explore/SKILL.md +2 -1
  245. package/skills/fo/fo-fix/SKILL.md +11 -3
  246. package/skills/fo/fo-handoff/SKILL.md +6 -8
  247. package/skills/fo/fo-idea-audit/SKILL.md +1 -1
  248. package/skills/fo/fo-idea-create-rfc/SKILL.md +3 -2
  249. package/skills/fo/fo-idea-enhance/SKILL.md +23 -4
  250. package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +86 -22
  251. package/skills/fo/fo-idea-implement/SKILL.md +57 -13
  252. package/skills/fo/fo-idea-plan/SKILL.md +28 -6
  253. package/skills/fo/fo-review/SKILL.md +69 -8
  254. package/skills/fo/fo-session-retro/SKILL.md +4 -3
  255. package/skills/fo/fo-session-save/SKILL.md +1 -1
  256. package/skills/fo/fo-step-commit/SKILL.md +2 -0
  257. package/skills/shared/grilling/SKILL.md +12 -0
  258. package/skills/shared/writing-great-skills/GLOSSARY.md +2 -0
  259. package/skills/shared/writing-great-skills/SKILL.md +2 -1
  260. package/src/cli-flags.ts +23 -5
  261. package/src/config/forge-config.ts +106 -57
  262. package/src/onboarding/create.ts +25 -9
  263. package/src/onboarding/doctor.ts +51 -2
  264. package/src/onboarding/init.ts +8 -3
  265. package/src/onboarding/upgrade.ts +36 -6
  266. package/src/pipeline-status.ts +32 -8
  267. package/src/tests/bindings-schema.test.ts +53 -1
  268. package/src/tests/cli-flags.test.ts +20 -0
  269. package/src/tests/cli-output.test.ts +13 -7
  270. package/src/tests/doctor-bindings.test.ts +7 -2
  271. package/src/tests/doctor-skills-unmanaged.test.ts +145 -0
  272. package/src/tests/fixtures/agents-generate-business-before.txt +7 -7
  273. package/src/tests/forge-config.test.ts +106 -2
  274. package/src/tests/implement-stamp.test.ts +38 -0
  275. package/src/tests/session-handlers.test.ts +33 -0
  276. package/src/tests/skill-validate.test.ts +180 -0
  277. package/src/tests/upgrade.test.ts +60 -0
  278. package/src/types.ts +13 -1
  279. package/src/utils/generated-marker.ts +28 -0
  280. package/src/validators/skill-validate.ts +43 -16
  281. package/skills/fo/fo-idea-implement/ADR-FLOW.md +0 -179
  282. package/skills/fo/fo-review/AXES.md +0 -70
@@ -0,0 +1,359 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>RFC-1242: validate that the live root docs/*.xml corpus describes the repository as it exists — every path token, workspace id, and knowledge-graph link target must resolve to a real file, directory, or declared node.</purpose>
4
+ <non-goals>
5
+ <item>Do not scan archived trees, vendored specs, sessions/metrics, or authored RFC/ADR/plan bodies — frozen history and forward-looking prose legitimately name paths that do not exist.</item>
6
+ <item>Do not validate markdown link targets — only the XML semantic layer is gated (markdown sweep is manual).</item>
7
+ <item>Do not mutate anything — this handler is read-only; pruning lives on compass.audit.validate --prune.</item>
8
+ </non-goals>
9
+ <KEY_DECISIONS>
10
+ <item>Tag-stack tokenizer instead of an XML dependency — the corpus is small authored XML; a balance check catches the literal-tag breakage class (COMPASS-DOC-00) without pulling fast-xml-parser.</item>
11
+ <item>DOC-03 link targets resolve to declared node ids OR file/dir conventions — dangling-by-convention stays a warning, never an error.</item>
12
+ </KEY_DECISIONS>
13
+ <CHANGE_SUMMARY>
14
+ <item>RFC-1242: review fixes — self-close detection strips quoted attr values first (a="foo/" no longer fakes a self-close), non-backticked path tokens get the same ellipsis strip, docs.* link slugs keep their basename prefix (docs.plans.plan-rfc-* resolves).</item>
15
+ <item>RFC-1242: created — scan docs/*.xml for unresolvable paths, workspace ids, and link targets (COMPASS-DOC-00..03).</item>
16
+ </CHANGE_SUMMARY>
17
+ */
18
+
19
+ import { resolve } from "node:path";
20
+ import { resolveIo } from "../../../src/utils/io.ts";
21
+ import type { Diagnostic, WorkspaceIO } from "../../../src/types.ts";
22
+ import type {
23
+ ForgeCommandInput,
24
+ ForgeCommandResult,
25
+ ForgeRuntimeContext,
26
+ } from "../../../src/types.ts";
27
+
28
+ const DOCS_GLOB = "*.xml";
29
+
30
+ // Well-formedness: minimal tag-stack check. Skips comments, CDATA, PIs and
31
+ // declarations; a literal `<name` inside prose opens a tag that never balances
32
+ // → COMPASS-DOC-00. The corpus is authored XML without < in attribute values.
33
+ function checkWellFormed(file: string, source: string): { line: number; message: string } | null {
34
+ const stack: Array<{ name: string; line: number }> = [];
35
+ let i = 0;
36
+ let line = 1;
37
+ const advanceLine = (chunk: string) => {
38
+ for (const c of chunk) if (c === "\n") line++;
39
+ };
40
+ while (i < source.length) {
41
+ const open = source.indexOf("<", i);
42
+ if (open === -1) break;
43
+ advanceLine(source.slice(i, open));
44
+ const rest = source.slice(open);
45
+ // comments / CDATA / PI / declarations
46
+ if (rest.startsWith("<!--")) {
47
+ const end = source.indexOf("-->", open + 4);
48
+ if (end === -1) return { line, message: "unterminated comment" };
49
+ advanceLine(source.slice(open, end + 3));
50
+ i = end + 3;
51
+ continue;
52
+ }
53
+ if (rest.startsWith("<![CDATA[")) {
54
+ const end = source.indexOf("]]>", open + 9);
55
+ if (end === -1) return { line, message: "unterminated CDATA" };
56
+ advanceLine(source.slice(open, end + 3));
57
+ i = end + 3;
58
+ continue;
59
+ }
60
+ if (rest.startsWith("<?")) {
61
+ const end = source.indexOf("?>", open + 2);
62
+ if (end === -1) return { line, message: "unterminated processing instruction" };
63
+ advanceLine(source.slice(open, end + 2));
64
+ i = end + 2;
65
+ continue;
66
+ }
67
+ if (rest.startsWith("<!")) {
68
+ const end = source.indexOf(">", open + 2);
69
+ if (end === -1) return { line, message: "unterminated declaration" };
70
+ advanceLine(source.slice(open, end + 1));
71
+ i = end + 1;
72
+ continue;
73
+ }
74
+ const tagMatch = /^<(\/?)([A-Za-z_][\w:.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)?(\/?)>/.exec(rest);
75
+ if (!tagMatch) {
76
+ // `<` not starting a tag — literal text, tolerated.
77
+ advanceLine(source.slice(open, open + 1));
78
+ i = open + 1;
79
+ continue;
80
+ }
81
+ const [, closing, name, attrs = "", selfClose] = tagMatch;
82
+ advanceLine(tagMatch[0]);
83
+ i = open + tagMatch[0].length;
84
+ if (closing === "/") {
85
+ const top = stack.pop();
86
+ if (!top || top.name !== name) {
87
+ return {
88
+ line,
89
+ message: `mismatched closing tag </${name}>${top ? ` — expected </${top.name}> opened at line ${top.line}` : " — no open tag"}`,
90
+ };
91
+ }
92
+ continue;
93
+ }
94
+ const attrsUnquoted = attrs.replace(/"[^"]*"|'[^']*'/g, "");
95
+ if (selfClose !== "/" && !attrsUnquoted.trimEnd().endsWith("/")) {
96
+ stack.push({ name, line });
97
+ }
98
+ }
99
+ if (stack.length > 0) {
100
+ const top = stack[stack.length - 1]!;
101
+ return { line: top.line, message: `unclosed tag <${top.name}>` };
102
+ }
103
+ return null;
104
+ }
105
+
106
+ interface ExtractedRefs {
107
+ paths: Array<{ value: string; line: number }>;
108
+ workspaceIds: Array<{ id: string; line: number }>;
109
+ linkTargets: Array<{ target: string; line: number }>;
110
+ }
111
+
112
+ function extractRefs(source: string): ExtractedRefs {
113
+ const refs: ExtractedRefs = { paths: [], workspaceIds: [], linkTargets: [] };
114
+ const lineOf = (index: number) => source.slice(0, index).split("\n").length;
115
+ for (const m of source.matchAll(/<path>([\s\S]*?)<\/path>/g)) {
116
+ refs.paths.push({ value: m[1] ?? "", line: lineOf(m.index) });
117
+ }
118
+ for (const m of source.matchAll(/<workspace\s[^>]*?id="([^"]+)"/g)) {
119
+ refs.workspaceIds.push({ id: m[1]!, line: lineOf(m.index) });
120
+ }
121
+ for (const m of source.matchAll(/<node\s[^>]*>/g)) {
122
+ // DOC-02 only applies to ids that claim a workspace: <workspace> entries
123
+ // and workspace-typed nodes. Layer/symbolic ids (apps.rules,
124
+ // packages.rules, changelog.*) name semantic handles, not directories.
125
+ const id = /\bid="([^"]+)"/.exec(m[0])?.[1];
126
+ const type = /\btype="([^"]+)"/.exec(m[0])?.[1];
127
+ if (id && type === "workspace") {
128
+ refs.workspaceIds.push({ id, line: lineOf(m.index) });
129
+ }
130
+ }
131
+ for (const m of source.matchAll(/<link\s[^>]*?target="([^"]+)"/g)) {
132
+ refs.linkTargets.push({ target: m[1]!, line: lineOf(m.index) });
133
+ }
134
+ return refs;
135
+ }
136
+
137
+ // Normalize a <path> payload into checkable path tokens. Each backticked
138
+ // fragment is one candidate — splitting on commas inside would shred brace
139
+ // expansions (`{a,b}`) and ellipsis ranges (`docs/x/…` … `leaf`). A bare tail
140
+ // fragment after an `…` outside the backticks resolves under the previous
141
+ // fragment's directory (range shorthand for two versions of the same dir).
142
+ function pathTokens(raw: string): string[] {
143
+ const tokens: string[] = [];
144
+ const fragments = [...raw.matchAll(/`([^`]*)`/g)].map((m) => m[1]!.trim());
145
+ if (fragments.length === 0) {
146
+ return raw
147
+ .split(/[\s,]+/)
148
+ .map((t) =>
149
+ t
150
+ .trim()
151
+ .replace(/(?:…|\.\.\.).*$/, "")
152
+ .replace(/\/+$/, ""),
153
+ )
154
+ .filter((t) => t.length > 0 && !t.startsWith("<") && t !== "…" && t !== "+");
155
+ }
156
+ const joinsWithEllipsis = raw.replace(/`[^`]*`/g, "").includes("…");
157
+ let parentDir: string | null = null;
158
+ for (const frag of fragments) {
159
+ const cleaned = frag.replace(/(?:…|\.\.\.).*$/, "").replace(/\/+$/, "");
160
+ if (!cleaned || cleaned === "+") continue;
161
+ if (joinsWithEllipsis && !cleaned.includes("/") && parentDir) {
162
+ tokens.push(`${parentDir}/${cleaned}`);
163
+ continue;
164
+ }
165
+ tokens.push(cleaned);
166
+ parentDir = cleaned.split("/").slice(0, -1).join("/") || parentDir;
167
+ }
168
+ return tokens;
169
+ }
170
+
171
+ /** Directory prefix that must exist for a path token (glob tails trimmed). */
172
+ function pathCheckTarget(token: string): string {
173
+ const globIdx = token.search(/[*{[]/);
174
+ if (globIdx === -1) return token;
175
+ const prefix = token.slice(0, globIdx);
176
+ return prefix.replace(/\/[^/]*$/, "") || ".";
177
+ }
178
+
179
+ /** Resolve a workspace-style id to the package/service directory it names. */
180
+ function workspaceIdTarget(id: string): string | null {
181
+ const pkg = /^pkg-([a-z0-9-]+)$/.exec(id);
182
+ if (pkg) return `packages/${pkg[1]}`;
183
+ const svc = /^svc-([a-z0-9-]+)$/.exec(id);
184
+ if (svc) return `services/${svc[1]}`;
185
+ const app = /^app-([a-z0-9-]+)$/.exec(id);
186
+ if (app) return `apps/${app[1]}`;
187
+ const dotted = /^(packages|apps|services)\.([a-z0-9-]+)/.exec(id);
188
+ if (dotted) return `${dotted[1]}/${dotted[2]}`;
189
+ return null;
190
+ }
191
+
192
+ /**
193
+ * DOC-03 resolution for dotted link targets: declared node id (checked by the
194
+ * caller) or a file/dir convention — docs.<dir>.<slug> → docs/<dir>/<slug>*,
195
+ * packages./apps./services. → workspace dir, spec.<x> → docs/specs/<x>*,
196
+ * dna-N → docs/architecture-dna.md, dotted command names → command manifest.
197
+ */
198
+ async function linkTargetResolves(
199
+ target: string,
200
+ declaredIds: Set<string>,
201
+ io: WorkspaceIO,
202
+ docsDir: string,
203
+ commandNames: Set<string>,
204
+ ): Promise<"node" | "file" | "none"> {
205
+ if (declaredIds.has(target)) return "node";
206
+ if (commandNames.has(target)) return "file";
207
+ if (/^dna-\d+$/.test(target)) {
208
+ return (await io.exists(resolve(docsDir, "architecture-dna.md"))) ? "file" : "none";
209
+ }
210
+ const docsId = /^docs\.([a-z-]+)\.(.+)$/.exec(target);
211
+ if (docsId) {
212
+ const [, dir, slug] = docsId;
213
+ const hits = await io.glob(`${dir}/**/*${slug}*.md`, { cwd: docsDir }).catch(() => []);
214
+ if (hits.length > 0) return "file";
215
+ // generic fallback: any file whose basename contains the slug under docs/
216
+ const broad = await io.glob(`**/*${slug}*`, { cwd: docsDir }).catch(() => []);
217
+ if (broad.length > 0) return "file";
218
+ return "none";
219
+ }
220
+ const ws = workspaceIdTarget(target);
221
+ if (ws) return (await io.exists(ws)) ? "file" : "none";
222
+ const spec = /^spec\.([a-z0-9-]+)$/.exec(target);
223
+ if (spec) {
224
+ const hits = await io.glob(`specs/${spec[1]}*/**`, { cwd: docsDir }).catch(() => []);
225
+ const dirs = await io.glob(`specs/${spec[1]}*`, { cwd: docsDir }).catch(() => []);
226
+ return hits.length + dirs.length > 0 ? "file" : "none";
227
+ }
228
+ return "none";
229
+ }
230
+
231
+ export async function runCompassDocsValidate(
232
+ input: ForgeCommandInput,
233
+ context: ForgeRuntimeContext,
234
+ ): Promise<
235
+ ForgeCommandResult<{
236
+ command: "compass.docs.validate";
237
+ status: "pass" | "fail";
238
+ scanned: { xmlFiles: number; pathsChecked: number; idsChecked: number };
239
+ diagnostics: Diagnostic[];
240
+ }>
241
+ > {
242
+ const io = resolveIo(context.io);
243
+ const docsDir = resolve(context.workspaceRoot, "docs");
244
+ const diagnostics: Diagnostic[] = [];
245
+ let pathsChecked = 0;
246
+ let idsChecked = 0;
247
+
248
+ // Command-name convention for DOC-03: dotted targets like ratgeber.hub.validate
249
+ // resolve when the command registry lists them.
250
+ const commandNames = new Set<string>();
251
+ const manifestPath = resolve(docsDir, "command-manifest.generated.yaml");
252
+ if (await io.exists(manifestPath)) {
253
+ const manifest = await io.readFile(manifestPath);
254
+ for (const m of manifest.matchAll(/^\s*- name: ([\w.-]+)\s*$/gm)) {
255
+ commandNames.add(m[1]!);
256
+ }
257
+ }
258
+
259
+ const xmlFiles = (await io.glob(DOCS_GLOB, { cwd: docsDir })).sort();
260
+ const declaredNodeIds = new Set<string>();
261
+
262
+ // Pass 1: collect declared node ids so link targets can resolve across files.
263
+ const sources = new Map<string, string>();
264
+ for (const rel of xmlFiles) {
265
+ const abs = resolve(docsDir, rel);
266
+ const source = await io.readFile(abs);
267
+ sources.set(rel, source);
268
+ for (const m of source.matchAll(/<node\s[^>]*?id="([^"]+)"/g)) {
269
+ declaredNodeIds.add(m[1]!);
270
+ }
271
+ }
272
+
273
+ for (const rel of xmlFiles) {
274
+ const file = `docs/${rel}`;
275
+ const source = sources.get(rel)!;
276
+
277
+ const malformed = checkWellFormed(file, source);
278
+ if (malformed) {
279
+ diagnostics.push({
280
+ ruleId: "COMPASS-DOC-00",
281
+ severity: "error",
282
+ file,
283
+ line: malformed.line,
284
+ message: `file is not well-formed XML: ${malformed.message}`,
285
+ });
286
+ continue; // refs from a broken file are noise
287
+ }
288
+
289
+ const refs = extractRefs(source);
290
+
291
+ for (const { value, line } of refs.paths) {
292
+ for (const token of pathTokens(value)) {
293
+ pathsChecked++;
294
+ const target = pathCheckTarget(token);
295
+ if (!(await io.exists(resolve(context.workspaceRoot, target)))) {
296
+ diagnostics.push({
297
+ ruleId: "COMPASS-DOC-01",
298
+ severity: "error",
299
+ file,
300
+ line,
301
+ message: `<path> references '${token}' — does not exist under the workspace root`,
302
+ fixHint: "Repoint to the live path or remove the stale reference",
303
+ });
304
+ }
305
+ }
306
+ }
307
+
308
+ for (const { id, line } of refs.workspaceIds) {
309
+ const target = workspaceIdTarget(id);
310
+ if (!target) continue;
311
+ idsChecked++;
312
+ if (!(await io.exists(resolve(context.workspaceRoot, target)))) {
313
+ diagnostics.push({
314
+ ruleId: "COMPASS-DOC-02",
315
+ severity: "error",
316
+ file,
317
+ line,
318
+ message: `id '${id}' names workspace path '${target}' — does not exist`,
319
+ fixHint: "Repoint to the live package or remove the stale entry",
320
+ });
321
+ }
322
+ }
323
+
324
+ for (const { target, line } of refs.linkTargets) {
325
+ idsChecked++;
326
+ const resolved = await linkTargetResolves(target, declaredNodeIds, io, docsDir, commandNames);
327
+ if (resolved === "none") {
328
+ diagnostics.push({
329
+ ruleId: "COMPASS-DOC-03",
330
+ severity: "warning",
331
+ file,
332
+ line,
333
+ message: `link target '${target}' resolves to no declared node and no file`,
334
+ });
335
+ }
336
+ }
337
+ }
338
+
339
+ const errors = diagnostics.filter((d) => d.severity === "error");
340
+ for (const d of diagnostics) {
341
+ context.logger[d.severity === "error" ? "error" : "warn"](
342
+ `[compass.docs.validate] ${d.ruleId}: ${d.file}${d.line ? `:${d.line}` : ""}: ${d.message}`,
343
+ );
344
+ }
345
+
346
+ return {
347
+ data: {
348
+ command: "compass.docs.validate",
349
+ status: errors.length === 0 ? "pass" : "fail",
350
+ scanned: { xmlFiles: xmlFiles.length, pathsChecked, idsChecked },
351
+ diagnostics,
352
+ },
353
+ exitCode: errors.length > 0 ? 1 : 0,
354
+ summary:
355
+ errors.length === 0
356
+ ? `[compass.docs.validate] OK (${xmlFiles.length} files, ${pathsChecked} paths, ${idsChecked} ids)`
357
+ : `[compass.docs.validate] ${errors.length} error(s)`,
358
+ };
359
+ }
@@ -15,7 +15,6 @@ for Compass source-file inventory.</purpose>
15
15
  <item>Policy literals come from resolveCompassPolicy — no stack- or consumer-specific values live in this module.</item>
16
16
  </KEY_DECISIONS>
17
17
  <CHANGE_SUMMARY>
18
- <item>RFC-1095: compass.summary.record, trim repair rewrite, commit integration</item>
19
18
  <item>RFC-1096: all policy literals externalized to resolveCompassPolicy — scan roots, extensions, ignored dirs/paths, layer/risk rules, governance-ID and boilerplate patterns come from generic defaults + profile + bindings.compass.</item>
20
19
  <item>RFC-1097: sweep — SKILL.md headers + classification fixes
21
20
 
@@ -24,7 +23,8 @@ Sweep batch 1: add Compass v2 headers to 45 SKILL.md files (purpose derived from
24
23
 
25
24
  Sweep batch 2: real KEY_DECISIONS on 10 files, expanded purposes (CONTRACT-02/PURPOSE-02), headers on mission/index + gen-upstreams, sanitizeItemText in summary.record (literal Compass tags corrupted history), excludedPaths for wrangler types, test-fixtures testPattern. forge+services now 0 diagnostics under --mode error.</item>
26
25
  <item>RFC-1111: normalize dynamic-route bracket stems in deriveFileTokens — [token]/[...slug]/[lang] segments strip brackets and dots so PURPOSE-02 is satisfiable for Astro dynamic routes.</item>
27
- <history>RFC-0348, RFC-0556, RFC-1094</history>
26
+ <item>RFC-1220: step 2 — fail-closed guards in history paths</item>
27
+ <history>RFC-0348, RFC-0556, RFC-1094, RFC-1095</history>
28
28
  </CHANGE_SUMMARY>
29
29
 
30
30
  */
@@ -520,7 +520,10 @@ function validateHistoryIds(historyIds: string[], policy: CompassPolicy): string
520
520
  }
521
521
  const lastSeenByNamespace = new Map<string, number>();
522
522
  for (const token of historyIds) {
523
- const parts = parseGovernanceIdParts(token)!;
523
+ const parts = parseGovernanceIdParts(token);
524
+ if (!parts) {
525
+ return `<history> contains an unparseable governance-ID token: "${token}"`;
526
+ }
524
527
  const last = lastSeenByNamespace.get(parts.namespace);
525
528
  if (last !== undefined && parts.numeric <= last) {
526
529
  return `<history> IDs are not in per-namespace ascending numeric order (${token} after ${parts.namespace}-${last})`;
@@ -15,7 +15,6 @@ items to CHANGE_SUMMARY blocks per RFC-1095. Collapses the 5-item window into
15
15
  <item>Item text is sanitized on record — literal Compass tags would corrupt history parsing.</item>
16
16
  </KEY_DECISIONS>
17
17
  <CHANGE_SUMMARY>
18
- <item>RFC-1095: header-region guard in summary.record, restore test fixture with dynamic tags</item>
19
18
  <item>RFC-1097: steps 1-4 — compass.migrate codemod
20
19
 
21
20
  Add the v1 to v2 Compass header codemod: migrateFile pure transform (collapse, strip, seed, reorder, purpose-flag actions), migrateWorkspace walker, runCompassMigrate handler with dirty-tree refusal and --force/--files/--dry-run flags, module registration, and 15 unit tests.</item>
@@ -28,11 +27,14 @@ Sweep batch 4: 73 Compass headers on headerless engine files (certification, com
28
27
  <item>RFC-1097: AC-4 banned literal in os/compass handlers
29
28
 
30
29
  compass-migrate-handler hint used a consumer-specific run command — switched to generic 'pnpm exec forge run' convention. Reworded recorded CHANGE_SUMMARY items in 3 handlers to drop the consumer-specific literal. compass-policy AC-4 test green (65/65).</item>
30
+ <item>RFC-1220: step 2 — fail-closed guards in history paths</item>
31
+ <item>RFC-1233: stripGitTrailers at the record point + sanitizeItemText star-slash guard — commit-message trailers (Token: value / Token #value, plus markdown-link attribution lines like "Generated with [X](url)") no longer pollute injected items.</item>
32
+ <item>Session-retro 2026-10-08: id-in-diff dedup — auto-inject skips when the worktree diff already carries a same-ID item (HEAD-vs-worktree via git show HEAD:path), closing the descriptive+generic duplicate class seen on RFC-1237.</item>
31
33
  <history>RFC-1095</history>
32
34
  </CHANGE_SUMMARY>
33
35
  */
34
36
 
35
- import { existsSync } from "../../../src/utils/sync-fs.ts";
37
+ import { execFile, existsSync } from "../../../src/utils/sync-fs.ts";
36
38
  import { ambientIo, resolveIo } from "../../../src/utils/io.ts";
37
39
 
38
40
  import { resolve, relative } from "node:path";
@@ -69,7 +71,7 @@ export interface SummaryRecordInput {
69
71
  workpiece?: string;
70
72
  }
71
73
 
72
- export type SummaryRecordSkipReason = "no-block" | "duplicate" | "unparseable";
74
+ export type SummaryRecordSkipReason = "no-block" | "duplicate" | "unparseable" | "id-in-diff";
73
75
 
74
76
  export interface SummaryRecordResult {
75
77
  command: "compass.summary.record";
@@ -111,7 +113,12 @@ function leadingGovernanceId(item: string, policy: CompassPolicy): string | null
111
113
  return match && match.index === 0 ? match[0] : null;
112
114
  }
113
115
 
114
- /** Merge existing + new history IDs: dedupe, per-namespace ascending numeric order. */
116
+ /**
117
+ * Merge existing + new history IDs: dedupe, per-namespace ascending numeric order.
118
+ * Tokens that pass `idPatternFull` yet fail `parseGovernanceIdParts` (policy/parser
119
+ * drift) are kept appended after the sorted parseable tokens in encounter order —
120
+ * provenance is preserved and the next `compass.validate` emits CS-07 (RFC-1220).
121
+ */
115
122
  export function mergeHistoryIds(
116
123
  existing: string[],
117
124
  incoming: string[],
@@ -120,12 +127,16 @@ export function mergeHistoryIds(
120
127
  const all = [...new Set([...existing, ...incoming])].filter((id) =>
121
128
  policy.idPatternFull.test(id),
122
129
  );
123
- return all.sort((a, b) => {
124
- const pa = parseGovernanceIdParts(a)!;
125
- const pb = parseGovernanceIdParts(b)!;
130
+ const partsById = new Map(all.map((id) => [id, parseGovernanceIdParts(id)]));
131
+ const parseable = all.filter((id) => partsById.get(id) !== null);
132
+ const unparseable = all.filter((id) => partsById.get(id) === null);
133
+ parseable.sort((a, b) => {
134
+ const pa = partsById.get(a)!;
135
+ const pb = partsById.get(b)!;
126
136
  const ns = pa.namespace.localeCompare(pb.namespace);
127
137
  return ns !== 0 ? ns : pa.numeric - pb.numeric;
128
138
  });
139
+ return [...parseable, ...unparseable];
129
140
  }
130
141
 
131
142
  export function getLineCommentPrefix(filePath: string): string | null {
@@ -158,6 +169,41 @@ export function stripConventionalPrefix(subject: string): string {
158
169
  return subject.replace(CONVENTIONAL_PREFIX_RE, "").trim();
159
170
  }
160
171
 
172
+ // RFC-1233: git-trailer tail block — strict `Token: value` / `Token #value`
173
+ // lines (interpret-trailers shape, empty value allowed), plus lines ending
174
+ // in a markdown link (the observed "Generated with [X](url)" attribution
175
+ // boilerplate has no separator). A link-shaped line is admitted only when
176
+ // the block also contains at least one strict trailer line — a lone link
177
+ // tail is prose.
178
+ const TRAILER_LINE_RE = /^[A-Za-z0-9-]+(:| #)\s*.*$/;
179
+ const MARKDOWN_LINK_TAIL_RE = /^.*\[[^\]]*\]\([^)]+\)\s*$/;
180
+
181
+ /**
182
+ * Remove a trailing git-trailer block: the contiguous tail of strict
183
+ * trailer lines and admitted markdown-link lines. A blank line ends the
184
+ * tail paragraph. Returns the message without the block; when the tail
185
+ * holds no strict trailer line the message is returned unchanged.
186
+ */
187
+ export function stripGitTrailers(message: string): string {
188
+ const lines = message.trimEnd().split("\n");
189
+ let start = lines.length;
190
+ let sawStrict = false;
191
+ while (start > 0) {
192
+ const line = lines[start - 1]!.trim();
193
+ if (line === "") break;
194
+ if (TRAILER_LINE_RE.test(line)) {
195
+ sawStrict = true;
196
+ start--;
197
+ } else if (MARKDOWN_LINK_TAIL_RE.test(line)) {
198
+ start--;
199
+ } else {
200
+ break;
201
+ }
202
+ }
203
+ if (!sawStrict || start === lines.length) return message;
204
+ return lines.slice(0, start).join("\n").trimEnd();
205
+ }
206
+
161
207
  // Literal Compass block tags inside an item corrupt block parsing — a commit
162
208
  // message saying "collapsed into history" would be matched by HISTORY_RE and
163
209
  // produce phantom non-ID tokens (CS-07). Strip the angle brackets on record.
@@ -165,7 +211,14 @@ const COMPASS_TAG_LITERAL_RE =
165
211
  /<\/?(?:CHANGE_SUMMARY|MODULE_CONTRACT|KEY_DECISIONS|history|item|purpose|non-goals)>/g;
166
212
 
167
213
  export function sanitizeItemText(text: string): string {
168
- return text.replace(COMPASS_TAG_LITERAL_RE, (tag) => tag.replace(/[<>]/g, ""));
214
+ return (
215
+ text
216
+ .replace(COMPASS_TAG_LITERAL_RE, (tag) => tag.replace(/[<>]/g, ""))
217
+ // RFC-1233: a literal star-slash sequence would terminate the
218
+ // surrounding block comment when the item lands in /* ... */ headers.
219
+ // ∕ (U+2215) is visually near-identical, idempotent, and never re-forms.
220
+ .replace(/\*\//g, "*\u2215")
221
+ );
169
222
  }
170
223
 
171
224
  export function isValidGovernanceId(id: string, policy: CompassPolicy): boolean {
@@ -181,6 +234,8 @@ interface RecordOutcome {
181
234
  /**
182
235
  * Append `ID: text` to one file's CHANGE_SUMMARY and collapse the window.
183
236
  * Returns the outcome; writes the file only when mutated and not dry-run.
237
+ * `headSource` is the file's content at HEAD (when resolvable) — it enables
238
+ * the id-in-diff dedup; pass undefined outside git workspaces.
184
239
  */
185
240
  export async function recordSummaryItem(
186
241
  absPath: string,
@@ -189,6 +244,7 @@ export async function recordSummaryItem(
189
244
  text: string,
190
245
  dryRun: boolean,
191
246
  policy: CompassPolicy,
247
+ headSource?: string,
192
248
  ): Promise<RecordOutcome> {
193
249
  const source = await io.readFile(absPath);
194
250
  const blockMatch = source.match(CHANGE_SUMMARY_BLOCK_RE);
@@ -219,6 +275,22 @@ export async function recordSummaryItem(
219
275
  return { recorded: false, collapsed: false, skipReason: "duplicate" };
220
276
  }
221
277
 
278
+ // A same-ID item already in the worktree but absent from HEAD means the
279
+ // pending change already carries a hand-authored record for this governance
280
+ // ID — the auto-injected subject-derived item would duplicate it at lower
281
+ // specificity (observed 2026-10-08: 27 files carried descriptive + generic
282
+ // RFC-1237 items). HEAD-resident same-ID items do NOT suppress: consecutive
283
+ // commits under one RFC legitimately stack step items.
284
+ if (headSource !== undefined) {
285
+ const headBlock = headSource.match(CHANGE_SUMMARY_BLOCK_RE);
286
+ const headItems = headBlock ? parseChangeSummary(headBlock[0]).items : [];
287
+ const idInWorktree = items.some((item) => leadingGovernanceId(item, policy) === id);
288
+ const idInHead = headItems.some((item) => leadingGovernanceId(item, policy) === id);
289
+ if (idInWorktree && !idInHead) {
290
+ return { recorded: false, collapsed: false, skipReason: "id-in-diff" };
291
+ }
292
+ }
293
+
222
294
  const nextItems = [...items, newItem];
223
295
  const collapsedIds: string[] = [];
224
296
  while (nextItems.length > CHANGE_SUMMARY_WINDOW) {
@@ -237,6 +309,27 @@ export async function recordSummaryItem(
237
309
  return { recorded: true, collapsed };
238
310
  }
239
311
 
312
+ function execGit(cwd: string, args: string[]): Promise<string | null> {
313
+ return new Promise((resolve) => {
314
+ execFile("git", args, { cwd, timeout: 5000 }, (err, stdout) => {
315
+ resolve(err ? null : stdout);
316
+ });
317
+ });
318
+ }
319
+
320
+ /**
321
+ * Read `path`'s content at HEAD — the baseline for the id-in-diff dedup.
322
+ * Returns undefined when the file is untracked or the workspace is not a git
323
+ * repo (the dedup then falls back to exact-text matching only).
324
+ */
325
+ async function readHeadSource(gitTop: string | null, absPath: string): Promise<string | undefined> {
326
+ if (!gitTop) return undefined;
327
+ const rel = relative(gitTop, absPath).replace(/\\/g, "/");
328
+ if (rel === "" || rel.startsWith("..")) return undefined;
329
+ const source = await execGit(gitTop, ["show", `HEAD:${rel}`]);
330
+ return source ?? undefined;
331
+ }
332
+
240
333
  function riskReminderNeeded(relPath: string, source: string, policy: CompassPolicy): boolean {
241
334
  const segments = relPath.split("/").filter(Boolean);
242
335
  const workspaceRel = getWorkspaceRelativeSegments(segments, policy).join("/");
@@ -269,7 +362,10 @@ export async function runCompassSummaryRecord(
269
362
  : [];
270
363
 
271
364
  const rawText = input.flags["text"];
272
- const text = typeof rawText === "string" && rawText.trim().length > 0 ? rawText.trim() : id;
365
+ // RFC-1233: strip the git-trailer block at the single injection point —
366
+ // ecosystem.commit and mission.git.commit both pipe the raw message here.
367
+ const stripped = typeof rawText === "string" ? stripGitTrailers(rawText).trim() : "";
368
+ const text = stripped.length > 0 ? stripped : id;
273
369
 
274
370
  const result: SummaryRecordResult = {
275
371
  command: "compass.summary.record",
@@ -280,6 +376,9 @@ export async function runCompassSummaryRecord(
280
376
  keyDecisionsReminders: [],
281
377
  };
282
378
 
379
+ const gitTopRaw = await execGit(baseRoot, ["rev-parse", "--show-toplevel"]);
380
+ const gitTop = gitTopRaw?.trim() || null;
381
+
283
382
  for (const file of files) {
284
383
  const absPath = resolve(baseRoot, file);
285
384
  const relPath = relative(baseRoot, absPath).replace(/\\/g, "/");
@@ -289,7 +388,16 @@ export async function runCompassSummaryRecord(
289
388
  continue;
290
389
  }
291
390
  try {
292
- const outcome = await recordSummaryItem(absPath, relPath, id, text, context.dryRun, policy);
391
+ const headSource = await readHeadSource(gitTop, absPath);
392
+ const outcome = await recordSummaryItem(
393
+ absPath,
394
+ relPath,
395
+ id,
396
+ text,
397
+ context.dryRun,
398
+ policy,
399
+ headSource,
400
+ );
293
401
  if (outcome.skipReason) {
294
402
  result.skipped.push({ file, reason: outcome.skipReason });
295
403
  continue;
@@ -16,6 +16,7 @@ import { execFile } from "node:child_process";
16
16
  import { promisify } from "node:util";
17
17
  import { parse as yamlParse } from "yaml";
18
18
  import { runCompassAuditRecord } from "../compass-audit-handler.ts";
19
+ import { ambientIo } from "../../../../src/utils/io.ts";
19
20
  import type { ForgeCommandInput, ForgeRuntimeContext } from "../../../../src/types.ts";
20
21
 
21
22
  const execFileAsync = promisify(execFile);
@@ -42,7 +43,7 @@ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
42
43
  logger: logger as never,
43
44
  dryRun: false,
44
45
  outputFormat: "json",
45
- io: {} as never,
46
+ io: ambientIo,
46
47
  actualState: undefined as never,
47
48
  fileIntents: [],
48
49
  } as unknown as ForgeRuntimeContext;