@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,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 settings (github.copilot.mcpServers)
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 getVSCodeSettingsPaths(homeDir) {
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', 'settings.json'),
98
- path.join(homeDir, 'Library', 'Application Support', 'Code - Insiders', 'User', 'settings.json'),
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', 'settings.json'),
104
- path.join(homeDir, '.config', 'Code - Insiders', 'User', 'settings.json'),
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', 'settings.json'),
111
- path.join(appData, 'Code - Insiders', 'User', 'settings.json'),
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
- const expected = entriesForSurface(registry, 'claude');
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 = JSON.parse(fs.readFileSync(file, 'utf8'));
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
- for (const settingsPath of paths) {
185
- try {
186
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
187
- for (const id of Object.keys(settings['github.copilot.mcpServers'] ?? {})) actualSet.add(id);
188
- } catch (err) {
189
- return { surface: 'vscode', kind: 'mcps', status: 'unreadable', file: settingsPath, error: err.message };
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
- const actual = [...actualSet].sort();
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 = JSON.parse(fs.readFileSync(file, 'utf8'));
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
- return new Set(specialists);
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
+ }