@smartmemory/compose 0.3.5-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.
- package/.claude/skills/compose/SKILL.md +42 -88
- package/bin/compose.js +288 -0
- package/bin/git-hooks/pre-push.template +29 -0
- package/bin/judgment-import.js +572 -0
- package/contracts/feature-json.schema.json +5 -0
- package/contracts/judgment-record.schema.json +821 -0
- package/dist/assets/App-PkZzHeMj.js +894 -0
- package/dist/assets/_baseUniq-Bo837sRJ.js +1 -0
- package/dist/assets/arc-BafGpyqE.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-BOBfUsqL.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-Dwodev1a.js +132 -0
- package/dist/assets/{browser-BSM23If2.js → browser-1ntj1-x_.js} +6 -6
- package/dist/assets/{c4Diagram-LMCZKHZV-CUmTCrMW.js → c4Diagram-AHTNJAMY-CU_bhYag.js} +1 -1
- package/dist/assets/channel-qVK_qn4E.js +1 -0
- package/dist/assets/{chunk-JWPE2WC7-DQFs7gjy.js → chunk-4BX2VUAB-p8WsDwnO.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-B8h7-eR0.js +206 -0
- package/dist/assets/{chunk-XXDRQBXY-CSdt06Hp.js → chunk-55IACEB6-DxeEr98s.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-Dvhgnm7E.js → chunk-EDXVE4YY-BYt8F151.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-Qw-L7OoJ.js → chunk-FMBD7UC4-DGSOVeie.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-B-QdgYR2.js +231 -0
- package/dist/assets/{chunk-2Q5K7J3B-Cyvi1AFH.js → chunk-QZHKN3VN-Du5UAZLs.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-B1y5FpOd.js → chunk-YZCP3GAM-C8JbNBSk.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +1 -0
- package/dist/assets/clone-Pu3RyLUh.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-G8l05tXy.js → cose-bilkent-S5V4N54A-O1ESaqge.js} +1 -1
- package/dist/assets/dagre-KV5264BT-CPTmFPHw.js +4 -0
- package/dist/assets/diagram-5BDNPKRD-B3PNrWs5.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-Cscfr6vc.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-CSfqZ-TM.js +43 -0
- package/dist/assets/diagram-TYMM5635-Cg4aYS7W.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-_ZqwG5pl.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-C83boxFT.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-CWnIjuEi.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-DrMdxZfH.js +106 -0
- package/dist/assets/graph-Bi99_6Yf.js +331 -0
- package/dist/assets/graph-RE4I7Ty7.js +1 -0
- package/dist/assets/index-Rm2RE-c0.js +123 -0
- package/dist/assets/infoDiagram-42DDH7IO-BLmP4Epr.js +2 -0
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-Hm4zSDlM.js → ishikawaDiagram-UXIWVN3A-yuWWshKN.js} +5 -5
- package/dist/assets/{journeyDiagram-5HDEW3XC-CIqcTFuO.js → journeyDiagram-VCZTEJTY-BOfhaJov.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-CB9jCXj_.js → kanban-definition-6JOO6SKY-Bbolde15.js} +7 -7
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-BSf33zm8.js +1 -0
- package/dist/assets/{linear-CsXqv70E.js → linear-AvSTWMqx.js} +1 -1
- package/dist/assets/min-QBM8H4xN.js +1 -0
- package/dist/assets/{mindmap-definition-LN4V7U3C-CpqH5F_P.js → mindmap-definition-QFDTVHPH-BuvgtqIc.js} +7 -7
- package/dist/assets/{mobile-CaoXUwAr.js → mobile-BnXEOE3U.js} +2 -2
- package/dist/assets/pieDiagram-DEJITSTG-DIzF16vh.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-D-mbUIjS.js +7 -0
- package/dist/assets/{requirementDiagram-TGXJPOKE-D1lG1P7h.js → requirementDiagram-MS252O5E-CEs4kCLd.js} +3 -3
- package/dist/assets/sankeyDiagram-XADWPNL6-DFsnCr9n.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-BEJYdTjQ.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-BBXs57uY.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +1 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-CzqqUDvt.js → timeline-definition-GMOUNBTQ-BGvLoVAY.js} +3 -3
- package/dist/assets/vennDiagram-DHZGUBPP-9LaBTMe0.js +34 -0
- package/dist/assets/wardley-RL74JXVD-P4MEqMTP.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-o-tmxnlC.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-Dpn7V6qk.js +7 -0
- package/dist/index.html +2 -2
- package/lib/bug-escalation.js +30 -4
- package/lib/build.js +777 -52
- package/lib/canon-guard.js +223 -0
- package/lib/canon-registry.js +187 -0
- package/lib/codex-preflight.js +26 -4
- package/lib/dispatch-ledger.js +301 -0
- package/lib/dispatch-metrics.js +236 -0
- package/lib/experiment-judge.js +6 -1
- package/lib/feature-writer.js +9 -0
- package/lib/gsd.js +11 -2
- package/lib/hooks-status.js +32 -3
- package/lib/idempotency.js +33 -8
- package/lib/judgment/schema.js +23 -0
- package/lib/judgment/store/index.js +207 -0
- package/lib/judgment/store/records.js +405 -0
- package/lib/judgment-attest.js +259 -0
- package/lib/judgment-gen.js +691 -0
- package/lib/judgment-verify.js +153 -0
- package/lib/judgment-write-guard.js +230 -0
- package/lib/judgment-writer.js +3403 -0
- package/lib/lane-gate.js +2 -0
- package/lib/lifecycle-modes.js +42 -0
- package/lib/local-claude-connector.js +199 -54
- package/lib/mcp-enforcement.js +21 -35
- package/lib/result-normalizer.js +93 -15
- package/lib/review-normalize.js +4 -0
- package/lib/stratum-mcp-client.js +131 -6
- package/package.json +2 -2
- package/server/compose-mcp-tools.js +49 -1
- package/server/compose-mcp.js +220 -0
- package/server/mcp-tool-policy.js +1 -0
- package/dist/assets/App-BG92y_CY.js +0 -896
- package/dist/assets/abnfDiagram-VRR7QNED-Bip6cMjP.js +0 -1
- package/dist/assets/arc-CyMTM07c.js +0 -1
- package/dist/assets/architectureDiagram-ZJ3FMSHR-BukV0CJw.js +0 -36
- package/dist/assets/blockDiagram-677ZJIJ3-Bjb_MNB4.js +0 -132
- package/dist/assets/channel-BzkmtaLy.js +0 -1
- package/dist/assets/chunk-EX3LRPZG-G_MZEji_.js +0 -231
- package/dist/assets/chunk-MOJQB5TN-CQZQYMcz.js +0 -88
- package/dist/assets/chunk-RYQCIY6F-CmAda4sZ.js +0 -1
- package/dist/assets/chunk-V7JOEXUC-CbjBz-Ek.js +0 -206
- package/dist/assets/classDiagram-OUVF2IWQ-BIvLcbex.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-BIvLcbex.js +0 -1
- package/dist/assets/cynefin-VYW2F7L2-C3n1PNJC.js +0 -178
- package/dist/assets/cynefinDiagram-TSTJHNR4-C8N_CvKJ.js +0 -62
- package/dist/assets/dagre-VKFMJZFB-B2pkwDhN.js +0 -4
- package/dist/assets/diagram-FQU43EPY-DPHD_t-1.js +0 -3
- package/dist/assets/diagram-G47NLZAW-DGmHIZRr.js +0 -24
- package/dist/assets/diagram-NH7WQ7WH-BlLWwKVt.js +0 -24
- package/dist/assets/diagram-OA4YK3LP-DEv704Re.js +0 -30
- package/dist/assets/diagram-WEI45ONY-Bj6PGUCe.js +0 -41
- package/dist/assets/ebnfDiagram-CCIWWBDH-Ch-icksT.js +0 -1
- package/dist/assets/erDiagram-Q63AITRT-CI3lwEFA.js +0 -85
- package/dist/assets/flowDiagram-23GEKE2U-DcUANpAz.js +0 -156
- package/dist/assets/ganttDiagram-NO4QXBWP-Cy0Ie8KS.js +0 -292
- package/dist/assets/gitGraphDiagram-IHSO6WYX-BswI52az.js +0 -106
- package/dist/assets/graph-BXPQrYYB.js +0 -331
- package/dist/assets/graph-C9eacEi8.js +0 -1
- package/dist/assets/index-B1H7-G2y.js +0 -119
- package/dist/assets/infoDiagram-FWYZ7A6U-BBunqvr-.js +0 -2
- package/dist/assets/katex-C5jXJg4s.js +0 -257
- package/dist/assets/layout-DEXfKzaS.js +0 -1
- package/dist/assets/map-Czzmt4hB.js +0 -1
- package/dist/assets/pegDiagram-2B236MQR-HpQyrDcm.js +0 -1
- package/dist/assets/pieDiagram-ENE6RG2P-xkcpHwnY.js +0 -39
- package/dist/assets/quadrantDiagram-ABIIQ3AL-kebZIOmu.js +0 -7
- package/dist/assets/railroadDiagram-RFXS5EU6-DNvGwWm9.js +0 -1
- package/dist/assets/sankeyDiagram-HTMAVEWB-DOEEcn52.js +0 -40
- package/dist/assets/sequenceDiagram-DBY2YBRQ-DynIVCk_.js +0 -162
- package/dist/assets/sizeCapture-X5ZJPWSS-DJXFjUUL.js +0 -1
- package/dist/assets/stateDiagram-2N3HPSRC-OZ7trFh5.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-GcPRHAJT.js +0 -1
- package/dist/assets/swimlanes-5IMT3BWC-DULYAFmB.js +0 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-BB0gCy01.js +0 -8
- package/dist/assets/vennDiagram-L72KCM5P-CdKmtcFL.js +0 -34
- package/dist/assets/wardleyDiagram-EHGQE667-DknGwjCA.js +0 -78
- package/dist/assets/xychartDiagram-FW5EYKEG-DdYyKfQ3.js +0 -7
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/judgment/store/records.js — the tracked-floor judgment canon store (S02).
|
|
3
|
+
*
|
|
4
|
+
* Records are canonical, git-tracked JSON under docs/judgment/records/
|
|
5
|
+
* (the feature.json precedent — NOT the gitignored vision store):
|
|
6
|
+
*
|
|
7
|
+
* positions/<slug>/r<N>.json append-only revision chain; highest N is current
|
|
8
|
+
* people/<slug>.json mutable person aggregate
|
|
9
|
+
* situation/<slug>.json mutable situation aggregate
|
|
10
|
+
* goal/v<N>.json append-only goal meaning chain
|
|
11
|
+
* goal/state.json mutable goal associations sidecar
|
|
12
|
+
* joints/<slug>.json one file per joint; state lives here
|
|
13
|
+
* predictions/<id>.json spawned by CONSTRUCT/commit events
|
|
14
|
+
* intents/<id>.json pending-intent records (intent-first transitions)
|
|
15
|
+
* ledger.jsonl append-only event stream
|
|
16
|
+
*
|
|
17
|
+
* Position status is DERIVED, never stored: a chain is `superseded` iff the
|
|
18
|
+
* latest revision of a non-retracted chain elsewhere names it in `supersedes`;
|
|
19
|
+
* `retracted` iff its own latest revision is a tombstone; `live` otherwise.
|
|
20
|
+
*
|
|
21
|
+
* All writes are atomic: `${path}.tmp.${process.pid}` + rename (the
|
|
22
|
+
* feature-json.js:87-90 idiom — the pid-less journal-writer variant is a
|
|
23
|
+
* known trap, do not copy it). The ledger append rewrites the whole stream
|
|
24
|
+
* through the same tmp+rename path so a crash can never leave a torn line.
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
readFileSync, writeFileSync, renameSync, unlinkSync,
|
|
28
|
+
mkdirSync, readdirSync, existsSync,
|
|
29
|
+
} from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
|
|
32
|
+
function atomicWrite(path, content) {
|
|
33
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(tmp, content);
|
|
36
|
+
renameSync(tmp, path);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
try { unlinkSync(tmp); } catch { /* tmp may not exist */ }
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readJson(path) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class RecordsStore {
|
|
52
|
+
/** @param {string} cwd project root */
|
|
53
|
+
constructor(cwd) {
|
|
54
|
+
this.cwd = cwd;
|
|
55
|
+
this.root = join(cwd, 'docs', 'judgment', 'records');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Enrichment capabilities — the floor has none (W4 adds them via config, not here). */
|
|
59
|
+
capabilities() {
|
|
60
|
+
return new Set();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── positions ────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
_chainNumbers({ dir, prefix }) {
|
|
66
|
+
if (!existsSync(dir)) return [];
|
|
67
|
+
const fileRe = new RegExp(`^${prefix}([1-9][0-9]*)\\.json$`);
|
|
68
|
+
return readdirSync(dir)
|
|
69
|
+
.map((file) => fileRe.exec(file))
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.map((match) => Number(match[1]))
|
|
72
|
+
.sort((a, b) => a - b);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_appendChainRecord(config, record) {
|
|
76
|
+
mkdirSync(config.dir, { recursive: true });
|
|
77
|
+
const numbers = this._chainNumbers(config);
|
|
78
|
+
const number = (numbers.length ? numbers[numbers.length - 1] : 0) + 1;
|
|
79
|
+
const path = join(config.dir, `${config.prefix}${number}.json`);
|
|
80
|
+
atomicWrite(
|
|
81
|
+
path,
|
|
82
|
+
JSON.stringify({ ...record, [config.numberField]: number }, null, 2) + '\n',
|
|
83
|
+
);
|
|
84
|
+
return {
|
|
85
|
+
[config.numberField]: number,
|
|
86
|
+
ref: config.ref(number),
|
|
87
|
+
path,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_readChainRecord(config, number) {
|
|
92
|
+
return readJson(join(config.dir, `${config.prefix}${number}.json`));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_readChain(config) {
|
|
96
|
+
return this._chainNumbers(config)
|
|
97
|
+
.map((number) => this._readChainRecord(config, number))
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_latestChainRecord(config) {
|
|
102
|
+
const numbers = this._chainNumbers(config);
|
|
103
|
+
if (numbers.length === 0) return null;
|
|
104
|
+
return this._readChainRecord(config, numbers[numbers.length - 1]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_replaceChainRecord(config, number, record) {
|
|
108
|
+
mkdirSync(config.dir, { recursive: true });
|
|
109
|
+
const path = join(config.dir, `${config.prefix}${number}.json`);
|
|
110
|
+
atomicWrite(
|
|
111
|
+
path,
|
|
112
|
+
JSON.stringify({ ...record, [config.numberField]: number }, null, 2) + '\n',
|
|
113
|
+
);
|
|
114
|
+
return {
|
|
115
|
+
[config.numberField]: number,
|
|
116
|
+
ref: config.ref(number),
|
|
117
|
+
path,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_positionDir(slug) {
|
|
122
|
+
return join(this.root, 'positions', slug);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_positionChain(slug) {
|
|
126
|
+
return {
|
|
127
|
+
dir: this._positionDir(slug),
|
|
128
|
+
prefix: 'r',
|
|
129
|
+
numberField: 'rev',
|
|
130
|
+
ref: (rev) => `${slug}#r${rev}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_revNumbers(slug) {
|
|
135
|
+
return this._chainNumbers(this._positionChain(slug));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Append the next revision of a chain. Stamps `rev` (next N); revisions are
|
|
140
|
+
* immutable once written — this never overwrites an existing r<N>.json.
|
|
141
|
+
* @returns {{ rev: number, ref: string, path: string }}
|
|
142
|
+
*/
|
|
143
|
+
writePositionRevision(record) {
|
|
144
|
+
return this._appendChainRecord(this._positionChain(record.slug), record);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Full chain, ascending by rev. */
|
|
148
|
+
readPositionChain(slug) {
|
|
149
|
+
return this._readChain(this._positionChain(slug));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
readPositionRevision(slug, rev) {
|
|
153
|
+
return this._readChainRecord(this._positionChain(slug), rev);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
listPositionSlugs() {
|
|
157
|
+
const dir = join(this.root, 'positions');
|
|
158
|
+
if (!existsSync(dir)) return [];
|
|
159
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
160
|
+
.filter((d) => d.isDirectory())
|
|
161
|
+
.map((d) => d.name)
|
|
162
|
+
.sort();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Latest revision of a chain, or null. */
|
|
166
|
+
latestPositionRevision(slug) {
|
|
167
|
+
return this._latestChainRecord(this._positionChain(slug));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Derived chain status: 'live' | 'superseded' | 'retracted' | null (no chain).
|
|
172
|
+
* Never stored anywhere — computed from the chains on every call.
|
|
173
|
+
*/
|
|
174
|
+
derivePositionStatus(slug) {
|
|
175
|
+
const latest = this.latestPositionRevision(slug);
|
|
176
|
+
if (!latest) return null;
|
|
177
|
+
if (latest.retracted === true) return 'retracted';
|
|
178
|
+
for (const other of this.listPositionSlugs()) {
|
|
179
|
+
if (other === slug) continue;
|
|
180
|
+
const otherLatest = this.latestPositionRevision(other);
|
|
181
|
+
if (!otherLatest || otherLatest.retracted === true) continue;
|
|
182
|
+
const ref = otherLatest.supersedes;
|
|
183
|
+
if (typeof ref === 'string' && ref.startsWith(`${slug}#r`)) return 'superseded';
|
|
184
|
+
}
|
|
185
|
+
return 'live';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── goal ─────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
_goalDir() {
|
|
191
|
+
return join(this.root, 'goal');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_goalChain() {
|
|
195
|
+
return {
|
|
196
|
+
dir: this._goalDir(),
|
|
197
|
+
prefix: 'v',
|
|
198
|
+
numberField: 'version',
|
|
199
|
+
ref: (version) => `goal:v${version}`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
writeGoalVersion(record) {
|
|
204
|
+
return this._appendChainRecord(this._goalChain(), record);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
readGoalVersion(version) {
|
|
208
|
+
return this._readChainRecord(this._goalChain(), version);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
readGoalChain() {
|
|
212
|
+
return this._readChain(this._goalChain());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
latestGoalVersion() {
|
|
216
|
+
return this._latestChainRecord(this._goalChain());
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
replaceGoalVersion(version, record) {
|
|
220
|
+
return this._replaceChainRecord(this._goalChain(), version, record);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_goalStatePath() {
|
|
224
|
+
return join(this._goalDir(), 'state.json');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
writeGoalState(record) {
|
|
228
|
+
mkdirSync(this._goalDir(), { recursive: true });
|
|
229
|
+
atomicWrite(this._goalStatePath(), JSON.stringify(record, null, 2) + '\n');
|
|
230
|
+
return { path: this._goalStatePath() };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
readGoalState() {
|
|
234
|
+
return readJson(this._goalStatePath());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── people ───────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
_personPath(slug) {
|
|
240
|
+
return join(this.root, 'people', `${slug}.json`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
writePerson(record) {
|
|
244
|
+
mkdirSync(join(this.root, 'people'), { recursive: true });
|
|
245
|
+
atomicWrite(this._personPath(record.slug), JSON.stringify(record, null, 2) + '\n');
|
|
246
|
+
return { slug: record.slug };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
readPerson(slug) {
|
|
250
|
+
return readJson(this._personPath(slug));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
listPeople() {
|
|
254
|
+
const dir = join(this.root, 'people');
|
|
255
|
+
if (!existsSync(dir)) return [];
|
|
256
|
+
return readdirSync(dir)
|
|
257
|
+
.filter((file) => file.endsWith('.json'))
|
|
258
|
+
.sort()
|
|
259
|
+
.map((file) => readJson(join(dir, file)))
|
|
260
|
+
.filter(Boolean);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── situation ────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
_situationEntityPath(slug) {
|
|
266
|
+
return join(this.root, 'situation', `${slug}.json`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
writeSituationEntity(record) {
|
|
270
|
+
mkdirSync(join(this.root, 'situation'), { recursive: true });
|
|
271
|
+
atomicWrite(
|
|
272
|
+
this._situationEntityPath(record.slug),
|
|
273
|
+
JSON.stringify(record, null, 2) + '\n',
|
|
274
|
+
);
|
|
275
|
+
return { slug: record.slug };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
readSituationEntity(slug) {
|
|
279
|
+
return readJson(this._situationEntityPath(slug));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
listSituationEntities() {
|
|
283
|
+
const dir = join(this.root, 'situation');
|
|
284
|
+
if (!existsSync(dir)) return [];
|
|
285
|
+
return readdirSync(dir)
|
|
286
|
+
.filter((file) => file.endsWith('.json'))
|
|
287
|
+
.sort()
|
|
288
|
+
.map((file) => readJson(join(dir, file)))
|
|
289
|
+
.filter(Boolean);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── joints ───────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
_jointPath(slug) {
|
|
295
|
+
return join(this.root, 'joints', `${slug}.json`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
writeJoint(record) {
|
|
299
|
+
mkdirSync(join(this.root, 'joints'), { recursive: true });
|
|
300
|
+
atomicWrite(this._jointPath(record.slug), JSON.stringify(record, null, 2) + '\n');
|
|
301
|
+
return { slug: record.slug };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
readJoint(slug) {
|
|
305
|
+
return readJson(this._jointPath(slug));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
listJoints() {
|
|
309
|
+
const dir = join(this.root, 'joints');
|
|
310
|
+
if (!existsSync(dir)) return [];
|
|
311
|
+
return readdirSync(dir)
|
|
312
|
+
.filter((f) => f.endsWith('.json'))
|
|
313
|
+
.sort()
|
|
314
|
+
.map((f) => readJson(join(dir, f)))
|
|
315
|
+
.filter(Boolean);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── predictions ──────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
_predictionPath(id) {
|
|
321
|
+
return join(this.root, 'predictions', `${id}.json`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
writePrediction(record) {
|
|
325
|
+
mkdirSync(join(this.root, 'predictions'), { recursive: true });
|
|
326
|
+
atomicWrite(this._predictionPath(record.id), JSON.stringify(record, null, 2) + '\n');
|
|
327
|
+
return { id: record.id };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
readPrediction(id) {
|
|
331
|
+
return readJson(this._predictionPath(id));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
listPredictions({ status } = {}) {
|
|
335
|
+
const dir = join(this.root, 'predictions');
|
|
336
|
+
if (!existsSync(dir)) return [];
|
|
337
|
+
return readdirSync(dir)
|
|
338
|
+
.filter((f) => f.endsWith('.json'))
|
|
339
|
+
.sort()
|
|
340
|
+
.map((f) => readJson(join(dir, f)))
|
|
341
|
+
.filter(Boolean)
|
|
342
|
+
.filter((p) => (status ? p.status === status : true));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── ledger ───────────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
_ledgerPath() {
|
|
348
|
+
return join(this.root, 'ledger.jsonl');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Append one event. Rewrites the stream via tmp+rename (atomic — a crash
|
|
353
|
+
* mid-append can never leave a torn trailing line). Append-only: there is
|
|
354
|
+
* no update or delete surface on this store.
|
|
355
|
+
*/
|
|
356
|
+
appendLedgerEvent(event) {
|
|
357
|
+
mkdirSync(this.root, { recursive: true });
|
|
358
|
+
const path = this._ledgerPath();
|
|
359
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
360
|
+
atomicWrite(path, existing + JSON.stringify(event) + '\n');
|
|
361
|
+
return { seq: existing.split('\n').filter(Boolean).length + 1 };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
readLedgerEvents() {
|
|
365
|
+
const path = this._ledgerPath();
|
|
366
|
+
if (!existsSync(path)) return [];
|
|
367
|
+
return readFileSync(path, 'utf8')
|
|
368
|
+
.split('\n')
|
|
369
|
+
.filter(Boolean)
|
|
370
|
+
.map((line) => {
|
|
371
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
372
|
+
})
|
|
373
|
+
.filter(Boolean);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── pending intents ──────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
_intentPath(id) {
|
|
379
|
+
return join(this.root, 'intents', `${id}.json`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
persistIntent(intent) {
|
|
383
|
+
mkdirSync(join(this.root, 'intents'), { recursive: true });
|
|
384
|
+
atomicWrite(this._intentPath(intent.id), JSON.stringify(intent, null, 2) + '\n');
|
|
385
|
+
return { id: intent.id };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
readIntents() {
|
|
389
|
+
const dir = join(this.root, 'intents');
|
|
390
|
+
if (!existsSync(dir)) return [];
|
|
391
|
+
return readdirSync(dir)
|
|
392
|
+
.filter((f) => f.endsWith('.json'))
|
|
393
|
+
.sort()
|
|
394
|
+
.map((f) => readJson(join(dir, f)))
|
|
395
|
+
.filter(Boolean);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
clearIntent(id) {
|
|
399
|
+
try {
|
|
400
|
+
unlinkSync(this._intentPath(id));
|
|
401
|
+
} catch (err) {
|
|
402
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
11
|
+
import {
|
|
12
|
+
basename,
|
|
13
|
+
dirname,
|
|
14
|
+
isAbsolute,
|
|
15
|
+
join,
|
|
16
|
+
relative,
|
|
17
|
+
resolve,
|
|
18
|
+
sep,
|
|
19
|
+
} from 'node:path';
|
|
20
|
+
|
|
21
|
+
const RECORDS_REL = 'docs/judgment/records';
|
|
22
|
+
// The manifest lives OUTSIDE `docs/judgment/**` entirely, so no deletion of the
|
|
23
|
+
// canon can also delete the baseline that would expose the deletion (R4
|
|
24
|
+
// fail-closed). Two review findings and one earlier fix converge here: a
|
|
25
|
+
// baseline stored inside the tree it attests dies with that tree and reads as a
|
|
26
|
+
// false GREEN. `.compose/` is git-tracked (only `.compose/data/` is ignored), so
|
|
27
|
+
// this location is BOTH committed — a fresh clone can verify — and immune to
|
|
28
|
+
// `rm -rf docs/judgment`. A wiped canon now surfaces every record as `removed`
|
|
29
|
+
// drift; a genuinely fresh repo (no canon, no manifest) stays green.
|
|
30
|
+
const MANIFEST_REL = '.compose/judgment-attest.json';
|
|
31
|
+
|
|
32
|
+
function absolutePath(cwd, relPath) {
|
|
33
|
+
return join(cwd, ...relPath.split('/'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sha256(bytes) {
|
|
37
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function inspectRecord(cwd, relPath) {
|
|
41
|
+
const bytes = readFileSync(absolutePath(cwd, relPath));
|
|
42
|
+
let malformed = false;
|
|
43
|
+
|
|
44
|
+
if (relPath.endsWith('.json')) {
|
|
45
|
+
try {
|
|
46
|
+
JSON.parse(bytes.toString('utf8'));
|
|
47
|
+
} catch {
|
|
48
|
+
malformed = true;
|
|
49
|
+
}
|
|
50
|
+
} else if (relPath.endsWith('.jsonl')) {
|
|
51
|
+
// `ledger.jsonl` is a record too, and R4's fail-closed rule covers it. Only
|
|
52
|
+
// hashing it was a hole: the store's reader silently DROPS unparseable
|
|
53
|
+
// ledger lines, so a corrupted ledger could be baselined and then verify
|
|
54
|
+
// green while its bad lines quietly vanished from every projection.
|
|
55
|
+
// Validate line-by-line instead.
|
|
56
|
+
const text = bytes.toString('utf8');
|
|
57
|
+
for (const line of text.split('\n')) {
|
|
58
|
+
if (line.trim() === '') continue;
|
|
59
|
+
try {
|
|
60
|
+
JSON.parse(line);
|
|
61
|
+
} catch {
|
|
62
|
+
malformed = true;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { hash: sha256(bytes), malformed };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function malformedRecordError(relPath) {
|
|
72
|
+
const error = new SyntaxError(`Malformed JSON judgment record: ${relPath}`);
|
|
73
|
+
error.code = 'JUDGMENT_RECORD_MALFORMED';
|
|
74
|
+
error.path = relPath;
|
|
75
|
+
error.kind = 'malformed';
|
|
76
|
+
return error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function recordFileSet(cwd) {
|
|
80
|
+
const recordsRoot = absolutePath(cwd, RECORDS_REL);
|
|
81
|
+
if (!existsSync(recordsRoot)) return [];
|
|
82
|
+
|
|
83
|
+
const paths = [];
|
|
84
|
+
|
|
85
|
+
function walk(absDir, relDir) {
|
|
86
|
+
const entries = readdirSync(absDir, { withFileTypes: true })
|
|
87
|
+
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
88
|
+
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
const absPath = join(absDir, entry.name);
|
|
91
|
+
const relPath = `${relDir}/${entry.name}`;
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
walk(absPath, relPath);
|
|
94
|
+
} else if (entry.isFile() && basename(relPath) !== '.attest.json') {
|
|
95
|
+
paths.push(relPath);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
walk(recordsRoot, RECORDS_REL);
|
|
101
|
+
return paths.sort();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function computeRecordHashes(cwd) {
|
|
105
|
+
const hashes = {};
|
|
106
|
+
|
|
107
|
+
for (const relPath of recordFileSet(cwd)) {
|
|
108
|
+
const record = inspectRecord(cwd, relPath);
|
|
109
|
+
if (record.malformed) throw malformedRecordError(relPath);
|
|
110
|
+
hashes[relPath] = record.hash;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return hashes;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function readManifest(cwd) {
|
|
117
|
+
const manifestPath = absolutePath(cwd, MANIFEST_REL);
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error?.code === 'ENOENT') return null;
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function writeManifest(cwd, hashes) {
|
|
127
|
+
const manifestPath = absolutePath(cwd, MANIFEST_REL);
|
|
128
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
129
|
+
|
|
130
|
+
const sortedHashes = {};
|
|
131
|
+
for (const relPath of Object.keys(hashes).sort()) {
|
|
132
|
+
sortedHashes[relPath] = hashes[relPath];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const tempPath = `${manifestPath}.tmp.${process.pid}.${randomUUID()}`;
|
|
136
|
+
try {
|
|
137
|
+
writeFileSync(tempPath, `${JSON.stringify(sortedHashes, null, 2)}\n`);
|
|
138
|
+
renameSync(tempPath, manifestPath);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
try {
|
|
141
|
+
unlinkSync(tempPath);
|
|
142
|
+
} catch {
|
|
143
|
+
// The temp file may not exist or may already have been renamed.
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Absolute path of the manifest for `cwd` — for user-facing messages. */
|
|
150
|
+
export function manifestPathFor(cwd) {
|
|
151
|
+
return absolutePath(cwd, MANIFEST_REL);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Write the FIRST baseline, failing with EEXIST if one is already there.
|
|
156
|
+
* Exclusive (`wx`) on purpose: overwriting an existing manifest is the
|
|
157
|
+
* laundering step, so the refusal has to be enforced by the write itself
|
|
158
|
+
* rather than by a check that a race — or a manifest whose contents are
|
|
159
|
+
* literally `null` — can slip past.
|
|
160
|
+
*/
|
|
161
|
+
export function initManifestExclusive(cwd, hashes) {
|
|
162
|
+
const manifestPath = absolutePath(cwd, MANIFEST_REL);
|
|
163
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
164
|
+
|
|
165
|
+
const sortedHashes = {};
|
|
166
|
+
for (const relPath of Object.keys(hashes).sort()) {
|
|
167
|
+
sortedHashes[relPath] = hashes[relPath];
|
|
168
|
+
}
|
|
169
|
+
// 'wx' → EEXIST if the path already exists. No temp+rename here: atomicity
|
|
170
|
+
// against a concurrent initializer IS the exclusivity.
|
|
171
|
+
writeFileSync(manifestPath, `${JSON.stringify(sortedHashes, null, 2)}\n`, { flag: 'wx' });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function stampRecord(cwd, relPath) {
|
|
175
|
+
const record = inspectRecord(cwd, relPath);
|
|
176
|
+
if (record.malformed) throw malformedRecordError(relPath);
|
|
177
|
+
|
|
178
|
+
const hashes = readManifest(cwd) ?? {};
|
|
179
|
+
hashes[relPath] = record.hash;
|
|
180
|
+
writeManifest(cwd, hashes);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function removeRecord(cwd, relPath) {
|
|
184
|
+
const hashes = readManifest(cwd) ?? {};
|
|
185
|
+
delete hashes[relPath];
|
|
186
|
+
writeManifest(cwd, hashes);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Synchronize the manifest for touched records under an absolute workspace
|
|
191
|
+
* root. Path entries may be absolute filesystem paths or repo-relative POSIX
|
|
192
|
+
* paths; non-record paths are ignored.
|
|
193
|
+
*/
|
|
194
|
+
export function syncManifest(cwd, paths) {
|
|
195
|
+
if (typeof cwd !== 'string' || !isAbsolute(cwd)) {
|
|
196
|
+
const error = new TypeError(
|
|
197
|
+
'syncManifest contract violation: cwd must be an absolute workspace path',
|
|
198
|
+
);
|
|
199
|
+
error.code = 'JUDGMENT_ATTEST_CWD_ABSOLUTE';
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const cwdAbs = resolve(cwd);
|
|
204
|
+
const recordPaths = new Map();
|
|
205
|
+
|
|
206
|
+
for (const path of paths) {
|
|
207
|
+
if (typeof path !== 'string') continue;
|
|
208
|
+
const absPath = resolve(cwdAbs, path);
|
|
209
|
+
const nativeRelPath = relative(cwdAbs, absPath);
|
|
210
|
+
if (
|
|
211
|
+
nativeRelPath === ''
|
|
212
|
+
|| nativeRelPath === '..'
|
|
213
|
+
|| nativeRelPath.startsWith(`..${sep}`)
|
|
214
|
+
|| isAbsolute(nativeRelPath)
|
|
215
|
+
) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const relPath = nativeRelPath.split(sep).join('/');
|
|
220
|
+
if (!relPath.startsWith(`${RECORDS_REL}/`)) continue;
|
|
221
|
+
recordPaths.set(relPath, absPath);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const [relPath, absPath] of recordPaths) {
|
|
225
|
+
if (existsSync(absPath)) stampRecord(cwdAbs, relPath);
|
|
226
|
+
else removeRecord(cwdAbs, relPath);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function verifyRecords(cwd) {
|
|
231
|
+
const manifest = readManifest(cwd) ?? {};
|
|
232
|
+
const currentPaths = recordFileSet(cwd);
|
|
233
|
+
const currentPathSet = new Set(currentPaths);
|
|
234
|
+
const drift = [];
|
|
235
|
+
|
|
236
|
+
for (const relPath of Object.keys(manifest)) {
|
|
237
|
+
if (!currentPathSet.has(relPath)) {
|
|
238
|
+
drift.push({ path: relPath, kind: 'removed' });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const relPath of currentPaths) {
|
|
243
|
+
const record = inspectRecord(cwd, relPath);
|
|
244
|
+
if (record.malformed) {
|
|
245
|
+
drift.push({ path: relPath, kind: 'malformed' });
|
|
246
|
+
} else if (!Object.hasOwn(manifest, relPath)) {
|
|
247
|
+
drift.push({ path: relPath, kind: 'added' });
|
|
248
|
+
} else if (manifest[relPath] !== record.hash) {
|
|
249
|
+
drift.push({ path: relPath, kind: 'modified' });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
drift.sort((a, b) => {
|
|
254
|
+
if (a.path !== b.path) return a.path < b.path ? -1 : 1;
|
|
255
|
+
return a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
return { ok: drift.length === 0, drift };
|
|
259
|
+
}
|