@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
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lib/hooks/orchestration-dispatch-guard.mjs — backstop against solo-authoring an orchestrated deliverable.
4
+ *
5
+ * The orchestration_policy tool classifies a request into immediate / focused /
6
+ * orchestrated. An orchestrated verdict means the orchestrator must dispatch the
7
+ * specialist chain (cx-architect, cx-security, cx-devil-advocate, …) before
8
+ * authoring the deliverable — not write it solo. The persona instructs this, but
9
+ * instruction is not enforcement: this hook is the Claude-only barrier.
10
+ *
11
+ * Three roles, one file, branched on event + tool:
12
+ * - PostToolUse on *orchestration_policy: record the verdict. orchestrated →
13
+ * {pending:true,dispatched:false}; immediate/focused → cleared. Each new
14
+ * classification resets state for the new request.
15
+ * - PostToolUse on a dispatch tool (*orchestration_run, *worker_run, Task) →
16
+ * {dispatched:true}: the chain is running, authoring is now legitimate.
17
+ * - PreToolUse on Write|Edit|MultiEdit → block (exit 2) when a substantial
18
+ * documentation/strategy deliverable is authored while pending && !dispatched.
19
+ *
20
+ * Scoped to documentation/strategy artifacts (.md outside the routine planning
21
+ * and tracking files), not code — solo code authoring is harder to separate
22
+ * from legitimate pre-dispatch scaffolding, so it is left to the persona
23
+ * contract rather than over-blocked here. Only the deterministic
24
+ * orchestrated-but-not-dispatched case blocks. Fails open on any internal error.
25
+ *
26
+ * @p95ms 8
27
+ * @maxBlockingScope orchestrated deliverable authoring without prior dispatch
28
+ *
29
+ * @lifecycle PreToolUse, PostToolUse
30
+ * @matcher Write|Edit|MultiEdit, mcp__.*__orchestration_policy, mcp__.*__orchestration_run, mcp__.*__worker_run, Task
31
+ * @exits 0 = pass · 2 = block (author after dispatch instead)
32
+ */
33
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
34
+ import { join, basename, relative, isAbsolute } from 'path';
35
+
36
+ const STALE_MS = 2 * 60 * 60 * 1000;
37
+ const SUBSTANTIAL_CHARS = 1500;
38
+ const SUBSTANTIAL_LINES = 40;
39
+
40
+ let input = {};
41
+ try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
42
+
43
+ const event = input?.hook_event_name || '';
44
+ const toolName = input?.tool_name || '';
45
+ const cwd = input?.cwd || process.cwd();
46
+
47
+ function statePath(root) {
48
+ return join(root, '.cx', 'runtime', 'orchestration-guard.json');
49
+ }
50
+
51
+ function readState(root) {
52
+ try { return JSON.parse(readFileSync(statePath(root), 'utf8')); } catch { return null; }
53
+ }
54
+
55
+ function writeState(root, state) {
56
+ try {
57
+ mkdirSync(join(root, '.cx', 'runtime'), { recursive: true });
58
+ writeFileSync(statePath(root), JSON.stringify(state));
59
+ } catch { /* fail open */ }
60
+ }
61
+
62
+ // The marker only lives inside an already-initialized construct project; never
63
+ // seed .cx into a project that does not have it.
64
+
65
+ function hasCx(root) {
66
+ return existsSync(join(root, '.cx'));
67
+ }
68
+
69
+ function isPolicyTool(name) {
70
+ return /orchestration_policy$/.test(name);
71
+ }
72
+
73
+ function isDispatchTool(name) {
74
+ return name === 'Task' || /orchestration_run$/.test(name) || /worker_run$/.test(name);
75
+ }
76
+
77
+ // PostToolUse: a classification verdict updates the marker. orchestrated arms
78
+ // the guard; immediate/focused disarms it. The verdict text can be nested in an
79
+ // MCP content envelope, so match against the stringified response.
80
+
81
+ if (event === 'PostToolUse' && isPolicyTool(toolName)) {
82
+ if (!hasCx(cwd)) process.exit(0);
83
+ const blob = JSON.stringify(input?.tool_response ?? '').replace(/\\/g, '');
84
+ const orchestrated = /"track"\s*:\s*"orchestrated"/.test(blob);
85
+ if (orchestrated) writeState(cwd, { pending: true, dispatched: false, ts: Date.now() });
86
+ else writeState(cwd, { pending: false, ts: Date.now() });
87
+ process.exit(0);
88
+ }
89
+
90
+ if (event === 'PostToolUse' && isDispatchTool(toolName)) {
91
+ if (!hasCx(cwd)) process.exit(0);
92
+ const state = readState(cwd);
93
+ if (state?.pending) writeState(cwd, { ...state, dispatched: true });
94
+ process.exit(0);
95
+ }
96
+
97
+ // PreToolUse on an authoring tool: block only the deterministic failure — a
98
+ // substantial documentation/strategy deliverable authored while a fresh
99
+ // orchestrated verdict is pending and no dispatch has run.
100
+
101
+ if (event === 'PreToolUse' && /^(Write|Edit|MultiEdit)$/.test(toolName)) {
102
+ if (!hasCx(cwd)) process.exit(0);
103
+ const state = readState(cwd);
104
+ if (!state?.pending || state?.dispatched) process.exit(0);
105
+ if (typeof state.ts === 'number' && Date.now() - state.ts > STALE_MS) process.exit(0);
106
+
107
+ const ti = input?.tool_input || {};
108
+ const filePath = ti.file_path || ti.filePath || '';
109
+ if (!isDeliverableArtifact(filePath, cwd)) process.exit(0);
110
+
111
+ const authored = authoredContent(toolName, ti);
112
+ const lineCount = authored ? authored.split('\n').length : 0;
113
+ if (authored.length < SUBSTANTIAL_CHARS && lineCount < SUBSTANTIAL_LINES) process.exit(0);
114
+
115
+ process.stderr.write(
116
+ '⛔ orchestration-dispatch-guard: orchestration_policy classified this request as ORCHESTRATED, '
117
+ + 'but no specialist dispatch (orchestration_run / Task) has run yet.\n'
118
+ + 'You may not author the deliverable solo. Emit the task-packet and dispatch the chain '
119
+ + '(e.g. cx-architect owns structure, cx-security challenges the credential/state model, '
120
+ + 'cx-devil-advocate pressure-tests the approach), then synthesize their verdicts in Construct\'s voice.\n'
121
+ + 'If this write IS post-dispatch synthesis, run the dispatch tool first so the guard clears.\n',
122
+ );
123
+ process.exit(2);
124
+ }
125
+
126
+ process.exit(0);
127
+
128
+ // A deliverable is a markdown doc/strategy artifact outside the routine
129
+ // planning and tracking files. Code paths are intentionally excluded — see
130
+ // the file header.
131
+
132
+ function isDeliverableArtifact(filePath, root) {
133
+ if (!filePath || !/\.mdx?$/.test(filePath)) return false;
134
+ let rel = filePath;
135
+ try { rel = isAbsolute(filePath) ? relative(root, filePath) : filePath; } catch { rel = filePath; }
136
+ rel = rel.replace(/\\/g, '/');
137
+ if (rel.startsWith('..')) return false;
138
+ if (rel.startsWith('.cx/')) return false;
139
+ const base = basename(rel).toLowerCase();
140
+ const routine = new Set(['plan.md', 'changelog.md', 'readme.md', 'agents.md', 'contributing.md']);
141
+ if (routine.has(base)) return false;
142
+ if (rel.startsWith('.cx/handoffs/') || rel.includes('/handoffs/')) return false;
143
+ return true;
144
+ }
145
+
146
+ function authoredContent(name, ti) {
147
+ if (name === 'Write') return String(ti.content ?? '');
148
+ if (name === 'Edit') return String(ti.new_string ?? '');
149
+ if (name === 'MultiEdit' && Array.isArray(ti.edits)) {
150
+ return ti.edits.map((e) => String(e?.new_string ?? '')).join('\n');
151
+ }
152
+ return '';
153
+ }
@@ -116,18 +116,27 @@ if (isGhPrCreate || isGhPrEdit) {
116
116
  echo();
117
117
  }
118
118
 
119
- // git push: refuse claude/* unconditionally.
120
-
121
- if (/\bclaude\//.test(command)) {
119
+ // git push: refuse agent-prefixed branches. Match the branch name(s) actually
120
+ // being pushed — the current branch plus any explicit refspec — anchored at the
121
+ // start, so a `.claude/agents` path argument anywhere in the command does not
122
+ // trip the gate.
123
+
124
+ const AGENT_BRANCH_PREFIX = /^(claude|cursor|copilot|codex|aider|devin)\//;
125
+ const pushTargets = [currentBranch()];
126
+ const pushArgs = (command.match(/\bgit\s+push\b([^|;&\n]*)/)?.[1] || '')
127
+ .trim().split(/\s+/).filter((a) => a && !a.startsWith('-'));
128
+ for (const arg of pushArgs.slice(1)) pushTargets.push(arg.split(':').pop());
129
+
130
+ if (pushTargets.some((b) => b && AGENT_BRANCH_PREFIX.test(b))) {
122
131
  process.stderr.write(
123
- `[pre-push-gate] Refusing to push a claude/* branch to remote (user policy).\n` +
132
+ `[pre-push-gate] Refusing to push an agent-prefixed branch to remote (user policy).\n` +
124
133
  ` Use a non-agent branch name.\n`,
125
134
  );
126
135
  emitRoleEvent({
127
136
  type: 'push_gate.fail',
128
- summary: 'claude/* branch push refused',
137
+ summary: 'agent-prefixed branch push refused',
129
138
  hookInput: input,
130
- context: { command },
139
+ context: { command, pushTargets },
131
140
  });
132
141
  process.exit(2);
133
142
  }
@@ -155,8 +155,16 @@ const costLogPath = join(home, '.cx', 'session-cost.jsonl');
155
155
  const lastAgentPath = join(home, '.cx', 'last-agent.json');
156
156
  const checkpointPath = join(home, '.cx', 'transcript-checkpoints.json');
157
157
 
158
+ // Consume-clear like warn-flags/files-changed below: a TS result is reported
159
+ // once, not re-fired on every subsequent no-op Stop. stop-typecheck rewrites
160
+ // the file each turn it actually runs.
158
161
  let tsResult = 'unchecked';
159
- try { tsResult = existsSync(tsResultPath) ? readFileSync(tsResultPath, 'utf8').trim() : 'unchecked'; } catch { /* non-critical */ }
162
+ try {
163
+ if (existsSync(tsResultPath)) {
164
+ tsResult = readFileSync(tsResultPath, 'utf8').trim() || 'unchecked';
165
+ writeFileSync(tsResultPath, 'unchecked');
166
+ }
167
+ } catch { /* non-critical */ }
160
168
 
161
169
  let warnings = [];
162
170
  try {
@@ -326,25 +334,32 @@ try {
326
334
  }
327
335
  } catch { /* review is best-effort */ }
328
336
 
337
+ // Stop fires per-turn, so a desktop banner only earns the interruption when the
338
+ // turn produced something actionable: files changed or a warning to review.
339
+ // A bare "TS OK" (the sticky-pass default on a no-op turn) is noise — the ledger
340
+ // and session bookkeeping above still run; only the banner is suppressed.
329
341
  const platform = process.platform;
330
- try {
331
- if (platform === 'darwin') {
332
- const escaped = body.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
333
- const titleEsc = title.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
334
- execSync(`osascript -e 'display notification "${escaped}" with title "${titleEsc}"'`, {
335
- timeout: 5000,
336
- stdio: 'pipe',
337
- });
338
- } else if (platform === 'linux') {
339
- execSync(`notify-send "${title}" "${body.replace(/"/g, '\\"')}"`, {
340
- timeout: 5000,
341
- stdio: 'pipe',
342
- });
343
- } else {
342
+ const shouldNotify = (hasWarnings || fileCount > 0) && Boolean(body);
343
+ if (shouldNotify) {
344
+ try {
345
+ if (platform === 'darwin') {
346
+ const escaped = body.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
347
+ const titleEsc = title.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
348
+ execSync(`osascript -e 'display notification "${escaped}" with title "${titleEsc}"'`, {
349
+ timeout: 5000,
350
+ stdio: 'pipe',
351
+ });
352
+ } else if (platform === 'linux') {
353
+ execSync(`notify-send "${title}" "${body.replace(/"/g, '\\"')}"`, {
354
+ timeout: 5000,
355
+ stdio: 'pipe',
356
+ });
357
+ } else {
358
+ process.stderr.write(`[stop-notify] ${title}: ${body}\n`);
359
+ }
360
+ } catch {
344
361
  process.stderr.write(`[stop-notify] ${title}: ${body}\n`);
345
362
  }
346
- } catch {
347
- process.stderr.write(`[stop-notify] ${title}: ${body}\n`);
348
363
  }
349
364
 
350
365
  process.exit(0);
@@ -31,8 +31,11 @@ const pending = existsSync(tcPath)
31
31
  ? readFileSync(tcPath, 'utf8').split('\n').filter(Boolean)
32
32
  : [];
33
33
 
34
+ // No files queued means no typecheck ran this turn — record 'unchecked', not a
35
+ // misleading 'pass'. A stale 'pass' here is what made stop-notify fire a bare
36
+ // "TS OK" banner on every no-op turn.
34
37
  if (pending.length === 0) {
35
- try { writeFileSync(tsResultPath, 'pass'); } catch { /* best effort */ }
38
+ try { writeFileSync(tsResultPath, 'unchecked'); } catch { /* best effort */ }
36
39
  process.exit(0);
37
40
  }
38
41
 
@@ -55,8 +58,10 @@ function findTsConfig(startDir) {
55
58
  const startDir = dirname(pending[0]);
56
59
  const tsconfig = findTsConfig(startDir);
57
60
 
61
+ // Files were edited but no tsconfig governs them — the typecheck couldn't run,
62
+ // so the honest state is 'unchecked', not 'pass'.
58
63
  if (!tsconfig) {
59
- try { writeFileSync(tsResultPath, 'pass'); } catch { /* best effort */ }
64
+ try { writeFileSync(tsResultPath, 'unchecked'); } catch { /* best effort */ }
60
65
  process.exit(0);
61
66
  }
62
67
 
@@ -118,6 +118,15 @@ function detectCopilotAvailability(homeDir = os.homedir()) {
118
118
  return { hasFiles };
119
119
  }
120
120
 
121
+ // `capability` is the honest, normalized functional classification per host:
122
+ // full-native — the host runs a multi-specialist chain itself (native
123
+ // dispatch or config-driven agents): Claude Code, OpenCode.
124
+ // mcp-orchestrated — no native subagent primitive, but the host calls the
125
+ // `orchestration_run` MCP tool to reach the same orchestrated
126
+ // outcome via the daemon: Codex, VS Code, Cursor, Copilot.
127
+ // prompt-only — neither native dispatch nor MCP; instruction files only.
128
+ // No supported 2026 host is prompt-only.
129
+
121
130
  export function detectHostCapabilities() {
122
131
  const claudeRaw = commandVersion("claude");
123
132
  const claudeVersion = parseClaudeVersion(claudeRaw);
@@ -135,6 +144,7 @@ export function detectHostCapabilities() {
135
144
  availability: claudeRaw ? "installed" : "missing",
136
145
  version: claudeRaw,
137
146
  orchestration: claudeTeamsSupported ? "full-multi-agent" : "primary-plus-subagents",
147
+ capability: "full-native",
138
148
  promptableWorkers: claudeTeamsSupported,
139
149
  sharedTaskRuntime: claudeTeamsSupported,
140
150
  lifecycleHooks: ["SubagentStop", "TeammateIdle", "TaskCreated", "TaskCompleted", "Stop"],
@@ -151,6 +161,7 @@ export function detectHostCapabilities() {
151
161
  availability: opencodeRaw ? "installed" : "missing",
152
162
  version: opencodeRaw,
153
163
  orchestration: "primary-plus-subagents",
164
+ capability: "full-native",
154
165
  promptableWorkers: false,
155
166
  sharedTaskRuntime: "tracker-plus-plan",
156
167
  lifecycleHooks: ["session.error", "session.idle", "tool.execute.before", "tool.execute.after"],
@@ -165,12 +176,13 @@ export function detectHostCapabilities() {
165
176
  availability: codexRaw ? "installed" : "missing",
166
177
  version: codexRaw,
167
178
  orchestration: "profile-and-mcp",
179
+ capability: "mcp-orchestrated",
168
180
  promptableWorkers: false,
169
181
  sharedTaskRuntime: "tracker-plus-plan",
170
182
  lifecycleHooks: [],
171
183
  notes: [
172
- "Use Construct profiles, MCP project/memory tools, and the active session.",
173
- "Native profile switching is not automatic; continue in-session when dispatch is unavailable.",
184
+ "Run a full multi-specialist chain via the `orchestration_run` MCP tool (start `construct dashboard`).",
185
+ "Use Construct profiles, MCP project/memory tools, and the active session for single-pass work.",
174
186
  ],
175
187
  },
176
188
  {
@@ -178,12 +190,13 @@ export function detectHostCapabilities() {
178
190
  availability: vscode.version || vscode.hasSettings ? "installed" : "missing",
179
191
  version: vscode.version,
180
192
  orchestration: "copilot-mcp",
193
+ capability: "mcp-orchestrated",
181
194
  promptableWorkers: false,
182
195
  sharedTaskRuntime: "editor-session",
183
196
  lifecycleHooks: [],
184
197
  notes: [
185
- "Construct manages GitHub Copilot MCP registrations in user settings when a VS Code settings.json already exists.",
186
- "VS Code is an editor surface, not Construct's primary multi-agent runtime.",
198
+ "Run a full multi-specialist chain via the `orchestration_run` MCP tool in Copilot agent mode (start `construct dashboard`).",
199
+ "Construct's MCP servers load from `.vscode/mcp.json` (project) and the user-profile `mcp.json` (global, only when it already exists).",
187
200
  ],
188
201
  },
189
202
  {
@@ -191,12 +204,13 @@ export function detectHostCapabilities() {
191
204
  availability: cursor.version || cursor.hasConfig ? "installed" : "missing",
192
205
  version: cursor.version,
193
206
  orchestration: "mcp-only",
207
+ capability: "mcp-orchestrated",
194
208
  promptableWorkers: false,
195
209
  sharedTaskRuntime: "editor-session",
196
210
  lifecycleHooks: [],
197
211
  notes: [
198
- "Construct manages Cursor MCP registrations in ~/.cursor/mcp.json when that file exists.",
199
- "Cursor agent profiles are not generated today; use Construct through MCP-backed tools and shared project state.",
212
+ "Run a full multi-specialist chain via the `orchestration_run` MCP tool (start `construct dashboard`).",
213
+ "Construct manages Cursor MCP registrations in `.cursor/mcp.json` (project) and `~/.cursor/mcp.json` (global, when it already exists).",
200
214
  ],
201
215
  },
202
216
  {
@@ -204,12 +218,13 @@ export function detectHostCapabilities() {
204
218
  availability: copilot.hasFiles ? "installed" : "missing",
205
219
  version: null,
206
220
  orchestration: "prompt-profiles",
221
+ capability: "mcp-orchestrated",
207
222
  promptableWorkers: false,
208
223
  sharedTaskRuntime: "editor-session",
209
224
  lifecycleHooks: [],
210
225
  notes: [
211
- "Construct writes reusable prompt profiles under ~/.github/prompts/ plus a managed block in ~/.github/copilot-instructions.md.",
212
- "Copilot does not expose Construct-style subagent dispatch; use the generated profiles for focused passes.",
226
+ "Copilot agent mode runs Construct's MCP tools use `orchestration_run` for a real multi-specialist chain (start `construct dashboard`).",
227
+ "Construct also writes reusable prompt profiles under `.github/prompts/` for single-pass role work.",
213
228
  ],
214
229
  },
215
230
  ];
@@ -224,6 +239,7 @@ export function printHostCapabilities(hosts = detectHostCapabilities()) {
224
239
  for (const host of hosts) {
225
240
  console.log("");
226
241
  console.log(`${host.host}: ${host.availability}${host.version ? ` (${host.version})` : ""}`);
242
+ console.log(` capability: ${host.capability}`);
227
243
  console.log(` orchestration: ${host.orchestration}`);
228
244
  console.log(` promptable workers: ${host.promptableWorkers === true ? "yes" : "no"}`);
229
245
  console.log(` shared task runtime: ${host.sharedTaskRuntime === true ? "native" : host.sharedTaskRuntime || "none"}`);
@@ -0,0 +1,76 @@
1
+ /**
2
+ * lib/host-disposition.mjs — single source of truth for the disposition of
3
+ * every artifact Construct creates in a host project (ADR-0027 §1).
4
+ *
5
+ * `IGNORED_PATTERNS` enumerates machine-specific, sync-regenerated artifacts
6
+ * that must never be committed; the init `.gitignore` writer and the
7
+ * `gitignore-coverage` reconciliation both read from here, and uninstall /
8
+ * adapter-prune consume `ADAPTER_DIRS`. Keep this 1:1 with the host-relevant
9
+ * subset of Construct's own repo .gitignore so forward-fix and backward-repair
10
+ * never diverge.
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ // Adapter directories `construct sync` regenerates per host platform. Each
17
+ // carries machine-specific absolute paths (MCP server paths, env-resolved
18
+ // tokens) and is recreated on demand, so none is durable repo state.
19
+
20
+ export const ADAPTER_DIRS = ['.claude', '.codex', '.cursor', '.vscode', '.opencode'];
21
+
22
+ // Every artifact whose disposition is `ignored`. A trailing slash marks a
23
+ // directory. `.construct/` is the per-project launcher (host scope only — the
24
+ // Construct repo itself tracks `.construct/version`, hence the divergence from
25
+ // the repo .gitignore for that one entry).
26
+
27
+ export const IGNORED_PATTERNS = [
28
+ '.cx/',
29
+ '.claude/',
30
+ '.codex/',
31
+ '.cursor/',
32
+ '.vscode/',
33
+ '.opencode/',
34
+ '.construct/',
35
+ '.mcp.json',
36
+ '.github/prompts/',
37
+ '.github/copilot-instructions.md',
38
+ 'config.env',
39
+ 'embed.yaml',
40
+ 'plan.md',
41
+ 'inbox/',
42
+ ];
43
+
44
+ // A pattern counts as already-ignored when an existing line matches it exactly,
45
+ // matches its bare/slashed form, or is a broader `*` / `**` catch-all.
46
+
47
+ export function isPatternIgnored(pattern, presentLines) {
48
+ const present = presentLines instanceof Set ? presentLines : new Set(presentLines);
49
+ if (present.has('*') || present.has('**')) return true;
50
+ const bare = pattern.replace(/\/$/, '');
51
+ return present.has(pattern) || present.has(bare) || present.has(`${bare}/`) || present.has(`${bare}/**`);
52
+ }
53
+
54
+ // Patterns from IGNORED_PATTERNS not yet covered by `content` (a .gitignore body).
55
+
56
+ export function missingIgnorePatterns(content) {
57
+ const present = new Set((content || '').split('\n').map((l) => l.trim()));
58
+ return IGNORED_PATTERNS.filter((p) => !isPatternIgnored(p, present));
59
+ }
60
+
61
+ // Detection: a package.json declaring this as the `construct` package. Used
62
+ // to prevent `construct init` and `construct sync` from misapplying host
63
+ // project rules (like ignoring .construct/) to the Construct repository itself.
64
+
65
+ export function isConstructPackageRepo(dir) {
66
+ try {
67
+ const pkgPath = path.join(dir, 'package.json');
68
+ if (!fs.existsSync(pkgPath)) return false;
69
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
70
+ if (!pkg) return false;
71
+ if (pkg.name === 'construct' || pkg.name === '@geraldmaron/construct') return true;
72
+ return pkg.bin && (pkg.bin === 'bin/construct' || (typeof pkg.bin === 'object' && pkg.bin.construct === 'bin/construct'));
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
@@ -114,7 +114,7 @@ async function callAnthropic({ model, apiKey, content, fetchImpl }) {
114
114
  async function callOpenRouter({ model, apiKey, content, fetchImpl }) {
115
115
  const res = await fetchImpl('https://openrouter.ai/api/v1/chat/completions', {
116
116
  method: 'POST',
117
- headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', 'HTTP-Referer': 'https://github.com/construct' },
117
+ headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', 'HTTP-Referer': 'https://github.com/geraldmaron/construct' },
118
118
  body: JSON.stringify({
119
119
  model: model.replace(/^openrouter\//, ''),
120
120
  max_tokens: MAX_OUTPUT_TOKENS,
@@ -15,12 +15,21 @@
15
15
 
16
16
  import { resolveSetting } from '../config/project-config.mjs';
17
17
  import { resolveEmbeddedModel } from '../embedded-contract/model-resolve.mjs';
18
+ import { resolveExecution } from '../embedded-contract/execution.mjs';
18
19
 
19
20
  export const INGEST_STRATEGIES = ['adapter', 'provider'];
20
21
  export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
21
22
 
23
+ // Orchestration is a second, independent axis from extraction (adapter|provider):
24
+ // `prompt-only` runs ingest as a deterministic extraction pass; `orchestrated`
25
+ // engages the Construct specialist chain for the evidence-ingest workflow. Both
26
+ // axes coexist — extraction is HOW bytes become text, orchestration is WHETHER
27
+ // specialists process the result.
28
+ export const INGEST_ORCHESTRATION_STRATEGIES = ['prompt-only', 'orchestrated'];
29
+
22
30
  export const DEFAULT_INGEST_STRATEGY = 'adapter';
23
31
  export const DEFAULT_INGEST_FALLBACK = 'none';
32
+ export const DEFAULT_INGEST_ORCHESTRATION = 'prompt-only';
24
33
 
25
34
  function coerceEnum(value, allowed) {
26
35
  if (typeof value !== 'string') return null;
@@ -55,11 +64,13 @@ function resolveProviderModel({ env, registryPath }) {
55
64
  * @param {object} opts
56
65
  * @param {object} [opts.config] loaded project config object
57
66
  * @param {Record<string,string>} [opts.env]
58
- * @param {string} [opts.override] explicit strategy override (CLI flag)
67
+ * @param {string} [opts.override] explicit extraction strategy override (CLI flag)
68
+ * @param {string} [opts.orchestrationOverride] explicit orchestration override (CLI flag)
69
+ * @param {string} [opts.cwd]
59
70
  * @param {string} [opts.registryPath]
60
- * @returns {{strategy:string, fallback:string, model:(string|null), provider:(string|null), modelResolution:(object|null)}}
71
+ * @returns {{strategy:string, fallback:string, orchestration:string, model:(string|null), provider:(string|null), modelResolution:(object|null), execution:object}}
61
72
  */
62
- export function resolveIngestStrategy({ config = null, env = process.env, override = null, registryPath = null } = {}) {
73
+ export function resolveIngestStrategy({ config = null, env = process.env, override = null, orchestrationOverride = null, cwd = process.cwd(), registryPath = null } = {}) {
63
74
  const overrideStrategy = coerceEnum(override, INGEST_STRATEGIES);
64
75
 
65
76
  const fromSettings = resolveSetting({
@@ -82,14 +93,35 @@ export function resolveIngestStrategy({ config = null, env = process.env, overri
82
93
  });
83
94
  const fallback = coerceEnum(fallbackSetting.value, INGEST_FALLBACKS) || DEFAULT_INGEST_FALLBACK;
84
95
 
96
+ const orchestrationSetting = resolveSetting({
97
+ config,
98
+ jsonPath: 'ingest.orchestration',
99
+ env,
100
+ envKey: 'CONSTRUCT_INGEST_ORCHESTRATION',
101
+ defaultValue: DEFAULT_INGEST_ORCHESTRATION,
102
+ });
103
+ const orchestration = coerceEnum(orchestrationOverride, INGEST_ORCHESTRATION_STRATEGIES)
104
+ || coerceEnum(orchestrationSetting.value, INGEST_ORCHESTRATION_STRATEGIES)
105
+ || DEFAULT_INGEST_ORCHESTRATION;
106
+
85
107
  const needsProviderModel = strategy === 'provider' || fallback === 'provider';
86
108
  const modelResolution = needsProviderModel ? resolveProviderModel({ env, registryPath }) : null;
87
109
 
110
+ // The execution-capability contract is the single source of truth for whether
111
+ // the ingest workflow will actually orchestrate; reuse it rather than deriving
112
+ // a second answer here.
113
+ const { warnings, modelResolution: _drop, ...execution } = resolveExecution(
114
+ { workflowType: INGEST_WORKFLOW_TYPE, requestedStrategy: orchestration },
115
+ { env, cwd, registryPath },
116
+ );
117
+
88
118
  return {
89
119
  strategy,
90
120
  fallback,
121
+ orchestration,
91
122
  model: modelResolution?.model || null,
92
123
  provider: modelResolution?.provider || null,
93
124
  modelResolution,
125
+ execution,
94
126
  };
95
127
  }
package/lib/init-docs.mjs CHANGED
@@ -785,7 +785,7 @@ async function main() {
785
785
  process.stdout.write(`[init:docs] skipping .cx/inbox/ — ${inboxDecision.reason}. Run with --force to scaffold anyway.\n`);
786
786
  skipped.push('.cx/inbox/ (deferred to existing intake)');
787
787
  } else {
788
- writeIfMissing(path.join(target, '.cx', 'inbox', '.gitkeep'), '');
788
+ fs.mkdirSync(path.join(target, '.cx', 'inbox'), { recursive: true });
789
789
  }
790
790
  }
791
791