@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.
- package/README.md +23 -5
- package/bin/construct +194 -16
- package/bin/construct-postinstall.mjs +25 -15
- package/lib/acp/server.mjs +113 -0
- package/lib/agent-instructions/inject.mjs +94 -0
- package/lib/auto-docs.mjs +10 -2
- package/lib/cli-commands.mjs +44 -16
- package/lib/comment-lint.mjs +115 -0
- package/lib/completions.mjs +7 -1
- package/lib/config/schema.mjs +22 -0
- package/lib/decisions/enforced-baseline.json +3 -0
- package/lib/docs-verify.mjs +15 -18
- package/lib/doctor/cli.mjs +8 -1
- package/lib/document-export.mjs +124 -0
- package/lib/document-ingest.mjs +13 -4
- package/lib/embed/daemon.mjs +1 -1
- package/lib/embed/inbox.mjs +25 -7
- package/lib/embedded-contract/triage.mjs +24 -2
- package/lib/embedded-contract/workflow-invoke.mjs +21 -0
- package/lib/features.mjs +11 -11
- package/lib/git-hooks-path.mjs +61 -0
- package/lib/home-namespace.mjs +60 -0
- package/lib/hooks/ci-status-check.mjs +62 -40
- package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
- package/lib/hooks/pre-push-gate.mjs +15 -6
- package/lib/hooks/stop-notify.mjs +32 -17
- package/lib/hooks/stop-typecheck.mjs +7 -2
- package/lib/host-capabilities.mjs +24 -8
- package/lib/host-disposition.mjs +76 -0
- package/lib/ingest/provider-extract.mjs +1 -1
- package/lib/ingest/strategy.mjs +35 -3
- package/lib/init-docs.mjs +1 -1
- package/lib/init-unified.mjs +320 -219
- package/lib/init-update.mjs +4 -84
- package/lib/init.mjs +9 -25
- package/lib/install/stage-project.mjs +8 -2
- package/lib/intent-classifier.mjs +1 -1
- package/lib/knowledge/search.mjs +52 -3
- package/lib/mcp/server.mjs +57 -14
- package/lib/mcp/tools/memory.mjs +2 -2
- package/lib/mcp/tools/orchestration-run.mjs +125 -0
- package/lib/model-registry.mjs +40 -33
- package/lib/observation-store.mjs +6 -2
- package/lib/opencode-config.mjs +1 -1
- package/lib/orchestration/events.mjs +66 -0
- package/lib/orchestration/run-store-postgres.mjs +85 -0
- package/lib/orchestration/run-store-sqlite.mjs +122 -0
- package/lib/orchestration/runtime.mjs +188 -50
- package/lib/orchestration/store.mjs +102 -0
- package/lib/orchestration/worker.mjs +215 -0
- package/lib/orchestration-policy.mjs +27 -3
- package/lib/parity.mjs +80 -26
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-init-shared.mjs +0 -173
- package/lib/reconcile/adapter-prune.mjs +105 -0
- package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
- package/lib/reconcile/gitignore-coverage.mjs +88 -0
- package/lib/reconcile/index.mjs +171 -0
- package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
- package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
- package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
- package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
- package/lib/reconcile/postgres-namespace.mjs +102 -0
- package/lib/runtime/uv-bootstrap.mjs +27 -3
- package/lib/schema-infer.mjs +16 -2
- package/lib/server/csrf.mjs +14 -2
- package/lib/server/index.mjs +95 -0
- package/lib/service-manager.mjs +39 -14
- package/lib/setup-prompts.mjs +2 -1
- package/lib/setup.mjs +165 -141
- package/lib/storage/backend.mjs +14 -0
- package/lib/storage/unified-storage.mjs +550 -0
- package/lib/template-registry.mjs +73 -0
- package/lib/term-format.mjs +75 -0
- package/lib/uninstall/uninstall.mjs +180 -7
- package/package.json +2 -2
- package/personas/construct.md +7 -8
- package/platforms/claude/settings.template.json +30 -4
- package/platforms/opencode/config.template.json +2 -2
- package/rules/common/neurodivergent-output.md +66 -0
- package/rules/common/tool-invisibility.md +37 -0
- package/scripts/sync-specialists.mjs +381 -95
- package/skills/operating/orchestration-reference.md +2 -16
- package/specialists/policy-inventory.json +14 -0
- package/specialists/prompts/cx-accessibility.md +2 -6
- package/specialists/prompts/cx-ai-engineer.md +0 -4
- package/specialists/prompts/cx-architect.md +3 -5
- package/specialists/prompts/cx-business-strategist.md +0 -5
- package/specialists/prompts/cx-data-analyst.md +0 -4
- package/specialists/prompts/cx-data-engineer.md +0 -4
- package/specialists/prompts/cx-debugger.md +2 -6
- package/specialists/prompts/cx-designer.md +0 -8
- package/specialists/prompts/cx-devil-advocate.md +2 -2
- package/specialists/prompts/cx-docs-keeper.md +0 -13
- package/specialists/prompts/cx-engineer.md +0 -13
- package/specialists/prompts/cx-evaluator.md +2 -2
- package/specialists/prompts/cx-explorer.md +4 -5
- package/specialists/prompts/cx-legal-compliance.md +4 -5
- package/specialists/prompts/cx-operations.md +0 -5
- package/specialists/prompts/cx-orchestrator.md +0 -4
- package/specialists/prompts/cx-platform-engineer.md +0 -8
- package/specialists/prompts/cx-product-manager.md +0 -8
- package/specialists/prompts/cx-qa.md +2 -11
- package/specialists/prompts/cx-rd-lead.md +0 -5
- package/specialists/prompts/cx-release-manager.md +0 -8
- package/specialists/prompts/cx-researcher.md +5 -29
- package/specialists/prompts/cx-reviewer.md +2 -6
- package/specialists/prompts/cx-security.md +2 -11
- package/specialists/prompts/cx-sre.md +0 -15
- package/specialists/prompts/cx-test-automation.md +0 -4
- package/specialists/prompts/cx-trace-reviewer.md +2 -2
- package/specialists/prompts/cx-ux-researcher.md +0 -4
- package/specialists/registry.json +29 -29
- package/templates/distribution/run.mjs +36 -7
- package/templates/docs/accessibility-audit.md +56 -0
- package/templates/docs/architecture-review.md +59 -0
- package/templates/docs/code-review-report.md +46 -0
- package/templates/docs/construct_guide.md +14 -14
- package/templates/docs/debug-investigation.md +53 -0
- package/templates/docs/qa-report.md +48 -0
- package/templates/docs/security-audit-report.md +48 -0
- package/templates/docs/task-packet.md +49 -0
- package/templates/docs/verdict.md +40 -0
- 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
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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: '
|
|
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 {
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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, '
|
|
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, '
|
|
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
|
-
"
|
|
173
|
-
"
|
|
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
|
-
"
|
|
186
|
-
"
|
|
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
|
-
"
|
|
199
|
-
"
|
|
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
|
|
212
|
-
"
|
|
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,
|
package/lib/ingest/strategy.mjs
CHANGED
|
@@ -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
|
-
|
|
788
|
+
fs.mkdirSync(path.join(target, '.cx', 'inbox'), { recursive: true });
|
|
789
789
|
}
|
|
790
790
|
}
|
|
791
791
|
|