@geraldmaron/construct 1.0.18 → 1.0.20

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 (93) hide show
  1. package/README.md +8 -4
  2. package/bin/construct +105 -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 +57 -6
  6. package/lib/comment-lint.mjs +44 -0
  7. package/lib/config/schema.mjs +31 -1
  8. package/lib/contracts/validate.mjs +106 -0
  9. package/lib/decisions/enforced-baseline.json +25 -0
  10. package/lib/decisions/golden.mjs +87 -0
  11. package/lib/decisions/precedence.mjs +46 -0
  12. package/lib/decisions/registry.mjs +469 -0
  13. package/lib/deployment/parity-contract.mjs +148 -0
  14. package/lib/document-ingest.mjs +71 -6
  15. package/lib/embed/cli.mjs +11 -0
  16. package/lib/embed/conflict-detection.mjs +4 -4
  17. package/lib/embed/customer-profiles.mjs +1 -1
  18. package/lib/embed/reconcile.mjs +60 -0
  19. package/lib/embedded-contract/capability.mjs +3 -3
  20. package/lib/embedded-contract/contract-version.mjs +1 -1
  21. package/lib/embedded-contract/execution.mjs +184 -0
  22. package/lib/embedded-contract/index.mjs +16 -0
  23. package/lib/embedded-contract/ingest.mjs +61 -7
  24. package/lib/gates-audit.mjs +2 -2
  25. package/lib/hooks/_lib/output-mode.mjs +101 -0
  26. package/lib/hooks/config-protection.mjs +22 -3
  27. package/lib/hooks/guard-bash.mjs +1 -1
  28. package/lib/hooks/session-start.mjs +13 -1
  29. package/lib/ingest/provider-extract.mjs +200 -0
  30. package/lib/ingest/strategy.mjs +95 -0
  31. package/lib/init-docs.mjs +1 -0
  32. package/lib/mcp/server.mjs +72 -4
  33. package/lib/mcp/tools/embedded-contract.mjs +17 -1
  34. package/lib/mode-commands.mjs +6 -8
  35. package/lib/observation-store.mjs +16 -2
  36. package/lib/opencode-telemetry.mjs +1 -1
  37. package/lib/orchestration/run-store.mjs +82 -0
  38. package/lib/orchestration/runtime.mjs +240 -0
  39. package/lib/roles/cli.mjs +10 -2
  40. package/lib/roles/gateway.mjs +50 -1
  41. package/lib/scheduler/index.mjs +31 -0
  42. package/lib/server/index.mjs +13 -3
  43. package/lib/server/static/index.html +1 -1
  44. package/lib/setup.mjs +6 -0
  45. package/lib/storage/hybrid-query.mjs +49 -38
  46. package/lib/storage/rrf.mjs +42 -0
  47. package/lib/storage/vector-client.mjs +18 -3
  48. package/lib/telemetry/backends/local.mjs +1 -1
  49. package/lib/telemetry/skill-calls.mjs +1 -1
  50. package/lib/templates/visual-requirements.mjs +84 -0
  51. package/package.json +10 -1
  52. package/rules/common/comments.md +3 -0
  53. package/rules/common/no-fabrication.md +3 -0
  54. package/rules/common/precedence.md +19 -0
  55. package/rules/common/research-sources.md +32 -0
  56. package/rules/common/research.md +59 -2
  57. package/skills/roles/data-engineer.pipeline.md +13 -1
  58. package/skills/roles/debugger.md +9 -0
  59. package/skills/roles/designer.accessibility.md +13 -3
  60. package/skills/roles/designer.md +10 -0
  61. package/skills/roles/engineer.platform.md +8 -0
  62. package/skills/roles/operator.md +10 -1
  63. package/skills/roles/operator.release.md +8 -0
  64. package/skills/roles/operator.sre.md +10 -1
  65. package/skills/roles/orchestrator.md +9 -2
  66. package/skills/roles/product-manager.business-strategy.md +10 -1
  67. package/skills/roles/researcher.explorer.md +12 -1
  68. package/skills/roles/researcher.ux.md +13 -1
  69. package/skills/roles/reviewer.devil-advocate.md +14 -2
  70. package/skills/roles/reviewer.evaluator.md +17 -4
  71. package/skills/roles/reviewer.trace.md +12 -1
  72. package/skills/roles/security.legal-compliance.md +8 -0
  73. package/skills/roles/security.md +11 -0
  74. package/specialists/contracts.json +18 -0
  75. package/specialists/prompts/cx-researcher.md +4 -2
  76. package/templates/docs/backlog-proposal.md +1 -1
  77. package/templates/docs/customer-profile.md +1 -1
  78. package/templates/docs/evidence-brief.md +5 -1
  79. package/templates/docs/incident-report.md +37 -21
  80. package/templates/docs/prfaq.md +2 -2
  81. package/templates/docs/product-intelligence-report.md +1 -1
  82. package/templates/docs/research-brief.md +8 -6
  83. package/templates/docs/research-finding.md +32 -7
  84. package/templates/docs/rfc.md +13 -1
  85. package/templates/docs/runbook.md +20 -1
  86. package/templates/docs/signal-brief.md +4 -1
  87. package/templates/docs/skill-artifact.md +27 -7
  88. package/templates/docs/strategy.md +23 -2
  89. package/lib/bootstrap/lazy-install.mjs +0 -161
  90. package/lib/embed/jobs/vector-sync.mjs +0 -198
  91. package/lib/knowledge/postgres-search.mjs +0 -132
  92. package/lib/services/pattern-promotion-service.mjs +0 -167
  93. package/lib/storage/unified-storage.mjs +0 -550
package/README.md CHANGED
@@ -159,9 +159,11 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
159
159
  |---|---|
160
160
  | `construct capability` | Describe what this Construct install can do (embedded contract; read-only, secret-free) |
161
161
  | `construct claude:allow` | Manage Claude Code `permissions.allow` from the outside (auto-classifier blocks the agent from editing it) |
162
+ | `construct execution` | Resolve the execution-capability contract for an embedded workflow (orchestrated vs prompt-only; descriptive, not enforced) |
162
163
  | `construct hosts` | Show host support for Construct orchestration |
163
164
  | `construct mcp` | Manage MCP integrations |
164
165
  | `construct models` | Show or update model tier assignments |
166
+ | `construct orchestrate` | Construct-owned local orchestration runtime (Mode-A: single-process, filesystem-backed, no Docker) |
165
167
  | `construct plugin` | Manage external Construct plugin manifests |
166
168
 
167
169
  ### Integrations
@@ -177,16 +179,16 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
177
179
  | Command | What it does |
178
180
  |---|---|
179
181
  | `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 |
182
+ | `construct eval-datasets` | Sync scored traces from the telemetry backend into eval datasets for prompt regression testing |
181
183
  | `construct evals` | Show evaluator catalog for prompt and agent experiments |
182
184
  | `construct feedback:history` | Show recorded outcome ratings |
183
185
  | `construct feedback:record` | Record an outcome rating for a recent specialist invocation |
184
186
  | `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 |
187
+ | `construct optimize` | Prompt optimization using telemetry trace quality scores |
188
+ | `construct review` | Generate agent performance review from the configured telemetry trace backend |
187
189
  | `construct telemetry` | Query telemetry traces and latency data |
188
190
  | `construct telemetry-backfill` | Backfill sparse traces with observations (trace backend) |
189
- | `construct telemetry-setup` | Configure Langfuse credentials and trace export |
191
+ | `construct telemetry-setup` | Configure telemetry backend credentials and trace export (OTLP or Langfuse-compatible) |
190
192
 
191
193
  ### Diagnostics
192
194
 
@@ -212,6 +214,8 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
212
214
  | `construct ci` | Local CI mirror: run CI jobs locally or view recent run status |
213
215
  | `construct completions` | Shell completion scripts |
214
216
  | `construct config` | Deployment mode configuration |
217
+ | `construct decisions` | Index load-bearing decisions and their enforcement bindings |
218
+ | `construct deployment` | Deployment posture tools (capability parity contract) |
215
219
  | `construct diff` | Show agent changes since HEAD |
216
220
  | `construct embed` | Embed mode management |
217
221
  | `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);
@@ -3325,6 +3335,83 @@ async function cmdValidate() {
3325
3335
  process.exit(1);
3326
3336
  }
3327
3337
 
3338
+ async function cmdExecution(args) {
3339
+ if (args[0] === 'resolve') {
3340
+ const flag = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : undefined; };
3341
+ const { resolveExecution } = await import('../lib/embedded-contract/execution.mjs');
3342
+ const { wrapContractResult } = await import('../lib/embedded-contract/envelope.mjs');
3343
+ const request = {
3344
+ workflowType: flag('--workflow-type'),
3345
+ requestedStrategy: flag('--strategy'),
3346
+ useConstruct: !args.includes('--no-construct'),
3347
+ host: flag('--host'),
3348
+ hostModel: flag('--host-model'),
3349
+ hostProvider: flag('--host-provider'),
3350
+ requestedTier: flag('--tier'),
3351
+ capabilities: flag('--capabilities')?.split(',').map((s) => s.trim()).filter(Boolean),
3352
+ allowCrossProviderFallback: args.includes('--allow-cross-provider'),
3353
+ };
3354
+ const envelope = wrapContractResult(resolveExecution(request), { surface: 'cli' });
3355
+ println(JSON.stringify(envelope, null, 2));
3356
+ return;
3357
+ }
3358
+ println('Usage: construct execution resolve [--workflow-type T] [--strategy orchestrated|prompt-only|auto] [--host-model M] [--host-provider P] [--tier reasoning|standard|fast] [--no-construct] [--allow-cross-provider] [--json]');
3359
+ }
3360
+
3361
+ async function cmdOrchestrate(args) {
3362
+ const sub = args[0];
3363
+ const rest = args.slice(1);
3364
+ const flag = (name) => { const i = rest.indexOf(name); return i !== -1 ? rest[i + 1] : undefined; };
3365
+ const wantsJson = rest.includes('--json') || args.includes('--json');
3366
+
3367
+ if (sub === 'run') {
3368
+ const { runOrchestration, planRun, hostAdapterMetadata } = await import('../lib/orchestration/runtime.mjs');
3369
+ const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1].startsWith('--') && !['--no-construct', '--no-execute', '--json'].includes(rest[i - 1])));
3370
+ const text = flag('--text') || positional.join(' ').trim();
3371
+ if (!text) { errorln('Usage: construct orchestrate run "<request>" [--workflow-type T] [--strategy orchestrated|prompt-only|auto] [--host H] [--host-model M] [--no-construct] [--no-execute] [--json]'); process.exit(1); }
3372
+ const request = {
3373
+ request: text,
3374
+ workflowType: flag('--workflow-type'),
3375
+ requestedStrategy: flag('--strategy') || 'auto',
3376
+ useConstruct: !rest.includes('--no-construct'),
3377
+ host: flag('--host'),
3378
+ hostModel: flag('--host-model'),
3379
+ hostProvider: flag('--host-provider'),
3380
+ fileCount: Number(flag('--file-count') || 0),
3381
+ moduleCount: Number(flag('--module-count') || 0),
3382
+ };
3383
+ const run = rest.includes('--no-execute') ? planRun(request, {}) : runOrchestration(request, {});
3384
+ if (wantsJson) { println(JSON.stringify(hostAdapterMetadata(run), null, 2)); return; }
3385
+ println(`Run ${run.runId} — ${run.status} · executionMode=${run.execution.executionMode} · backend=${run.workerBackend} · hostRole=${run.hostRole}`);
3386
+ if (run.execution.degraded) println(` degraded: ${run.execution.degradationReason}`);
3387
+ if (run.tasks.length) println(` tasks: ${run.tasks.map((t) => `${t.role}(${t.status})`).join(' → ')}`);
3388
+ else println(' tasks: none (no Construct-owned specialist sequence for this execution mode)');
3389
+ return;
3390
+ }
3391
+
3392
+ if (sub === 'status') {
3393
+ const { getRun, getRuns, hostAdapterMetadata } = await import('../lib/orchestration/runtime.mjs');
3394
+ const runId = rest.find((a) => !a.startsWith('--'));
3395
+ if (runId) {
3396
+ const run = getRun(process.cwd(), runId);
3397
+ if (!run) { errorln(`Run not found: ${runId}`); process.exit(1); }
3398
+ if (wantsJson) { println(JSON.stringify(hostAdapterMetadata(run), null, 2)); return; }
3399
+ println(`Run ${run.runId} — ${run.status} · executionMode=${run.execution.executionMode}`);
3400
+ for (const t of run.tasks) println(` ${t.id} ${t.role} — ${t.status}`);
3401
+ return;
3402
+ }
3403
+ const runs = getRuns(process.cwd());
3404
+ if (wantsJson) { println(JSON.stringify(runs, null, 2)); return; }
3405
+ if (!runs.length) { println('No orchestration runs recorded.'); return; }
3406
+ for (const r of runs) println(` ${r.runId} — ${r.status} · ${r.executionMode || ''} · ${r.createdAt}`);
3407
+ return;
3408
+ }
3409
+
3410
+ println('Usage: construct orchestrate <run|status> [options]');
3411
+ println(' run "<request>" [--workflow-type T] [--strategy S] [--host H] [--host-model M] [--no-construct] [--no-execute] [--json]');
3412
+ println(' status [run-id] [--json]');
3413
+ }
3414
+
3328
3415
  async function cmdModels(args) {
3329
3416
  if (args[0] === 'resolve') {
3330
3417
  const flag = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : undefined; };
@@ -4097,7 +4184,7 @@ async function cmdCiPreview(args) {
4097
4184
  const jobsSection = jobsStart >= 0 ? yml.slice(jobsStart + 1) : '';
4098
4185
 
4099
4186
  // Find all top-level job keys (2-space indent) and slice blocks between them.
4100
- const jobKeyPattern = /^ (\w[\w-]*):\s*$/gm;
4187
+ const jobKeyPattern = /^ {2}(\w[\w-]*):\s*$/gm;
4101
4188
  const jobPositions = [];
4102
4189
  let km;
4103
4190
  while ((km = jobKeyPattern.exec(jobsSection)) !== null) {
@@ -4111,7 +4198,7 @@ async function cmdCiPreview(args) {
4111
4198
  const end = jobPositions[i + 1]?.start ?? jobsSection.length;
4112
4199
  const block = jobsSection.slice(start, end);
4113
4200
 
4114
- const nameMatch = block.match(/^ name:\s*(.+)\s*$/m);
4201
+ const nameMatch = block.match(/^ {4}name:\s*(.+)\s*$/m);
4115
4202
  const displayName = nameMatch
4116
4203
  ? nameMatch[1].replace(/\$\{\{[^}]+\}\}/g, '<matrix>').trim()
4117
4204
  : id;
@@ -5030,6 +5117,8 @@ const handlers = new Map([
5030
5117
  ['docs:reconcile', cmdDocsReconcile],
5031
5118
  ['init:update', cmdInitUpdate],
5032
5119
  ['models', cmdModels],
5120
+ ['execution', cmdExecution],
5121
+ ['orchestrate', cmdOrchestrate],
5033
5122
  ['beads:stats', async (args) => {
5034
5123
  const { getContentionStats, getHumanStatus } = await import('../lib/beads-optimistic.mjs');
5035
5124
  const jsonOutput = args.includes('--json');
@@ -5173,6 +5262,19 @@ const handlers = new Map([
5173
5262
  ['lint:research', cmdLintResearch],
5174
5263
  ['lint:agents', cmdLintAgents],
5175
5264
  ['lint:contracts', cmdLintContracts],
5265
+ ['decisions', async (args) => {
5266
+ const { runDecisionsCli } = await import('../lib/decisions/registry.mjs');
5267
+ return runDecisionsCli(args);
5268
+ }],
5269
+ ['deployment', async (args) => {
5270
+ const sub = args[0];
5271
+ if (sub === 'parity') {
5272
+ const { runDeploymentParityCli } = await import('../lib/deployment/parity-contract.mjs');
5273
+ return runDeploymentParityCli(args.slice(1));
5274
+ }
5275
+ errorln(`Unknown deployment subcommand: ${sub || '(none)'}. Available: parity`);
5276
+ process.exit(1);
5277
+ }],
5176
5278
  ['audit', async (args) => {
5177
5279
  const sub = args[0];
5178
5280
  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
@@ -250,7 +250,7 @@ export const CLI_COMMANDS = [
250
250
  category: 'Work',
251
251
  core: false,
252
252
  description: 'Convert documents to indexed markdown',
253
- usage: 'construct ingest <file> [--strict] [--legacy-extractor]',
253
+ usage: 'construct ingest <file> [--strategy=adapter|provider] [--strict] [--legacy-extractor]',
254
254
  },
255
255
  {
256
256
  name: 'infer',
@@ -323,6 +323,29 @@ export const CLI_COMMANDS = [
323
323
  { name: 'describe --json', desc: 'Emit versions, interfaces, roles, skills, workflows, schemas, models, policies, telemetry, plugins' },
324
324
  ],
325
325
  },
326
+ {
327
+ name: 'execution',
328
+ emoji: '🪢',
329
+ category: 'Models & Integrations',
330
+ core: false,
331
+ description: 'Resolve the execution-capability contract for an embedded workflow (orchestrated vs prompt-only; descriptive, not enforced)',
332
+ usage: 'construct execution resolve --json',
333
+ subcommands: [
334
+ { name: 'resolve --json', desc: 'Report executionMode, active Construct capabilities, and any degradation given host/strategy context' },
335
+ ],
336
+ },
337
+ {
338
+ name: 'orchestrate',
339
+ emoji: '🎼',
340
+ category: 'Models & Integrations',
341
+ core: false,
342
+ description: 'Construct-owned local orchestration runtime (Mode-A: single-process, filesystem-backed, no Docker)',
343
+ usage: 'construct orchestrate <run|status> [options]',
344
+ subcommands: [
345
+ { name: 'run "<request>" [--strategy S] [--host H] [--no-construct] [--no-execute] [--json]', desc: 'Plan and run a request through a Construct-owned specialist chain; returns host-adapter metadata' },
346
+ { name: 'status [run-id] [--json]', desc: 'Inspect a run, or list recent runs' },
347
+ ],
348
+ },
326
349
  {
327
350
  name: 'mcp',
328
351
  emoji: '🔌',
@@ -373,7 +396,7 @@ export const CLI_COMMANDS = [
373
396
  emoji: '📈',
374
397
  category: 'Observability',
375
398
  core: false,
376
- description: 'Generate agent performance review from Langfuse trace backend',
399
+ description: 'Generate agent performance review from the configured telemetry trace backend',
377
400
  usage: 'construct review [--agent=<id>] [--days=N]',
378
401
  },
379
402
  {
@@ -381,7 +404,7 @@ export const CLI_COMMANDS = [
381
404
  emoji: '⚙️',
382
405
  category: 'Observability',
383
406
  core: false,
384
- description: 'Prompt optimization using Langfuse trace quality scores',
407
+ description: 'Prompt optimization using telemetry trace quality scores',
385
408
  usage: 'construct optimize <agent>',
386
409
  },
387
410
  {
@@ -397,7 +420,7 @@ export const CLI_COMMANDS = [
397
420
  emoji: '📊',
398
421
  category: 'Observability',
399
422
  core: false,
400
- description: 'Sync scored Langfuse traces into eval datasets for prompt regression testing',
423
+ description: 'Sync scored traces from the telemetry backend into eval datasets for prompt regression testing',
401
424
  usage: 'construct eval-datasets',
402
425
  },
403
426
  {
@@ -580,7 +603,7 @@ export const CLI_COMMANDS = [
580
603
  category: 'Advanced',
581
604
  core: false,
582
605
  description: 'Role framework management',
583
- usage: 'construct role <list|set|latest>',
606
+ usage: 'construct role <list|latest|show|status|resolve|prune|reset>',
584
607
  },
585
608
  {
586
609
  name: 'embed',
@@ -646,6 +669,34 @@ export const CLI_COMMANDS = [
646
669
  description: 'Audit policy gates',
647
670
  usage: 'construct gates:audit',
648
671
  },
672
+ {
673
+ name: 'decisions',
674
+ emoji: '🧭',
675
+ category: 'Advanced',
676
+ core: false,
677
+ description: 'Index load-bearing decisions and their enforcement bindings',
678
+ usage: 'construct decisions [list|validate|json|check|baseline|golden]',
679
+ options: [
680
+ { flag: 'list', desc: 'Show decisions with status and enforcement (default)' },
681
+ { flag: 'validate', desc: 'Validate registry structure; exit 1 on error' },
682
+ { flag: 'check', desc: 'Fail on dangling markers, enforcement/supersede/linkage/precedence drift' },
683
+ { flag: 'baseline', desc: 'Print the enforced baseline; --write to regenerate it' },
684
+ { flag: 'golden', desc: 'Check the CLI/agent/hook surface snapshot; --write to regenerate it' },
685
+ { flag: 'json', desc: 'Emit the full registry as JSON' },
686
+ ],
687
+ },
688
+ {
689
+ name: 'deployment',
690
+ emoji: '🛰️',
691
+ category: 'Advanced',
692
+ core: false,
693
+ description: 'Deployment posture tools (capability parity contract)',
694
+ usage: 'construct deployment parity',
695
+ options: [
696
+ { flag: 'parity', desc: 'Show and validate capability parity across solo/team/enterprise' },
697
+ { flag: '--json', desc: 'Emit the parity contract as JSON' },
698
+ ],
699
+ },
649
700
  {
650
701
  name: 'policy',
651
702
  emoji: '🔒',
@@ -838,7 +889,7 @@ export const CLI_COMMANDS = [
838
889
  emoji: '🩹',
839
890
  category: 'Observability',
840
891
  core: false,
841
- description: 'Configure Langfuse credentials and trace export',
892
+ description: 'Configure telemetry backend credentials and trace export (OTLP or Langfuse-compatible)',
842
893
  usage: 'construct telemetry-setup',
843
894
  },
844
895
  // ── 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;
@@ -5,7 +5,7 @@
5
5
  * AJV/zod to keep Construct dependency-free at startup — the loader runs
6
6
  * before npm install completes in some bootstrap paths.
7
7
  *
8
- * Schema v1 covers: alias, deployment, providers, autoEmbed, telemetry.
8
+ * Schema v1 covers: alias, deployment, providers, autoEmbed, ingest, telemetry.
9
9
  * Tier model selection lives in `specialists/registry.json` only — this config
10
10
  * file is intentionally not a second edit surface for model assignments.
11
11
  * Later phases (6, 7b) extend it with `resources` and `costs` blocks
@@ -24,6 +24,14 @@ export const DEFAULT_PROFILE_ID = 'rnd';
24
24
 
25
25
  export const SURFACES = ['claude', 'opencode', 'codex', 'copilot', 'vscode', 'cursor'];
26
26
 
27
+ export const INGEST_STRATEGIES = ['adapter', 'provider'];
28
+ export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
29
+
30
+ // SessionStart context routing. `auto` keeps the rich payload on stdout for
31
+ // interactive sessions and suppresses it (to a debug log) for non-interactive /
32
+ // SDK invocations, so a one-shot command's stdout stays reserved for its output.
33
+ export const HOOK_OUTPUT_MODES = ['auto', 'silent', 'stderr', 'stdout'];
34
+
27
35
  export const DEFAULT_PROJECT_CONFIG = Object.freeze({
28
36
  version: CONFIG_SCHEMA_VERSION,
29
37
  alias: 'Construct',
@@ -36,9 +44,16 @@ export const DEFAULT_PROJECT_CONFIG = Object.freeze({
36
44
  providers: Object.freeze({}),
37
45
  profile: DEFAULT_PROFILE_ID,
38
46
  autoEmbed: false,
47
+ ingest: Object.freeze({
48
+ strategy: 'adapter',
49
+ fallback: 'none',
50
+ }),
39
51
  telemetry: Object.freeze({
40
52
  enabled: true,
41
53
  }),
54
+ hooks: Object.freeze({
55
+ outputMode: 'auto',
56
+ }),
42
57
  roleSelection: Object.freeze({
43
58
  primary: null,
44
59
  secondary: null,
@@ -84,6 +99,14 @@ export const FIELD_RULES = {
84
99
  providers: { type: 'object', required: false },
85
100
  profile: { type: 'string', required: false, maxLength: 40 },
86
101
  autoEmbed: { type: 'boolean', required: false },
102
+ ingest: {
103
+ type: 'object',
104
+ required: false,
105
+ fields: {
106
+ strategy: { type: 'string', enum: INGEST_STRATEGIES },
107
+ fallback: { type: 'string', enum: INGEST_FALLBACKS },
108
+ },
109
+ },
87
110
  telemetry: {
88
111
  type: 'object',
89
112
  required: false,
@@ -91,6 +114,13 @@ export const FIELD_RULES = {
91
114
  enabled: { type: 'boolean' },
92
115
  },
93
116
  },
117
+ hooks: {
118
+ type: 'object',
119
+ required: false,
120
+ fields: {
121
+ outputMode: { type: 'string', enum: HOOK_OUTPUT_MODES },
122
+ },
123
+ },
94
124
  roleSelection: {
95
125
  type: 'object',
96
126
  required: false,
@@ -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,25 @@
1
+ {
2
+ "enforced": [
3
+ "ADR-0015",
4
+ "ADR-0016",
5
+ "ADR-0017",
6
+ "ADR-0018",
7
+ "ADR-0019",
8
+ "ADR-0020",
9
+ "any-to-business-strategist",
10
+ "any-to-explorer",
11
+ "architect-to-data-engineer",
12
+ "architect-to-evaluator",
13
+ "architect-to-legal-compliance",
14
+ "construct-to-rd-lead",
15
+ "product-manager-to-architect",
16
+ "product-manager-to-data-analyst",
17
+ "product-manager-to-ux-researcher",
18
+ "qa-to-test-automation",
19
+ "researcher-to-product-manager",
20
+ "rule:common/comments",
21
+ "rule:common/no-fabrication",
22
+ "rule:common/precedence",
23
+ "rule:common/research-sources"
24
+ ]
25
+ }