@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
package/README.md CHANGED
@@ -177,16 +177,16 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
177
177
  | Command | What it does |
178
178
  |---|---|
179
179
  | `construct efficiency` | Show read efficiency, repeated files, and context-budget guidance |
180
- | `construct eval-datasets` | Sync scored Langfuse traces into eval datasets for prompt regression testing |
180
+ | `construct eval-datasets` | Sync scored traces from the telemetry backend into eval datasets for prompt regression testing |
181
181
  | `construct evals` | Show evaluator catalog for prompt and agent experiments |
182
182
  | `construct feedback:history` | Show recorded outcome ratings |
183
183
  | `construct feedback:record` | Record an outcome rating for a recent specialist invocation |
184
184
  | `construct llm-judge` | Run LLM-as-a-judge evaluations on unscored traces for continuous quality feedback |
185
- | `construct optimize` | Prompt optimization using Langfuse trace quality scores |
186
- | `construct review` | Generate agent performance review from Langfuse trace backend |
185
+ | `construct optimize` | Prompt optimization using telemetry trace quality scores |
186
+ | `construct review` | Generate agent performance review from the configured telemetry trace backend |
187
187
  | `construct telemetry` | Query telemetry traces and latency data |
188
188
  | `construct telemetry-backfill` | Backfill sparse traces with observations (trace backend) |
189
- | `construct telemetry-setup` | Configure Langfuse credentials and trace export |
189
+ | `construct telemetry-setup` | Configure telemetry backend credentials and trace export (OTLP or Langfuse-compatible) |
190
190
 
191
191
  ### Diagnostics
192
192
 
@@ -212,6 +212,8 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
212
212
  | `construct ci` | Local CI mirror: run CI jobs locally or view recent run status |
213
213
  | `construct completions` | Shell completion scripts |
214
214
  | `construct config` | Deployment mode configuration |
215
+ | `construct decisions` | Index load-bearing decisions and their enforcement bindings |
216
+ | `construct deployment` | Deployment posture tools (capability parity contract) |
215
217
  | `construct diff` | Show agent changes since HEAD |
216
218
  | `construct embed` | Embed mode management |
217
219
  | `construct gates:audit` | Audit policy gates |
package/bin/construct CHANGED
@@ -1441,9 +1441,19 @@ async function cmdSearch(args) {
1441
1441
  async function cmdStorage(args) {
1442
1442
  const [action, ...rest] = args;
1443
1443
  if (!action) {
1444
- errorln('Usage: construct storage <sync|status|reset|delete-ingested|repair-migrations|migrations>');
1444
+ errorln('Usage: construct storage <sync|status|reset|reconcile|delete-ingested|repair-migrations|migrations>');
1445
1445
  process.exit(1);
1446
1446
  }
1447
+ if (action === 'reconcile') {
1448
+ const { reconcileObservationEmbeddings } = await import('../lib/embed/reconcile.mjs');
1449
+ const result = await reconcileObservationEmbeddings(process.cwd());
1450
+ if (result.skipped) {
1451
+ println(`Reconcile skipped: ${result.skipped} (no Postgres backend — local index is authoritative).`);
1452
+ return;
1453
+ }
1454
+ println(`Reconciled observations: re-embedded ${result.reembedded} of ${result.checked} (model ${result.model}).`);
1455
+ return;
1456
+ }
1447
1457
  if (action === 'migrations') {
1448
1458
  const { describeMigrations } = await import('../lib/storage/migrations.mjs');
1449
1459
  const sqlClient = createSqlClient(process.env);
@@ -4097,7 +4107,7 @@ async function cmdCiPreview(args) {
4097
4107
  const jobsSection = jobsStart >= 0 ? yml.slice(jobsStart + 1) : '';
4098
4108
 
4099
4109
  // Find all top-level job keys (2-space indent) and slice blocks between them.
4100
- const jobKeyPattern = /^ (\w[\w-]*):\s*$/gm;
4110
+ const jobKeyPattern = /^ {2}(\w[\w-]*):\s*$/gm;
4101
4111
  const jobPositions = [];
4102
4112
  let km;
4103
4113
  while ((km = jobKeyPattern.exec(jobsSection)) !== null) {
@@ -4111,7 +4121,7 @@ async function cmdCiPreview(args) {
4111
4121
  const end = jobPositions[i + 1]?.start ?? jobsSection.length;
4112
4122
  const block = jobsSection.slice(start, end);
4113
4123
 
4114
- const nameMatch = block.match(/^ name:\s*(.+)\s*$/m);
4124
+ const nameMatch = block.match(/^ {4}name:\s*(.+)\s*$/m);
4115
4125
  const displayName = nameMatch
4116
4126
  ? nameMatch[1].replace(/\$\{\{[^}]+\}\}/g, '<matrix>').trim()
4117
4127
  : id;
@@ -5173,6 +5183,19 @@ const handlers = new Map([
5173
5183
  ['lint:research', cmdLintResearch],
5174
5184
  ['lint:agents', cmdLintAgents],
5175
5185
  ['lint:contracts', cmdLintContracts],
5186
+ ['decisions', async (args) => {
5187
+ const { runDecisionsCli } = await import('../lib/decisions/registry.mjs');
5188
+ return runDecisionsCli(args);
5189
+ }],
5190
+ ['deployment', async (args) => {
5191
+ const sub = args[0];
5192
+ if (sub === 'parity') {
5193
+ const { runDeploymentParityCli } = await import('../lib/deployment/parity-contract.mjs');
5194
+ return runDeploymentParityCli(args.slice(1));
5195
+ }
5196
+ errorln(`Unknown deployment subcommand: ${sub || '(none)'}. Available: parity`);
5197
+ process.exit(1);
5198
+ }],
5176
5199
  ['audit', async (args) => {
5177
5200
  const sub = args[0];
5178
5201
  if (sub === 'skills') return runAuditSkillsCli(args.slice(1));
@@ -0,0 +1,14 @@
1
+ -- db/schema/003_observation_reconciliation.sql
2
+ -- Stamp each observation with the content hash and embedding model that
3
+ -- produced its vector. An idempotent reconciliation pass uses (content_hash,
4
+ -- model) to find rows whose embedding is missing or stale — content edited, or
5
+ -- the embedding model changed — and re-embed only those, rather than relying on
6
+ -- the inline write at creation time always succeeding. Mirrors the
7
+ -- (content_hash, model) tracking construct_embeddings already carries for
8
+ -- documents. Fully idempotent: safe to re-apply.
9
+
10
+ ALTER TABLE construct_observations ADD COLUMN IF NOT EXISTS content_hash text;
11
+ ALTER TABLE construct_observations ADD COLUMN IF NOT EXISTS model text;
12
+
13
+ CREATE INDEX IF NOT EXISTS idx_observations_content_hash ON construct_observations (content_hash);
14
+ CREATE INDEX IF NOT EXISTS idx_observations_model ON construct_observations (model);
@@ -17,7 +17,6 @@
17
17
  * The registry is consulted by:
18
18
  * - `construct init` walks every resource, asks consent, installs
19
19
  * - `construct doctor --bootstrap` re-probes every resource verbosely
20
- * - lazy-install paths in hooks (consult the cached consent silently)
21
20
  *
22
21
  * Resources are "additive" — anyone can register a new resource via
23
22
  * `registerResource()` so plugins (or future built-ins) can extend the set
@@ -373,7 +373,7 @@ export const CLI_COMMANDS = [
373
373
  emoji: '📈',
374
374
  category: 'Observability',
375
375
  core: false,
376
- description: 'Generate agent performance review from Langfuse trace backend',
376
+ description: 'Generate agent performance review from the configured telemetry trace backend',
377
377
  usage: 'construct review [--agent=<id>] [--days=N]',
378
378
  },
379
379
  {
@@ -381,7 +381,7 @@ export const CLI_COMMANDS = [
381
381
  emoji: '⚙️',
382
382
  category: 'Observability',
383
383
  core: false,
384
- description: 'Prompt optimization using Langfuse trace quality scores',
384
+ description: 'Prompt optimization using telemetry trace quality scores',
385
385
  usage: 'construct optimize <agent>',
386
386
  },
387
387
  {
@@ -397,7 +397,7 @@ export const CLI_COMMANDS = [
397
397
  emoji: '📊',
398
398
  category: 'Observability',
399
399
  core: false,
400
- description: 'Sync scored Langfuse traces into eval datasets for prompt regression testing',
400
+ description: 'Sync scored traces from the telemetry backend into eval datasets for prompt regression testing',
401
401
  usage: 'construct eval-datasets',
402
402
  },
403
403
  {
@@ -580,7 +580,7 @@ export const CLI_COMMANDS = [
580
580
  category: 'Advanced',
581
581
  core: false,
582
582
  description: 'Role framework management',
583
- usage: 'construct role <list|set|latest>',
583
+ usage: 'construct role <list|latest|show|status|resolve|prune|reset>',
584
584
  },
585
585
  {
586
586
  name: 'embed',
@@ -646,6 +646,34 @@ export const CLI_COMMANDS = [
646
646
  description: 'Audit policy gates',
647
647
  usage: 'construct gates:audit',
648
648
  },
649
+ {
650
+ name: 'decisions',
651
+ emoji: '🧭',
652
+ category: 'Advanced',
653
+ core: false,
654
+ description: 'Index load-bearing decisions and their enforcement bindings',
655
+ usage: 'construct decisions [list|validate|json|check|baseline|golden]',
656
+ options: [
657
+ { flag: 'list', desc: 'Show decisions with status and enforcement (default)' },
658
+ { flag: 'validate', desc: 'Validate registry structure; exit 1 on error' },
659
+ { flag: 'check', desc: 'Fail on dangling markers, enforcement/supersede/linkage/precedence drift' },
660
+ { flag: 'baseline', desc: 'Print the enforced baseline; --write to regenerate it' },
661
+ { flag: 'golden', desc: 'Check the CLI/agent/hook surface snapshot; --write to regenerate it' },
662
+ { flag: 'json', desc: 'Emit the full registry as JSON' },
663
+ ],
664
+ },
665
+ {
666
+ name: 'deployment',
667
+ emoji: '🛰️',
668
+ category: 'Advanced',
669
+ core: false,
670
+ description: 'Deployment posture tools (capability parity contract)',
671
+ usage: 'construct deployment parity',
672
+ options: [
673
+ { flag: 'parity', desc: 'Show and validate capability parity across solo/team/enterprise' },
674
+ { flag: '--json', desc: 'Emit the parity contract as JSON' },
675
+ ],
676
+ },
649
677
  {
650
678
  name: 'policy',
651
679
  emoji: '🔒',
@@ -838,7 +866,7 @@ export const CLI_COMMANDS = [
838
866
  emoji: '🩹',
839
867
  category: 'Observability',
840
868
  core: false,
841
- description: 'Configure Langfuse credentials and trace export',
869
+ description: 'Configure telemetry backend credentials and trace export (OTLP or Langfuse-compatible)',
842
870
  usage: 'construct telemetry-setup',
843
871
  },
844
872
  // ── Internal — callable but not advertised in help or completions ────
@@ -223,6 +223,49 @@ function hasCitationNearby(lines, idx) {
223
223
  return false;
224
224
  }
225
225
 
226
+ // A citation marker proves nothing if it resolves to nothing. Two marker kinds
227
+ // are mechanically verifiable offline: a `[^n]` footnote must have a matching
228
+ // `[^n]:` definition, and a `[source: <repo-path>]` must point at a file that
229
+ // exists. URLs and free-text sources are left alone — unverifiable here, and
230
+ // flagging them would punish honest prose. Skip code fences and tables so regex
231
+ // examples and tabular data never read as dangling citations.
232
+
233
+ function looksLikeRepoPath(value) {
234
+ return /^[\w./@-]+$/.test(value) && value.includes('/') && !/^https?:/i.test(value);
235
+ }
236
+
237
+ export function findDanglingCitations(content, { rootDir = process.cwd() } = {}) {
238
+ const lines = content.split('\n');
239
+ const skip = buildSkipMap(lines);
240
+ const defs = new Set();
241
+ lines.forEach((line, i) => {
242
+ if (skip[i]) return;
243
+ const d = line.match(/^\s*\[\^([^\]]+)\]:/);
244
+ if (d) defs.add(d[1]);
245
+ });
246
+
247
+ const violations = [];
248
+ lines.forEach((line, i) => {
249
+ if (skip[i] || line.includes('construct-lint-ignore')) return;
250
+ const scan = line.replace(/`[^`]*`/g, '');
251
+ let m;
252
+ const refRe = /\[\^([^\]]+)\](?!:)/g;
253
+ while ((m = refRe.exec(scan)) !== null) {
254
+ if (!defs.has(m[1])) {
255
+ violations.push({ line: i + 1, label: `dangling footnote citation [^${m[1]}] has no definition`, kind: 'artifact' });
256
+ }
257
+ }
258
+ const srcRe = /\[source:\s*([^\]]+)\]/gi;
259
+ while ((m = srcRe.exec(scan)) !== null) {
260
+ const value = m[1].trim();
261
+ if (looksLikeRepoPath(value) && !fs.existsSync(path.resolve(rootDir, value))) {
262
+ violations.push({ line: i + 1, label: `citation source path not found: ${value}`, kind: 'artifact' });
263
+ }
264
+ }
265
+ });
266
+ return violations;
267
+ }
268
+
226
269
  function checkArtifactBanned(content, rel) {
227
270
  if (!isArtifactPath(rel)) return [];
228
271
  const lines = content.split('\n');
@@ -316,6 +359,7 @@ export function lintFile(filePath, { rootDir = process.cwd(), fix = false } = {}
316
359
  // kind:'artifact' so the hook can let the edit through while still surfacing
317
360
  // the finding.
318
361
  const artifactHits = checkArtifactBanned(content, rel);
362
+ if (isArtifactPath(rel)) artifactHits.push(...findDanglingCitations(content, { rootDir }));
319
363
  if (artifactHits.length > 0) {
320
364
  const mode = process.env.CONSTRUCT_ARTIFACT_LINT_MODE || 'warn';
321
365
  const bucket = mode === 'block' ? errors : warnings;
@@ -300,6 +300,77 @@ function hasSection(body, sectionTitle) {
300
300
  return false;
301
301
  }
302
302
 
303
+ // A fenced ```mermaid block, optionally of a required diagram kind (the kind is
304
+ // matched against the first keyword inside the fence, e.g. flowchart, sequenceDiagram).
305
+
306
+ function hasMermaid(body, kind) {
307
+ const re = /```mermaid\s*\n([\s\S]*?)```/g;
308
+ let m;
309
+ while ((m = re.exec(body))) {
310
+ if (!kind) return true;
311
+ if (new RegExp(`\\b${kind}\\b`, 'i').test(m[1])) return true;
312
+ }
313
+ return false;
314
+ }
315
+
316
+ function hasTable(body) {
317
+ const lines = body.split('\n');
318
+ for (let i = 0; i < lines.length - 1; i++) {
319
+ if (/\|/.test(lines[i]) && /^\s*\|?[\s:-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
320
+ return true;
321
+ }
322
+ }
323
+ return false;
324
+ }
325
+
326
+ // Header rows of every GFM table in the body, each as a lowercased cell list.
327
+ // A table is a `| ... |` line immediately followed by a `|---|` separator row.
328
+
329
+ function tableHeaderRows(body) {
330
+ const lines = body.split('\n');
331
+ const headers = [];
332
+ for (let i = 0; i < lines.length - 1; i++) {
333
+ if (/\|/.test(lines[i]) && /^\s*\|?[\s:-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
334
+ const cells = lines[i].split('|').map((c) => c.trim().toLowerCase()).filter(Boolean);
335
+ headers.push({ line: i + 1, cells, rowsBelow: countRows(lines, i + 2) });
336
+ }
337
+ }
338
+ return headers;
339
+ }
340
+
341
+ function countRows(lines, start) {
342
+ let n = 0;
343
+ for (let i = start; i < lines.length; i++) {
344
+ if (/^\s*\|/.test(lines[i]) || (/\|/.test(lines[i]) && lines[i].trim())) n += 1;
345
+ else break;
346
+ }
347
+ return n;
348
+ }
349
+
350
+ function tableWithColumns(body, columns) {
351
+ const want = columns.map((c) => c.trim().toLowerCase());
352
+ for (const header of tableHeaderRows(body)) {
353
+ if (want.every((w) => header.cells.includes(w))) return header;
354
+ }
355
+ return null;
356
+ }
357
+
358
+ function sectionIsNonEmpty(body, sectionTitle) {
359
+ const target = sectionTitle.trim().toLowerCase();
360
+ const lines = body.split('\n');
361
+ let inSection = false;
362
+ for (const line of lines) {
363
+ const heading = line.match(/^#{1,6}\s+(.+)$/);
364
+ if (heading) {
365
+ if (inSection) return false;
366
+ inSection = heading[1].trim().toLowerCase() === target;
367
+ continue;
368
+ }
369
+ if (inSection && line.trim() && !line.trim().startsWith('<!--')) return true;
370
+ }
371
+ return false;
372
+ }
373
+
303
374
  function claimsHaveCitations(body) {
304
375
  const lines = body.split('\n');
305
376
  const offenders = [];
@@ -367,6 +438,41 @@ export function validateArtifactPostconditions({ contract, artifactPath }) {
367
438
  }
368
439
  break;
369
440
  }
441
+ case 'artifact-has-mermaid': {
442
+ if (!hasMermaid(body, pc.diagram)) {
443
+ errors.push(`[${tag}] artifact missing required mermaid diagram${pc.diagram ? ` (${pc.diagram})` : ''}`);
444
+ }
445
+ break;
446
+ }
447
+ case 'artifact-has-table': {
448
+ if (!hasTable(body)) {
449
+ errors.push(`[${tag}] artifact missing a required table`);
450
+ }
451
+ break;
452
+ }
453
+ case 'artifact-table-has-columns': {
454
+ if (!Array.isArray(pc.columns) || pc.columns.length === 0) {
455
+ errors.push(`[${tag}] postcondition missing 'columns' for artifact-table-has-columns`);
456
+ break;
457
+ }
458
+ const match = tableWithColumns(body, pc.columns);
459
+ if (!match) {
460
+ errors.push(`[${tag}] no table has the required columns: ${pc.columns.join(', ')}`);
461
+ } else if (match.rowsBelow === 0) {
462
+ errors.push(`[${tag}] required table (${pc.columns.join(', ')}) has no data rows`);
463
+ }
464
+ break;
465
+ }
466
+ case 'artifact-section-nonempty': {
467
+ if (!pc.section) {
468
+ errors.push(`[${tag}] postcondition missing 'section' for artifact-section-nonempty`);
469
+ break;
470
+ }
471
+ if (!sectionIsNonEmpty(body, pc.section)) {
472
+ errors.push(`[${tag}] required section is empty or missing: "${pc.section}"`);
473
+ }
474
+ break;
475
+ }
370
476
  default:
371
477
  errors.push(`[${tag}] unknown postcondition check: ${pc.check}`);
372
478
  }
@@ -0,0 +1,23 @@
1
+ {
2
+ "enforced": [
3
+ "ADR-0015",
4
+ "ADR-0016",
5
+ "ADR-0017",
6
+ "ADR-0018",
7
+ "any-to-business-strategist",
8
+ "any-to-explorer",
9
+ "architect-to-data-engineer",
10
+ "architect-to-evaluator",
11
+ "architect-to-legal-compliance",
12
+ "construct-to-rd-lead",
13
+ "product-manager-to-architect",
14
+ "product-manager-to-data-analyst",
15
+ "product-manager-to-ux-researcher",
16
+ "qa-to-test-automation",
17
+ "researcher-to-product-manager",
18
+ "rule:common/comments",
19
+ "rule:common/no-fabrication",
20
+ "rule:common/precedence",
21
+ "rule:common/research-sources"
22
+ ]
23
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * lib/decisions/golden.mjs — behavioral golden snapshot of core surfaces.
3
+ *
4
+ * Pins the surfaces whose silent change is a drift hazard: the CLI command set,
5
+ * the specialist roster, and hook execution order. buildSurfaceSnapshot computes
6
+ * the live surface from source; a committed snapshot (tests/fixtures/golden/
7
+ * surface.json) is the expectation. A mismatch fails the suite until the snapshot
8
+ * is regenerated on purpose (`construct decisions golden --write`) — the same
9
+ * conscious-update discipline as the enforced baseline (bead wvbf.5).
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { join, dirname, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
17
+
18
+ const HOOK_EVENTS = ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop'];
19
+
20
+ function hookName(command) {
21
+ const m = command.match(/hooks\/([\w-]+)\.mjs/);
22
+ if (m) return m[1];
23
+ if (/node\s+-e/.test(command)) return 'inline';
24
+ return 'cmd';
25
+ }
26
+
27
+ export function buildSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
28
+ const snapshot = { commands: [], agents: [], hooks: {} };
29
+
30
+ const cli = readFileSync(join(repoRoot, 'lib', 'cli-commands.mjs'), 'utf8');
31
+ const nameRe = /name:\s*'([^']+)'[\s\S]*?core:\s*(true|false)/g;
32
+ let m;
33
+ const seen = new Set();
34
+ while ((m = nameRe.exec(cli)) !== null) {
35
+ if (seen.has(m[1])) continue;
36
+ seen.add(m[1]);
37
+ snapshot.commands.push({ name: m[1], core: m[2] === 'true' });
38
+ }
39
+ snapshot.commands.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
40
+
41
+ const registry = JSON.parse(readFileSync(join(repoRoot, 'specialists', 'registry.json'), 'utf8'));
42
+ snapshot.agents = (registry.specialists || []).map((s) => s.name).sort();
43
+
44
+ const settings = JSON.parse(readFileSync(join(repoRoot, 'platforms', 'claude', 'settings.template.json'), 'utf8'));
45
+ for (const event of HOOK_EVENTS) {
46
+ const entries = settings.hooks?.[event] || [];
47
+ const ordered = [];
48
+ for (const entry of entries) {
49
+ for (const h of entry.hooks || []) ordered.push(hookName(h.command));
50
+ }
51
+ snapshot.hooks[event] = ordered;
52
+ }
53
+
54
+ return snapshot;
55
+ }
56
+
57
+ function snapshotPath(repoRoot) {
58
+ return join(repoRoot, 'tests', 'fixtures', 'golden', 'surface.json');
59
+ }
60
+
61
+ export function compareSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
62
+ const file = snapshotPath(repoRoot);
63
+ if (!existsSync(file)) return { ok: false, diffs: ['no committed snapshot — run: construct decisions golden --write'] };
64
+ const expected = JSON.stringify(JSON.parse(readFileSync(file, 'utf8')));
65
+ const actual = JSON.stringify(buildSurfaceSnapshot({ repoRoot }));
66
+ if (expected === actual) return { ok: true, diffs: [] };
67
+ return { ok: false, diffs: ['surface snapshot drift — review the change, then regenerate with: construct decisions golden --write'] };
68
+ }
69
+
70
+ export function writeSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
71
+ writeFileSync(snapshotPath(repoRoot), JSON.stringify(buildSurfaceSnapshot({ repoRoot }), null, 2) + '\n');
72
+ }
73
+
74
+ export async function runGoldenCli(args = []) {
75
+ if (args.includes('--write')) {
76
+ writeSurfaceSnapshot();
77
+ process.stdout.write('✓ wrote surface golden snapshot\n');
78
+ return;
79
+ }
80
+ const { ok, diffs } = compareSurfaceSnapshot();
81
+ if (ok) {
82
+ process.stdout.write('✓ surface matches the golden snapshot\n');
83
+ return;
84
+ }
85
+ for (const d of diffs) process.stderr.write(`✗ ${d}\n`);
86
+ process.exit(1);
87
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * lib/decisions/precedence.mjs — explicit precedence for conflicting guidance.
3
+ *
4
+ * When two rules give contradictory direction, the conflict must resolve the same
5
+ * way every time, not by whichever the model read last. PRECEDENCE_TIERS is the
6
+ * canonical order (index 0 wins); a rule declares its tier in frontmatter
7
+ * (`precedence_tier`), and resolvePrecedence answers which of two tiers governs.
8
+ *
9
+ * Scope is deliberate: this provides deterministic TIER-based precedence, not
10
+ * natural-language contradiction detection. It makes the resolution order
11
+ * explicit and validates that declared tiers are real; judging whether two rules
12
+ * actually contradict remains human work. See rules/common/precedence.md.
13
+ */
14
+
15
+ export const PRECEDENCE_TIERS = ['safety', 'security', 'correctness', 'durability', 'performance', 'style'];
16
+
17
+ export function tierRank(tier) {
18
+ const i = PRECEDENCE_TIERS.indexOf(tier);
19
+ return i === -1 ? Number.POSITIVE_INFINITY : i;
20
+ }
21
+
22
+ /**
23
+ * Resolve which tier governs. Returns -1 if a wins, 1 if b wins, 0 if equal rank.
24
+ * A lower index is higher priority (safety beats style).
25
+ */
26
+ export function resolvePrecedence(a, b) {
27
+ const ra = tierRank(a);
28
+ const rb = tierRank(b);
29
+ if (ra < rb) return -1;
30
+ if (ra > rb) return 1;
31
+ return 0;
32
+ }
33
+
34
+ /**
35
+ * Validate declared precedence tiers over a decisions array (bead wvbf.8): any
36
+ * rule that declares a `precedence_tier` must use one from PRECEDENCE_TIERS.
37
+ */
38
+ export function validatePrecedenceTiersOn(decisions) {
39
+ const violations = [];
40
+ for (const d of decisions) {
41
+ if (d.precedenceTier && !PRECEDENCE_TIERS.includes(d.precedenceTier)) {
42
+ violations.push(`${d.id}: unknown precedence_tier "${d.precedenceTier}" (expected one of ${PRECEDENCE_TIERS.join(', ')})`);
43
+ }
44
+ }
45
+ return { ok: violations.length === 0, violations };
46
+ }