@geraldmaron/construct 1.0.18 → 1.0.19

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 (78) hide show
  1. package/README.md +6 -4
  2. package/bin/construct +26 -3
  3. package/db/schema/003_observation_reconciliation.sql +14 -0
  4. package/lib/bootstrap/resources.mjs +0 -1
  5. package/lib/cli-commands.mjs +33 -5
  6. package/lib/comment-lint.mjs +44 -0
  7. package/lib/contracts/validate.mjs +106 -0
  8. package/lib/decisions/enforced-baseline.json +23 -0
  9. package/lib/decisions/golden.mjs +87 -0
  10. package/lib/decisions/precedence.mjs +46 -0
  11. package/lib/decisions/registry.mjs +469 -0
  12. package/lib/deployment/parity-contract.mjs +148 -0
  13. package/lib/embed/cli.mjs +11 -0
  14. package/lib/embed/conflict-detection.mjs +4 -4
  15. package/lib/embed/customer-profiles.mjs +1 -1
  16. package/lib/embed/reconcile.mjs +60 -0
  17. package/lib/gates-audit.mjs +2 -2
  18. package/lib/hooks/config-protection.mjs +22 -3
  19. package/lib/hooks/guard-bash.mjs +1 -1
  20. package/lib/init-docs.mjs +1 -0
  21. package/lib/mode-commands.mjs +6 -8
  22. package/lib/observation-store.mjs +16 -2
  23. package/lib/opencode-telemetry.mjs +1 -1
  24. package/lib/roles/cli.mjs +10 -2
  25. package/lib/roles/gateway.mjs +50 -1
  26. package/lib/scheduler/index.mjs +31 -0
  27. package/lib/server/index.mjs +13 -3
  28. package/lib/server/static/index.html +1 -1
  29. package/lib/setup.mjs +6 -0
  30. package/lib/storage/hybrid-query.mjs +49 -38
  31. package/lib/storage/rrf.mjs +42 -0
  32. package/lib/storage/vector-client.mjs +18 -3
  33. package/lib/telemetry/backends/local.mjs +1 -1
  34. package/lib/telemetry/skill-calls.mjs +1 -1
  35. package/lib/templates/visual-requirements.mjs +84 -0
  36. package/package.json +9 -1
  37. package/rules/common/comments.md +3 -0
  38. package/rules/common/no-fabrication.md +3 -0
  39. package/rules/common/precedence.md +19 -0
  40. package/rules/common/research-sources.md +32 -0
  41. package/rules/common/research.md +59 -2
  42. package/skills/roles/data-engineer.pipeline.md +13 -1
  43. package/skills/roles/debugger.md +9 -0
  44. package/skills/roles/designer.accessibility.md +13 -3
  45. package/skills/roles/designer.md +10 -0
  46. package/skills/roles/engineer.platform.md +8 -0
  47. package/skills/roles/operator.md +10 -1
  48. package/skills/roles/operator.release.md +8 -0
  49. package/skills/roles/operator.sre.md +10 -1
  50. package/skills/roles/orchestrator.md +9 -2
  51. package/skills/roles/product-manager.business-strategy.md +10 -1
  52. package/skills/roles/researcher.explorer.md +12 -1
  53. package/skills/roles/researcher.ux.md +13 -1
  54. package/skills/roles/reviewer.devil-advocate.md +14 -2
  55. package/skills/roles/reviewer.evaluator.md +17 -4
  56. package/skills/roles/reviewer.trace.md +12 -1
  57. package/skills/roles/security.legal-compliance.md +8 -0
  58. package/skills/roles/security.md +11 -0
  59. package/specialists/contracts.json +18 -0
  60. package/specialists/prompts/cx-researcher.md +4 -2
  61. package/templates/docs/backlog-proposal.md +1 -1
  62. package/templates/docs/customer-profile.md +1 -1
  63. package/templates/docs/evidence-brief.md +5 -1
  64. package/templates/docs/incident-report.md +37 -21
  65. package/templates/docs/prfaq.md +2 -2
  66. package/templates/docs/product-intelligence-report.md +1 -1
  67. package/templates/docs/research-brief.md +8 -6
  68. package/templates/docs/research-finding.md +32 -7
  69. package/templates/docs/rfc.md +13 -1
  70. package/templates/docs/runbook.md +20 -1
  71. package/templates/docs/signal-brief.md +4 -1
  72. package/templates/docs/skill-artifact.md +27 -7
  73. package/templates/docs/strategy.md +23 -2
  74. package/lib/bootstrap/lazy-install.mjs +0 -161
  75. package/lib/embed/jobs/vector-sync.mjs +0 -198
  76. package/lib/knowledge/postgres-search.mjs +0 -132
  77. package/lib/services/pattern-promotion-service.mjs +0 -167
  78. package/lib/storage/unified-storage.mjs +0 -550
@@ -0,0 +1,469 @@
1
+ /**
2
+ * lib/decisions/registry.mjs — machine-readable index of load-bearing decisions.
3
+ *
4
+ * The registry is a derived VIEW computed from source on every call, never a
5
+ * committed snapshot — the same discipline as lib/doctor/watchers/consistency.mjs.
6
+ * A snapshot would itself drift; computing from source keeps the ADRs, rules, and
7
+ * contracts as the single source of truth.
8
+ *
9
+ * Three decision sources are indexed:
10
+ * - ADRs docs/adr/*.md (status + supersedes parsed from body)
11
+ * - Contracts specialists/contracts.json (executable postconditions = enforcement)
12
+ * - Rules rules/**\/*.md (enforcement link via frontmatter; bead wvbf.4)
13
+ *
14
+ * Enforcement bindings are discovered by scanning tests/ and lib/hooks/ for an
15
+ * `@enforces <decision-id>` marker. A decision with no binding is `advisory: true`.
16
+ * Orphan detection (binding ↔ decision) is owned by bead wvbf.2; supersede-chain
17
+ * validation by bead wvbf.3. This module supplies the data both build on.
18
+ *
19
+ * Pure and deterministic: same tree → same registry, ordered by id.
20
+ */
21
+
22
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
23
+ import { join, dirname, resolve, relative } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ export const DECISION_STATUSES = ['proposed', 'accepted', 'superseded', 'deprecated'];
27
+
28
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
29
+
30
+ // The binding marker is a dedicated annotation line, not a mid-sentence mention:
31
+ // a line whose only content (after a comment leader) is `@enforces <decision-id>`.
32
+ // Anchoring to the whole line keeps prose that merely names the marker from
33
+ // registering as a false binding.
34
+
35
+ const ENFORCES_LINE_RE = /^(?:\/\/|#|\*)?\s*@enforces\s+(\S+)\s*$/;
36
+
37
+ // The body carries Status and Supersedes as markdown definition lines, not
38
+ // frontmatter (see docs/adr/_template.md); parse them positionally.
39
+
40
+ // ADR field styles vary across the lane: inline bold (`- **Status**: x`,
41
+ // `**Status:** X`) and heading-then-value (`## Status` followed by the value on
42
+ // the next line). Extract a field across all three rather than pinning one style.
43
+
44
+ function extractField(body, field) {
45
+ const inline = body.match(new RegExp(`\\*\\*${field}:?\\*\\*:?\\s*(.+)`, 'i'));
46
+ if (inline && inline[1].trim()) return inline[1].trim();
47
+ const lines = body.split('\n');
48
+ const headingIdx = lines.findIndex((l) => new RegExp(`^#{1,6}\\s*${field}\\s*$`, 'i').test(l));
49
+ if (headingIdx !== -1) {
50
+ for (let i = headingIdx + 1; i < lines.length; i++) {
51
+ const v = lines[i].trim();
52
+ if (!v) continue;
53
+ if (/^#{1,6}\s/.test(v)) break;
54
+ return v.replace(/^[-*]\s*/, '');
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function parseAdr(body) {
61
+ const heading = body.match(/^#\s+ADR[-\s]?\d+:\s*(.+)$/m);
62
+ const title = heading ? heading[1].trim() : null;
63
+ const statusRaw = extractField(body, 'Status');
64
+ const status = statusRaw ? statusRaw.split('|')[0].trim().replace(/[*_`]/g, '').toLowerCase() : null;
65
+ let supersedes = extractField(body, 'Supersedes');
66
+ if (supersedes && /^none$/i.test(supersedes)) supersedes = null;
67
+ const supersededMatch = supersedes && supersedes.match(/ADR[-\s]?(\d+)/);
68
+ const supersedesId = supersededMatch ? `ADR-${supersededMatch[1].padStart(4, '0')}` : null;
69
+ return { title, status, supersedes: supersedesId };
70
+ }
71
+
72
+ function parseFrontmatter(text) {
73
+ if (!text.startsWith('---\n')) return {};
74
+ const end = text.indexOf('\n---\n', 4);
75
+ if (end === -1) return {};
76
+ const block = text.slice(4, end);
77
+ const out = {};
78
+ for (const line of block.split('\n')) {
79
+ const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
80
+ if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
81
+ }
82
+ return out;
83
+ }
84
+
85
+ // Walk a directory tree collecting files that match a predicate.
86
+
87
+ function walk(dir, predicate, acc = []) {
88
+ if (!existsSync(dir)) return acc;
89
+ for (const entry of readdirSync(dir)) {
90
+ const full = join(dir, entry);
91
+ const st = statSync(full);
92
+ if (st.isDirectory()) walk(full, predicate, acc);
93
+ else if (predicate(full)) acc.push(full);
94
+ }
95
+ return acc;
96
+ }
97
+
98
+ // Build the @enforces binding map: decision-id → [ "relpath:line", ... ].
99
+ // Scans the surfaces where enforcement actually lives: tests and hooks.
100
+
101
+ function collectEnforcementBindings({ repoRoot }) {
102
+ const bindings = new Map();
103
+ const files = [
104
+ ...walk(join(repoRoot, 'tests'), (f) => f.endsWith('.mjs') || f.endsWith('.js')),
105
+ ...walk(join(repoRoot, 'lib', 'hooks'), (f) => f.endsWith('.mjs')),
106
+ ];
107
+ for (const file of files) {
108
+ const lines = readFileSync(file, 'utf8').split('\n');
109
+ lines.forEach((line, i) => {
110
+ const stripped = line.trim().replace(/^(?:\/\/|#|\*)\s?/, '');
111
+ const m = stripped.match(ENFORCES_LINE_RE);
112
+ if (!m) return;
113
+ const id = m[1];
114
+ if (!bindings.has(id)) bindings.set(id, []);
115
+ bindings.get(id).push(`${relative(repoRoot, file)}:${i + 1}`);
116
+ });
117
+ }
118
+ return bindings;
119
+ }
120
+
121
+ function adrDecisions({ repoRoot, bindings }) {
122
+ const dir = join(repoRoot, 'docs', 'adr');
123
+ const out = [];
124
+ if (!existsSync(dir)) return out;
125
+ for (const name of readdirSync(dir)) {
126
+ if (!name.endsWith('.md') || name === '_template.md' || name === 'README.md') continue;
127
+ const numMatch = name.match(/^(\d{4})-/);
128
+ if (!numMatch) continue;
129
+ const id = `ADR-${numMatch[1]}`;
130
+ const file = join(dir, name);
131
+ const text = readFileSync(file, 'utf8');
132
+ const fm = parseFrontmatter(text);
133
+ const { title, status, supersedes } = parseAdr(text);
134
+ const enforcingTests = bindings.get(id) || [];
135
+ out.push({
136
+ id,
137
+ kind: 'adr',
138
+ title,
139
+ status,
140
+ supersedes,
141
+ supersededBy: null,
142
+ file: relative(repoRoot, file),
143
+ lastVerifiedAt: fm.last_verified_at || null,
144
+ enforcingTests,
145
+ enforcingChecks: [],
146
+ advisory: enforcingTests.length === 0,
147
+ });
148
+ }
149
+ return out;
150
+ }
151
+
152
+ function contractDecisions({ repoRoot, bindings }) {
153
+ const file = join(repoRoot, 'specialists', 'contracts.json');
154
+ if (!existsSync(file)) return [];
155
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
156
+ const out = [];
157
+ for (const c of parsed.contracts || []) {
158
+ const checks = [];
159
+ let advisoryPostconditions = 0;
160
+ for (const pc of c.postconditions || []) {
161
+ if (typeof pc === 'string') advisoryPostconditions += 1;
162
+ else if (pc && pc.check) checks.push(pc.id || pc.check);
163
+ }
164
+ const enforcingTests = bindings.get(c.id) || [];
165
+ out.push({
166
+ id: c.id,
167
+ kind: 'contract',
168
+ title: `${c.producer} → ${c.consumer}`,
169
+ status: 'accepted',
170
+ supersedes: null,
171
+ supersededBy: null,
172
+ file: relative(repoRoot, file),
173
+ lastVerifiedAt: null,
174
+ enforcingTests,
175
+ enforcingChecks: checks,
176
+ advisoryPostconditions,
177
+ advisory: checks.length === 0 && enforcingTests.length === 0,
178
+ });
179
+ }
180
+ return out;
181
+ }
182
+
183
+ function ruleDecisions({ repoRoot, bindings }) {
184
+ const dir = join(repoRoot, 'rules');
185
+ const out = [];
186
+ for (const file of walk(dir, (f) => f.endsWith('.md'))) {
187
+ const text = readFileSync(file, 'utf8');
188
+ const fm = parseFrontmatter(text);
189
+ const rel = relative(repoRoot, file);
190
+ const id = `rule:${relative(join(repoRoot, 'rules'), file).replace(/\.md$/, '')}`;
191
+ const enforcedBy = fm.enforced_by ? fm.enforced_by.split(',').map((s) => s.trim()).filter(Boolean) : [];
192
+ const enforcingTests = bindings.get(id) || [];
193
+ out.push({
194
+ id,
195
+ kind: 'rule',
196
+ title: fm.description || rel,
197
+ status: 'accepted',
198
+ supersedes: null,
199
+ supersededBy: null,
200
+ file: rel,
201
+ lastVerifiedAt: null,
202
+ enforcedBy,
203
+ adrReference: fm.adr_reference || null,
204
+ precedenceTier: fm.precedence_tier || null,
205
+ enforcingTests,
206
+ enforcingChecks: [],
207
+ advisory: enforcedBy.length === 0 && enforcingTests.length === 0,
208
+ });
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * Build the full decision registry from source. Returns { decisions, byId }.
215
+ * `supersededBy` is back-filled by inverting each decision's `supersedes` edge.
216
+ */
217
+ export function buildRegistry({ repoRoot = REPO_ROOT } = {}) {
218
+ const bindings = collectEnforcementBindings({ repoRoot });
219
+ const decisions = [
220
+ ...adrDecisions({ repoRoot, bindings }),
221
+ ...contractDecisions({ repoRoot, bindings }),
222
+ ...ruleDecisions({ repoRoot, bindings }),
223
+ ].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
224
+
225
+ const byId = new Map(decisions.map((d) => [d.id, d]));
226
+ for (const d of decisions) {
227
+ if (d.supersedes && byId.has(d.supersedes)) byId.get(d.supersedes).supersededBy = d.id;
228
+ }
229
+ return { decisions, byId };
230
+ }
231
+
232
+ /**
233
+ * Structural validation of the registry itself: every ADR id is well-formed and
234
+ * every declared status is in the canonical enum. Binding-orphan checks (wvbf.2)
235
+ * and supersede-chain checks (wvbf.3) are deliberately out of scope here.
236
+ */
237
+ export function validateRegistry({ repoRoot = REPO_ROOT } = {}) {
238
+ const { decisions } = buildRegistry({ repoRoot });
239
+ const errors = [];
240
+ for (const d of decisions) {
241
+ if (d.status && !DECISION_STATUSES.includes(d.status)) {
242
+ errors.push(`${d.id}: invalid status "${d.status}" (expected one of ${DECISION_STATUSES.join(', ')})`);
243
+ }
244
+ if (d.kind === 'adr' && !/^ADR-\d{4}$/.test(d.id)) {
245
+ errors.push(`${d.id}: ADR id is not in ADR-NNNN form (${d.file})`);
246
+ }
247
+ }
248
+ return { ok: errors.length === 0, errors, count: decisions.length };
249
+ }
250
+
251
+ const BASELINE_FILE = join(REPO_ROOT, 'lib', 'decisions', 'enforced-baseline.json');
252
+
253
+ // A decision is enforced when something mechanical guards it: an @enforces test
254
+ // binding, an executable contract check, or a declared rule enforcer. Kept in
255
+ // lockstep with the `advisory` flag set per decision in the builders.
256
+
257
+ function isEnforced(d) {
258
+ return (d.enforcingTests && d.enforcingTests.length > 0)
259
+ || (d.enforcingChecks && d.enforcingChecks.length > 0)
260
+ || (d.enforcedBy && d.enforcedBy.length > 0);
261
+ }
262
+
263
+ export function enforcedIds({ repoRoot = REPO_ROOT } = {}) {
264
+ return buildRegistry({ repoRoot }).decisions.filter(isEnforced).map((d) => d.id).sort();
265
+ }
266
+
267
+ function loadBaseline(repoRoot) {
268
+ const file = repoRoot === REPO_ROOT ? BASELINE_FILE : join(repoRoot, 'lib', 'decisions', 'enforced-baseline.json');
269
+ if (!existsSync(file)) return [];
270
+ return JSON.parse(readFileSync(file, 'utf8')).enforced || [];
271
+ }
272
+
273
+ /**
274
+ * The durability gate (bead wvbf.2). Two failure classes, each a silent reversal
275
+ * of a decision:
276
+ * - dangling: an @enforces marker points at a decision id absent from the
277
+ * registry (a typo, or a binding that outlived its decision).
278
+ * - regression: a decision in the enforced baseline lacks enforcement in the
279
+ * live tree (its enforcing test or contract check is gone). This is the
280
+ * ratchet — the baseline is the floor; enforcement may only be added by
281
+ * regenerating it, and dropping below it fails the gate.
282
+ */
283
+ export function checkBindings({ repoRoot = REPO_ROOT } = {}) {
284
+ const { decisions, byId } = buildRegistry({ repoRoot });
285
+ const dangling = [];
286
+ const bindings = collectEnforcementBindings({ repoRoot });
287
+ for (const [id, locations] of bindings) {
288
+ if (!byId.has(id)) dangling.push({ id, locations });
289
+ }
290
+
291
+ const enforcedNow = new Set(decisions.filter(isEnforced).map((d) => d.id));
292
+ const regressions = loadBaseline(repoRoot).filter((id) => !enforcedNow.has(id));
293
+
294
+ return { ok: dangling.length === 0 && regressions.length === 0, dangling, regressions };
295
+ }
296
+
297
+ /**
298
+ * Supersede-chain validation (bead wvbf.3), pure over a decisions array so the
299
+ * failure cases are testable without fixtures on disk. Four violation classes:
300
+ * - a Supersedes pointing at an unknown decision;
301
+ * - a superseding edge whose target is not marked status `superseded`;
302
+ * - a decision marked `superseded` that nothing supersedes (a dangling claim);
303
+ * - a cycle in the supersede graph.
304
+ */
305
+ export function checkSupersessionOn(decisions) {
306
+ const byId = new Map(decisions.map((d) => [d.id, d]));
307
+ const supersededByMap = new Map();
308
+ for (const d of decisions) {
309
+ if (d.supersedes && byId.has(d.supersedes)) supersededByMap.set(d.supersedes, d.id);
310
+ }
311
+ const violations = [];
312
+
313
+ for (const d of decisions) {
314
+ if (!d.supersedes) continue;
315
+ const target = byId.get(d.supersedes);
316
+ if (!target) {
317
+ violations.push(`${d.id} supersedes unknown decision ${d.supersedes}`);
318
+ continue;
319
+ }
320
+ if (target.status !== 'superseded') {
321
+ violations.push(`${d.id} supersedes ${target.id}, but ${target.id} status is "${target.status || 'n/a'}" (expected superseded)`);
322
+ }
323
+ }
324
+
325
+ for (const d of decisions) {
326
+ if (d.status === 'superseded' && !supersededByMap.has(d.id)) {
327
+ violations.push(`${d.id} is marked superseded but no decision supersedes it`);
328
+ }
329
+ }
330
+
331
+ for (const start of decisions) {
332
+ const seen = new Set();
333
+ let cur = start;
334
+ while (cur && cur.supersedes) {
335
+ if (seen.has(cur.id)) {
336
+ violations.push(`supersede cycle through ${cur.id}`);
337
+ break;
338
+ }
339
+ seen.add(cur.id);
340
+ cur = byId.get(cur.supersedes);
341
+ }
342
+ }
343
+
344
+ return { ok: violations.length === 0, violations: [...new Set(violations)] };
345
+ }
346
+
347
+ export function checkSupersession({ repoRoot = REPO_ROOT } = {}) {
348
+ return checkSupersessionOn(buildRegistry({ repoRoot }).decisions);
349
+ }
350
+
351
+ // A rule may declare its enforcer in frontmatter (enforced_by: path[, path],
352
+ // adr_reference: ADR-NNNN). Declaring it is optional; declaring it wrong is not.
353
+
354
+ function looksLikePath(value) {
355
+ return value.includes('/') || /\.(mjs|js|json|sh)$/.test(value);
356
+ }
357
+
358
+ /**
359
+ * Validate rule → enforcer linkage (bead wvbf.4). For every rule that declares
360
+ * `enforced_by`, each path-like reference must exist; a declared `adr_reference`
361
+ * must resolve to a known ADR. Broken linkage fails; absent linkage does not.
362
+ */
363
+ export function checkRuleLinkage({ repoRoot = REPO_ROOT } = {}) {
364
+ const { decisions, byId } = buildRegistry({ repoRoot });
365
+ const violations = [];
366
+ for (const d of decisions) {
367
+ if (d.kind !== 'rule') continue;
368
+ for (const ref of d.enforcedBy || []) {
369
+ if (looksLikePath(ref) && !existsSync(resolve(repoRoot, ref))) {
370
+ violations.push(`${d.id}: enforced_by path not found: ${ref}`);
371
+ }
372
+ }
373
+ if (d.adrReference && !byId.has(d.adrReference)) {
374
+ violations.push(`${d.id}: adr_reference ${d.adrReference} does not resolve to a known decision`);
375
+ }
376
+ }
377
+ return { ok: violations.length === 0, violations };
378
+ }
379
+
380
+ function summarize(decisions) {
381
+ const byKind = {};
382
+ let advisory = 0;
383
+ for (const d of decisions) {
384
+ byKind[d.kind] = (byKind[d.kind] || 0) + 1;
385
+ if (d.advisory) advisory += 1;
386
+ }
387
+ return { total: decisions.length, byKind, advisory, enforced: decisions.length - advisory };
388
+ }
389
+
390
+ /**
391
+ * CLI entry: `construct decisions [list|validate|json]`.
392
+ * list (default) prints a table; validate runs validateRegistry and exits 1 on
393
+ * error; json emits the full registry for downstream tooling.
394
+ */
395
+ export async function runDecisionsCli(args = []) {
396
+ const sub = args[0] && !args[0].startsWith('-') ? args[0] : 'list';
397
+
398
+ if (sub === 'json' || args.includes('--json')) {
399
+ const { decisions } = buildRegistry();
400
+ process.stdout.write(JSON.stringify({ decisions, summary: summarize(decisions) }, null, 2) + '\n');
401
+ return;
402
+ }
403
+
404
+ if (sub === 'validate') {
405
+ const { ok, errors, count } = validateRegistry();
406
+ if (ok) {
407
+ process.stdout.write(`✓ decision registry valid — ${count} decisions\n`);
408
+ return;
409
+ }
410
+ process.stderr.write(`✗ decision registry has ${errors.length} error(s):\n`);
411
+ for (const e of errors) process.stderr.write(` - ${e}\n`);
412
+ process.exit(1);
413
+ }
414
+
415
+ if (sub === 'check') {
416
+ const { validatePrecedenceTiersOn } = await import('./precedence.mjs');
417
+ const bindings = checkBindings();
418
+ const supersession = checkSupersession();
419
+ const linkage = checkRuleLinkage();
420
+ const precedence = validatePrecedenceTiersOn(buildRegistry().decisions);
421
+ if (bindings.ok && supersession.ok && linkage.ok && precedence.ok) {
422
+ process.stdout.write('✓ decision graph intact — bindings bound, supersede chains valid, rule linkage resolves, precedence tiers valid\n');
423
+ return;
424
+ }
425
+ for (const d of bindings.dangling) {
426
+ process.stderr.write(`✗ dangling @enforces "${d.id}" at ${d.locations.join(', ')}\n`);
427
+ }
428
+ for (const id of bindings.regressions) {
429
+ process.stderr.write(`✗ enforcement regression: "${id}" is in the baseline but no longer enforced\n`);
430
+ }
431
+ for (const v of supersession.violations) {
432
+ process.stderr.write(`✗ supersede: ${v}\n`);
433
+ }
434
+ for (const v of linkage.violations) {
435
+ process.stderr.write(`✗ rule-linkage: ${v}\n`);
436
+ }
437
+ for (const v of precedence.violations) {
438
+ process.stderr.write(`✗ precedence: ${v}\n`);
439
+ }
440
+ process.exit(1);
441
+ }
442
+
443
+ if (sub === 'golden') {
444
+ const { runGoldenCli } = await import('./golden.mjs');
445
+ return runGoldenCli(args.slice(1));
446
+ }
447
+
448
+ if (sub === 'baseline') {
449
+ const enforced = enforcedIds();
450
+ if (args.includes('--write')) {
451
+ const { writeFileSync } = await import('node:fs');
452
+ writeFileSync(BASELINE_FILE, JSON.stringify({ enforced }, null, 2) + '\n');
453
+ process.stdout.write(`✓ wrote enforced baseline — ${enforced.length} decisions\n`);
454
+ return;
455
+ }
456
+ process.stdout.write(JSON.stringify({ enforced }, null, 2) + '\n');
457
+ return;
458
+ }
459
+
460
+ const { decisions } = buildRegistry();
461
+ const s = summarize(decisions);
462
+ process.stdout.write(`Decisions: ${s.total} (${s.enforced} enforced, ${s.advisory} advisory)\n`);
463
+ process.stdout.write(` by kind: ${Object.entries(s.byKind).map(([k, n]) => `${k}=${n}`).join(' ')}\n\n`);
464
+ for (const d of decisions) {
465
+ const mark = d.advisory ? '○' : '●';
466
+ const sup = d.supersededBy ? ` superseded-by ${d.supersededBy}` : '';
467
+ process.stdout.write(`${mark} ${d.id} [${d.kind}/${d.status || 'n/a'}] ${d.title || ''}${sup}\n`);
468
+ }
469
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * lib/deployment/parity-contract.mjs — capability parity across deployment modes.
3
+ *
4
+ * Declares, for every dimension of lib/deployment-mode.mjs RESOURCE_TOPOLOGY,
5
+ * whether the capability is `parity` (present in every mode, backend may differ)
6
+ * or `mode-specific` (present only in some modes, by design). validateParityContract
7
+ * cross-checks the declaration against the live topology so the two cannot drift:
8
+ * a new topology dimension with no declaration, or a `parity` capability absent in
9
+ * a mode, fails the check. See ADR-0016.
10
+ *
11
+ * Scope is the static guard only. A live dual-run harness (run a capability in
12
+ * solo and team, compare) is the stronger complement, tracked separately — it
13
+ * needs a provisioned database and so cannot gate every change.
14
+ */
15
+
16
+ import { DEPLOYMENT_MODES, resolveResourceMode } from '../deployment-mode.mjs';
17
+
18
+ export const PARITY_CLASSES = ['parity', 'mode-specific'];
19
+
20
+ // Backend value 'optional' means the capability is present but may run on a
21
+ // degraded local backend (e.g. file BM25 in place of pgvector); it still counts
22
+ // as present for parity purposes.
23
+
24
+ export const PARITY_CONTRACT = {
25
+ queue: {
26
+ parityClass: 'parity',
27
+ capability: 'Work intake queue (enqueue, list pending, mark processed)',
28
+ rationale: 'Filesystem queue (solo) and Postgres queue (team) implement one interface via lib/intake/queue.mjs.',
29
+ evidence: 'tests/intake-queue-factory.test.mjs',
30
+ },
31
+ memory: {
32
+ parityClass: 'parity',
33
+ capability: 'Observation/memory store',
34
+ rationale: 'Local index (solo) and shared Postgres (team) both satisfy the observation schema; Postgres is a secondary store.',
35
+ evidence: 'lib/observation-store.mjs',
36
+ },
37
+ database: {
38
+ parityClass: 'parity',
39
+ capability: 'Knowledge retrieval and durable state',
40
+ rationale: 'Present in every mode; solo degrades to file BM25 + local vectors when Postgres is absent.',
41
+ evidence: 'lib/storage/hybrid-query.mjs',
42
+ },
43
+ telemetry: {
44
+ parityClass: 'parity',
45
+ capability: 'Trace capture',
46
+ rationale: 'Local JSONL is the default in every mode; remote export (OTel/Langfuse) is opt-in, not required for the capability.',
47
+ evidence: 'lib/telemetry/client.mjs',
48
+ },
49
+ workers: {
50
+ parityClass: 'mode-specific',
51
+ capability: 'Background worker pool that claims queued work',
52
+ rationale: 'Solo runs work in-process in the user session; the async Docker/isolated worker pool exists only in team and enterprise.',
53
+ evidence: 'lib/deployment-mode.mjs',
54
+ },
55
+ policy: {
56
+ parityClass: 'mode-specific',
57
+ capability: 'Server-side policy enforcement',
58
+ rationale: 'Solo enforces via local hooks (lightweight); server-side/enforceable policy is a team and enterprise capability.',
59
+ evidence: 'lib/deployment-mode.mjs',
60
+ },
61
+ mcp: {
62
+ parityClass: 'mode-specific',
63
+ capability: 'Brokered MCP with policy, audit, and rate limiting',
64
+ rationale: 'Solo calls MCP tools directly; the broker (and its audit/rate-limit/policy) exists only in team and enterprise.',
65
+ evidence: 'lib/mcp/broker.mjs',
66
+ },
67
+ };
68
+
69
+ function topologyByDimension() {
70
+ const byMode = {};
71
+ for (const mode of DEPLOYMENT_MODES) byMode[mode] = resolveResourceMode(mode);
72
+ const dimensions = new Set();
73
+ for (const mode of DEPLOYMENT_MODES) for (const dim of Object.keys(byMode[mode])) dimensions.add(dim);
74
+ return { byMode, dimensions: [...dimensions] };
75
+ }
76
+
77
+ /**
78
+ * Cross-check the declared parity contract against the live resource topology.
79
+ * Returns { ok, errors }. Failures: an undeclared topology dimension, a declared
80
+ * capability with no topology dimension, an invalid parityClass, a `parity`
81
+ * capability missing from some mode, or a `mode-specific` capability whose backend
82
+ * is identical across all modes (then it is not actually mode-specific).
83
+ */
84
+ export function validateParityContract() {
85
+ const { byMode, dimensions } = topologyByDimension();
86
+ const errors = [];
87
+
88
+ for (const dim of dimensions) {
89
+ if (!PARITY_CONTRACT[dim]) {
90
+ errors.push(`topology dimension "${dim}" has no parity declaration (declare it parity or mode-specific in PARITY_CONTRACT)`);
91
+ }
92
+ }
93
+
94
+ for (const [dim, decl] of Object.entries(PARITY_CONTRACT)) {
95
+ if (!dimensions.includes(dim)) {
96
+ errors.push(`parity declaration "${dim}" has no matching topology dimension`);
97
+ continue;
98
+ }
99
+ if (!PARITY_CLASSES.includes(decl.parityClass)) {
100
+ errors.push(`"${dim}": invalid parityClass "${decl.parityClass}"`);
101
+ continue;
102
+ }
103
+ const values = DEPLOYMENT_MODES.map((m) => byMode[m][dim]);
104
+ if (decl.parityClass === 'parity') {
105
+ const missing = DEPLOYMENT_MODES.filter((m) => !byMode[m][dim]);
106
+ if (missing.length > 0) {
107
+ errors.push(`"${dim}" is declared parity but absent in mode(s): ${missing.join(', ')}`);
108
+ }
109
+ } else if (new Set(values).size === 1) {
110
+ errors.push(`"${dim}" is declared mode-specific but its backend is identical across all modes (${values[0]})`);
111
+ }
112
+ if (!decl.rationale) errors.push(`"${dim}": mode-specific and parity declarations both require a rationale`);
113
+ }
114
+
115
+ return { ok: errors.length === 0, errors };
116
+ }
117
+
118
+ export function describeParityContract() {
119
+ const { byMode } = topologyByDimension();
120
+ const rows = Object.entries(PARITY_CONTRACT).map(([dim, decl]) => {
121
+ const backends = DEPLOYMENT_MODES.map((m) => `${m}:${byMode[m][dim]}`).join(' ');
122
+ return { dimension: dim, parityClass: decl.parityClass, backends, capability: decl.capability, rationale: decl.rationale };
123
+ });
124
+ return rows;
125
+ }
126
+
127
+ export async function runDeploymentParityCli(args = []) {
128
+ if (args.includes('--json')) {
129
+ const { ok, errors } = validateParityContract();
130
+ process.stdout.write(JSON.stringify({ ok, errors, contract: describeParityContract() }, null, 2) + '\n');
131
+ if (!ok) process.exit(1);
132
+ return;
133
+ }
134
+
135
+ const { ok, errors } = validateParityContract();
136
+ for (const row of describeParityContract()) {
137
+ const mark = row.parityClass === 'parity' ? '=' : '≠';
138
+ process.stdout.write(`${mark} ${row.dimension.padEnd(10)} [${row.parityClass}] ${row.backends}\n ${row.capability}\n`);
139
+ }
140
+ process.stdout.write('\n');
141
+ if (ok) {
142
+ process.stdout.write('✓ parity contract reconciled with deployment topology\n');
143
+ return;
144
+ }
145
+ process.stderr.write(`✗ parity contract has ${errors.length} error(s):\n`);
146
+ for (const e of errors) process.stderr.write(` - ${e}\n`);
147
+ process.exit(1);
148
+ }
package/lib/embed/cli.mjs CHANGED
@@ -533,6 +533,17 @@ async function cmdEmbedMigrateModel(args) {
533
533
  process.stdout.write(
534
534
  `Done. embeddingsSynced=${result.embeddingsSynced} model=${result.embeddingModel}\n`
535
535
  );
536
+
537
+ // Observations share the embedding model/dimension. Re-dimension their
538
+ // column too, then reconcile re-embeds them from the local source so both
539
+ // corpora live in one vector space after a model change.
540
+ await client.unsafe(
541
+ `BEGIN; TRUNCATE TABLE construct_observations; ` +
542
+ `ALTER TABLE construct_observations ALTER COLUMN embedding TYPE vector(${targetDim}); COMMIT;`
543
+ );
544
+ const { reconcileObservationEmbeddings } = await import('./reconcile.mjs');
545
+ const recon = await reconcileObservationEmbeddings(process.cwd());
546
+ process.stdout.write(`Observations reconciled: re-embedded ${recon.reembedded}.\n`);
536
547
  } catch (err) {
537
548
  process.stderr.write(`migrate-model failed: ${err.message}\n`);
538
549
  process.exit(1);
@@ -15,7 +15,7 @@
15
15
  * Rebuilt on first use or when artifact file count changes.
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, renameSync } from 'node:fs';
18
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, renameSync, unlinkSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
20
  import { embed, cosineSimilarity, extractTextFromPacket } from './semantic.mjs';
21
21
  import { listArtifacts } from './artifact.mjs';
@@ -253,9 +253,9 @@ export async function detectInterItemConflicts(pendingPackets, { threshold = 0.7
253
253
  */
254
254
  export function resetArtifactIndex() {
255
255
  _artifactIndex = null;
256
- if (existsSync(INDEX_FILE)) {
257
- const { unlinkSync } = require('node:fs');
258
- try { unlinkSync(INDEX_FILE); } catch { /* ignore */ }
256
+ const { indexFile } = indexPaths();
257
+ if (existsSync(indexFile)) {
258
+ try { unlinkSync(indexFile); } catch { /* ignore */ }
259
259
  }
260
260
  }
261
261
 
@@ -273,7 +273,7 @@ export function searchCustomerProfiles(query) {
273
273
  id: entry.id,
274
274
  name: entry.name,
275
275
  status: entry.status,
276
- path: join(PROFILES_DIR, `${slugify(entry.name)}-${entry.id}.md`),
276
+ path: join(profilesDir, `${slugify(entry.name)}-${entry.id}.md`),
277
277
  });
278
278
  }
279
279
  }