@smartmemory/compose 0.3.6-beta → 0.3.7

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 (134) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/bin/compose.js +288 -0
  3. package/bin/git-hooks/pre-push.template +29 -0
  4. package/bin/judgment-import.js +7 -0
  5. package/contracts/feature-json.schema.json +5 -0
  6. package/contracts/judgment-record.schema.json +425 -4
  7. package/dist/assets/App-PkZzHeMj.js +894 -0
  8. package/dist/assets/_baseUniq-Bo837sRJ.js +1 -0
  9. package/dist/assets/arc-BafGpyqE.js +1 -0
  10. package/dist/assets/architectureDiagram-Q4EWVU46-BOBfUsqL.js +36 -0
  11. package/dist/assets/blockDiagram-DXYQGD6D-Dwodev1a.js +132 -0
  12. package/dist/assets/{browser-BSM23If2.js → browser-1ntj1-x_.js} +6 -6
  13. package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-CU_bhYag.js} +1 -1
  14. package/dist/assets/channel-qVK_qn4E.js +1 -0
  15. package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-p8WsDwnO.js} +1 -1
  16. package/dist/assets/chunk-4TB4RGXK-B8h7-eR0.js +206 -0
  17. package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-DxeEr98s.js} +1 -1
  18. package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-BYt8F151.js} +1 -1
  19. package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-DGSOVeie.js} +1 -1
  20. package/dist/assets/chunk-OYMX7WX6-B-QdgYR2.js +231 -0
  21. package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-Du5UAZLs.js} +1 -1
  22. package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-C8JbNBSk.js} +1 -1
  23. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +1 -0
  24. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +1 -0
  25. package/dist/assets/clone-Pu3RyLUh.js +1 -0
  26. package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-O1ESaqge.js} +1 -1
  27. package/dist/assets/dagre-KV5264BT-CPTmFPHw.js +4 -0
  28. package/dist/assets/diagram-5BDNPKRD-B3PNrWs5.js +10 -0
  29. package/dist/assets/diagram-G4DWMVQ6-Cscfr6vc.js +24 -0
  30. package/dist/assets/diagram-MMDJMWI5-CSfqZ-TM.js +43 -0
  31. package/dist/assets/diagram-TYMM5635-Cg4aYS7W.js +24 -0
  32. package/dist/assets/erDiagram-SMLLAGMA-_ZqwG5pl.js +85 -0
  33. package/dist/assets/flowDiagram-DWJPFMVM-C83boxFT.js +162 -0
  34. package/dist/assets/ganttDiagram-T4ZO3ILL-CWnIjuEi.js +292 -0
  35. package/dist/assets/gitGraphDiagram-UUTBAWPF-DrMdxZfH.js +106 -0
  36. package/dist/assets/graph-Bi99_6Yf.js +331 -0
  37. package/dist/assets/graph-RE4I7Ty7.js +1 -0
  38. package/dist/assets/index-Rm2RE-c0.js +123 -0
  39. package/dist/assets/infoDiagram-42DDH7IO-BLmP4Epr.js +2 -0
  40. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-yuWWshKN.js} +5 -5
  41. package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-BOfhaJov.js} +1 -1
  42. package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-Bbolde15.js} +7 -7
  43. package/dist/assets/katex-DkKDou_j.js +257 -0
  44. package/dist/assets/layout-BSf33zm8.js +1 -0
  45. package/dist/assets/{linear-Ck7gpa5N.js → linear-AvSTWMqx.js} +1 -1
  46. package/dist/assets/min-QBM8H4xN.js +1 -0
  47. package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-BuvgtqIc.js} +7 -7
  48. package/dist/assets/{mobile-CaoXUwAr.js → mobile-BnXEOE3U.js} +2 -2
  49. package/dist/assets/pieDiagram-DEJITSTG-DIzF16vh.js +30 -0
  50. package/dist/assets/quadrantDiagram-34T5L4WZ-D-mbUIjS.js +7 -0
  51. package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-CEs4kCLd.js} +3 -3
  52. package/dist/assets/sankeyDiagram-XADWPNL6-DFsnCr9n.js +10 -0
  53. package/dist/assets/sequenceDiagram-FGHM5R23-BEJYdTjQ.js +157 -0
  54. package/dist/assets/stateDiagram-FHFEXIEX-BBXs57uY.js +1 -0
  55. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +1 -0
  56. package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BGvLoVAY.js} +3 -3
  57. package/dist/assets/vennDiagram-DHZGUBPP-9LaBTMe0.js +34 -0
  58. package/dist/assets/wardley-RL74JXVD-P4MEqMTP.js +162 -0
  59. package/dist/assets/wardleyDiagram-NUSXRM2D-o-tmxnlC.js +20 -0
  60. package/dist/assets/xychartDiagram-5P7HB3ND-Dpn7V6qk.js +7 -0
  61. package/dist/index.html +2 -2
  62. package/lib/bug-escalation.js +30 -4
  63. package/lib/build.js +777 -52
  64. package/lib/canon-guard.js +223 -0
  65. package/lib/canon-registry.js +187 -0
  66. package/lib/codex-preflight.js +26 -4
  67. package/lib/dispatch-ledger.js +301 -0
  68. package/lib/dispatch-metrics.js +236 -0
  69. package/lib/experiment-judge.js +6 -1
  70. package/lib/feature-writer.js +9 -0
  71. package/lib/gsd.js +11 -2
  72. package/lib/hooks-status.js +32 -3
  73. package/lib/judgment/store/index.js +158 -0
  74. package/lib/judgment/store/records.js +183 -24
  75. package/lib/judgment-attest.js +259 -0
  76. package/lib/judgment-gen.js +370 -21
  77. package/lib/judgment-verify.js +153 -0
  78. package/lib/judgment-writer.js +2803 -277
  79. package/lib/lane-gate.js +2 -0
  80. package/lib/local-claude-connector.js +199 -54
  81. package/lib/mcp-enforcement.js +21 -35
  82. package/lib/result-normalizer.js +93 -15
  83. package/lib/review-normalize.js +4 -0
  84. package/lib/stratum-mcp-client.js +131 -6
  85. package/package.json +2 -2
  86. package/server/compose-mcp-tools.js +15 -1
  87. package/server/compose-mcp.js +96 -1
  88. package/server/mcp-tool-policy.js +1 -1
  89. package/dist/assets/App-BG3ngu8H.js +0 -896
  90. package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
  91. package/dist/assets/arc-_v4hR_uD.js +0 -1
  92. package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
  93. package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
  94. package/dist/assets/channel-B3U5wFAT.js +0 -1
  95. package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
  96. package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
  97. package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
  98. package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
  99. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
  100. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
  101. package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
  102. package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
  103. package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
  104. package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
  105. package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
  106. package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
  107. package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
  108. package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
  109. package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
  110. package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
  111. package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
  112. package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
  113. package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
  114. package/dist/assets/graph-BXPQrYYB.js +0 -331
  115. package/dist/assets/graph-C9eacEi8.js +0 -1
  116. package/dist/assets/index-3ZH5eMcZ.js +0 -119
  117. package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
  118. package/dist/assets/katex-C5jXJg4s.js +0 -257
  119. package/dist/assets/layout-DEXfKzaS.js +0 -1
  120. package/dist/assets/map-Czzmt4hB.js +0 -1
  121. package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
  122. package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
  123. package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
  124. package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
  125. package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
  126. package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
  127. package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
  128. package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
  129. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
  130. package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
  131. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
  132. package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
  133. package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
  134. package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * lib/judgment-gen.js — judgment projections (S04).
3
3
  *
4
- * records → REGISTER.md, LEDGER.md, OBJECTIVE.md, positions/<slug>.md, and
5
- * the OKF bundle root index.md. Projections are PURE OUTPUT: generation
4
+ * records → REGISTER.md, LEDGER.md, OBJECTIVE.md, SITUATION.md,
5
+ * people/<slug>.md, positions/<slug>.md, and the OKF bundle root index.md.
6
+ * Projections are PURE OUTPUT: generation
6
7
  * reads only records — never an existing projection — so any hand-edit is
7
8
  * overwritten by the next regeneration and there are no preserved sections
8
9
  * (curated prose is `note` records rendered at their anchors).
@@ -22,7 +23,11 @@ import {
22
23
  readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync, existsSync, readdirSync,
23
24
  } from 'node:fs';
24
25
  import { join } from 'node:path';
25
- import { createJudgmentStore } from './judgment/store/index.js';
26
+ import {
27
+ createJudgmentStore,
28
+ effectiveStore,
29
+ goalCutoverComplete,
30
+ } from './judgment/store/index.js';
26
31
 
27
32
  function atomicWrite(path, content) {
28
33
  const tmp = `${path}.tmp.${process.pid}`;
@@ -50,8 +55,9 @@ function smartmemoryTeamId(cwd) {
50
55
  * Load everything projection-relevant from the store into a plain snapshot,
51
56
  * so the generator core stays pure.
52
57
  */
53
- function loadSnapshot(cwd) {
54
- const store = createJudgmentStore(cwd);
58
+ export function loadSnapshot(cwd) {
59
+ const rawStore = createJudgmentStore(cwd);
60
+ const store = effectiveStore(rawStore);
55
61
  const positions = store.listPositionSlugs().map((slug) => ({
56
62
  slug,
57
63
  chain: store.readPositionChain(slug),
@@ -61,6 +67,11 @@ function loadSnapshot(cwd) {
61
67
  positions,
62
68
  joints: store.listJoints(),
63
69
  ledger: store.readLedgerEvents(),
70
+ people: store.listPeople(),
71
+ situationEntities: store.listSituationEntities(),
72
+ goalChain: store.readGoalChain(),
73
+ goalState: store.readGoalState(),
74
+ goalCutoverComplete: goalCutoverComplete(store),
64
75
  teamId: smartmemoryTeamId(cwd),
65
76
  };
66
77
  }
@@ -75,11 +86,22 @@ function renderNotes(notes) {
75
86
  return notes.map((n) => `> **${n.title}** — ${n.body ?? ''}`.trimEnd()).join('\n\n');
76
87
  }
77
88
 
89
+ // Plain YAML scalars that need no quoting: no leading/trailing space, none
90
+ // of the indicator/structure characters that would change the parse. Risky
91
+ // titles (e.g. "Jane: CEO") are emitted JSON-quoted, which is valid YAML
92
+ // double-quote style.
93
+ const YAML_PLAIN_SAFE_RE = /^[A-Za-z0-9](?:[A-Za-z0-9 _./()-]*[A-Za-z0-9_./()-])?$/;
94
+
95
+ function yamlScalar(value) {
96
+ const s = String(value);
97
+ return YAML_PLAIN_SAFE_RE.test(s) ? s : JSON.stringify(s);
98
+ }
99
+
78
100
  function okfFrontmatter({ type, title, timestamp, resource }) {
79
101
  const lines = ['---'];
80
102
  if (type) lines.push(`type: ${type}`);
81
103
  else lines.push('okf_version: "0.1"');
82
- lines.push(`title: ${title}`);
104
+ lines.push(`title: ${yamlScalar(title)}`);
83
105
  if (timestamp) lines.push(`timestamp: "${timestamp}"`);
84
106
  if (resource) lines.push(`resource: "${resource}"`);
85
107
  lines.push('smartmemory:');
@@ -94,6 +116,194 @@ function buildResource(teamId, providerId) {
94
116
  return `smartmemory://${encodeURIComponent(teamId)}/${encodeURIComponent(providerId)}`;
95
117
  }
96
118
 
119
+ function auditValue(value) {
120
+ const encoded = JSON.stringify(value);
121
+ return encoded === undefined ? String(value) : encoded;
122
+ }
123
+
124
+ function renderTraceLines(trace = [], { prefix = ' - ' } = {}) {
125
+ return trace.map((entry) => {
126
+ const prior = Object.entries(entry.prior ?? {})
127
+ .map(([key, value]) => `${key}=${auditValue(value)}`)
128
+ .join(', ');
129
+ return `${prefix}corrected from ${prior} at ${entry.corrected_at ?? 'unknown'}`;
130
+ });
131
+ }
132
+
133
+ function renderRecordedAndRemoval(entry, { prefix = ' - ' } = {}) {
134
+ const lines = [];
135
+ lines.push(`${prefix}recorded at ${entry.provenance?.written_at ?? 'unknown'}`);
136
+ if (entry.removed) {
137
+ lines.push(`${prefix}removed at ${entry.removed.at ?? 'unknown'} — ${entry.removed.reason}`);
138
+ }
139
+ return lines;
140
+ }
141
+
142
+ function renderAuditedFact(fact) {
143
+ const lines = [`- **${fact.id}** ${fact.text}`];
144
+ lines.push(` - channel: \`${fact.channel}\``);
145
+ if (fact.via !== undefined) lines.push(` - via: ${fact.via}`);
146
+ lines.push(` - at: ${fact.at}`);
147
+ lines.push(...renderTraceLines(fact.trace));
148
+ return lines.join('\n');
149
+ }
150
+
151
+ const PERSON_SECTIONS = ['role', 'life', 'stated', 'revealed'];
152
+
153
+ function sectionTitle(section) {
154
+ return section[0].toUpperCase() + section.slice(1);
155
+ }
156
+
157
+ function orderedPairLabel(left, right) {
158
+ const suffix = (id) => Number.parseInt(String(id).replace(/^[^0-9]+/, ''), 10);
159
+ const ids = [left, right].sort((a, b) => suffix(a) - suffix(b) || a.localeCompare(b));
160
+ return `${ids[0]} ↔ ${ids[1]}`;
161
+ }
162
+
163
+ function renderPerson(person) {
164
+ const lifecycle = person.facts.some((fact) => fact.channel === 'said') ? 'spoken' : 'stub';
165
+ const parts = [okfFrontmatter({
166
+ type: 'person',
167
+ title: person.display_name,
168
+ timestamp: person.provenance?.written_at,
169
+ })];
170
+ parts.push('');
171
+ parts.push(`# ${person.display_name}`);
172
+ parts.push('');
173
+ parts.push(`**Lifecycle:** ${lifecycle}`);
174
+ parts.push(`**Record:** \`${person.slug}\``);
175
+
176
+ const factsById = new Map(person.facts.map((fact) => [fact.id, fact]));
177
+ const rendered = new Set();
178
+ for (const section of PERSON_SECTIONS) {
179
+ parts.push('');
180
+ parts.push(`## ${sectionTitle(section)}`);
181
+ parts.push('');
182
+ const sectionLines = [];
183
+ for (const fact of person.facts.filter((entry) => entry.section === section)) {
184
+ if (rendered.has(fact.id)) continue;
185
+ const paired = fact.diverges_with ? factsById.get(fact.diverges_with) : null;
186
+ const isStatedRevealedPair = (
187
+ section === 'stated'
188
+ && paired?.section === 'revealed'
189
+ && paired.diverges_with === fact.id
190
+ );
191
+ sectionLines.push(renderAuditedFact(fact));
192
+ rendered.add(fact.id);
193
+ if (isStatedRevealedPair) {
194
+ sectionLines.push(renderAuditedFact(paired));
195
+ sectionLines.push(` - divergence pair: ${orderedPairLabel(fact.id, paired.id)}`);
196
+ rendered.add(paired.id);
197
+ }
198
+ }
199
+ parts.push(sectionLines.length > 0 ? sectionLines.join('\n') : 'No facts recorded.');
200
+ }
201
+
202
+ parts.push('');
203
+ parts.push('## Edges');
204
+ parts.push('');
205
+ if (person.edges.length === 0) {
206
+ parts.push('None.');
207
+ } else {
208
+ for (const edge of person.edges) {
209
+ parts.push(`- **${edge.id}** \`${edge.removed ? 'removed' : 'active'}\` — ${edge.kind} → [${edge.to}](${edge.to}.md)`);
210
+ parts.push(...renderRecordedAndRemoval(edge));
211
+ }
212
+ }
213
+
214
+ parts.push('');
215
+ parts.push('## Open fields');
216
+ parts.push('');
217
+ if (person.open_fields.length === 0) {
218
+ parts.push('None.');
219
+ } else {
220
+ for (const field of person.open_fields) {
221
+ parts.push(`- **${field.id}** \`${field.status}\` — ${field.name}`);
222
+ parts.push(` - filled_by: ${field.filled_by ?? '—'}`);
223
+ parts.push(` - recorded at ${field.provenance?.written_at ?? 'unknown'}`);
224
+ parts.push(...renderTraceLines(field.trace));
225
+ }
226
+ }
227
+
228
+ parts.push('');
229
+ parts.push('## Load links');
230
+ parts.push('');
231
+ if (person.load_links.length === 0) {
232
+ parts.push('None.');
233
+ } else {
234
+ for (const link of person.load_links) {
235
+ parts.push(`- **${link.id}** \`${link.removed ? 'removed' : 'active'}\` — fact ${link.fact} carries ${link.carries}`);
236
+ parts.push(...renderRecordedAndRemoval(link));
237
+ }
238
+ }
239
+ return parts.join('\n') + '\n';
240
+ }
241
+
242
+ function latestWrittenAt(records) {
243
+ return records
244
+ .map((record) => record.provenance?.written_at)
245
+ .filter(Boolean)
246
+ .sort()
247
+ .at(-1);
248
+ }
249
+
250
+ function renderSituation(snapshot) {
251
+ const entities = [...(snapshot.situationEntities ?? [])].sort((a, b) => (
252
+ a.display_name.localeCompare(b.display_name) || a.slug.localeCompare(b.slug)
253
+ ));
254
+ const parts = [okfFrontmatter({
255
+ type: 'situation',
256
+ title: 'Situation',
257
+ timestamp: latestWrittenAt(entities),
258
+ })];
259
+ parts.push('');
260
+ parts.push('# Situation');
261
+ if (entities.length === 0) {
262
+ parts.push('');
263
+ parts.push('No situation entities recorded.');
264
+ }
265
+ for (const entity of entities) {
266
+ parts.push('');
267
+ parts.push(`## ${entity.display_name} (\`${entity.slug}\`)`);
268
+ parts.push('');
269
+ parts.push('### Facts');
270
+ parts.push('');
271
+ if (entity.facts.length === 0) {
272
+ parts.push('None.');
273
+ } else {
274
+ for (const fact of entity.facts) parts.push(renderAuditedFact(fact));
275
+ }
276
+
277
+ parts.push('');
278
+ parts.push('### Owed');
279
+ parts.push('');
280
+ if (entity.owed.length === 0) {
281
+ parts.push('None.');
282
+ } else {
283
+ for (const owed of entity.owed) {
284
+ parts.push(`> **OWED ${owed.id} · ${owed.status}** ${owed.name}`);
285
+ parts.push(`> - why load-bearing: ${owed.why_load_bearing}`);
286
+ parts.push(`> - filled_by: ${owed.filled_by ?? '—'}`);
287
+ parts.push(`> - recorded at ${owed.provenance?.written_at ?? 'unknown'}`);
288
+ parts.push(...renderTraceLines(owed.trace, { prefix: '> - ' }));
289
+ }
290
+ }
291
+
292
+ parts.push('');
293
+ parts.push('### Load links');
294
+ parts.push('');
295
+ if (entity.load_links.length === 0) {
296
+ parts.push('None.');
297
+ } else {
298
+ for (const link of entity.load_links) {
299
+ parts.push(`- **${link.id}** \`${link.removed ? 'removed' : 'active'}\` — fact ${link.fact} carries ${link.carries}`);
300
+ parts.push(...renderRecordedAndRemoval(link));
301
+ }
302
+ }
303
+ }
304
+ return parts.join('\n') + '\n';
305
+ }
306
+
97
307
  function renderClaim(claim) {
98
308
  const lines = [`- **${claim.id}** \`[${claim.grounding}]\`${claim.owner_locked ? ' `[owner-locked]`' : ''} ${claim.text}`];
99
309
  if (Array.isArray(claim.supports) && claim.supports.length > 0) {
@@ -218,6 +428,7 @@ const EVENT_DETAIL_KEYS = [
218
428
  'refs', 'rejected', 'conviction', 'trigger', 'open_joints', 'prediction',
219
429
  'disposition', 'recall_verdict', 'attribution', 'prediction_ref',
220
430
  'prediction_grade', 'reason', 'rank_change', 'elicitation',
431
+ 'intent_id', 'tool', 'op', 'rests_on',
221
432
  ];
222
433
 
223
434
  function renderLedger(snapshot) {
@@ -248,8 +459,118 @@ function renderLedger(snapshot) {
248
459
  return parts.join('\n') + '\n';
249
460
  }
250
461
 
462
+ function renderGoalClause(clause) {
463
+ const lines = [`- **${clause.id}** ${clause.text}`];
464
+ lines.push(` - channel: \`${clause.channel}\``);
465
+ if (clause.via !== undefined) lines.push(` - via: ${clause.via}`);
466
+ const citation = clause.elicitation;
467
+ lines.push(
468
+ ` - elicitation: asked "${citation.asked}" (answered ${citation.answered_at}, ref ${citation.answer_ref})`,
469
+ );
470
+ lines.push(...renderTraceLines(clause.trace));
471
+ return lines.join('\n');
472
+ }
473
+
474
+ function tableCell(value) {
475
+ return String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
476
+ }
477
+
478
+ function renderGoalObjective(snapshot) {
479
+ const chain = snapshot.goalChain ?? [];
480
+ const current = chain.at(-1);
481
+ if (!current) {
482
+ return [
483
+ okfFrontmatter({ type: 'goal', title: 'Objective' }),
484
+ '',
485
+ '# Objective',
486
+ '',
487
+ 'No goal recorded.',
488
+ ].join('\n') + '\n';
489
+ }
490
+
491
+ const state = snapshot.goalState ?? { joints: [], load_links: [] };
492
+ const parts = [okfFrontmatter({
493
+ type: 'goal',
494
+ title: 'Objective',
495
+ timestamp: current.provenance?.written_at,
496
+ })];
497
+ parts.push('');
498
+ parts.push('# Objective');
499
+ parts.push('');
500
+ parts.push(`**Current version:** v${current.version}`);
501
+ const draft = (
502
+ ['import', 'migration'].includes(current.provenance?.via)
503
+ && !current.ratification
504
+ );
505
+ if (draft) {
506
+ parts.push('');
507
+ parts.push('**DRAFT HEALTH WARNING:** This imported/migrated goal is not owner-ratified.');
508
+ }
509
+
510
+ parts.push('');
511
+ parts.push(`## Current clauses (v${current.version})`);
512
+ parts.push('');
513
+ for (const clause of current.clauses) parts.push(renderGoalClause(clause));
514
+
515
+ parts.push('');
516
+ parts.push('## Ratification citation');
517
+ parts.push('');
518
+ if (current.ratification) {
519
+ parts.push(`- asked "${current.ratification.asked}"`);
520
+ parts.push(`- quote: "${current.ratification.quote}"`);
521
+ parts.push(
522
+ `- answered ${current.ratification.answered_at}, ref ${current.ratification.answer_ref}`,
523
+ );
524
+ } else {
525
+ parts.push('None — imported/migrated draft.');
526
+ }
527
+
528
+ parts.push('');
529
+ parts.push('## Goal joints');
530
+ parts.push('');
531
+ if ((state.joints ?? []).length === 0) {
532
+ parts.push('None.');
533
+ } else {
534
+ for (const link of state.joints) {
535
+ parts.push(`- **${link.id}** \`${link.removed ? 'removed' : 'active'}\` — [${link.joint}](REGISTER.md#${link.joint})`);
536
+ parts.push(...renderRecordedAndRemoval(link));
537
+ }
538
+ }
539
+
540
+ parts.push('');
541
+ parts.push('## Load-link bill');
542
+ parts.push('');
543
+ if ((state.load_links ?? []).length === 0) {
544
+ parts.push('None.');
545
+ } else {
546
+ for (const link of state.load_links) {
547
+ const version = Number(/^v([1-9][0-9]*)#/.exec(link.clause)?.[1]);
548
+ const versionMark = version === current.version ? 'current version' : 'superseded version';
549
+ parts.push(`- **${link.id}** \`${link.removed ? 'removed' : 'active'}\` — ${link.clause} \`${versionMark}\` carries ${link.carries}`);
550
+ parts.push(...renderRecordedAndRemoval(link));
551
+ }
552
+ }
553
+
554
+ parts.push('');
555
+ parts.push('## Trajectory');
556
+ parts.push('');
557
+ parts.push('| Version | Date | Provocation | Diff note | Wording fix |');
558
+ parts.push('|---|---|---|---|---|');
559
+ for (const version of chain) {
560
+ const provocation = version.provocation?.quote ?? 'unknown (migrated)';
561
+ const wordingFix = version.clauses.some((clause) => (clause.trace?.length ?? 0) > 0)
562
+ ? 'yes'
563
+ : 'no';
564
+ parts.push(
565
+ `| v${version.version} | ${tableCell(version.provenance?.written_at ?? 'unknown')} | ${tableCell(provocation)} | ${tableCell(version.diff_note)} | ${wordingFix} |`,
566
+ );
567
+ }
568
+ return parts.join('\n') + '\n';
569
+ }
570
+
251
571
  function renderObjective(snapshot) {
252
- const objective = snapshot.positions.find((p) => p.slug === 'objective');
572
+ if (snapshot.goalCutoverComplete) return renderGoalObjective(snapshot);
573
+ const objective = (snapshot.positions ?? []).find((p) => p.slug === 'objective');
253
574
  if (objective) return renderPosition(objective, snapshot);
254
575
  return [
255
576
  okfFrontmatter({ type: 'position', title: 'objective' }),
@@ -261,6 +582,9 @@ function renderObjective(snapshot) {
261
582
  }
262
583
 
263
584
  function renderIndex(snapshot) {
585
+ const positions = (snapshot.positions ?? []).filter(
586
+ (position) => !(snapshot.goalCutoverComplete && position.slug === 'objective'),
587
+ );
264
588
  const parts = [okfFrontmatter({ type: null, title: 'Judgment canon' })];
265
589
  parts.push('');
266
590
  parts.push('# Judgment canon (generated)');
@@ -270,11 +594,11 @@ function renderIndex(snapshot) {
270
594
  parts.push('- [Register](REGISTER.md)');
271
595
  parts.push('- [Ledger](LEDGER.md)');
272
596
  parts.push('- [Objective](OBJECTIVE.md)');
273
- if (snapshot.positions.length) {
597
+ if (positions.length) {
274
598
  parts.push('');
275
599
  parts.push('## Positions');
276
600
  parts.push('');
277
- for (const p of snapshot.positions) {
601
+ for (const p of positions) {
278
602
  parts.push(`- [${p.slug}](positions/${p.slug}.md) — ${p.status}`);
279
603
  }
280
604
  }
@@ -293,14 +617,40 @@ export function generateFromRecords(snapshot, { now } = {}) { // eslint-disable-
293
617
  'docs/judgment/REGISTER.md': renderRegister(snapshot),
294
618
  'docs/judgment/LEDGER.md': renderLedger(snapshot),
295
619
  'docs/judgment/OBJECTIVE.md': renderObjective(snapshot),
620
+ 'docs/judgment/SITUATION.md': renderSituation(snapshot),
296
621
  'docs/judgment/index.md': renderIndex(snapshot),
297
622
  };
298
- for (const position of snapshot.positions) {
623
+ for (const person of [...(snapshot.people ?? [])].sort((a, b) => a.slug.localeCompare(b.slug))) {
624
+ files[`docs/judgment/people/${person.slug}.md`] = renderPerson(person);
625
+ }
626
+ for (const position of (snapshot.positions ?? [])) {
627
+ if (snapshot.goalCutoverComplete && position.slug === 'objective') continue;
299
628
  files[`docs/judgment/positions/${position.slug}.md`] = renderPosition(position, snapshot);
300
629
  }
301
630
  return files;
302
631
  }
303
632
 
633
+ const MANAGED_MARKDOWN_DIRS = [
634
+ 'docs/judgment/people',
635
+ 'docs/judgment/positions',
636
+ ];
637
+
638
+ function managedStaleProjections(cwd, files) {
639
+ const stale = [];
640
+ for (const relDir of MANAGED_MARKDOWN_DIRS) {
641
+ const dir = join(cwd, relDir);
642
+ if (!existsSync(dir)) continue;
643
+ const entries = readdirSync(dir, { withFileTypes: true })
644
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
645
+ .sort((a, b) => a.name.localeCompare(b.name));
646
+ for (const entry of entries) {
647
+ const rel = `${relDir}/${entry.name}`;
648
+ if (!(rel in files)) stale.push(rel);
649
+ }
650
+ }
651
+ return stale;
652
+ }
653
+
304
654
  /**
305
655
  * Regenerate every projection from records on disk. Atomic per file.
306
656
  * @returns {{ files: string[] }} relative paths written
@@ -312,6 +662,9 @@ export function regenerateProjections(cwd) {
312
662
  mkdirSync(join(path, '..'), { recursive: true });
313
663
  atomicWrite(path, content);
314
664
  }
665
+ for (const rel of managedStaleProjections(cwd, files)) {
666
+ unlinkSync(join(cwd, rel));
667
+ }
315
668
  return { files: Object.keys(files) };
316
669
  }
317
670
 
@@ -325,18 +678,14 @@ export function checkProjectionRoundtrip(cwd) {
325
678
  const diffs = [];
326
679
  for (const [rel, content] of Object.entries(files)) {
327
680
  const path = join(cwd, rel);
328
- const onDisk = existsSync(path) ? readFileSync(path, 'utf8') : null;
329
- if (onDisk !== content) diffs.push(rel);
330
- }
331
- // Orphan detection: a projection file the generator no longer emits (stale
332
- // position projection, leftover from an older generator) is drift too.
333
- const positionsDir = join(cwd, 'docs', 'judgment', 'positions');
334
- if (existsSync(positionsDir)) {
335
- for (const f of readdirSync(positionsDir)) {
336
- if (!f.endsWith('.md')) continue;
337
- const rel = `docs/judgment/positions/${f}`;
338
- if (!(rel in files)) diffs.push(`${rel} (orphan — not emitted by the generator)`);
681
+ if (!existsSync(path)) {
682
+ diffs.push(`${rel} (missing)`);
683
+ continue;
339
684
  }
685
+ if (readFileSync(path, 'utf8') !== content) diffs.push(`${rel} (drift)`);
686
+ }
687
+ for (const rel of managedStaleProjections(cwd, files)) {
688
+ diffs.push(`${rel} (orphan — not emitted by the generator)`);
340
689
  }
341
690
  return { fixedPoint: diffs.length === 0, diffs };
342
691
  }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Combined drift verification for the judgment canon.
3
+ *
4
+ * The inventory, projection roundtrip, and record attestation are evaluated
5
+ * under the writer's advisory lock so a legitimate write cannot expose a
6
+ * partially-published canon to the verifier.
7
+ */
8
+ import {
9
+ existsSync,
10
+ lstatSync,
11
+ readdirSync,
12
+ } from 'node:fs';
13
+ import { join, posix } from 'node:path';
14
+
15
+ import { readManifest, recordFileSet, verifyRecords } from './judgment-attest.js';
16
+ import {
17
+ checkProjectionRoundtrip,
18
+ generateFromRecords,
19
+ loadSnapshot,
20
+ } from './judgment-gen.js';
21
+ import { withJudgmentLock } from './judgment-writer.js';
22
+
23
+ const JUDGMENT_REL = 'docs/judgment';
24
+ const MANIFEST_REL = `${JUDGMENT_REL}/.attest.json`;
25
+ const RECORDS_REL = `${JUDGMENT_REL}/records`;
26
+ const STRUCTURAL_DIRS = [
27
+ RECORDS_REL,
28
+ `${JUDGMENT_REL}/people`,
29
+ `${JUDGMENT_REL}/positions`,
30
+ ];
31
+ // The legitimate depth-1 dirs under records/. Anything else there (e.g. a
32
+ // Bash-created `records/garbage/`) is drift. Deeper legitimate dirs — such as
33
+ // `records/positions/<slug>/` — are admitted by expectedTree's parent-walk over
34
+ // the real record files, so this list stays exact rather than blanket-exempting
35
+ // every nested path.
36
+ const RECORD_STRUCTURAL_DIRS = [
37
+ 'goal', 'intents', 'joints', 'people', 'positions', 'predictions', 'situation',
38
+ ].map((name) => `${RECORDS_REL}/${name}`);
39
+
40
+ function expectedTree(cwd) {
41
+ // Derive the expected projection set from the SAME snapshot builder that
42
+ // checkProjectionRoundtrip uses (loadSnapshot, exported from judgment-gen).
43
+ // Reconstructing it here would be a mirror that silently diverges into false
44
+ // tree drift the moment loadSnapshot changes.
45
+ const projections = Object.keys(generateFromRecords(loadSnapshot(cwd)));
46
+ const records = recordFileSet(cwd);
47
+ // The manifest is NOT in this set: it lives at .compose/judgment-attest.json,
48
+ // outside the tree being inventoried.
49
+ const files = new Set([...projections, ...records]);
50
+ const directories = new Set([JUDGMENT_REL, ...STRUCTURAL_DIRS, ...RECORD_STRUCTURAL_DIRS]);
51
+
52
+ for (const relPath of files) {
53
+ let parent = posix.dirname(relPath);
54
+ while (parent.startsWith(`${JUDGMENT_REL}/`)) {
55
+ directories.add(parent);
56
+ parent = posix.dirname(parent);
57
+ }
58
+ }
59
+
60
+ return { files, directories };
61
+ }
62
+
63
+ function inventoryTree(cwd, expected) {
64
+ const root = join(cwd, ...JUDGMENT_REL.split('/'));
65
+ if (!existsSync(root)) return [];
66
+
67
+ const rootStat = lstatSync(root);
68
+ if (rootStat.isSymbolicLink()) {
69
+ return [{ path: JUDGMENT_REL, kind: 'symlink' }];
70
+ }
71
+ if (!rootStat.isDirectory()) {
72
+ return [{ path: JUDGMENT_REL, kind: 'unexpected' }];
73
+ }
74
+
75
+ const drift = [];
76
+
77
+ function walk(absDir, relDir) {
78
+ const entries = readdirSync(absDir)
79
+ .sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
80
+
81
+ for (const name of entries) {
82
+ const absPath = join(absDir, name);
83
+ const relPath = `${relDir}/${name}`;
84
+ const stat = lstatSync(absPath);
85
+
86
+ if (stat.isSymbolicLink()) {
87
+ drift.push({ path: relPath, kind: 'symlink' });
88
+ continue;
89
+ }
90
+
91
+ if (stat.isDirectory()) {
92
+ // An EMPTY unexpected dir is not reported: it holds no canon, and
93
+ // UndoLog.restore unlinks files without removing their directories, so a
94
+ // rolled-back op legitimately leaves e.g. an empty positions/<slug>/.
95
+ // Flagging those would false-RED on correct behaviour. A non-empty stray
96
+ // dir is still caught — every file inside it fails the file rule below.
97
+ if (!expected.directories.has(relPath) && readdirSync(absPath).length > 0) {
98
+ drift.push({ path: relPath, kind: 'unexpected' });
99
+ }
100
+ walk(absPath, relPath);
101
+ continue;
102
+ }
103
+
104
+ if (!stat.isFile() || !expected.files.has(relPath)) {
105
+ drift.push({ path: relPath, kind: 'unexpected' });
106
+ }
107
+ }
108
+ }
109
+
110
+ walk(root, JUDGMENT_REL);
111
+ drift.sort((a, b) => {
112
+ if (a.path !== b.path) return a.path < b.path ? -1 : 1;
113
+ return a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0;
114
+ });
115
+ return drift;
116
+ }
117
+
118
+ /**
119
+ * True when this project has no judgment canon at all — no `docs/judgment/`
120
+ * tree and no attestation baseline. There is nothing to verify, so verification
121
+ * must pass: most Compose projects never adopt the judgment layer, and a
122
+ * gate that fails them would break every pre-push and ship in those repos.
123
+ *
124
+ * This is NOT the same as a deleted canon. The baseline lives outside
125
+ * `docs/judgment/**` (at `.compose/judgment-attest.json`), so wiping the canon
126
+ * leaves the manifest behind — `hasNoCanon` is false and every record it
127
+ * remembers surfaces as `removed` drift.
128
+ */
129
+ function hasNoCanon(cwd) {
130
+ return !existsSync(join(cwd, ...JUDGMENT_REL.split('/')))
131
+ && readManifest(cwd) === null;
132
+ }
133
+
134
+ export async function verifyJudgmentCanon(cwd) {
135
+ return withJudgmentLock(cwd, async () => {
136
+ if (hasNoCanon(cwd)) {
137
+ return { ok: true, treeDrift: [], projectionDrift: [], recordDrift: [] };
138
+ }
139
+
140
+ const treeDrift = inventoryTree(cwd, expectedTree(cwd));
141
+ const projection = checkProjectionRoundtrip(cwd);
142
+ const records = verifyRecords(cwd);
143
+ const projectionDrift = projection.diffs;
144
+ const recordDrift = records.drift;
145
+
146
+ return {
147
+ ok: treeDrift.length === 0 && projection.fixedPoint && records.ok,
148
+ treeDrift,
149
+ projectionDrift,
150
+ recordDrift,
151
+ };
152
+ });
153
+ }