@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,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/worker.mjs — provider-backed worker backend for the local
|
|
3
|
+
* orchestration runtime.
|
|
4
|
+
*
|
|
5
|
+
* The inline backend (runtime.mjs) PREPARES a specialist task and stops short of
|
|
6
|
+
* model reasoning (ADR-0020). The provider backend closes that gap: it EXECUTES
|
|
7
|
+
* one specialist task by calling the configured provider/model with the
|
|
8
|
+
* specialist's persona prompt as system context and the run's request as the
|
|
9
|
+
* user turn, returning the model's real output (ADR-0021). Because it genuinely
|
|
10
|
+
* runs the model, the prepare-only disclaimer does not apply to provider-executed
|
|
11
|
+
* tasks — runtime.mjs records `task.output` and `executor='provider:<provider>:<model>'`
|
|
12
|
+
* so a host can distinguish prepared from executed work.
|
|
13
|
+
*
|
|
14
|
+
* Provider selection mirrors lib/ingest/provider-extract.mjs: Anthropic
|
|
15
|
+
* `/v1/messages` for Claude-family models, OpenRouter `/v1/chat/completions`
|
|
16
|
+
* otherwise; key resolution reads env (ANTHROPIC_API_KEY / OPENROUTER_API_KEY)
|
|
17
|
+
* and augments with ambient dotenv only when the caller did not inject an
|
|
18
|
+
* explicit env (hermetic-when-explicit). `fetchImpl` is injectable so the path is
|
|
19
|
+
* exercised end to end against a mock provider without a live key. Failures
|
|
20
|
+
* surface as structured codes (PROVIDER_KEY_MISSING, PROVIDER_MODEL_UNRESOLVED,
|
|
21
|
+
* PROVIDER_EXECUTION_FAILED) rather than opaque HTTP errors.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
export const INLINE = 'inline';
|
|
30
|
+
export const PROVIDER = 'provider';
|
|
31
|
+
export const WORKER_BACKEND_SET = [INLINE, PROVIDER];
|
|
32
|
+
|
|
33
|
+
const MAX_OUTPUT_TOKENS = 2048;
|
|
34
|
+
|
|
35
|
+
// Extended-thinking budget requested only when a caller opts into reasoning
|
|
36
|
+
// capture (chainOfThought !== 'hidden'). Anthropic requires budget_tokens < the
|
|
37
|
+
// turn's max_tokens, so the budget is added on top of the output ceiling.
|
|
38
|
+
|
|
39
|
+
const REASONING_BUDGET_TOKENS = 1024;
|
|
40
|
+
|
|
41
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const PROMPTS_DIR = join(HERE, '..', '..', 'specialists', 'prompts');
|
|
43
|
+
|
|
44
|
+
function providerError(code, reason, remediation) {
|
|
45
|
+
const err = new Error(reason);
|
|
46
|
+
err.code = code;
|
|
47
|
+
err.remediation = remediation;
|
|
48
|
+
return err;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isAnthropic(provider, model) {
|
|
52
|
+
return /anthropic|claude/i.test(provider || '') || /claude/i.test(model || '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Extended-thinking shape is model-specific. Opus 4.8 / 4.7 REJECT the legacy
|
|
56
|
+
// `{type:'enabled', budget_tokens}` form with a 400 and require adaptive
|
|
57
|
+
// thinking; Opus 4.6 and Sonnet 4.6 recommend adaptive too. Only genuinely
|
|
58
|
+
// older models (Sonnet 4.5 and earlier) still take the budget form. On adaptive
|
|
59
|
+
// models `display:'summarized'` is required, or returned thinking blocks carry
|
|
60
|
+
// empty text. (Source: claude-api skill — extended/adaptive thinking; ADR-0030.)
|
|
61
|
+
|
|
62
|
+
function anthropicThinkingConfig(model) {
|
|
63
|
+
const adaptive = /(opus-4-(?:[6-9]|\d\d)|sonnet-4-(?:[6-9]|\d\d))/i.test(model || '');
|
|
64
|
+
return adaptive
|
|
65
|
+
? { type: 'adaptive', display: 'summarized' }
|
|
66
|
+
: { type: 'enabled', budget_tokens: REASONING_BUDGET_TOKENS };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Key resolution mirrors provider-extract: env first, then the two cheap dotenv
|
|
70
|
+
// files, and only when the caller did not inject an explicit env. A caller that
|
|
71
|
+
// passes its own env object (embedded callers, tests) is authoritative and
|
|
72
|
+
// hermetic — ambient discovery is suppressed so a developer's real key never
|
|
73
|
+
// bleeds into a test run.
|
|
74
|
+
|
|
75
|
+
function resolveKey(varName, env, allowAmbient) {
|
|
76
|
+
if (env[varName] && typeof env[varName] === 'string' && env[varName].length > 0) return env[varName];
|
|
77
|
+
if (!allowAmbient) return null;
|
|
78
|
+
for (const file of [join(homedir(), '.construct', 'config.env'), join(homedir(), '.env')]) {
|
|
79
|
+
try {
|
|
80
|
+
if (!existsSync(file)) continue;
|
|
81
|
+
const m = readFileSync(file, 'utf8').match(new RegExp(`^${varName}=["']?(.+?)["']?$`, 'm'));
|
|
82
|
+
if (m && m[1]) return m[1].trim();
|
|
83
|
+
} catch { /* unreadable dotenv is not authoritative */ }
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The persona prompt is the specialist's system context. It lives in
|
|
89
|
+
// specialists/prompts/cx-<role>.md; absent that file, a minimal role-named system
|
|
90
|
+
// prompt keeps the backend functional rather than failing on a missing persona.
|
|
91
|
+
|
|
92
|
+
function loadPersona(role) {
|
|
93
|
+
const slug = String(role || '').replace(/^cx-/, '');
|
|
94
|
+
const file = join(PROMPTS_DIR, `cx-${slug}.md`);
|
|
95
|
+
try {
|
|
96
|
+
if (existsSync(file)) return readFileSync(file, 'utf8');
|
|
97
|
+
} catch { /* unreadable persona falls back to the minimal prompt */ }
|
|
98
|
+
return `You are the ${slug} specialist. Execute your part of the request within your role and return your result directly.`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildUserPrompt({ task, run }) {
|
|
102
|
+
const summary = run?.request?.summary || '';
|
|
103
|
+
const reason = task?.reason ? `\n\nWhy you were dispatched: ${task.reason}` : '';
|
|
104
|
+
const handoff = task?.handoffContract ? `\nHandoff contract: ${task.handoffContract}` : '';
|
|
105
|
+
return `Request: ${summary}${reason}${handoff}\n\nDo your part of this request as the ${String(task?.role || '').replace(/^cx-/, '')} specialist. Return your result directly.`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function bodyExcerpt(res) {
|
|
109
|
+
try {
|
|
110
|
+
return (await res.text()).slice(0, 200);
|
|
111
|
+
} catch {
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// OpenRouter normalizes reasoning into a plaintext `reasoning` string and/or a
|
|
117
|
+
// structured `reasoning_details` array (text / summary / encrypted entries). We
|
|
118
|
+
// read the plaintext first, then assemble any readable detail entries; encrypted
|
|
119
|
+
// carriers have no displayable text and are skipped.
|
|
120
|
+
|
|
121
|
+
function extractOpenRouterReasoning(message) {
|
|
122
|
+
if (typeof message.reasoning === 'string' && message.reasoning.trim()) return message.reasoning.trim();
|
|
123
|
+
const details = Array.isArray(message.reasoning_details) ? message.reasoning_details : [];
|
|
124
|
+
return details.map((d) => d?.text || d?.summary || '').filter(Boolean).join('\n').trim();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function callAnthropic({ model, apiKey, system, user, fetchImpl, reasoning }) {
|
|
128
|
+
const thinking = reasoning ? anthropicThinkingConfig(model) : null;
|
|
129
|
+
const body = {
|
|
130
|
+
model: model.replace(/^anthropic\//, ''),
|
|
131
|
+
max_tokens: thinking ? MAX_OUTPUT_TOKENS + REASONING_BUDGET_TOKENS : MAX_OUTPUT_TOKENS,
|
|
132
|
+
system,
|
|
133
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
|
|
134
|
+
};
|
|
135
|
+
if (thinking) body.thinking = thinking;
|
|
136
|
+
const res = await fetchImpl('https://api.anthropic.com/v1/messages', {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
});
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
throw providerError('PROVIDER_EXECUTION_FAILED', `Anthropic specialist execution failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and ANTHROPIC_API_KEY, or set orchestration.workerBackend to "inline".');
|
|
143
|
+
}
|
|
144
|
+
const data = await res.json();
|
|
145
|
+
const blocks = data.content || [];
|
|
146
|
+
const output = blocks.filter((b) => b && b.type === 'text').map((b) => b.text).join('');
|
|
147
|
+
const thought = blocks.filter((b) => b && b.type === 'thinking').map((b) => b.thinking || '').join('').trim();
|
|
148
|
+
return { output, reasoning: thought };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function callOpenRouter({ model, apiKey, system, user, fetchImpl, reasoning }) {
|
|
152
|
+
const body = {
|
|
153
|
+
model: model.replace(/^openrouter\//, ''),
|
|
154
|
+
max_tokens: MAX_OUTPUT_TOKENS,
|
|
155
|
+
messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
|
|
156
|
+
};
|
|
157
|
+
// Enable reasoning when wanted; explicitly exclude it otherwise — some models
|
|
158
|
+
// (e.g. Gemini) return reasoning by default, which would leak in hidden mode.
|
|
159
|
+
// An empty `{}` is a no-op on OpenRouter; `{enabled:true}` is the on switch.
|
|
160
|
+
body.reasoning = reasoning ? { enabled: true } : { exclude: true };
|
|
161
|
+
const res = await fetchImpl('https://openrouter.ai/api/v1/chat/completions', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', 'HTTP-Referer': 'https://github.com/geraldmaron/construct' },
|
|
164
|
+
body: JSON.stringify(body),
|
|
165
|
+
});
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
throw providerError('PROVIDER_EXECUTION_FAILED', `OpenRouter specialist execution failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and OPENROUTER_API_KEY, or set orchestration.workerBackend to "inline".');
|
|
168
|
+
}
|
|
169
|
+
const data = await res.json();
|
|
170
|
+
const message = data.choices?.[0]?.message || {};
|
|
171
|
+
return { output: message.content || '', reasoning: reasoning ? extractOpenRouterReasoning(message) : '' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Execute one specialist task via the configured provider/model.
|
|
176
|
+
*
|
|
177
|
+
* @param {object} opts
|
|
178
|
+
* @param {object} opts.task the run task (carries role, reason, handoffContract)
|
|
179
|
+
* @param {object} opts.run the run (carries request summary)
|
|
180
|
+
* @param {string} opts.model resolved provider model id
|
|
181
|
+
* @param {string} [opts.provider] resolved provider id (selects the API)
|
|
182
|
+
* @param {Record<string,string>} [opts.env]
|
|
183
|
+
* @param {Function} [opts.fetchImpl] injectable fetch (for tests)
|
|
184
|
+
* @param {string} [opts.chainOfThought] reasoning disclosure mode: `hidden` (default,
|
|
185
|
+
* no reasoning requested) | `surface` | `telemetry_only` (both request and
|
|
186
|
+
* return the model's reasoning for the caller to display or record)
|
|
187
|
+
* @returns {Promise<{output:string, reasoning:string, model:string, provider:string, characters:number}>}
|
|
188
|
+
*/
|
|
189
|
+
export async function runTaskViaProvider({ task, run, model, provider = null, env = process.env, fetchImpl = globalThis.fetch, chainOfThought = 'hidden' } = {}) {
|
|
190
|
+
if (!model) throw providerError('PROVIDER_MODEL_UNRESOLVED', 'Provider worker backend selected but no model resolved.', 'Configure the model tier registry so a model resolves, or set orchestration.workerBackend to "inline".');
|
|
191
|
+
if (typeof fetchImpl !== 'function') throw providerError('PROVIDER_NO_FETCH', 'No fetch implementation available for provider execution.', 'Run on a runtime with global fetch (Node 18+) or inject fetchImpl.');
|
|
192
|
+
|
|
193
|
+
const anthropic = isAnthropic(provider, model);
|
|
194
|
+
const keyVar = anthropic ? 'ANTHROPIC_API_KEY' : 'OPENROUTER_API_KEY';
|
|
195
|
+
const apiKey = resolveKey(keyVar, env, env === process.env);
|
|
196
|
+
if (!apiKey) {
|
|
197
|
+
throw providerError('PROVIDER_KEY_MISSING', `No API key for ${anthropic ? 'Anthropic' : 'OpenRouter'} specialist execution.`, `Set ${keyVar}, or set orchestration.workerBackend to "inline".`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const system = loadPersona(task?.role);
|
|
201
|
+
const user = buildUserPrompt({ task, run });
|
|
202
|
+
const wantsReasoning = chainOfThought !== 'hidden';
|
|
203
|
+
const { output, reasoning } = anthropic
|
|
204
|
+
? await callAnthropic({ model, apiKey, system, user, fetchImpl, reasoning: wantsReasoning })
|
|
205
|
+
: await callOpenRouter({ model, apiKey, system, user, fetchImpl, reasoning: wantsReasoning });
|
|
206
|
+
|
|
207
|
+
const text = output || '';
|
|
208
|
+
return {
|
|
209
|
+
output: text,
|
|
210
|
+
reasoning: wantsReasoning ? (reasoning || '') : '',
|
|
211
|
+
model,
|
|
212
|
+
provider: anthropic ? 'anthropic' : 'openrouter',
|
|
213
|
+
characters: text.length,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -370,9 +370,9 @@ export function formatOverlaySelection(roleFlavors) {
|
|
|
370
370
|
export function detectRiskFlags(request = '') {
|
|
371
371
|
const text = String(request).toLowerCase();
|
|
372
372
|
return {
|
|
373
|
-
architecture: containsAny(text, ['architecture', 'interface contract', 'api contract', 'dependency', 'module boundary', 'data model', 'indexing', 'retrieval design']),
|
|
374
|
-
security: containsAny(text, ['security', 'permission', 'secret', 'privacy', 'payment', 'authentication', 'authorization']),
|
|
375
|
-
dataIntegrity: containsAny(text, ['migration', 'data', 'sync', 'consistency', 'state']),
|
|
373
|
+
architecture: containsAny(text, ['architecture', 'interface contract', 'api contract', 'dependency', 'module boundary', 'data model', 'indexing', 'retrieval design', 'terraform', 'infrastructure', 'iac', 'provisioning', 'blast radius', 'rollout', 'deployment strategy']),
|
|
374
|
+
security: containsAny(text, ['security', 'permission', 'secret', 'privacy', 'payment', 'authentication', 'authorization', 'credential', 'oidc', 'iam', 'access token']),
|
|
375
|
+
dataIntegrity: containsAny(text, ['migration', 'data', 'sync', 'consistency', 'state', 'tfstate', 'drift']),
|
|
376
376
|
ui: containsAny(text, ['ui', 'ux', 'design system', 'screen', 'layout', 'visual', 'onboarding']) && !containsAny(text, ['requirements']),
|
|
377
377
|
docs: containsAny(text, ['docs', 'readme', 'runbook', 'adr']),
|
|
378
378
|
ai: containsAny(text, ['llm', ' agent', 'prompt', 'rag', 'model behavior', 'retrieval', 'embedding', 'vector']),
|
|
@@ -594,6 +594,30 @@ export function buildDispatchPlan({ track, intent, specialists = [] }) {
|
|
|
594
594
|
return `Plan: ${phases.join(' → ')}.`;
|
|
595
595
|
}
|
|
596
596
|
|
|
597
|
+
// Format active flavor overlays into a one-line trace, e.g.
|
|
598
|
+
// "Overlays: architect=platform (matched kubernetes, infra), engineer=ai (matched llm, agent)"
|
|
599
|
+
|
|
600
|
+
export function formatOverlayTrace(roleFlavors = {}, request = '') {
|
|
601
|
+
const text = String(request).toLowerCase();
|
|
602
|
+
const active = Object.entries(roleFlavors).filter(([, flavor]) => flavor);
|
|
603
|
+
if (active.length === 0) return '';
|
|
604
|
+
const FLAVOR_KEYWORDS = {
|
|
605
|
+
architect: ['agent', 'rag', 'retrieval', 'embedding', 'integration', 'webhook', 'warehouse', 'schema', 'sso', 'rbac', 'platform', 'kubernetes', 'infra'],
|
|
606
|
+
productManager: ['platform', 'api', 'enterprise', 'ai product', 'agent', 'growth', 'activation'],
|
|
607
|
+
qa: ['agent', 'prompt', 'api', 'sdk', 'pipeline', 'etl', 'ui', 'ux', 'accessibility'],
|
|
608
|
+
security: ['prompt injection', 'privacy', 'pii', 'dependency', 'cloud', 'iam', 'auth', 'xss', 'csrf'],
|
|
609
|
+
dataAnalyst: ['experiment', 'a/b', 'telemetry', 'metric', 'funnel'],
|
|
610
|
+
dataEngineer: ['vector', 'embedding', 'warehouse', 'pipeline', 'etl', 'pgvector'],
|
|
611
|
+
engineer: ['llm', 'agent', 'rag', 'kubernetes', 'k8s', 'terraform', 'infra', 'docker', 'pipeline', 'etl', 'data model'],
|
|
612
|
+
};
|
|
613
|
+
const parts = active.map(([role, flavor]) => {
|
|
614
|
+
const kws = (FLAVOR_KEYWORDS[role] || []).filter((kw) => text.includes(kw));
|
|
615
|
+
const matched = kws.slice(0, 3).join(', ');
|
|
616
|
+
return matched ? `${role}=${flavor} (matched ${matched})` : `${role}=${flavor}`;
|
|
617
|
+
});
|
|
618
|
+
return `Overlays: ${parts.join(', ')}`;
|
|
619
|
+
}
|
|
620
|
+
|
|
597
621
|
export function routeRequest(options = {}) {
|
|
598
622
|
const intent = classifyIntent(options.request);
|
|
599
623
|
const riskFlags = detectRiskFlags(options.request);
|
package/lib/parity.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* opencode ~/.config/opencode/opencode.json (agent table)
|
|
19
19
|
* codex ~/.codex/agents/*.toml
|
|
20
20
|
* copilot ~/.github/prompts/*.prompt.md
|
|
21
|
-
* vscode VS Code user
|
|
21
|
+
* vscode VS Code user mcp.json (servers)
|
|
22
22
|
* cursor ~/.cursor/mcp.json
|
|
23
23
|
*/
|
|
24
24
|
|
|
@@ -30,6 +30,38 @@ import { fileURLToPath } from 'node:url';
|
|
|
30
30
|
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
31
31
|
const ROOT_DIR = path.resolve(MODULE_DIR, '..');
|
|
32
32
|
|
|
33
|
+
// VS Code and Cursor user settings are JSONC: line/block comments and trailing
|
|
34
|
+
// commas are valid and common. Strict JSON.parse rejects them, producing a
|
|
35
|
+
// false "unreadable" parity failure on a perfectly valid editor config. This
|
|
36
|
+
// strips comments string-aware (so `//` inside a "https://…" value survives)
|
|
37
|
+
// and drops trailing commas before parsing.
|
|
38
|
+
|
|
39
|
+
function parseJsonc(text) {
|
|
40
|
+
let out = '';
|
|
41
|
+
let inString = false;
|
|
42
|
+
let escaped = false;
|
|
43
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
44
|
+
const ch = text[i];
|
|
45
|
+
const next = text[i + 1];
|
|
46
|
+
if (inString) {
|
|
47
|
+
out += ch;
|
|
48
|
+
if (escaped) escaped = false;
|
|
49
|
+
else if (ch === '\\') escaped = true;
|
|
50
|
+
else if (ch === '"') inString = false;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (ch === '"') { inString = true; out += ch; continue; }
|
|
54
|
+
if (ch === '/' && next === '/') { while (i < text.length && text[i] !== '\n') i += 1; out += '\n'; continue; }
|
|
55
|
+
if (ch === '/' && next === '*') { i += 2; while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1; i += 1; continue; }
|
|
56
|
+
out += ch;
|
|
57
|
+
}
|
|
58
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readJsoncFile(file) {
|
|
62
|
+
return parseJsonc(fs.readFileSync(file, 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
|
|
33
65
|
function loadRegistry(rootDir = ROOT_DIR) {
|
|
34
66
|
const file = path.join(rootDir, 'specialists', 'registry.json');
|
|
35
67
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -90,57 +122,68 @@ function checkFileSurface({ surface, kind, dir, extension, expected }) {
|
|
|
90
122
|
};
|
|
91
123
|
}
|
|
92
124
|
|
|
93
|
-
function
|
|
125
|
+
function getVSCodeUserMcpPaths(homeDir) {
|
|
94
126
|
const platform = os.platform();
|
|
95
127
|
if (platform === 'darwin') {
|
|
96
128
|
return [
|
|
97
|
-
path.join(homeDir, 'Library', 'Application Support', 'Code', 'User', '
|
|
98
|
-
path.join(homeDir, 'Library', 'Application Support', 'Code - Insiders', 'User', '
|
|
129
|
+
path.join(homeDir, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
|
|
130
|
+
path.join(homeDir, 'Library', 'Application Support', 'Code - Insiders', 'User', 'mcp.json'),
|
|
99
131
|
];
|
|
100
132
|
}
|
|
101
133
|
if (platform === 'linux') {
|
|
102
134
|
return [
|
|
103
|
-
path.join(homeDir, '.config', 'Code', 'User', '
|
|
104
|
-
path.join(homeDir, '.config', 'Code - Insiders', 'User', '
|
|
135
|
+
path.join(homeDir, '.config', 'Code', 'User', 'mcp.json'),
|
|
136
|
+
path.join(homeDir, '.config', 'Code - Insiders', 'User', 'mcp.json'),
|
|
105
137
|
];
|
|
106
138
|
}
|
|
107
139
|
if (platform === 'win32') {
|
|
108
140
|
const appData = process.env.APPDATA ?? path.join(homeDir, 'AppData', 'Roaming');
|
|
109
141
|
return [
|
|
110
|
-
path.join(appData, 'Code', 'User', '
|
|
111
|
-
path.join(appData, 'Code - Insiders', 'User', '
|
|
142
|
+
path.join(appData, 'Code', 'User', 'mcp.json'),
|
|
143
|
+
path.join(appData, 'Code - Insiders', 'User', 'mcp.json'),
|
|
112
144
|
];
|
|
113
145
|
}
|
|
114
146
|
return [];
|
|
115
147
|
}
|
|
116
148
|
|
|
117
149
|
function checkClaude(registry, { homeDir = os.homedir() } = {}) {
|
|
118
|
-
|
|
150
|
+
// Global ~/.claude/agents ships no agent — the project orchestrator is the
|
|
151
|
+
// front door, and a global agent doubles in editors that read both scopes
|
|
152
|
+
// (VS Code). The expected user-scope set is therefore empty.
|
|
119
153
|
return checkFileSurface({
|
|
120
154
|
surface: 'claude',
|
|
121
155
|
kind: 'agents',
|
|
122
156
|
dir: path.join(homeDir, '.claude', 'agents'),
|
|
123
157
|
extension: '.md',
|
|
124
|
-
expected,
|
|
158
|
+
expected: [],
|
|
125
159
|
});
|
|
126
160
|
}
|
|
127
161
|
|
|
162
|
+
const OPENCODE_BUILTIN_AGENTS = new Set(['title', 'summary', 'compaction']);
|
|
163
|
+
|
|
128
164
|
function checkOpenCode(registry, { homeDir = os.homedir() } = {}) {
|
|
129
165
|
const file = path.join(homeDir, '.config', 'opencode', 'opencode.json');
|
|
130
166
|
if (!fs.existsSync(file)) return { surface: 'opencode', kind: 'agents', status: 'absent', file };
|
|
131
167
|
let config;
|
|
132
168
|
try {
|
|
133
|
-
config =
|
|
169
|
+
config = readJsoncFile(file);
|
|
134
170
|
} catch (err) {
|
|
135
171
|
return { surface: 'opencode', kind: 'agents', status: 'unreadable', file, error: err.message };
|
|
136
172
|
}
|
|
137
173
|
const expected = entriesForSurface(registry, 'opencode');
|
|
138
|
-
const actual = Object.keys(config.agent || config.agents || {})
|
|
174
|
+
const actual = Object.keys(config.agent || config.agents || {})
|
|
175
|
+
.filter((id) => !OPENCODE_BUILTIN_AGENTS.has(id));
|
|
139
176
|
const { missing, extra } = diffSets(expected, actual);
|
|
177
|
+
|
|
178
|
+
// OpenCode's own system agents (title/summary/compaction) are filtered from
|
|
179
|
+
// `actual` above, so any remaining extra is real drift here. checkParity then
|
|
180
|
+
// reclassifies drift → legacy-install when every extra is a registry cx-*
|
|
181
|
+
// specialist (a soft upgrade state), via reclassifyLegacy.
|
|
182
|
+
|
|
140
183
|
return {
|
|
141
184
|
surface: 'opencode',
|
|
142
185
|
kind: 'agents',
|
|
143
|
-
status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
|
|
186
|
+
status: (missing.length === 0 && extra.length === 0) ? 'ok' : 'drift',
|
|
144
187
|
file,
|
|
145
188
|
expectedCount: expected.length,
|
|
146
189
|
actualCount: actual.length,
|
|
@@ -177,19 +220,27 @@ function checkCopilot(registry, { homeDir = os.homedir() } = {}) {
|
|
|
177
220
|
}
|
|
178
221
|
|
|
179
222
|
function checkVSCode(registry, { homeDir = os.homedir() } = {}) {
|
|
180
|
-
const paths = getVSCodeSettingsPaths(homeDir).filter((settingsPath) => fs.existsSync(settingsPath));
|
|
181
|
-
if (paths.length === 0) return { surface: 'vscode', kind: 'mcps', status: 'absent', paths };
|
|
182
223
|
const expected = Object.keys(registry.mcpServers ?? {}).filter((id) => id !== 'memory');
|
|
183
224
|
const actualSet = new Set();
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
225
|
+
const paths = [];
|
|
226
|
+
|
|
227
|
+
// An empty or hand-malformed user `mcp.json` is a normal VS Code state (a
|
|
228
|
+
// 0-byte file is common) — global Construct MCP is opt-in there, so an
|
|
229
|
+
// unparseable file counts as "not configured", not a hard parity failure.
|
|
230
|
+
|
|
231
|
+
for (const mcpPath of getVSCodeUserMcpPaths(homeDir)) {
|
|
232
|
+
if (!fs.existsSync(mcpPath)) continue;
|
|
233
|
+
if (!fs.readFileSync(mcpPath, 'utf8').trim()) continue;
|
|
234
|
+
let config;
|
|
235
|
+
try { config = readJsoncFile(mcpPath); } catch { continue; }
|
|
236
|
+
paths.push(mcpPath);
|
|
237
|
+
for (const id of Object.keys(config.servers ?? {})) actualSet.add(id);
|
|
191
238
|
}
|
|
192
|
-
|
|
239
|
+
if (paths.length === 0) return { surface: 'vscode', kind: 'mcps', status: 'absent', paths };
|
|
240
|
+
|
|
241
|
+
// `memory` is the optional local cm bridge — sync writes it, but it is not
|
|
242
|
+
// required (excluded from `expected`), so it must not count as drift-extra.
|
|
243
|
+
const actual = [...actualSet].filter((id) => id !== 'memory').sort();
|
|
193
244
|
const { missing, extra } = diffSets(expected, actual);
|
|
194
245
|
return {
|
|
195
246
|
surface: 'vscode',
|
|
@@ -208,12 +259,12 @@ function checkCursor(registry, { homeDir = os.homedir() } = {}) {
|
|
|
208
259
|
if (!fs.existsSync(file)) return { surface: 'cursor', kind: 'mcps', status: 'absent', file };
|
|
209
260
|
let config;
|
|
210
261
|
try {
|
|
211
|
-
config =
|
|
262
|
+
config = readJsoncFile(file);
|
|
212
263
|
} catch (err) {
|
|
213
264
|
return { surface: 'cursor', kind: 'mcps', status: 'unreadable', file, error: err.message };
|
|
214
265
|
}
|
|
215
266
|
const expected = Object.keys(registry.mcpServers ?? {}).filter((id) => id !== 'memory');
|
|
216
|
-
const actual = Object.keys(config.mcpServers ?? {});
|
|
267
|
+
const actual = Object.keys(config.mcpServers ?? {}).filter((id) => id !== 'memory');
|
|
217
268
|
const { missing, extra } = diffSets(expected, actual);
|
|
218
269
|
return {
|
|
219
270
|
surface: 'cursor',
|
|
@@ -235,7 +286,10 @@ function checkCursor(registry, { homeDir = os.homedir() } = {}) {
|
|
|
235
286
|
function legacyUserScopeRoster(registry) {
|
|
236
287
|
const prefix = registry.prefix || 'cx';
|
|
237
288
|
const specialists = (registry.specialists || []).map((s) => `${prefix}-${s.name}`);
|
|
238
|
-
|
|
289
|
+
// The front-door agent is project-scoped too now, so a leftover `construct.md`
|
|
290
|
+
// in user-scope `.claude/agents` is a soft upgrade state, not real drift.
|
|
291
|
+
const frontDoor = registry.orchestrator?.name ? [registry.orchestrator.name] : [];
|
|
292
|
+
return new Set([...specialists, ...frontDoor]);
|
|
239
293
|
}
|
|
240
294
|
|
|
241
295
|
/**
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// lib/policy/unified-gates.mjs
|
|
2
|
+
// Single policy declaration for all enforcement layers
|
|
3
|
+
|
|
4
|
+
export const POLICIES = {
|
|
5
|
+
commentStyle: {
|
|
6
|
+
id: 'comment-style',
|
|
7
|
+
description: 'Comments must follow project standards',
|
|
8
|
+
layers: ['write', 'commit', 'ci'],
|
|
9
|
+
bypass: 'CONSTRUCT_SKIP_COMMENT_STYLE',
|
|
10
|
+
critical: false,
|
|
11
|
+
},
|
|
12
|
+
docCoupling: {
|
|
13
|
+
id: 'doc-coupling',
|
|
14
|
+
description: 'Code changes require documentation updates',
|
|
15
|
+
layers: ['commit', 'push'],
|
|
16
|
+
bypass: 'CONSTRUCT_SKIP_DOCS',
|
|
17
|
+
critical: false,
|
|
18
|
+
},
|
|
19
|
+
secretScan: {
|
|
20
|
+
id: 'secret-scan',
|
|
21
|
+
description: 'No secrets in committed code',
|
|
22
|
+
layers: ['commit', 'push', 'ci'],
|
|
23
|
+
bypass: 'CONSTRUCT_SKIP_SECRET_SCAN',
|
|
24
|
+
critical: true,
|
|
25
|
+
},
|
|
26
|
+
ciStatus: {
|
|
27
|
+
id: 'ci-status',
|
|
28
|
+
description: 'CI must be green before push',
|
|
29
|
+
layers: ['push', 'session-end'],
|
|
30
|
+
bypass: 'CONSTRUCT_SKIP_CI_CHECK',
|
|
31
|
+
critical: true,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class UnifiedGateEngine {
|
|
36
|
+
constructor(options = {}) {
|
|
37
|
+
this.env = options.env || process.env;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
isBypassed(policyId) {
|
|
41
|
+
const policy = POLICIES[policyId];
|
|
42
|
+
if (!policy) return { bypassed: false };
|
|
43
|
+
|
|
44
|
+
if (policy.bypass && this.env[policy.bypass] === '1') {
|
|
45
|
+
return { bypassed: true, envVar: policy.bypass };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (this.env.CONSTRUCT_SKIP_ALL_GATES === '1') {
|
|
49
|
+
return { bypassed: true, envVar: 'CONSTRUCT_SKIP_ALL_GATES' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { bypassed: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async evaluateLayer(layer, context = {}) {
|
|
56
|
+
const results = [];
|
|
57
|
+
|
|
58
|
+
for (const [id, policy] of Object.entries(POLICIES)) {
|
|
59
|
+
if (policy.layers.includes(layer)) {
|
|
60
|
+
const bypass = this.isBypassed(id);
|
|
61
|
+
results.push({
|
|
62
|
+
policy: id,
|
|
63
|
+
passed: bypass.bypassed || true,
|
|
64
|
+
bypassed: bypass.bypassed,
|
|
65
|
+
bypassEnvVar: bypass.envVar,
|
|
66
|
+
critical: policy.critical,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const failed = results.filter(r => !r.passed && r.critical);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
layer,
|
|
75
|
+
passed: failed.length === 0,
|
|
76
|
+
canProceed: failed.length === 0,
|
|
77
|
+
results,
|
|
78
|
+
summary: `${results.filter(r => r.passed).length}/${results.length} passed`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function checkGates(layer, context = {}, options = {}) {
|
|
84
|
+
const engine = new UnifiedGateEngine(options);
|
|
85
|
+
return await engine.evaluateLayer(layer, context);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function listPolicies() {
|
|
89
|
+
return Object.entries(POLICIES).map(([id, policy]) => ({
|
|
90
|
+
id,
|
|
91
|
+
description: policy.description,
|
|
92
|
+
layers: policy.layers,
|
|
93
|
+
bypass: policy.bypass,
|
|
94
|
+
critical: policy.critical,
|
|
95
|
+
}));
|
|
96
|
+
}
|