@geraldmaron/construct 1.0.20 → 1.0.23

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 (124) hide show
  1. package/README.md +23 -5
  2. package/bin/construct +194 -16
  3. package/bin/construct-postinstall.mjs +25 -15
  4. package/lib/acp/server.mjs +113 -0
  5. package/lib/agent-instructions/inject.mjs +94 -0
  6. package/lib/auto-docs.mjs +10 -2
  7. package/lib/cli-commands.mjs +44 -16
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +22 -0
  11. package/lib/decisions/enforced-baseline.json +3 -0
  12. package/lib/docs-verify.mjs +15 -18
  13. package/lib/doctor/cli.mjs +8 -1
  14. package/lib/document-export.mjs +124 -0
  15. package/lib/document-ingest.mjs +13 -4
  16. package/lib/embed/daemon.mjs +1 -1
  17. package/lib/embed/inbox.mjs +25 -7
  18. package/lib/embedded-contract/triage.mjs +24 -2
  19. package/lib/embedded-contract/workflow-invoke.mjs +21 -0
  20. package/lib/features.mjs +11 -11
  21. package/lib/git-hooks-path.mjs +61 -0
  22. package/lib/home-namespace.mjs +60 -0
  23. package/lib/hooks/ci-status-check.mjs +62 -40
  24. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  25. package/lib/hooks/pre-push-gate.mjs +15 -6
  26. package/lib/hooks/stop-notify.mjs +32 -17
  27. package/lib/hooks/stop-typecheck.mjs +7 -2
  28. package/lib/host-capabilities.mjs +24 -8
  29. package/lib/host-disposition.mjs +76 -0
  30. package/lib/ingest/provider-extract.mjs +1 -1
  31. package/lib/ingest/strategy.mjs +35 -3
  32. package/lib/init-docs.mjs +1 -1
  33. package/lib/init-unified.mjs +320 -219
  34. package/lib/init-update.mjs +4 -84
  35. package/lib/init.mjs +9 -25
  36. package/lib/install/stage-project.mjs +8 -2
  37. package/lib/intent-classifier.mjs +1 -1
  38. package/lib/knowledge/search.mjs +52 -3
  39. package/lib/mcp/server.mjs +57 -14
  40. package/lib/mcp/tools/memory.mjs +2 -2
  41. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  42. package/lib/model-registry.mjs +40 -33
  43. package/lib/observation-store.mjs +6 -2
  44. package/lib/opencode-config.mjs +1 -1
  45. package/lib/orchestration/events.mjs +66 -0
  46. package/lib/orchestration/run-store-postgres.mjs +85 -0
  47. package/lib/orchestration/run-store-sqlite.mjs +122 -0
  48. package/lib/orchestration/runtime.mjs +188 -50
  49. package/lib/orchestration/store.mjs +102 -0
  50. package/lib/orchestration/worker.mjs +215 -0
  51. package/lib/orchestration-policy.mjs +27 -3
  52. package/lib/parity.mjs +80 -26
  53. package/lib/policy/unified-gates.mjs +96 -0
  54. package/lib/project-init-shared.mjs +0 -173
  55. package/lib/reconcile/adapter-prune.mjs +105 -0
  56. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  57. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  58. package/lib/reconcile/index.mjs +171 -0
  59. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  60. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  61. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  62. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  63. package/lib/reconcile/postgres-namespace.mjs +102 -0
  64. package/lib/runtime/uv-bootstrap.mjs +27 -3
  65. package/lib/schema-infer.mjs +16 -2
  66. package/lib/server/csrf.mjs +14 -2
  67. package/lib/server/index.mjs +95 -0
  68. package/lib/service-manager.mjs +39 -14
  69. package/lib/setup-prompts.mjs +2 -1
  70. package/lib/setup.mjs +165 -141
  71. package/lib/storage/backend.mjs +14 -0
  72. package/lib/storage/unified-storage.mjs +550 -0
  73. package/lib/template-registry.mjs +73 -0
  74. package/lib/term-format.mjs +75 -0
  75. package/lib/uninstall/uninstall.mjs +180 -7
  76. package/package.json +2 -2
  77. package/personas/construct.md +7 -8
  78. package/platforms/claude/settings.template.json +30 -4
  79. package/platforms/opencode/config.template.json +2 -2
  80. package/rules/common/neurodivergent-output.md +66 -0
  81. package/rules/common/tool-invisibility.md +37 -0
  82. package/scripts/sync-specialists.mjs +381 -95
  83. package/skills/operating/orchestration-reference.md +2 -16
  84. package/specialists/policy-inventory.json +14 -0
  85. package/specialists/prompts/cx-accessibility.md +2 -6
  86. package/specialists/prompts/cx-ai-engineer.md +0 -4
  87. package/specialists/prompts/cx-architect.md +3 -5
  88. package/specialists/prompts/cx-business-strategist.md +0 -5
  89. package/specialists/prompts/cx-data-analyst.md +0 -4
  90. package/specialists/prompts/cx-data-engineer.md +0 -4
  91. package/specialists/prompts/cx-debugger.md +2 -6
  92. package/specialists/prompts/cx-designer.md +0 -8
  93. package/specialists/prompts/cx-devil-advocate.md +2 -2
  94. package/specialists/prompts/cx-docs-keeper.md +0 -13
  95. package/specialists/prompts/cx-engineer.md +0 -13
  96. package/specialists/prompts/cx-evaluator.md +2 -2
  97. package/specialists/prompts/cx-explorer.md +4 -5
  98. package/specialists/prompts/cx-legal-compliance.md +4 -5
  99. package/specialists/prompts/cx-operations.md +0 -5
  100. package/specialists/prompts/cx-orchestrator.md +0 -4
  101. package/specialists/prompts/cx-platform-engineer.md +0 -8
  102. package/specialists/prompts/cx-product-manager.md +0 -8
  103. package/specialists/prompts/cx-qa.md +2 -11
  104. package/specialists/prompts/cx-rd-lead.md +0 -5
  105. package/specialists/prompts/cx-release-manager.md +0 -8
  106. package/specialists/prompts/cx-researcher.md +5 -29
  107. package/specialists/prompts/cx-reviewer.md +2 -6
  108. package/specialists/prompts/cx-security.md +2 -11
  109. package/specialists/prompts/cx-sre.md +0 -15
  110. package/specialists/prompts/cx-test-automation.md +0 -4
  111. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  112. package/specialists/prompts/cx-ux-researcher.md +0 -4
  113. package/specialists/registry.json +29 -29
  114. package/templates/distribution/run.mjs +36 -7
  115. package/templates/docs/accessibility-audit.md +56 -0
  116. package/templates/docs/architecture-review.md +59 -0
  117. package/templates/docs/code-review-report.md +46 -0
  118. package/templates/docs/construct_guide.md +14 -14
  119. package/templates/docs/debug-investigation.md +53 -0
  120. package/templates/docs/qa-report.md +48 -0
  121. package/templates/docs/security-audit-report.md +48 -0
  122. package/templates/docs/task-packet.md +49 -0
  123. package/templates/docs/verdict.md +40 -0
  124. package/lib/server/static/index.html +0 -1
@@ -14,7 +14,7 @@ import { syncFileStateToSql } from './storage/sync.mjs';
14
14
  import { stampFrontmatter } from './doc-stamp.mjs';
15
15
  import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
16
16
  import { loadProjectConfig } from './config/project-config.mjs';
17
- import { resolveIngestStrategy, INGEST_STRATEGIES } from './ingest/strategy.mjs';
17
+ import { resolveIngestStrategy, INGEST_STRATEGIES, INGEST_ORCHESTRATION_STRATEGIES } from './ingest/strategy.mjs';
18
18
  import { extractViaProvider } from './ingest/provider-extract.mjs';
19
19
 
20
20
  const DEFAULT_TARGET_DIR = '.cx/knowledge/internal';
@@ -167,6 +167,7 @@ export async function ingestDocuments(inputPaths, {
167
167
  strict = false,
168
168
  highFidelity = true,
169
169
  strategy = null,
170
+ orchestration = null,
170
171
  env = process.env,
171
172
  } = {}) {
172
173
  if (!Array.isArray(inputPaths) || inputPaths.length === 0) {
@@ -182,7 +183,7 @@ export async function ingestDocuments(inputPaths, {
182
183
  }
183
184
 
184
185
  const { config } = loadProjectConfig(cwd, env);
185
- const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy });
186
+ const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy, orchestrationOverride: orchestration, cwd });
186
187
 
187
188
  const results = [];
188
189
  let totalDrops = 0;
@@ -266,9 +267,11 @@ export async function ingestDocuments(inputPaths, {
266
267
  ingestion: {
267
268
  strategy: resolvedStrategy.strategy,
268
269
  fallback: resolvedStrategy.fallback,
270
+ orchestration: resolvedStrategy.orchestration,
269
271
  model: resolvedStrategy.model,
270
272
  provider: resolvedStrategy.provider,
271
273
  fallbackApplied,
274
+ execution: resolvedStrategy.execution,
272
275
  },
273
276
  };
274
277
  }
@@ -282,6 +285,7 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
282
285
  let strict = false;
283
286
  let highFidelity = true;
284
287
  let strategy = null;
288
+ let orchestration = null;
285
289
 
286
290
  for (const arg of argv) {
287
291
  if (arg.startsWith('--out=')) outputPath = arg.split('=').slice(1).join('=');
@@ -289,6 +293,7 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
289
293
  else if (arg.startsWith('--target=')) target = arg.split('=').slice(1).join('=');
290
294
  else if (arg.startsWith('--strategy=')) strategy = arg.split('=').slice(1).join('=');
291
295
  else if (arg === '--strategy') strategy = '';
296
+ else if (arg.startsWith('--orchestration=')) orchestration = arg.split('=').slice(1).join('=');
292
297
  else if (arg === '--sync') sync = true;
293
298
  else if (arg === '--strict') strict = true;
294
299
  else if (arg === '--legacy-extractor') highFidelity = false;
@@ -299,8 +304,9 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
299
304
  if (inputs.length === 0) {
300
305
  throw new Error(
301
306
  `Usage: construct ingest <file-or-dir> [more paths] [--out=FILE] [--out-dir=DIR] ` +
302
- `[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--sync] [--strict] [--legacy-extractor]\n` +
307
+ `[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--orchestration=prompt-only|orchestrated] [--sync] [--strict] [--legacy-extractor]\n` +
303
308
  ` --strategy: extraction strategy override (adapter = local extractors; provider = configured provider/model)\n` +
309
+ ` --orchestration: prompt-only (deterministic extraction) or orchestrated (engage the specialist chain)\n` +
304
310
  ` --strict: exit non-zero if any extraction drops occur\n` +
305
311
  ` --legacy-extractor: use the pre-docling regex extractor (lower fidelity)\n` +
306
312
  ` knowledge subdirs: ${KNOWLEDGE_SUBDIRS.join(', ')}`,
@@ -309,6 +315,9 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
309
315
  if (strategy !== null && strategy !== '' && !INGEST_STRATEGIES.includes(strategy)) {
310
316
  throw new Error(`Unsupported strategy: ${strategy}. Valid strategies: ${INGEST_STRATEGIES.join(', ')}`);
311
317
  }
318
+ if (orchestration !== null && !INGEST_ORCHESTRATION_STRATEGIES.includes(orchestration)) {
319
+ throw new Error(`Unsupported orchestration: ${orchestration}. Valid values: ${INGEST_ORCHESTRATION_STRATEGIES.join(', ')}`);
320
+ }
312
321
  const knowledgeTargets = KNOWLEDGE_SUBDIRS.map((s) => `knowledge/${s}`);
313
322
  if (!['sibling', ...knowledgeTargets].includes(target)) {
314
323
  throw new Error(
@@ -316,5 +325,5 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
316
325
  );
317
326
  }
318
327
 
319
- return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, env });
328
+ return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, orchestration: orchestration || null, env });
320
329
  }
@@ -1077,7 +1077,7 @@ this.#scheduler.register(
1077
1077
  method: 'POST',
1078
1078
  headers: {
1079
1079
  Authorization: `Bearer ${orKey}`, 'content-type': 'application/json',
1080
- 'HTTP-Referer': 'https://github.com/construct',
1080
+ 'HTTP-Referer': 'https://github.com/geraldmaron/construct',
1081
1081
  },
1082
1082
  body: JSON.stringify({
1083
1083
  model: modelId.replace(/^openrouter\//, ''),
@@ -296,17 +296,22 @@ export class InboxWatcher {
296
296
  #rootDir;
297
297
  #env;
298
298
  #cwd;
299
+ #prepareIntakeFn;
299
300
 
300
301
  /**
301
302
  * @param {object} opts
302
303
  * @param {string} opts.rootDir - Root dir for observation store + state file
303
304
  * @param {object} [opts.env] - Env override (default: process.env)
304
305
  * @param {string} [opts.cwd] - Working dir for ingest output (default: rootDir)
306
+ * @param {Function} [opts.prepareIntakeFn] - Override for prepareIntakeForIngestedFile
307
+ * (test seam): receives the same args; returns null/throws to simulate a failed
308
+ * packet creation so tests can assert the manifest-gate behavior (construct-k4bg).
305
309
  */
306
- constructor({ rootDir, env = process.env, cwd } = {}) {
310
+ constructor({ rootDir, env = process.env, cwd, prepareIntakeFn } = {}) {
307
311
  this.#rootDir = rootDir;
308
312
  this.#env = env;
309
313
  this.#cwd = cwd ?? rootDir;
314
+ this.#prepareIntakeFn = prepareIntakeFn ?? prepareIntakeForIngestedFile;
310
315
  }
311
316
 
312
317
  /**
@@ -423,13 +428,15 @@ export class InboxWatcher {
423
428
  extractedText,
424
429
  });
425
430
 
426
- // Queue an intake packet so the agent can triage the new
427
- // signal against the existing corpus. Failure here is
428
- // non-fatal — ingest already succeeded; the queue is a
429
- // best-effort handoff, not the ingestion's source of truth.
431
+ // Queue an intake packet so the agent can triage the new signal
432
+ // against the existing corpus. Failure here is non-fatal — ingest
433
+ // already succeeded; the queue is a best-effort handoff, not the
434
+ // ingestion's source of truth.
435
+
430
436
  let intakeId = null;
437
+ let intakeFailed = false;
431
438
  try {
432
- const intakeResult = await prepareIntakeForIngestedFile({
439
+ const intakeResult = await this.#prepareIntakeFn({
433
440
  rootDir: this.#rootDir,
434
441
  ingestedFile: {
435
442
  sourcePath: candidate.path,
@@ -441,7 +448,9 @@ export class InboxWatcher {
441
448
  env: this.#env,
442
449
  });
443
450
  intakeId = intakeResult?.id || null;
451
+ if (!intakeId) intakeFailed = true;
444
452
  } catch (err) {
453
+ intakeFailed = true;
445
454
  if (this.#env.CONSTRUCT_DEBUG_INTAKE === '1') {
446
455
  process.stderr.write(`[inbox] intake queue write failed for ${candidate.path}: ${err.message}\n`);
447
456
  }
@@ -449,7 +458,14 @@ export class InboxWatcher {
449
458
 
450
459
  state[key] = { mtimeMs: candidate.mtimeMs, processedAt: new Date().toISOString(), outputPath: fileResult.outputPath };
451
460
 
452
- if (manifestEnabled && candidateSha) {
461
+ // The dedup manifest entry is the signal that `construct intake
462
+ // process` uses to skip already-handled files. Recording it when
463
+ // packet creation failed strands the file: explicit `intake process`
464
+ // reports "skipped (unchanged)" while `intake list` is empty, with
465
+ // no path back. Record only on a successful intake id so a failed
466
+ // auto-ingest stays retriable via the explicit path (construct-k4bg).
467
+
468
+ if (manifestEnabled && candidateSha && intakeId && !intakeFailed) {
453
469
  try {
454
470
  recordFile(this.#rootDir, candidateSha, {
455
471
  sourcePath: candidate.path,
@@ -462,6 +478,8 @@ export class InboxWatcher {
462
478
  process.stderr.write(`[inbox] manifest record failed for ${candidate.path}: ${err.message}\n`);
463
479
  }
464
480
  }
481
+ } else if (manifestEnabled && intakeFailed) {
482
+ process.stderr.write(`[inbox] ${candidate.path}: ingest succeeded but intake-packet creation failed; rerun \`construct intake process\` to retry.\n`);
465
483
  }
466
484
 
467
485
  processed.push({ path: candidate.path, outputPath: fileResult.outputPath, docsPath, characters: fileResult.characters, knowledgeSubdir, intakeId, dedupHash: candidateSha });
@@ -16,6 +16,7 @@ import { classifyRdIntake } from '../intake/classify.mjs';
16
16
  import { getDeploymentMode } from '../deployment-mode.mjs';
17
17
  import { roleMap, roleRationale, skillsForChain, contractFacts } from './role-facts.mjs';
18
18
  import { workflowTypeForIntake } from './workflow-defs.mjs';
19
+ import { resolveExecution } from './execution.mjs';
19
20
 
20
21
  const CONFIDENCE_FLOOR = 0.6;
21
22
 
@@ -46,7 +47,7 @@ function buildRiskFactors(triage) {
46
47
  * @returns {object}
47
48
  */
48
49
  export function recommendPlan(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
49
- const { input = '', sourcePath = '', artifactType, availableRoles, profile = null, ingestion = null } = request;
50
+ const { input = '', sourcePath = '', artifactType, availableRoles, profile = null, ingestion = null, host, hostModel, hostProvider, constructStrategy = 'auto' } = request;
50
51
  const warnings = [];
51
52
 
52
53
  // Surface extraction provenance and flag low-yield/truncated inputs so a
@@ -109,6 +110,26 @@ export function recommendPlan(request = {}, { env = process.env, cwd = process.c
109
110
  if (canExecute) nextStepOptions.push({ action: 'invoke-workflow', description: 'Invoke the recommended role chain as an embedded workflow.' });
110
111
  if (!canExecute) nextStepOptions.push({ action: 'clarify', description: 'Request more detail; classification confidence is low or unknown.' });
111
112
 
113
+ const suggestedWorkflowType = workflowTypeForIntake(triage.intakeType);
114
+
115
+ // Execution preview: forecast the executionMode an invocation of the suggested
116
+ // workflow would resolve to, but only when the host supplies context — absent
117
+ // it, forcing a model resolution on every triage call would be wasteful and
118
+ // the preview would be uninformative. null keeps the field parity-stable.
119
+ let execution = null;
120
+ if (suggestedWorkflowType && (hostModel || hostProvider || (constructStrategy && constructStrategy !== 'auto'))) {
121
+ const e = resolveExecution({ workflowType: suggestedWorkflowType, requestedStrategy: constructStrategy, host, hostModel, hostProvider }, { env, cwd });
122
+ execution = {
123
+ executionMode: e.executionMode,
124
+ effectiveStrategy: e.effectiveStrategy,
125
+ requestedStrategy: e.requestedStrategy,
126
+ constructCapabilitiesActive: e.constructCapabilitiesActive,
127
+ degraded: e.degraded,
128
+ degradationReason: e.degradationReason,
129
+ semantics: e.semantics,
130
+ };
131
+ }
132
+
112
133
  return {
113
134
  classification: { intakeType: triage.intakeType, rdStage: triage.rdStage },
114
135
  ingestion,
@@ -117,7 +138,8 @@ export function recommendPlan(request = {}, { env = process.env, cwd = process.c
117
138
  primaryOwner,
118
139
  recommendedAction: triage.recommendedAction,
119
140
  recommendedChain: chain,
120
- suggestedWorkflowType: workflowTypeForIntake(triage.intakeType),
141
+ suggestedWorkflowType,
142
+ execution,
121
143
  roleRationale: rationale,
122
144
  suggestedSkills,
123
145
  evidenceRequirements,
@@ -26,6 +26,7 @@ import { recordApprovalRequest } from '../roles/approval-surface.mjs';
26
26
  import { getWorkflowDef, WORKFLOW_TYPES } from './workflow-defs.mjs';
27
27
  import { roleMap, roleRationale, skillsForChain, contractFacts } from './role-facts.mjs';
28
28
  import { resolveEmbeddedModel } from './model-resolve.mjs';
29
+ import { resolveExecution } from './execution.mjs';
29
30
  import { resolveWriteGate, newTraceId, DEFAULT_APPROVAL_MODE } from './audit.mjs';
30
31
 
31
32
  const VALID_STRATEGIES = ['auto', 'explicit', 'constrained'];
@@ -91,6 +92,7 @@ function errorResult({ workflowId, traceId, approvalMode, code, message, warning
91
92
  * @param {string} [request.host]
92
93
  * @param {string} [request.hostModel]
93
94
  * @param {string} [request.hostProvider]
95
+ * @param {string} [request.constructStrategy] orchestrated | prompt-only | auto (execution mode)
94
96
  * @param {object} [opts] { env, cwd }
95
97
  * @returns {Promise<object>}
96
98
  */
@@ -98,6 +100,7 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
98
100
  const {
99
101
  workflowType, context = {}, roleStrategy = 'auto', requestedRoles,
100
102
  approvalMode, trace = true, host, hostModel, hostProvider, ingestion = null,
103
+ constructStrategy = 'auto',
101
104
  } = request;
102
105
  const warnings = [];
103
106
  if (ingestion) {
@@ -129,6 +132,23 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
129
132
  );
130
133
  for (const w of modelWarnings) warnings.push(`model-resolution: ${w}`);
131
134
 
135
+ // The execution-capability contract reports the PLANNED executionMode for this
136
+ // run (descriptive, not enforced — ADR-0019). Construct returns a plan; the
137
+ // host runtime executes it, so this never claims observed specialist execution.
138
+ const executionData = resolveExecution(
139
+ { workflowType, requestedStrategy: constructStrategy, host, hostModel, hostProvider, requestedTier: def.tier },
140
+ { env, cwd },
141
+ );
142
+ const execution = {
143
+ executionMode: executionData.executionMode,
144
+ effectiveStrategy: executionData.effectiveStrategy,
145
+ requestedStrategy: executionData.requestedStrategy,
146
+ constructCapabilitiesActive: executionData.constructCapabilitiesActive,
147
+ degraded: executionData.degraded,
148
+ degradationReason: executionData.degradationReason,
149
+ semantics: executionData.semantics,
150
+ };
151
+
132
152
  const primaryOwner = selectedRoles[0];
133
153
  const facts = contractFacts(primaryOwner);
134
154
  const skillsApplied = skillsForChain(selectedRoles, map);
@@ -204,6 +224,7 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
204
224
  roleRationale: rationale,
205
225
  skillsApplied,
206
226
  modelResolution,
227
+ execution,
207
228
  outputs,
208
229
  recommendations,
209
230
  evidence: { requirements: facts.evidenceRequirements, satisfied, missing: missingEvidence, traceId },
package/lib/features.mjs CHANGED
@@ -63,27 +63,27 @@ function readOpenCodeConfig(overrides = {}) {
63
63
  return null;
64
64
  }
65
65
 
66
- function getVSCodeSettingsPaths(overrides = {}) {
67
- if (overrides.vscodeSettingsPaths) return overrides.vscodeSettingsPaths.filter(existsSync);
66
+ function getVSCodeUserMcpPaths(overrides = {}) {
67
+ if (overrides.vscodeMcpPaths) return overrides.vscodeMcpPaths.filter(existsSync);
68
68
  const home = getHomeDir(overrides);
69
69
  const { platform } = process;
70
70
  if (platform === 'darwin') {
71
71
  return [
72
- join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
73
- join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'settings.json'),
72
+ join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
73
+ join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'mcp.json'),
74
74
  ].filter(existsSync);
75
75
  }
76
76
  if (platform === 'linux') {
77
77
  return [
78
- join(home, '.config', 'Code', 'User', 'settings.json'),
79
- join(home, '.config', 'Code - Insiders', 'User', 'settings.json'),
78
+ join(home, '.config', 'Code', 'User', 'mcp.json'),
79
+ join(home, '.config', 'Code - Insiders', 'User', 'mcp.json'),
80
80
  ].filter(existsSync);
81
81
  }
82
82
  if (platform === 'win32') {
83
83
  const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
84
84
  return [
85
- join(appData, 'Code', 'User', 'settings.json'),
86
- join(appData, 'Code - Insiders', 'User', 'settings.json'),
85
+ join(appData, 'Code', 'User', 'mcp.json'),
86
+ join(appData, 'Code - Insiders', 'User', 'mcp.json'),
87
87
  ].filter(existsSync);
88
88
  }
89
89
  return [];
@@ -230,9 +230,9 @@ function getMcpPlatforms(mcp, overrides = {}) {
230
230
  }
231
231
 
232
232
  // --- VS Code / Copilot ---
233
- for (const p of getVSCodeSettingsPaths(overrides)) {
234
- const settings = readJSON(p);
235
- if (configHasAny(settings?.['github.copilot.mcpServers'], matchIds)) {
233
+ for (const p of getVSCodeUserMcpPaths(overrides)) {
234
+ const config = readJSON(p);
235
+ if (configHasAny(config?.servers, matchIds)) {
236
236
  const label = p.includes('Insiders') ? 'VS Code Insiders' : 'VS Code';
237
237
  if (!platforms.includes(label)) platforms.push(label);
238
238
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * lib/git-hooks-path.mjs — wire a git repo's `core.hooksPath` to `.beads/hooks`
3
+ * so Construct's pre-commit secret-scan, policy gates, and the beads dispatcher
4
+ * activate.
5
+ *
6
+ * Project-scoped: it mutates the git config of the directory passed in, so it
7
+ * is owned by `construct init` (project scaffolding), never `construct install`
8
+ * (machine setup must not touch the cwd repo — ADR-0027 §3). The git default
9
+ * (`.git/hooks`, set or unset) is treated as "no active choice" and overwritten;
10
+ * a non-default custom hooksPath is left alone with a warning.
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { spawnSync } from 'node:child_process';
16
+
17
+ export function ensureGitHooksPath({ cwd = process.cwd() } = {}) {
18
+ const hooksDir = path.join(cwd, '.beads', 'hooks');
19
+ if (!fs.existsSync(path.join(hooksDir, 'pre-commit'))) {
20
+ return { status: 'skipped', reason: 'no .beads/hooks/pre-commit in this directory' };
21
+ }
22
+ const inGit = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, stdio: 'pipe', encoding: 'utf8' });
23
+ if (inGit.status !== 0) {
24
+ return { status: 'skipped', reason: 'not a git working tree' };
25
+ }
26
+ const current = spawnSync('git', ['config', '--get', 'core.hooksPath'], { cwd, stdio: 'pipe', encoding: 'utf8' });
27
+ const currentValue = current.status === 0 ? (current.stdout || '').trim() : '';
28
+ const desired = '.beads/hooks';
29
+ if (currentValue === desired) {
30
+ return { status: 'ok', message: 'core.hooksPath already wired to .beads/hooks' };
31
+ }
32
+
33
+ // Treat the git default (`.git/hooks` or an absolute path to it) as
34
+ // equivalent to unset. A user who has accepted the default has not made an
35
+ // active choice that justifies "leave alone" semantics; without this branch
36
+ // an install that happens to land while `.git/hooks` is already in play
37
+ // leaves policy gates inactive permanently.
38
+
39
+ const isGitDefault =
40
+ !currentValue ||
41
+ currentValue === '.git/hooks' ||
42
+ currentValue === path.join(cwd, '.git', 'hooks') ||
43
+ currentValue.replace(/\/+$/, '').endsWith(`${path.sep}.git${path.sep}hooks`);
44
+ if (!isGitDefault) {
45
+ return {
46
+ status: 'warning',
47
+ message: `core.hooksPath is set to '${currentValue}'. Leaving alone. Set to '.beads/hooks' to activate Construct policy gates.`,
48
+ };
49
+ }
50
+ const result = spawnSync('git', ['config', 'core.hooksPath', desired], { cwd, stdio: 'pipe', encoding: 'utf8' });
51
+ if (result.status !== 0) {
52
+ return {
53
+ status: 'error',
54
+ message: `failed to set core.hooksPath: ${(result.stderr || '').trim() || 'unknown error'}`,
55
+ };
56
+ }
57
+ return {
58
+ status: 'set',
59
+ message: 'core.hooksPath set to .beads/hooks (activates pre-commit secret-scan + Construct policy gates + beads dispatcher)',
60
+ };
61
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * lib/home-namespace.mjs — per-home derivation of local-service identifiers so
3
+ * isolated HOMEs (test sandboxes, multiple users on one machine, parallel CI)
4
+ * never collide on the same Postgres container/port or memory port (construct-lb7b).
5
+ *
6
+ * The canonical `construct-postgres`:54329 container and the memory :8765 port are
7
+ * singular per machine; two HOMEs sharing them clobber each other's data and
8
+ * fight over the port. Each identifier here is derived from a stable hash of the
9
+ * resolved home directory, so every HOME gets its own deterministic namespace.
10
+ *
11
+ * Explicit env overrides win (CONSTRUCT_PG_PORT, MEMORY_PORT, CONSTRUCT_PG_CONTAINER),
12
+ * so a pinned setup keeps its values. The LaunchAgent label stays singular — it is
13
+ * a machine-level guard, not a per-home resource.
14
+ */
15
+
16
+ import { createHash } from 'node:crypto';
17
+ import { homeDir } from './paths.mjs';
18
+
19
+ const PG_PORT_BASE = 54329;
20
+ const MEMORY_PORT_BASE = 8765;
21
+ const PORT_SPAN = 2000;
22
+
23
+ function digest(home) {
24
+ return createHash('sha256').update(home).digest('hex');
25
+ }
26
+
27
+ export function homeNamespaceSuffix(home = homeDir()) {
28
+ return digest(home).slice(0, 8);
29
+ }
30
+
31
+ // Distinct hash slices for the two ports so they do not move in lockstep across
32
+ // homes (which would re-create collisions one base apart).
33
+
34
+ function offsetFromSlice(home, start) {
35
+ return parseInt(digest(home).slice(start, start + 8), 16) % PORT_SPAN;
36
+ }
37
+
38
+ export function postgresPort(env = process.env, home = homeDir()) {
39
+ const override = env.CONSTRUCT_PG_PORT;
40
+ if (override && /^\d+$/.test(override)) return Number(override);
41
+ return PG_PORT_BASE + offsetFromSlice(home, 0);
42
+ }
43
+
44
+ export function memoryPort(env = process.env, home = homeDir()) {
45
+ const override = env.MEMORY_PORT;
46
+ if (override && /^\d+$/.test(override)) return Number(override);
47
+ return MEMORY_PORT_BASE + offsetFromSlice(home, 8);
48
+ }
49
+
50
+ export function postgresContainerName(env = process.env, home = homeDir()) {
51
+ return env.CONSTRUCT_PG_CONTAINER || `construct-postgres-${homeNamespaceSuffix(home)}`;
52
+ }
53
+
54
+ // The legacy singular identifiers prior versions wrote. The postgres-namespace
55
+ // reconciliation (lib/reconcile/) uses these to detect state that predates the
56
+ // per-home derivation.
57
+
58
+ export const LEGACY_PG_CONTAINER = 'construct-postgres';
59
+ export const LEGACY_PG_PORT = 54329;
60
+ export const LEGACY_MEMORY_PORT = 8765;
@@ -2,17 +2,22 @@
2
2
  /**
3
3
  * lib/hooks/ci-status-check.mjs — UserPromptSubmit hook: inject remote CI status into agent context.
4
4
  *
5
- * Queries `gh run list --branch=<current> --limit=1` once per user prompt and,
6
- * if the last run on the current branch failed, prints a stderr line that the
7
- * agent will see in its next observation. Without this, an agent has no way
8
- * to know remote CI is red without explicitly running gh which means failed
9
- * jobs go unnoticed until the user catches them. Non-blocking.
5
+ * Reads a 60s on-disk cache of the last `gh run list` result for the current
6
+ * branch and, when the cached conclusion is `failure`, emits a stderr line the
7
+ * agent sees in its next observation. The synchronous fast path does no
8
+ * subprocess spawning beyond a 50ms `git branch` probe every cache miss is
9
+ * served by detaching a background refresher (stale-while-revalidate), so the
10
+ * UserPromptSubmit critical path stays under its 200ms budget regardless of
11
+ * `gh` / network latency. Without this hook, an agent has no way to know
12
+ * remote CI is red without explicitly running gh.
10
13
  *
11
- * Cached on disk for 60s so frequent prompts don't spam the gh API.
14
+ * The background refresher is `scripts/refresh-ci-status.mjs` invoked with
15
+ * `--branch=<name> --cwd=<dir> --cache=<path>`; it inherits no stdio and
16
+ * detaches so a slow `gh` invocation can't keep the hook process alive.
12
17
  * Skipped silently if gh is not installed, not authenticated, or the dir is
13
18
  * not a git repo.
14
19
  *
15
- * @p95ms 4000
20
+ * @p95ms 200
16
21
  * @maxBlockingScope none (UserPromptSubmit, non-blocking)
17
22
  *
18
23
  * @lifecycle UserPromptSubmit
@@ -20,13 +25,16 @@
20
25
  * @exits 0 = pass
21
26
  */
22
27
 
23
- import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
24
- import { execSync } from 'node:child_process';
28
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
29
+ import { execSync, spawn } from 'node:child_process';
25
30
  import { homedir } from 'node:os';
26
- import { join, dirname } from 'node:path';
31
+ import { join, dirname, resolve } from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
27
33
 
28
34
  const CACHE_PATH = join(homedir(), '.cx', 'ci-status-cache.json');
29
35
  const CACHE_TTL_MS = 60_000;
36
+ const HERE = dirname(fileURLToPath(import.meta.url));
37
+ const REFRESHER = resolve(HERE, '..', '..', 'scripts', 'refresh-ci-status.mjs');
30
38
 
31
39
  let cwd = process.cwd();
32
40
  try {
@@ -34,12 +42,20 @@ try {
34
42
  if (input?.cwd) cwd = input.cwd;
35
43
  } catch {}
36
44
 
37
- function safeExec(cmd, opts = {}) {
38
- try { return execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, ...opts }).toString().trim(); }
39
- catch { return null; }
45
+ // 50ms hard timeout on `git branch --show-current`: bounded enough that even
46
+ // a hung git invocation cannot blow the 200ms hook budget.
47
+
48
+ function safeGitBranch() {
49
+ try {
50
+ return execSync('git branch --show-current', {
51
+ cwd,
52
+ stdio: ['ignore', 'pipe', 'ignore'],
53
+ timeout: 50,
54
+ }).toString().trim();
55
+ } catch { return null; }
40
56
  }
41
57
 
42
- const branch = safeExec('git branch --show-current');
58
+ const branch = safeGitBranch();
43
59
  if (!branch) process.exit(0);
44
60
 
45
61
  const cacheKey = `${cwd}::${branch}`;
@@ -48,39 +64,45 @@ try { cache = JSON.parse(readFileSync(CACHE_PATH, 'utf8')); } catch {}
48
64
  const cached = cache[cacheKey];
49
65
  const now = Date.now();
50
66
 
51
- let conclusion = null;
52
- let runId = null;
53
- let runUrl = null;
67
+ // Stale-while-revalidate: serve any cached value (even stale) on the
68
+ // critical path, and detach a background refresher when the cache is stale
69
+ // or absent. The first prompt against a never-cached branch sees nothing;
70
+ // the second prompt (after the detached refresher has written) sees the
71
+ // real status. That tradeoff buys a flat ~50ms p95.
54
72
 
55
- if (cached && (now - cached.fetchedAt) < CACHE_TTL_MS) {
56
- conclusion = cached.conclusion;
57
- runId = cached.runId;
58
- runUrl = cached.runUrl;
59
- } else {
60
- const json = safeExec(
61
- `gh run list --branch=${JSON.stringify(branch)} --limit=1 --json conclusion,databaseId,url`,
62
- { timeout: 4000 },
63
- );
64
- if (!json) process.exit(0);
73
+ const isStale = !cached || (now - cached.fetchedAt) >= CACHE_TTL_MS;
74
+
75
+ if (isStale && existsSync(REFRESHER)) {
65
76
  try {
66
- const [run] = JSON.parse(json);
67
- if (run) {
68
- conclusion = run.conclusion;
69
- runId = run.databaseId;
70
- runUrl = run.url;
71
- cache[cacheKey] = { conclusion, runId, runUrl, fetchedAt: now };
72
- try {
73
- mkdirSync(dirname(CACHE_PATH), { recursive: true });
74
- writeFileSync(CACHE_PATH, JSON.stringify(cache));
75
- } catch {}
76
- }
77
+ const child = spawn(process.execPath, [
78
+ REFRESHER,
79
+ `--branch=${branch}`,
80
+ `--cwd=${cwd}`,
81
+ `--cache=${CACHE_PATH}`,
82
+ ], {
83
+ cwd,
84
+ detached: true,
85
+ stdio: 'ignore',
86
+ });
87
+ child.unref();
77
88
  } catch {}
78
89
  }
79
90
 
80
- if (conclusion === 'failure') {
91
+ if (cached?.conclusion === 'failure') {
81
92
  process.stderr.write(
82
- `[ci] Last CI run on branch '${branch}' FAILED (run ${runId}).\n ${runUrl || ''}\n Investigate with: gh run view ${runId} --log-failed\n Do not stop the session until the failure is fixed or explicitly acknowledged (CONSTRUCT_STOP_OK_RED_CI=1).\n`,
93
+ `[ci] Last CI run on branch '${branch}' FAILED (run ${cached.runId}).\n ${cached.runUrl || ''}\n Investigate with: gh run view ${cached.runId} --log-failed\n Do not stop the session until the failure is fixed or explicitly acknowledged (CONSTRUCT_STOP_OK_RED_CI=1).\n`,
83
94
  );
84
95
  }
85
96
 
97
+ // First-prompt edge case: no cache exists yet AND we just kicked off the
98
+ // refresher. We still exit 0 (silent). On the next prompt, the cache will
99
+ // be populated and any failure surfaces.
100
+
101
+ if (!cached) {
102
+ try {
103
+ mkdirSync(dirname(CACHE_PATH), { recursive: true });
104
+ if (!existsSync(CACHE_PATH)) writeFileSync(CACHE_PATH, '{}');
105
+ } catch {}
106
+ }
107
+
86
108
  process.exit(0);