@geraldmaron/construct 1.0.23 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +0 -2
  2. package/bin/construct +189 -207
  3. package/lib/agent-instructions/inject.mjs +25 -4
  4. package/lib/audit-rules.mjs +127 -0
  5. package/lib/audit-skills.mjs +43 -1
  6. package/lib/beads-client.mjs +9 -0
  7. package/lib/beads-optimistic.mjs +23 -71
  8. package/lib/bridges/copilot-proxy.mjs +116 -0
  9. package/lib/cli-commands.mjs +5 -1
  10. package/lib/comment-lint.mjs +1 -1
  11. package/lib/config/schema.mjs +1 -1
  12. package/lib/document-extract/docling-client.mjs +16 -6
  13. package/lib/document-extract/docling-sidecar.py +32 -2
  14. package/lib/document-extract.mjs +37 -10
  15. package/lib/document-ingest.mjs +90 -5
  16. package/lib/embed/inbox.mjs +6 -3
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/embed/roadmap.mjs +16 -1
  20. package/lib/engine/consolidate.mjs +160 -3
  21. package/lib/engine/contradiction-judge.mjs +71 -0
  22. package/lib/engine/contradiction.mjs +74 -0
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/session-reflect.mjs +5 -1
  25. package/lib/host-capabilities.mjs +30 -0
  26. package/lib/ingest/docling-remote.mjs +90 -0
  27. package/lib/ingest/strategy.mjs +1 -1
  28. package/lib/init-unified.mjs +9 -13
  29. package/lib/intake/git-queue.mjs +195 -0
  30. package/lib/intake/queue.mjs +9 -16
  31. package/lib/logging/rotate.mjs +18 -0
  32. package/lib/mcp/server.mjs +124 -12
  33. package/lib/mcp/tool-budget.mjs +53 -0
  34. package/lib/mcp/tools/storage.mjs +2 -3
  35. package/lib/mcp-catalog.json +4 -4
  36. package/lib/mcp-manager.mjs +59 -3
  37. package/lib/observation-store.mjs +38 -166
  38. package/lib/ollama/capability-store.mjs +78 -0
  39. package/lib/ollama/provision-context.mjs +344 -0
  40. package/lib/opencode-config.mjs +148 -0
  41. package/lib/opencode-telemetry.mjs +7 -0
  42. package/lib/orchestration/runtime.mjs +3 -2
  43. package/lib/orchestration-policy.mjs +41 -6
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +12 -8
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reconcile/index.mjs +0 -2
  48. package/lib/reflect/extractor.mjs +14 -1
  49. package/lib/reflect/salience.mjs +65 -0
  50. package/lib/rules-delivery.mjs +122 -0
  51. package/lib/runtime/uv-bootstrap.mjs +32 -17
  52. package/lib/service-manager.mjs +79 -259
  53. package/lib/setup.mjs +44 -425
  54. package/lib/specialists/prompt-schema.mjs +162 -0
  55. package/lib/specialists/scaffold.mjs +109 -0
  56. package/lib/status.mjs +3 -6
  57. package/lib/storage/admin.mjs +48 -325
  58. package/lib/storage/backend.mjs +10 -57
  59. package/lib/storage/embeddings-engine.mjs +19 -5
  60. package/lib/storage/hybrid-query.mjs +15 -196
  61. package/lib/storage/sync.mjs +36 -177
  62. package/lib/storage/vector-client.mjs +256 -235
  63. package/lib/strategy-store.mjs +35 -286
  64. package/lib/telemetry/beads-fallback.mjs +40 -0
  65. package/lib/telemetry/hook-calls.mjs +138 -0
  66. package/lib/worker/entrypoint.mjs +6 -14
  67. package/package.json +6 -5
  68. package/personas/construct.md +1 -1
  69. package/platforms/capabilities.json +76 -0
  70. package/platforms/claude/settings.template.json +0 -7
  71. package/platforms/opencode/sync-config.mjs +121 -25
  72. package/rules/common/neurodivergent-output.md +1 -1
  73. package/rules/web/coding-style.md +8 -0
  74. package/rules/web/design-quality.md +8 -0
  75. package/rules/web/hooks.md +8 -0
  76. package/rules/web/patterns.md +8 -0
  77. package/rules/web/performance.md +8 -0
  78. package/rules/web/security.md +8 -0
  79. package/rules/web/testing.md +8 -0
  80. package/scripts/sync-specialists.mjs +174 -40
  81. package/specialists/prompts/cx-architect.md +20 -0
  82. package/specialists/prompts/cx-qa.md +1 -1
  83. package/specialists/prompts/cx-test-automation.md +12 -0
  84. package/specialists/registry.json +0 -8
  85. package/templates/docs/construct_guide.md +1 -1
  86. package/db/schema/001_init.sql +0 -40
  87. package/db/schema/002_pgvector.sql +0 -182
  88. package/db/schema/003_intake.sql +0 -47
  89. package/db/schema/003_observation_reconciliation.sql +0 -14
  90. package/db/schema/004_recommendations.sql +0 -46
  91. package/db/schema/005_strategy.sql +0 -21
  92. package/db/schema/006_graph.sql +0 -24
  93. package/db/schema/007_tags.sql +0 -30
  94. package/db/schema/008_skill_usage.sql +0 -24
  95. package/db/schema/009_scheduler.sql +0 -14
  96. package/db/schema/010_cx_scores.sql +0 -51
  97. package/lib/intake/postgres-queue.mjs +0 -240
  98. package/lib/reconcile/postgres-namespace.mjs +0 -102
  99. package/lib/services/local-postgres.mjs +0 -15
  100. package/lib/storage/backup.mjs +0 -347
  101. package/lib/storage/migrations.mjs +0 -187
  102. package/lib/storage/postgres-backup.mjs +0 -124
  103. package/lib/storage/sql-store.mjs +0 -45
  104. package/lib/storage/store-version.mjs +0 -115
  105. package/lib/storage/unified-storage.mjs +0 -550
  106. package/lib/storage/vector-store.mjs +0 -100
package/README.md CHANGED
@@ -269,7 +269,6 @@ construct/
269
269
  ├── commands Command prompt assets
270
270
  ├── config
271
271
  ├── dashboard
272
- ├── db
273
272
  ├── deploy
274
273
  ├── docs Architecture notes, runbooks, and documentation contract
275
274
  ├── examples
@@ -282,7 +281,6 @@ construct/
282
281
  ├── rules Coding and quality standards
283
282
  ├── schemas
284
283
  ├── scripts
285
- ├── services
286
284
  ├── skills Reusable domain knowledge files
287
285
  ├── specialists
288
286
  ├── templates
package/bin/construct CHANGED
@@ -67,9 +67,7 @@ import { readCostLog, summarizeCostData, formatCostReport, clearCostLog } from '
67
67
  import { readEfficiencyLog, summarizeEfficiencyData, formatEfficiencyReport } from '../lib/efficiency.mjs';
68
68
  import { resolveColors } from '../lib/term-format.mjs';
69
69
  import { buildHybridSearchResultsAsync } from '../lib/storage/hybrid-query.mjs';
70
- import { createSqlClient, closeSqlClient, readVectorConfig } from '../lib/storage/backend.mjs';
71
- import { describeVectorStore } from '../lib/storage/vector-store.mjs';
72
- import { describeSqlStore } from '../lib/storage/sql-store.mjs';
70
+ import { createSqlClient, closeSqlClient } from '../lib/storage/backend.mjs';
73
71
  import { syncFileStateToSql } from '../lib/storage/sync.mjs';
74
72
  import { deleteIngestedArtifacts, getStorageStatus, inferProjectName, resetStorage } from '../lib/storage/admin.mjs';
75
73
  import { runPressureRelease } from '../lib/runtime-pressure.mjs';
@@ -171,6 +169,44 @@ function runNodeScript(scriptPath, args = [], extraEnv = {}, { exitOnError = tru
171
169
  return status;
172
170
  }
173
171
 
172
+ async function cmdRegistryStatus(args = []) {
173
+ const { readFileSync, existsSync } = await import('node:fs');
174
+ const { join } = await import('node:path');
175
+ const matrixPath = join(ROOT_DIR, 'tests', 'registry', 'capability-matrix.json');
176
+
177
+ if (!existsSync(matrixPath)) {
178
+ errorln('Capability matrix not found. Run "construct sync" or ensure tests/registry/capability-matrix.json exists.');
179
+ process.exit(1);
180
+ }
181
+
182
+ const { capabilities } = JSON.parse(readFileSync(matrixPath, 'utf8'));
183
+ const jsonOutput = args.includes('--json');
184
+
185
+ if (jsonOutput) {
186
+ println(JSON.stringify(capabilities, null, 2));
187
+ return;
188
+ }
189
+
190
+ println(`${COLORS.bold}Workflow & Surface Capability Registry${COLORS.reset}`);
191
+ println('='.repeat(40));
192
+ println('');
193
+
194
+ for (const cap of capabilities) {
195
+ const tierColor = cap.criticality === 'P0' ? COLORS.red : cap.criticality === 'P1' ? COLORS.yellow : COLORS.blue;
196
+ println(`${tierColor}[${cap.criticality}]${COLORS.reset} ${COLORS.bold}${cap.name}${COLORS.reset} (${cap.id})`);
197
+ println(` ${COLORS.dim}${cap.description}${COLORS.reset}`);
198
+
199
+ const surfaces = Object.entries(cap.surfaces);
200
+ for (const [name, status] of surfaces) {
201
+ const icon = status.quality_score === null ? '⚪' : status.quality_score >= 0.9 ? '🟢' : status.quality_score >= 0.7 ? '🟡' : '🔴';
202
+ const score = status.quality_score !== null ? `${(status.quality_score * 100).toFixed(0)}%` : 'N/A';
203
+ const date = status.last_validated ? new Date(status.last_validated).toLocaleDateString() : 'never';
204
+ println(` ${icon} ${name.padEnd(10)} | Score: ${score.padEnd(5)} | Validated: ${date}`);
205
+ }
206
+ println('');
207
+ }
208
+ }
209
+
174
210
  async function cmdStatus() {
175
211
  const jsonOutput = restArgsCache.includes('--json');
176
212
  const status = await buildStatus({ rootDir: ROOT_DIR, cwd: process.cwd(), homeDir: HOME, env: process.env });
@@ -822,35 +858,10 @@ async function cmdDoctor() {
822
858
  const researchLintResults = lintResearchRepo({ rootDir: process.cwd() });
823
859
  add('Research artifacts meet minimum evidence structure', researchLintResults.every((entry) => entry.errors.length === 0), true);
824
860
 
825
- const sqlConfig = describeSqlStore(process.env);
826
- const sqlClient = createSqlClient(process.env);
827
- // Track SQL reachability so the schema-migration check can cascade correctly
828
- // rather than emitting a redundant second warning for the same offline server.
829
- let sqlReachable = false;
830
- if (sqlConfig.configured && sqlClient) {
831
- try {
832
- await sqlClient`select 1 as ok`;
833
- sqlReachable = true;
834
- add('SQL backend reachable', true);
835
- } catch {
836
- // In solo mode the local vector index is the active storage path, so an
837
- // unreachable Postgres is expected unless the user has started it. Emit a
838
- // contextual message rather than a bare ⚠ so the fix is obvious.
839
- const { getDeploymentMode } = await import('../lib/deployment-mode.mjs');
840
- const mode = getDeploymentMode(process.env) ?? 'solo';
841
- const hint = mode === 'solo'
842
- ? ' (solo mode — run `construct dev` to start Postgres)'
843
- : ' — check DATABASE_URL and that the server is running';
844
- add(`SQL backend unreachable${hint}`, false, true);
845
- } finally {
846
- await closeSqlClient(sqlClient);
847
- }
848
- } else {
849
- add('SQL backend configured', sqlConfig.fallbackAvailable, true);
850
- }
851
-
852
- const vectorConfig = describeVectorStore(process.env);
853
- add('Vector backend configured', Boolean(vectorConfig.endpoint || vectorConfig.indexPath || vectorConfig.mode === 'file'));
861
+ const { VectorClient } = await import('../lib/storage/vector-client.mjs');
862
+ const vectorClient = new VectorClient({ env: process.env });
863
+ const vectorOk = await vectorClient.isHealthy();
864
+ add('Vector backend (LanceDB) reachable', vectorOk);
854
865
 
855
866
  const { regenerateDocs } = await import('../lib/auto-docs.mjs');
856
867
  const { changed: stale } = await regenerateDocs({ rootDir: ROOT_DIR, check: true });
@@ -979,35 +990,6 @@ async function cmdDoctor() {
979
990
  add('Observation size', false, true);
980
991
  }
981
992
 
982
- // Only run the schema migration check when SQL is both configured AND
983
- // currently reachable. If the SQL check above already failed (sqlReachable
984
- // is false) this check will also fail with the same ECONNREFUSED and would
985
- // produce a redundant second warning about the same offline server.
986
- if (sqlConfig.configured && sqlReachable) {
987
- try {
988
- const migClient = createSqlClient(process.env);
989
- if (migClient) {
990
- const { describeMigrations } = await import('../lib/storage/migrations.mjs');
991
- const m = await describeMigrations(migClient);
992
- await closeSqlClient(migClient);
993
- const ok = !!m.ok && m.drift.length === 0;
994
- let label = 'Schema migrations applied + no drift';
995
- if (!ok && m.drift && m.drift.length > 0) {
996
- const repairable = m.drift.filter((d) => d.idempotent).length;
997
- const notRepairable = m.drift.length - repairable;
998
- if (notRepairable === 0) {
999
- label = `Schema migrations: ${m.drift.length} drifted (all idempotent — run \`construct storage repair-migrations --yes\`)`;
1000
- } else {
1001
- label = `Schema migrations: ${m.drift.length} drifted (${notRepairable} non-idempotent — write a new migration file)`;
1002
- }
1003
- }
1004
- add(label, ok, true);
1005
- }
1006
- } catch {
1007
- add('Schema migrations applied + no drift', false, true);
1008
- }
1009
- }
1010
-
1011
993
  try {
1012
994
  const { detectBeadsDrift } = await import('../lib/beads/drift.mjs');
1013
995
  const drift = detectBeadsDrift();
@@ -1037,24 +1019,6 @@ async function cmdDoctor() {
1037
1019
  add('cm available', false, true);
1038
1020
  }
1039
1021
 
1040
- try {
1041
- const dockerCli = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['docker'], { stdio: 'pipe' });
1042
- let dockerOk = false;
1043
- let dockerNote = 'Docker not installed (optional — managed Postgres needs it)';
1044
- if (dockerCli.status === 0) {
1045
- const dockerInfo = spawnSync('docker', ['info'], { stdio: 'pipe' });
1046
- if (dockerInfo.status === 0) {
1047
- dockerOk = true;
1048
- dockerNote = 'Docker daemon reachable';
1049
- } else {
1050
- dockerNote = 'Docker CLI installed but daemon not reachable — run `construct up` on macOS to auto-start, or start Docker manually';
1051
- }
1052
- }
1053
- add(dockerOk ? 'Docker daemon reachable' : `Docker daemon reachable (${dockerNote})`, dockerOk, true);
1054
- } catch {
1055
- add('Docker daemon reachable', false, true);
1056
- }
1057
-
1058
1022
  try {
1059
1023
  const failuresPath = path.join(HOME, '.cx', 'hook-failures.jsonl');
1060
1024
  if (fs.existsSync(failuresPath)) {
@@ -1125,6 +1089,69 @@ async function cmdDoctor() {
1125
1089
  add(`Reconciliation drift check failed: ${err.message}`, false, true);
1126
1090
  }
1127
1091
 
1092
+ // Docling document-extraction runtime (Python venv via uv). Provisioned lazily
1093
+ // on first document ingest, or eagerly via `construct install --with-docling`.
1094
+ // Advisory: absence is fine until a document is ingested.
1095
+ try {
1096
+ const { describeDoclingRuntime } = await import('../lib/runtime/uv-bootstrap.mjs');
1097
+ const docling = describeDoclingRuntime();
1098
+ if (docling.available) {
1099
+ add(`Docling runtime ready (docling ${docling.marker?.doclingVersion ?? '?'})`, true, true);
1100
+ } else {
1101
+ add('Docling runtime not provisioned (`construct install --with-docling`, or auto on first ingest)', false, true);
1102
+ }
1103
+ } catch (err) {
1104
+ add(`Docling runtime check failed: ${err.message}`, false, true);
1105
+ }
1106
+
1107
+ // Capability honesty without re-probing: if the OpenCode default model is one the
1108
+ // probe recorded as COLLAPSED (and its digest still matches), flag it at health
1109
+ // time. Reads the persisted store only — the slow probe stays behind --probe-local.
1110
+ try {
1111
+ const { readOpenCodeConfig } = await import('../lib/opencode-config.mjs');
1112
+ const { isKnownCollapsed } = await import('../lib/ollama/capability-store.mjs');
1113
+ const { modelDigest } = await import('../lib/ollama/provision-context.mjs');
1114
+ const { config } = readOpenCodeConfig();
1115
+ const defaultModel = (config?.model || config?.defaultModel || '').replace(/^ollama\//, '');
1116
+ if (defaultModel && isKnownCollapsed(defaultModel, modelDigest(defaultModel))) {
1117
+ add(`Default local model ${defaultModel} probed COLLAPSED — not agentic-capable (re-verify: construct doctor --probe-local)`, false, true);
1118
+ }
1119
+ } catch { /* advisory */ }
1120
+
1121
+ // Opt-in agentic-coherence probe for local models (`construct doctor --probe-local`).
1122
+ // Loads each registered Ollama model and measures repetition collapse on an agentic
1123
+ // prompt, so it is gated behind the flag. COLLAPSED models produce word salad no
1124
+ // matter how context and tools are tuned (capability is not predictable from size).
1125
+ if (args.includes('--probe-local')) {
1126
+ try {
1127
+ const { ollamaAvailable, listModels, probeAgenticCoherence, probeStreamingToolCallLeak, modelDigest } = await import('../lib/ollama/provision-context.mjs');
1128
+ const { recordProbeResult } = await import('../lib/ollama/capability-store.mjs');
1129
+ // --probe-stream additionally drives a streaming turn and flags a tool-call
1130
+ // leaked into the text channel (`<function=…>`) — the artifact the buffered
1131
+ // probe cannot reproduce. It reloads each model, so it stays opt-in.
1132
+ const probeStream = args.includes('--probe-stream');
1133
+ if (!ollamaAvailable()) {
1134
+ add('Local-model probe: Ollama not available', false, true);
1135
+ } else {
1136
+ const models = listModels().filter((m) => !/embed/i.test(m));
1137
+ for (const m of models) {
1138
+ const r = await probeAgenticCoherence(m);
1139
+ if (r.ok) recordProbeResult(m, r, modelDigest(m));
1140
+ const verdict = r.ok ? (r.coherent ? `COHERENT (repeat ${r.repeatRatio})` : `COLLAPSED (repeat ${r.repeatRatio})`) : `error: ${r.reason}`;
1141
+ add(`Local probe ${m}: ${verdict}`, r.ok && r.coherent, true);
1142
+ if (probeStream && r.ok && r.coherent) {
1143
+ const s = await probeStreamingToolCallLeak(m);
1144
+ if (s.ok) add(`Stream probe ${m}: ${s.leaked ? `TOOL-CALL LEAK (${s.marker})` : 'clean (native tool_calls)'}`, !s.leaked, true);
1145
+ }
1146
+ }
1147
+ add('Quantization: prefer Q4_K_M+ — low quants are a known word-salad source', true, true);
1148
+ add('Verified agentic-capable locals: qwen3-coder:32k, devstral:24b', true, true);
1149
+ }
1150
+ } catch (err) {
1151
+ add(`Local-model probe failed: ${err.message}`, false, true);
1152
+ }
1153
+ }
1154
+
1128
1155
  println('Construct Health Check');
1129
1156
  println('══════════════════════');
1130
1157
  println('');
@@ -1569,49 +1596,11 @@ async function cmdStorage(args) {
1569
1596
  println(`Reconciled observations: re-embedded ${result.reembedded} of ${result.checked} (model ${result.model}).`);
1570
1597
  return;
1571
1598
  }
1572
- if (action === 'migrations') {
1573
- const { describeMigrations } = await import('../lib/storage/migrations.mjs');
1574
- const sqlClient = createSqlClient(process.env);
1575
- if (!sqlClient) { errorln('No DATABASE_URL configured.'); process.exit(1); }
1576
- try {
1577
- const report = await describeMigrations(sqlClient);
1578
- println(JSON.stringify(report, null, 2));
1579
- if (report.drift?.length > 0) process.exit(2);
1580
- } finally {
1581
- await closeSqlClient(sqlClient);
1582
- }
1583
- return;
1584
- }
1585
- if (action === 'repair-migrations') {
1586
- const confirm = rest.includes('--yes');
1587
- if (!confirm) {
1588
- errorln('This re-applies drifted migrations (idempotent only) and updates their recorded SHA.');
1589
- errorln('Destructive migrations (DROP / TRUNCATE / ALTER…DROP / DELETE) are refused.');
1590
- errorln('Re-run with --yes to proceed.');
1591
- process.exit(1);
1592
- }
1593
- const { runMigrations } = await import('../lib/storage/migrations.mjs');
1594
- const sqlClient = createSqlClient(process.env);
1595
- if (!sqlClient) { errorln('No DATABASE_URL configured.'); process.exit(1); }
1596
- try {
1597
- const result = await runMigrations(sqlClient, { repair: true });
1598
- println(JSON.stringify({
1599
- applied: result.applied,
1600
- repaired: result.repaired,
1601
- skipped: result.skipped,
1602
- drift: result.drift,
1603
- }, null, 2));
1604
- if (result.drift.length > 0) {
1605
- errorln('');
1606
- errorln('Refused to repair non-idempotent migrations:');
1607
- for (const d of result.drift) errorln(` ${d.filename}`);
1608
- process.exit(2);
1609
- }
1610
- info(`Repaired ${result.repaired.length} migration(s); applied ${result.applied.length} new.`);
1611
- } finally {
1612
- await closeSqlClient(sqlClient);
1613
- }
1614
- return;
1599
+ if (action === 'migrations' || action === 'repair-migrations') {
1600
+ // Postgres schema migrations were removed with the SQL backend; LanceDB
1601
+ // tables are created on demand and need no migration runner.
1602
+ errorln('construct storage ' + action + ' is no longer supported: Postgres was removed.');
1603
+ process.exit(1);
1615
1604
  }
1616
1605
  if (action === 'sync') {
1617
1606
  const result = await syncFileStateToSql(process.cwd(), { env: process.env, project: inferProjectName(process.cwd()) });
@@ -3543,6 +3532,7 @@ async function cmdOrchestrate(args) {
3543
3532
  host: flag('--host'),
3544
3533
  hostModel: flag('--host-model'),
3545
3534
  hostProvider: flag('--host-provider'),
3535
+ workerBackend: flag('--worker-backend'),
3546
3536
  fileCount: Number(flag('--file-count') || 0),
3547
3537
  moduleCount: Number(flag('--module-count') || 0),
3548
3538
  };
@@ -4242,15 +4232,29 @@ async function cmdMemory(args) {
4242
4232
  const { getEngine } = await import('../lib/engine/index.mjs');
4243
4233
  const engine = await getEngine({ rootDir: process.cwd() });
4244
4234
  const summariser = engine.layers.compressor;
4235
+
4236
+ // The judge resolves the value-swap contradictions the heuristic abstains on
4237
+ // when a local model is available; null offline, so consolidation runs the
4238
+ // same with heuristic-only.
4239
+ const { createContradictionJudge } = await import('../lib/engine/contradiction-judge.mjs');
4240
+ const contradictionJudge = createContradictionJudge();
4245
4241
  const thresholdArg = args.find((a) => a.startsWith('--threshold='));
4246
4242
  const archiveDaysArg = args.find((a) => a.startsWith('--archive-days='));
4247
- const opts = { summariser };
4243
+ const opts = { summariser, contradictionJudge };
4248
4244
  if (thresholdArg) opts.similarityThreshold = Number(thresholdArg.split('=')[1]);
4249
4245
  if (archiveDaysArg) opts.archiveAfterDays = Number(archiveDaysArg.split('=')[1]);
4246
+ if (args.includes('--no-supersede')) opts.supersedeDuplicates = false;
4247
+ if (args.includes('--no-contradictions')) opts.detectContradictions = false;
4250
4248
  const result = await consolidate(process.cwd(), opts);
4249
+ const superseded = result.superseded ?? [];
4250
+ const contradictions = superseded.filter((s) => s.reason === 'contradiction').length;
4251
+ const restatements = superseded.length - contradictions;
4252
+ const agedOut = result.archived.length - superseded.length;
4251
4253
  process.stdout.write(
4252
4254
  `consolidate: ${result.clustersBefore} observations → ${result.clusters} insights, ` +
4253
- `${result.archived.length} archived, ${result.archivePruned ?? 0} pruned from archive\n`
4255
+ `${restatements} restated, ${contradictions} contradicted, ${agedOut} aged-out, ` +
4256
+ `${result.archivePruned ?? 0} pruned from archive` +
4257
+ `${result.contradictionScanSkipped ? ' (contradiction scan skipped: store too large)' : ''}\n`
4254
4258
  );
4255
4259
  return;
4256
4260
  }
@@ -4565,100 +4569,64 @@ async function cmdLintContracts() {
4565
4569
  process.exit(1);
4566
4570
  }
4567
4571
 
4568
- async function cmdBackup(args) {
4569
- const sub = args[0];
4570
-
4571
- if (!sub || sub === 'create') {
4572
- const includeSecrets = args.includes('--include-secrets');
4573
- const noPrune = args.includes('--no-prune');
4574
- const keepArg = args.find((a) => a.startsWith('--keep='));
4575
- const keep = keepArg
4576
- ? Number(keepArg.split('=')[1])
4577
- : Number(process.env.CONSTRUCT_BACKUP_RETAIN || 10);
4578
- const { createBackup, pruneBackups } = await import('../lib/storage/backup.mjs');
4579
- println('Creating backup…');
4580
- try {
4581
- const result = await createBackup({ includeSecrets });
4582
- ok(`Backup created: ${result.path}`);
4583
- println(` Contents: ${result.manifest.contents.join(', ') || '(empty)'}`);
4584
- if (!includeSecrets) println(' Secrets redacted. Use --include-secrets to include them.');
4585
- if (!noPrune) {
4586
- const pruned = pruneBackups({ keep });
4587
- if (pruned.removed.length) {
4588
- println(` Auto-pruned ${pruned.removed.length} old backup(s); kept ${pruned.kept}.`);
4589
- }
4590
- }
4591
- } catch (err) {
4592
- errorln(`Backup failed: ${err.message}`);
4593
- process.exit(1);
4594
- }
4572
+ async function cmdLintPrompts() {
4573
+ const { validatePromptFiles } = await import('../lib/specialists/prompt-schema.mjs');
4574
+ const { errors, warnings, total, converted } = validatePromptFiles({ rootDir: ROOT_DIR });
4575
+ for (const w of warnings) console.warn(` warn ${w}`);
4576
+ if (errors.length === 0) {
4577
+ console.log(`specialist prompts: ${total} files (${converted} converted), 0 errors, ${warnings.length} warning(s)`);
4595
4578
  return;
4596
4579
  }
4580
+ console.error(`specialist prompts: ${errors.length} error(s) across ${total} files`);
4581
+ for (const err of errors) console.error(` ${err}`);
4582
+ process.exit(1);
4583
+ }
4597
4584
 
4598
- if (sub === 'prune') {
4599
- const keepArg = args.find((a) => a.startsWith('--keep='));
4600
- const keep = keepArg
4601
- ? Number(keepArg.split('=')[1])
4602
- : Number(process.env.CONSTRUCT_BACKUP_RETAIN || 10);
4603
- const { pruneBackups } = await import('../lib/storage/backup.mjs');
4604
- const result = pruneBackups({ keep });
4605
- if (result.removed.length === 0) {
4606
- println(`Nothing to prune (have ${result.kept}, keeping ${keep}).`);
4607
- } else {
4608
- ok(`Pruned ${result.removed.length} backup(s); kept ${result.kept}.`);
4609
- for (const r of result.removed) println(` removed ${r}`);
4610
- }
4611
- return;
4612
- }
4585
+ async function cmdSpecialist(args) {
4586
+ const sub = args[0];
4587
+ if (sub === 'lint') return cmdLintPrompts();
4613
4588
 
4614
- if (sub === 'verify') {
4615
- const archivePath = args[1];
4616
- if (!archivePath) { errorln('Usage: construct backup verify <archive>'); process.exit(1); }
4617
- const { verifyBackup } = await import('../lib/storage/backup.mjs');
4618
- const result = await verifyBackup(archivePath);
4619
- if (result.ok) {
4620
- ok('Backup verifiedall checksums match.');
4621
- } else {
4622
- errorln(`Backup verification failed:\n ${result.errors.join('\n ')}`);
4623
- process.exit(1);
4624
- }
4589
+ if (sub === 'create') {
4590
+ const role = args[1];
4591
+ if (!role) { errorln('Usage: construct specialist create <role>'); process.exit(1); }
4592
+ const { createSpecialistDraft } = await import('../lib/specialists/scaffold.mjs');
4593
+ try {
4594
+ const { relPath } = createSpecialistDraft({ rootDir: ROOT_DIR, role });
4595
+ println(`Created ${relPath}fill in the stubbed sections, add it to specialists/registry.json, then run \`construct specialist lint\`.`);
4596
+ } catch (e) { errorln(e.message); process.exit(1); }
4625
4597
  return;
4626
4598
  }
4627
4599
 
4628
- if (sub === 'restore') {
4629
- const archivePath = args[1];
4630
- const yes = args.includes('--yes');
4631
- if (!archivePath) { errorln('Usage: construct backup restore <archive> [--yes]'); process.exit(1); }
4632
- if (!yes) {
4633
- println(`About to restore from: ${archivePath}`);
4634
- println('This will overwrite observations, sessions, config.env, and registry.json.');
4635
- println('Press Ctrl+C to cancel, or re-run with --yes to proceed.');
4636
- await new Promise((r) => setTimeout(r, 3000));
4637
- }
4638
- const { restoreBackup } = await import('../lib/storage/backup.mjs');
4639
- const result = await restoreBackup(archivePath, { yes });
4640
- if (result.ok) {
4641
- ok(`Restored: ${result.restored.join(', ')}`);
4642
- } else {
4643
- if (result.restored.length) println(`Partially restored: ${result.restored.join(', ')}`);
4644
- errorln(`Errors:\n ${result.errors.join('\n ')}`);
4645
- process.exit(1);
4600
+ if (sub === 'edit') {
4601
+ const role = args[1];
4602
+ if (!role) { errorln('Usage: construct specialist edit <role> [--set-bias=… --set-tension=… --add-overlay=… --bump-version]'); process.exit(1); }
4603
+ const { editSpecialistFrontmatter } = await import('../lib/specialists/scaffold.mjs');
4604
+ const setPerspective = {};
4605
+ for (const f of ['bias', 'tension', 'openingQuestion', 'failureMode']) {
4606
+ const a = args.find((x) => x.startsWith(`--set-${f}=`));
4607
+ if (a) setPerspective[f] = a.slice(`--set-${f}=`.length);
4646
4608
  }
4609
+ const overlayArg = args.find((x) => x.startsWith('--add-overlay='));
4610
+ try {
4611
+ const { relPath } = editSpecialistFrontmatter({
4612
+ rootDir: ROOT_DIR, role, setPerspective,
4613
+ addOverlay: overlayArg ? overlayArg.slice('--add-overlay='.length) : undefined,
4614
+ bumpVersion: args.includes('--bump-version'),
4615
+ });
4616
+ println(`Updated ${relPath || `specialists/prompts/cx-${role}.md`} — run \`construct specialist lint\` to confirm.`);
4617
+ } catch (e) { errorln(e.message); process.exit(1); }
4647
4618
  return;
4648
4619
  }
4649
4620
 
4650
- if (sub === 'list') {
4651
- const { listBackups } = await import('../lib/storage/backup.mjs');
4652
- const backups = listBackups();
4653
- if (!backups.length) { println('No backups found.'); return; }
4654
- for (const b of backups) {
4655
- const mb = (b.size / 1024 / 1024).toFixed(1);
4656
- println(` ${b.name} (${mb} MB) ${b.mtime.toISOString().slice(0, 19)}`);
4657
- }
4658
- return;
4659
- }
4621
+ errorln(`Unknown specialist subcommand: ${sub || '(none)'}. Available: create, edit, lint`);
4622
+ process.exit(1);
4623
+ }
4660
4624
 
4661
- errorln(`Unknown backup subcommand: ${sub}. Available: create, verify, restore, list, prune`);
4625
+ async function cmdBackup(args) {
4626
+ // Postgres-era backups were removed with the SQL backend (lib/storage/backup.mjs).
4627
+ // State is now Git-backed (.cx/) plus the local LanceDB index under .cx/lancedb.
4628
+ void args;
4629
+ errorln('construct backup is no longer supported: Postgres was removed. Commit .cx/ for Git-backed state; the LanceDB index lives under .cx/lancedb.');
4662
4630
  process.exit(1);
4663
4631
  }
4664
4632
 
@@ -5200,8 +5168,19 @@ async function cmdHook(args) {
5200
5168
  errorln(`Hook not found: ${hookPath}`);
5201
5169
  process.exit(1);
5202
5170
  }
5171
+ const started = Date.now();
5203
5172
  const result = spawnSync(process.execPath, [hookPath, ...args.slice(1)], { stdio: 'inherit' });
5204
- process.exit(result.status ?? 0);
5173
+ const exitCode = result.status ?? 0;
5174
+
5175
+ // Record fire + outcome so hooks can be judged on whether they ever gate
5176
+ // anything, not just on latency. Dynamic import keeps non-hook commands from
5177
+ // paying the load; a telemetry failure must never change the hook's exit code.
5178
+ try {
5179
+ const { logHookCall } = await import('../lib/telemetry/hook-calls.mjs');
5180
+ logHookCall({ hookId: safe, exitCode, latencyMs: Date.now() - started });
5181
+ } catch { /* telemetry is best-effort */ }
5182
+
5183
+ process.exit(exitCode);
5205
5184
  }
5206
5185
 
5207
5186
  const [command, ...rest] = process.argv.slice(2);
@@ -5257,6 +5236,7 @@ const handlers = new Map([
5257
5236
  ['infer', cmdInfer],
5258
5237
  ['search', cmdSearch],
5259
5238
  ['storage', cmdStorage],
5239
+ ['registry:status', cmdRegistryStatus],
5260
5240
  // Pricing / cost readouts are stubbed out: the ledger writes, model-pricing
5261
5241
  // catalog, and per-turn accounting still run, but no CLI surface exposes
5262
5242
  // them to the user. Handlers (cmdPricing, cmdCosts, cmdCost) remain in this
@@ -5440,6 +5420,8 @@ const handlers = new Map([
5440
5420
  ['lint:research', cmdLintResearch],
5441
5421
  ['lint:agents', cmdLintAgents],
5442
5422
  ['lint:contracts', cmdLintContracts],
5423
+ ['lint:prompts', cmdLintPrompts],
5424
+ ['specialist', cmdSpecialist],
5443
5425
  ['decisions', async (args) => {
5444
5426
  const { runDecisionsCli } = await import('../lib/decisions/registry.mjs');
5445
5427
  return runDecisionsCli(args);
@@ -22,7 +22,17 @@ import fs from 'node:fs';
22
22
  import path from 'node:path';
23
23
  import { createHash } from 'node:crypto';
24
24
 
25
- export const CONSTRUCT_INTEGRATION_VERSION = 1;
25
+ export const CONSTRUCT_INTEGRATION_VERSION = 2;
26
+
27
+ // AGENTS.md is the cross-platform single source (the agents.md standard read by
28
+ // Codex/OpenCode/etc.); CLAUDE.md gets a thin pointer whose `@AGENTS.md` import
29
+ // makes Claude Code load the same content at session start — the pattern the
30
+ // Claude Code memory docs recommend for repos that carry both files. One mapping,
31
+ // shared by init and reconcile, so the two writers cannot disagree.
32
+
33
+ export function variantForFile(filePath) {
34
+ return path.basename(filePath) === 'CLAUDE.md' ? 'pointer' : 'full';
35
+ }
26
36
 
27
37
  const BEGIN_PREFIX = '<!-- BEGIN CONSTRUCT INTEGRATION';
28
38
  const END_MARKER = '<!-- END CONSTRUCT INTEGRATION -->';
@@ -37,7 +47,18 @@ function shortHash(body) {
37
47
  // with Construct (≤60 lines). When a Beads Integration block is already present
38
48
  // the tracker line defers to it rather than duplicating `bd` commands.
39
49
 
40
- export function buildConstructIntegrationBody({ hasBeadsBlock = false } = {}) {
50
+ export function buildConstructIntegrationBody({ hasBeadsBlock = false, variant = 'full' } = {}) {
51
+ if (variant === 'pointer') {
52
+ return [
53
+ '## Construct integration',
54
+ '',
55
+ '@AGENTS.md',
56
+ '',
57
+ 'The Construct integration guidance for this project lives in `AGENTS.md`, imported',
58
+ 'above so Claude Code loads it at session start. Address `@construct` and ask for the',
59
+ 'outcome — Construct routes to the right specialist chain.',
60
+ ].join('\n');
61
+ }
41
62
  const tracker = hasBeadsBlock
42
63
  ? '- **Tracker**: see the Beads Integration block below for `bd` commands.'
43
64
  : '- **Tracker**: use Beads (`bd`) for all task tracking — run `bd prime` for the workflow. Do not use ad-hoc TODO lists.';
@@ -78,11 +99,11 @@ export function injectConstructBlock(content, body, version = CONSTRUCT_INTEGRAT
78
99
  // (with `header`) when missing; otherwise injects/updates in place, preserving
79
100
  // all surrounding content. Writes only when the content changes.
80
101
 
81
- export function injectIntoAgentFile(filePath, { version = CONSTRUCT_INTEGRATION_VERSION, header = '' } = {}) {
102
+ export function injectIntoAgentFile(filePath, { version = CONSTRUCT_INTEGRATION_VERSION, header = '', variant = null } = {}) {
82
103
  const existed = fs.existsSync(filePath);
83
104
  const current = existed ? fs.readFileSync(filePath, 'utf8') : '';
84
105
  const hasBeadsBlock = BEADS_BLOCK_RE.test(current);
85
- const body = buildConstructIntegrationBody({ hasBeadsBlock });
106
+ const body = buildConstructIntegrationBody({ hasBeadsBlock, variant: variant ?? variantForFile(filePath) });
86
107
  const base = !existed && header ? (header.endsWith('\n') ? header : `${header}\n`) : current;
87
108
  const { content, action } = injectConstructBlock(base, body, version);
88
109
  const changed = !existed || action !== 'unchanged';