@geraldmaron/construct 1.3.2 → 1.4.1

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 (51) hide show
  1. package/README.md +2 -1
  2. package/bin/construct +61 -15
  3. package/lib/a11y-audit.mjs +104 -0
  4. package/lib/artifact-completion-states.mjs +35 -0
  5. package/lib/artifact-completion.mjs +66 -0
  6. package/lib/artifact-gate-levels.mjs +69 -0
  7. package/lib/artifact-manifest.mjs +21 -0
  8. package/lib/artifact-release-gate.mjs +19 -1
  9. package/lib/artifact-workflow.mjs +16 -0
  10. package/lib/brand-contrast.mjs +60 -0
  11. package/lib/brand-tokens.mjs +14 -0
  12. package/lib/cli-commands.mjs +5 -3
  13. package/lib/codex-config.mjs +7 -1
  14. package/lib/deck-export-pptx.mjs +223 -18
  15. package/lib/diagram-quality.mjs +161 -0
  16. package/lib/document-export.mjs +1 -1
  17. package/lib/env-config.mjs +8 -8
  18. package/lib/export-validate.mjs +106 -0
  19. package/lib/gates-audit.mjs +34 -0
  20. package/lib/health-check.mjs +23 -9
  21. package/lib/hooks/guard-bash.mjs +3 -1
  22. package/lib/hooks/session-start.mjs +2 -1
  23. package/lib/init-unified.mjs +5 -3
  24. package/lib/mcp/server.mjs +53 -3
  25. package/lib/mcp/tool-recovery.mjs +56 -0
  26. package/lib/mcp/tools/web-search.mjs +127 -0
  27. package/lib/mcp-manager.mjs +11 -4
  28. package/lib/mcp-platform-config.mjs +18 -1
  29. package/lib/models/provider-poll.mjs +32 -6
  30. package/lib/orchestration/readiness.mjs +183 -0
  31. package/lib/orchestration-policy.mjs +36 -0
  32. package/lib/output-quality.mjs +90 -0
  33. package/lib/pixel-regression.mjs +172 -0
  34. package/lib/providers/op-run.mjs +6 -5
  35. package/lib/providers/secret-audit-wiring.mjs +32 -0
  36. package/lib/providers/secret-resolver.mjs +90 -20
  37. package/lib/publish.mjs +64 -1
  38. package/lib/render-evidence.mjs +55 -0
  39. package/lib/render-pipeline.mjs +136 -0
  40. package/lib/service-manager.mjs +31 -10
  41. package/lib/templates/doc-presentation.mjs +24 -0
  42. package/lib/tracking-surfaces.mjs +36 -0
  43. package/lib/visual-review.mjs +70 -0
  44. package/package.json +1 -1
  45. package/scripts/sync-specialists.mjs +107 -11
  46. package/specialists/artifact-manifest.json +18 -0
  47. package/specialists/artifact-manifest.schema.json +46 -2
  48. package/templates/distribution/construct-brand.typ +1 -2
  49. package/templates/distribution/construct-deck.html +34 -34
  50. package/templates/distribution/construct-pdf.typ +1 -1
  51. package/templates/distribution/construct-web.html +8 -10
@@ -0,0 +1,106 @@
1
+ /**
2
+ * lib/export-validate.mjs — Post-export validity, content roundtrip, and reference integrity.
3
+ *
4
+ * Three deterministic checks that today only a >1KB file-size heuristic covered: a PDF is parsed
5
+ * by pdfinfo for a real page count, exported text is extracted and compared against the source's
6
+ * key phrases to catch dropped content, and local image/link targets are resolved on disk. Each
7
+ * returns a typed degradation reason when its tool is absent rather than a silent pass. Closes the
8
+ * gaps in subagents/document-export-quality.md and absorbs the construct-amfg PDF-fidelity work.
9
+ */
10
+ import { spawnSync } from 'node:child_process';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { whichBin } from './document-export.mjs';
14
+
15
+ function degraded(reason, message, extra = {}) {
16
+ return { ok: false, degradation: reason, message, ...extra };
17
+ }
18
+
19
+ export function validatePdf(pdfPath, env = process.env) {
20
+ if (!whichBin('pdfinfo', env)) return degraded('missing-dependency', 'pdfinfo not available', { valid: null, pageCount: 0 });
21
+ if (!fs.existsSync(pdfPath)) return degraded('skipped-by-policy', `pdf not found: ${pdfPath}`, { valid: false, pageCount: 0 });
22
+ const result = spawnSync('pdfinfo', [pdfPath], { encoding: 'utf8', env });
23
+ if (result.status !== 0) {
24
+ return { ok: false, valid: false, pageCount: 0, degradation: null, message: (result.stderr || '').trim() || 'pdfinfo reported an invalid PDF' };
25
+ }
26
+ const match = result.stdout.match(/^Pages:\s+(\d+)/m);
27
+ const pageCount = match ? Number(match[1]) : 0;
28
+ return { ok: pageCount > 0, valid: pageCount > 0, pageCount, degradation: null, message: `valid PDF, ${pageCount} page(s)` };
29
+ }
30
+
31
+ // Each format uses the cheapest faithful text extractor; a missing tool degrades with a typed
32
+ // reason instead of reporting empty text as a pass.
33
+
34
+ function extractText(filePath, format, env) {
35
+ if (format === 'pdf') {
36
+ if (!whichBin('pdftotext', env)) return { degradation: 'missing-dependency' };
37
+ const result = spawnSync('pdftotext', [filePath, '-'], { encoding: 'utf8', env });
38
+ return result.status === 0 ? { text: result.stdout } : { degradation: 'unavailable-renderer' };
39
+ }
40
+ if (format === 'html') {
41
+ const raw = fs.readFileSync(filePath, 'utf8');
42
+ const text = raw
43
+ .replace(/<!--[\s\S]*?-->/g, ' ')
44
+ .replace(/<(style|script)[\s\S]*?<\/\1>/gi, ' ')
45
+ .replace(/<[^>]+>/g, ' ');
46
+ return { text };
47
+ }
48
+ if (format === 'docx' || format === 'pptx') {
49
+ if (!whichBin('unzip', env)) return { degradation: 'missing-dependency' };
50
+ const member = format === 'docx' ? 'word/document.xml' : 'ppt/slides/slide*.xml';
51
+ const result = spawnSync('unzip', ['-p', filePath, member], { encoding: 'utf8', env });
52
+ return result.status === 0 ? { text: result.stdout.replace(/<[^>]+>/g, ' ') } : { degradation: 'unavailable-renderer' };
53
+ }
54
+ return { degradation: 'unsupported-format' };
55
+ }
56
+
57
+ function sourcePhrases(sourceMarkdown) {
58
+ const body = sourceMarkdown.replace(/^---\n[\s\S]*?\n---\n/, '').replace(/```[\s\S]*?```/g, '');
59
+ const headings = [...body.matchAll(/^#{1,6}\s+(.+)$/gm)]
60
+ .map((match) => match[1].trim())
61
+ .filter((heading) => !/[{}<>]/.test(heading));
62
+ return [...new Set(headings)].slice(0, 12);
63
+ }
64
+
65
+ export function contentRoundtrip({ exportPath, format, sourceMarkdown, env = process.env } = {}) {
66
+ if (!fs.existsSync(exportPath)) return degraded('skipped-by-policy', `export not found: ${exportPath}`, { textRatio: 0, missingPhrases: [] });
67
+ const extracted = extractText(exportPath, format, env);
68
+ if (extracted.degradation) return degraded(extracted.degradation, `cannot extract text from ${format}`, { textRatio: 0, missingPhrases: [] });
69
+
70
+ // PDF and DOCX text extraction wrap lines arbitrarily, so a heading that survives the export
71
+ // can still appear with an embedded newline. Collapse all whitespace on both sides before the
72
+ // substring match; otherwise a faithful export reads as dropped content.
73
+
74
+ const normalize = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
75
+ const haystack = normalize(extracted.text || '');
76
+ const phrases = sourcePhrases(sourceMarkdown);
77
+ const missingPhrases = phrases.filter((phrase) => !haystack.includes(normalize(phrase)));
78
+ const ratio = phrases.length ? (phrases.length - missingPhrases.length) / phrases.length : 1;
79
+ return {
80
+ ok: missingPhrases.length === 0,
81
+ textRatio: Math.round(ratio * 100) / 100,
82
+ missingPhrases,
83
+ degradation: null,
84
+ message: `${phrases.length - missingPhrases.length}/${phrases.length} key phrases preserved`,
85
+ };
86
+ }
87
+
88
+ // Local image and link targets must resolve on disk; remote URLs, anchors, and mailto are out of
89
+ // scope for an on-disk integrity check.
90
+
91
+ export function referenceIntegrity(sourceMarkdown, baseDir) {
92
+ const body = sourceMarkdown.replace(/```[\s\S]*?```/g, '');
93
+ const missingImages = [];
94
+ const brokenLinks = [];
95
+ for (const match of body.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
96
+ const ref = match[1].split(/\s+/)[0];
97
+ if (/^https?:|^data:/.test(ref)) continue;
98
+ if (!fs.existsSync(path.resolve(baseDir, ref))) missingImages.push(ref);
99
+ }
100
+ for (const match of body.matchAll(/(?<!!)\[[^\]]*\]\(([^)]+)\)/g)) {
101
+ const ref = match[1].split(/\s+/)[0];
102
+ if (/^https?:|^#|^mailto:/.test(ref)) continue;
103
+ if (!fs.existsSync(path.resolve(baseDir, ref))) brokenLinks.push(ref);
104
+ }
105
+ return { ok: missingImages.length === 0 && brokenLinks.length === 0, missingImages, brokenLinks };
106
+ }
@@ -22,6 +22,7 @@ import { readFileSync, existsSync } from 'node:fs';
22
22
  import { join, dirname } from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { execSync } from 'node:child_process';
25
+ import { loadArtifactManifest, validateArtifactManifest } from './artifact-manifest.mjs';
25
26
 
26
27
  const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
27
28
  const DEFAULT_ROOT_DIR = join(MODULE_DIR, '..');
@@ -58,6 +59,7 @@ const GATE_DEFINITIONS = [
58
59
  { ciJob: 'docs drift check', prePushLabel: null, critical: true, requireMergeVia: MERGE_AGGREGATOR, localMirror: 'ci-only' },
59
60
  { ciJob: 'comment policy', prePushLabel: null, preCommitCheck: 'Construct comment-lint', critical: true, note: 'pre-commit Construct policy section calls `lint:comments` across the full worktree', requireMergeVia: MERGE_AGGREGATOR },
60
61
  { ciJob: 'template policy', prePushLabel: null, preCommitGhPrIntercept: true, critical: true, note: 'pre-push-gate intercepts `gh pr create` / `gh pr edit` and lints the body', requireMergeVia: MERGE_AGGREGATOR },
62
+ { ciJob: 'certification gate', prePushLabel: null, preCommitCheck: null, critical: true, note: 'artifact release-gate certification: `construct certify gate`; mirrored locally by release:check', requireMergeVia: MERGE_AGGREGATOR, localMirror: 'ci-only' },
61
63
  { ciJob: MERGE_AGGREGATOR, prePushLabel: null, preCommitCheck: null, critical: true, note: 'aggregator: succeeds iff every wrapped conditional job in ci.yml ended in success or skipped', localMirror: 'ci-only' },
62
64
  ];
63
65
 
@@ -194,6 +196,23 @@ function identifyGaps(state) {
194
196
  return gaps;
195
197
  }
196
198
 
199
+ // Artifact-gate config drift: the manifest's qualityContract gate levels and required completion
200
+ // states are the source of truth for what each artifact's release gate enforces. A typo or an
201
+ // out-of-enum level silently weakens the gate, so the audit validates the manifest and reports any
202
+ // drift. A repo with no manifest (the gates-audit fixture) is skipped, not failed.
203
+
204
+ export function auditArtifactGateConfig(rootDir = DEFAULT_ROOT_DIR) {
205
+ const manifestPath = join(rootDir, 'specialists', 'artifact-manifest.json');
206
+ if (!existsSync(manifestPath)) return { ok: true, errors: [], skipped: 'no manifest' };
207
+ try {
208
+ const manifest = loadArtifactManifest({ rootDir, force: true });
209
+ const result = validateArtifactManifest(manifest);
210
+ return { ok: result.valid, errors: result.errors || [] };
211
+ } catch (err) {
212
+ return { ok: false, errors: [`artifact-manifest unreadable: ${err.message}`] };
213
+ }
214
+ }
215
+
197
216
  export function auditGates({ rootDir = DEFAULT_ROOT_DIR, repo, branch = 'main' } = {}) {
198
217
  const inCI = process.env.CI === 'true' || process.env.CI === '1';
199
218
  const ciJobs = parseCIJobs(rootDir);
@@ -204,6 +223,12 @@ export function auditGates({ rootDir = DEFAULT_ROOT_DIR, repo, branch = 'main' }
204
223
 
205
224
  const state = { ciJobs, prePushJobs, preCommitChecks, branchProtection, hooksPath, rootDir, inCI };
206
225
  const gaps = identifyGaps(state);
226
+
227
+ const artifactGateConfig = auditArtifactGateConfig(rootDir);
228
+ for (const error of artifactGateConfig.errors) {
229
+ gaps.push({ kind: 'artifact-gate-config-drift', gate: error, critical: true });
230
+ }
231
+
207
232
  const criticalGaps = gaps.filter((g) => g.critical);
208
233
 
209
234
  return {
@@ -213,6 +238,7 @@ export function auditGates({ rootDir = DEFAULT_ROOT_DIR, repo, branch = 'main' }
213
238
  preCommitChecks,
214
239
  hooksPath,
215
240
  branchProtection,
241
+ artifactGateConfig,
216
242
  gaps,
217
243
  criticalGaps,
218
244
  inCI,
@@ -241,6 +267,14 @@ export function formatReport(report) {
241
267
  lines.push(`core.hooksPath: ${report.hooksPath || '(unset)'}`);
242
268
  lines.push('');
243
269
 
270
+ if (report.artifactGateConfig) {
271
+ const cfg = report.artifactGateConfig;
272
+ const state = cfg.skipped ? `skipped (${cfg.skipped})` : (cfg.ok ? 'OK ✓' : `${cfg.errors.length} drift issue(s) ✗`);
273
+ lines.push(`Artifact gate config: ${state}`);
274
+ for (const e of cfg.errors || []) lines.push(` • ${e}`);
275
+ lines.push('');
276
+ }
277
+
244
278
  if (report.branchProtection.status === 'fetched') {
245
279
  lines.push(`Branch protection (main): ${report.branchProtection.requiredContexts.length} required contexts`);
246
280
  for (const c of report.branchProtection.requiredContexts) lines.push(` • ${c}`);
@@ -9,7 +9,7 @@
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
- import { existsSync, readFileSync } from 'node:fs';
12
+ import { existsSync, readFileSync, chmodSync } from 'node:fs';
13
13
  import { extractOpRef, hasAnySecret, hasSecret } from './providers/secret-resolver.mjs';
14
14
  import { configDir } from './config/xdg.mjs';
15
15
 
@@ -296,12 +296,15 @@ const CREDENTIAL_VARS = [
296
296
  ];
297
297
 
298
298
  /**
299
- * Link discovered credential references into the XDG config dir config.env without
300
- * invoking `op read`. Plain values and op:// refs are persisted as found.
299
+ * Discover where credential env vars are configured, without invoking `op read`.
300
+ * Returns a presence map (op:// refs as-is, plain values as found) for reporting.
301
+ * When writeConfig is set, only op:// references are persisted into the XDG
302
+ * config.env (mode 0600) — a resolved or plain secret value is never copied to
303
+ * disk, so config.env holds late-bound references rather than materialized keys.
301
304
  * @param {Object} options
302
305
  * @param {string} options.homeDir
303
- * @param {boolean} options.writeConfig - Write keys to config.env (default: false)
304
- * @returns {Promise<Record<string, string>>} raw values linked (never plaintext from op)
306
+ * @param {boolean} options.writeConfig - Persist discovered op:// refs to config.env (default: false)
307
+ * @returns {Promise<Record<string, string>>} discovered raw values (refs or plain), never resolved via op
305
308
  */
306
309
  export async function resolveCredentials(options = {}) {
307
310
  const { homeDir = os.homedir(), writeConfig = false } = options;
@@ -317,10 +320,21 @@ export async function resolveCredentials(options = {}) {
317
320
  linked[varName] = ref || raw;
318
321
  }
319
322
 
320
- if (writeConfig && Object.keys(linked).length > 0) {
321
- try {
322
- writeEnvValues(getUserEnvPath(homeDir), linked);
323
- } catch { /* best effort */ }
323
+ // Persist references only. A plain value discovered in a dotenv or shell rc is
324
+ // reported as present but never written into config.env — copying a resolved
325
+ // secret to disk is the late-binding violation this path must not commit.
326
+ if (writeConfig) {
327
+ const refsOnly = {};
328
+ for (const [name, value] of Object.entries(linked)) {
329
+ if (extractOpRef(value)) refsOnly[name] = value;
330
+ }
331
+ if (Object.keys(refsOnly).length > 0) {
332
+ try {
333
+ const file = getUserEnvPath(homeDir);
334
+ writeEnvValues(file, refsOnly);
335
+ chmodSync(file, 0o600);
336
+ } catch { /* best effort */ }
337
+ }
324
338
  }
325
339
 
326
340
  return linked;
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * Runs as PreToolUse on Bash. Scans the command against a blocklist of destructive patterns (rm -rf, force push to main, etc.) and exits 2 to block matches.
6
6
  *
7
- * @p95ms 5
7
+ * Budget covers the role-fence path below — doctorRoot lookup, last-agent file reads, and the lazy manifest/fence/approval imports — which the original 5ms target predated; the nightly bench (variance-heavy CI lane) measured ~17-19ms marginal, so the budget reflects that real cost with the ×2 gate tolerance still catching a true regression.
8
+ *
9
+ * @p95ms 15
8
10
  * @maxBlockingScope PreToolUse
9
11
  *
10
12
  * @lifecycle PreToolUse
@@ -260,6 +260,7 @@ try {
260
260
  // `knowledge_search` instead of training-data recall. Full policy in persona.
261
261
 
262
262
  const selfKnowledgeNote = '\nSelf-knowledge: call `knowledge_search` for any Construct-specific question.\n';
263
+ const orchestrationReadinessNote = '\nOrchestration readiness: not checked for this host session — run `construct orchestrate preflight --json` or call MCP `orchestration_readiness` before relying on multi-specialist execution.\n';
263
264
 
264
265
  // Configured provider sources hint — tell Construct what repos/projects are
265
266
  // wired so it knows to call `provider_fetch` instead of saying "no context".
@@ -386,7 +387,7 @@ try {
386
387
  // that pollutes the caller's output contract, so the resolved output mode routes
387
388
  // the payload to stdout (interactive), stderr, or a debug log (silent).
388
389
 
389
- const payload = `${header}\n${body}${stateNote}${efficiencyNote}${observationsNote}${concurrencyNote}${skillScopeNote}${dropNote}${embedStatusNote}${sourcesNote}${selfKnowledgeNote}${rolesNote}${intakeReviewNote}${permissionPostureNote}${envCheckNote}\n${footer}${pendingNote}\n`;
390
+ const payload = `${header}\n${body}${stateNote}${efficiencyNote}${observationsNote}${concurrencyNote}${skillScopeNote}${dropNote}${embedStatusNote}${sourcesNote}${selfKnowledgeNote}${orchestrationReadinessNote}${rolesNote}${intakeReviewNote}${permissionPostureNote}${envCheckNote}\n${footer}${pendingNote}\n`;
390
391
  try {
391
392
  const { mode } = resolveHookOutputMode({ cwd, env: process.env });
392
393
  writeHookContext({ payload, mode });
@@ -612,12 +612,14 @@ function preflight(target) {
612
612
  throw new Error('Not a git repository. Run `git init` first.');
613
613
  }
614
614
 
615
- // Check working tree clean (silent unless verbose)
615
+ // A dirty working tree before init/upgrade is surfaced by default so the user knows scaffolding
616
+ // lands on top of uncommitted changes; the file-count detail stays behind --verbose.
617
+
616
618
  const porcelain = execSync('git status --porcelain', { cwd: target, encoding: 'utf8' }).trim();
617
619
  const clean = porcelain === '';
618
- if (!clean && verbose) {
620
+ if (!clean) {
619
621
  console.warn('Warning: Working tree has uncommitted changes');
620
- if (porcelain.split('\n').length > 10) {
622
+ if (verbose && porcelain.split('\n').length > 10) {
621
623
  console.warn(` (${porcelain.split('\n').length} files modified)`);
622
624
  }
623
625
  }
@@ -3,7 +3,8 @@
3
3
  * lib/mcp/server.mjs — Construct MCP server: tool registry and request dispatcher.
4
4
  *
5
5
  * Thin dispatcher only — all tool implementations live in lib/mcp/tools/*.mjs.
6
- * Registers 52 tools across 8 modules: project, document, storage, skills, workflow, telemetry, memory, scope.
6
+ * Registers the Construct MCP tool catalog across project, document, storage,
7
+ * skills, workflow, telemetry, memory, scope, and orchestration modules.
7
8
  * Consumed by Claude Code, OpenCode, and any MCP-compatible host.
8
9
  */
9
10
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
@@ -62,6 +63,7 @@ import { modelResolve, triageRecommend, workflowInvoke, capabilityDescribe, exec
62
63
  import { authorArtifact } from './tools/artifact-author.mjs';
63
64
  import { findTool } from './tools/find-tool.mjs';
64
65
  import { recoverToolName, recordToolNameMiss, isGatewayName } from './tool-recovery.mjs';
66
+ import { buildOrchestrationReadiness } from '../orchestration/readiness.mjs';
65
67
 
66
68
  const DEFAULT_ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
67
69
  const ROOT_DIR = resolve(process.env.CX_TOOLKIT_DIR || DEFAULT_ROOT_DIR);
@@ -1284,6 +1286,19 @@ const ALL_TOOL_DEFS = [
1284
1286
  },
1285
1287
  },
1286
1288
  },
1289
+ {
1290
+ name: 'web_search',
1291
+ description: 'Search the PUBLIC WEB and return CITED results — the only search surface that reaches the open web, kept distinct from knowledge_search / provider_fetch / repo search so it is never conflated or faked. Requires a governed provider (WEB_SEARCH_URL); without one it returns a typed degradation (capability-unavailable) and zero results, never source/repo results dressed as web. Every result carries a verifiable URL, a claim-relative class, and an Admiralty grade with derived confidence (ADR-0017; high reserved for A1/A2/B1).',
1292
+ inputSchema: {
1293
+ type: 'object',
1294
+ required: ['query', 'claim'],
1295
+ properties: {
1296
+ query: { type: 'string', description: 'The search query string.' },
1297
+ claim: { type: 'string', description: 'The claim the results are meant to support — drives claim-relative source classification (ADR-0017).' },
1298
+ recency: { type: 'string', description: 'Optional freshness window hint (e.g. "30d").' },
1299
+ },
1300
+ },
1301
+ },
1287
1302
  {
1288
1303
  name: 'orchestration_status',
1289
1304
  description: 'Inspect orchestration runs on the local Construct daemon: pass run_id for the full record (status, per-task status/executor/output/error), or omit it for a list of recent runs. Fails fast if the daemon is unreachable.',
@@ -1295,6 +1310,22 @@ const ALL_TOOL_DEFS = [
1295
1310
  },
1296
1311
  },
1297
1312
  },
1313
+ {
1314
+ name: 'orchestration_readiness',
1315
+ description: 'Report whether this MCP session has the required Construct orchestration tools attached and reachable now. Returns a pass/fail verdict, typed reasonCode, one next step, required/observed/missing tools, and a redacted diagnostic bundle for support.',
1316
+ inputSchema: {
1317
+ type: 'object',
1318
+ properties: {
1319
+ host: { type: 'string', description: 'Host/IDE identifier, if known.' },
1320
+ session_id: { type: 'string', description: 'Host session/thread id, if known.' },
1321
+ observed_tools: { type: 'array', items: { type: 'string' }, description: 'Optional tool names the host observed in tools/list. Defaults to this server catalog.' },
1322
+ reachable_tools: { type: 'array', items: { type: 'string' }, description: 'Optional long-tail tools reachable through a gateway enum.' },
1323
+ required_tools: { type: 'array', items: { type: 'string' }, description: 'Tools required for orchestration. Defaults to orchestration_policy + orchestration_run.' },
1324
+ client_contract_version: { type: 'string', description: 'Client contract version for compatibility checks.' },
1325
+ observation_scope: { type: 'string', enum: ['host-session', 'local-config'], description: 'What was observed. MCP calls normally use host-session.' },
1326
+ },
1327
+ },
1328
+ },
1298
1329
  ];
1299
1330
 
1300
1331
  // Curated flat core: high-frequency, low-arg tools the orchestrator and the
@@ -1303,10 +1334,10 @@ const ALL_TOOL_DEFS = [
1303
1334
  // host/model; the long tail is reachable, just not front-loaded.
1304
1335
 
1305
1336
  const CORE_TOOL_NAMES = new Set([
1306
- 'orchestration_policy', 'get_skill', 'get_template', 'search_skills', 'knowledge_search',
1337
+ 'orchestration_policy', 'orchestration_run', 'get_skill', 'get_template', 'search_skills', 'knowledge_search',
1307
1338
  'memory_search', 'project_context', 'summarize_diff', 'find_tool',
1308
1339
  'author_artifact', 'document_export', 'publish_run', 'artifact_workflow',
1309
- 'workflow_invoke', 'triage_recommend',
1340
+ 'workflow_invoke', 'triage_recommend', 'orchestration_readiness',
1310
1341
  ]);
1311
1342
 
1312
1343
  const LONG_TAIL_DEFS = ALL_TOOL_DEFS.filter((t) => !CORE_TOOL_NAMES.has(t.name));
@@ -1340,6 +1371,15 @@ export function exposedTools() {
1340
1371
 
1341
1372
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: exposedTools() }));
1342
1373
 
1374
+ function currentMcpToolCatalog() {
1375
+ const tools = exposedTools();
1376
+ const observedTools = tools.map((t) => t.name);
1377
+ const reachableTools = tools.flatMap((t) => (
1378
+ t.name === 'call' ? (t.inputSchema?.properties?.tool?.enum ?? []) : []
1379
+ ));
1380
+ return { observedTools, reachableTools };
1381
+ }
1382
+
1343
1383
  // Single dispatch table shared by the direct CallTool path and the construct_call
1344
1384
  // meta-tool, so collapsing the surface costs no capability — every tool stays
1345
1385
  // reachable by name. construct_call re-enters here once (guarded against
@@ -1466,7 +1506,17 @@ export async function dispatchToolByName(name, args = {}) {
1466
1506
  else if (name === 'artifact_workflow') result = artifactWorkflow(args);
1467
1507
  else if (name === 'author_artifact') result = await authorArtifact(args, opts);
1468
1508
  else if (name === 'find_tool') result = await findTool(args, { toolDefs: ALL_TOOL_DEFS, env: process.env });
1509
+ else if (name === 'orchestration_readiness') {
1510
+ const catalog = currentMcpToolCatalog();
1511
+ result = buildOrchestrationReadiness({
1512
+ ...args,
1513
+ observedTools: args.observed_tools ?? catalog.observedTools,
1514
+ reachableTools: args.reachable_tools ?? catalog.reachableTools,
1515
+ observationScope: args.observation_scope ?? 'host-session',
1516
+ }, { env: process.env, cwd: process.cwd() });
1517
+ }
1469
1518
  else if (name === 'orchestration_run') { const m = await import('./tools/orchestration-run.mjs'); result = await m.orchestrationRun(args); }
1519
+ else if (name === 'web_search') { const m = await import('./tools/web-search.mjs'); result = await m.webSearch(args); }
1470
1520
  else if (name === 'orchestration_status') { const m = await import('./tools/orchestration-run.mjs'); result = await m.orchestrationStatus(args); }
1471
1521
  else if (name === 'call') {
1472
1522
  const inner = String(args?.tool || '');
@@ -42,3 +42,59 @@ export function recordToolNameMiss(rootDir, { name, recovered = null } = {}) {
42
42
  /* telemetry is best-effort; never break dispatch */
43
43
  }
44
44
  }
45
+
46
+ function readJsonl(rootDir, file, kind) {
47
+ const out = [];
48
+ try {
49
+ for (const line of fs.readFileSync(path.join(rootDir, '.cx', 'observations', file), 'utf8').split('\n')) {
50
+ if (!line) continue;
51
+ let entry;
52
+ try { entry = JSON.parse(line); } catch { continue; }
53
+ if (entry.kind === kind) out.push(entry);
54
+ }
55
+ } catch { /* absent file = no records */ }
56
+ return out;
57
+ }
58
+
59
+ function aggregateByKey(entries, key) {
60
+ const counts = new Map();
61
+ let recovered = 0;
62
+ for (const e of entries) {
63
+ const k = e[key] || '(unknown)';
64
+ const c = counts.get(k) || { name: k, count: 0, recovered: 0 };
65
+ c.count += 1;
66
+ if (e.recovered) { c.recovered += 1; recovered += 1; }
67
+ counts.set(k, c);
68
+ }
69
+ const byName = [...counts.values()].sort((a, b) => b.count - a.count);
70
+ return { total: entries.length, recovered, byName };
71
+ }
72
+
73
+ // The read side of recordToolNameMiss: aggregate misses by name so the
74
+ // discoverability signal is acted on, not just stored. A name missed repeatedly
75
+ // is a tool agents cannot find — the candidate for a doc/alias fix.
76
+
77
+ export function summarizeToolNameMisses(rootDir, { top = 5 } = {}) {
78
+ const agg = aggregateByKey(readJsonl(rootDir, 'tool-name-misses.jsonl', 'tool-name-miss'), 'name');
79
+ return { ...agg, top: agg.byName.slice(0, top) };
80
+ }
81
+
82
+ // Failure capture: record a tool/command failure (timeout, error, gate denial) as
83
+ // an observation so repeated failures become a learnable anti-pattern rather than
84
+ // an invisible one-off. Best-effort, like the miss telemetry above.
85
+
86
+ export function recordToolFailure(rootDir, { tool, code = null, message = '' } = {}) {
87
+ try {
88
+ const dir = path.join(rootDir, '.cx', 'observations');
89
+ fs.mkdirSync(dir, { recursive: true });
90
+ const line = `${JSON.stringify({ at: new Date().toISOString(), kind: 'tool-failure', name: tool, code, message })}\n`;
91
+ fs.appendFileSync(path.join(dir, 'tool-failures.jsonl'), line);
92
+ } catch {
93
+ /* telemetry is best-effort; never break dispatch */
94
+ }
95
+ }
96
+
97
+ export function summarizeToolFailures(rootDir, { top = 5 } = {}) {
98
+ const agg = aggregateByKey(readJsonl(rootDir, 'tool-failures.jsonl', 'tool-failure'), 'name');
99
+ return { ...agg, top: agg.byName.slice(0, top) };
100
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * lib/mcp/tools/web-search.mjs — governed public web search (construct-rr63.5.3).
3
+ *
4
+ * The one Construct search surface that reaches the public web, kept distinct from the source/repo/
5
+ * local surfaces so it can never be faked or conflated (the contract in
6
+ * docs/notes/research/construct-self-audit/synthesis/web-search-capability-contract.md). Three
7
+ * invariants: (1) every returned result carries a verifiable URL, a claim-relative class, and an
8
+ * Admiralty grade with a derived confidence (ADR-0017) — results without a URL are dropped, not
9
+ * guessed; (2) every result is labeled `source: 'web'` and the tool never falls back to repo/source
10
+ * search; (3) when no governed provider is configured or the provider is unreachable, the tool
11
+ * returns a typed degradation and zero results rather than claiming it searched the web.
12
+ */
13
+
14
+ const COMMUNITY_HOSTS = ['reddit.com', 'stackoverflow.com', 'news.ycombinator.com', 'discord.com', 'github.com'];
15
+ const HIGH_CONFIDENCE_GRADES = new Set(['A1', 'A2', 'B1']);
16
+
17
+ // Recency enforcement (rules/common/research.md §1, research-execution-policy
18
+ // recencyRule). The researcher is told to search most-recent-first and treat
19
+ // sources older than 12 months as stale, but the provider returns relevance- and
20
+ // authority-ranked results with the date dropped, so older established sources
21
+ // surface over newer ones. Normalize a date from whatever field the provider
22
+ // supplies, flag staleness against the 12-month window, and reorder
23
+ // most-recent-first. Admiralty-derived `confidence` is left intact (ADR-0017);
24
+ // recency rides alongside it as `date` + `stale` rather than folded into it.
25
+
26
+ const STALE_AFTER_DAYS = 365;
27
+
28
+ function normalizeDate(it) {
29
+ const raw = it.date || it.publishedAt || it.published || it.published_at || it.datePublished || null;
30
+ if (!raw) return null;
31
+ const t = Date.parse(raw);
32
+ return Number.isNaN(t) ? null : new Date(t).toISOString().slice(0, 10);
33
+ }
34
+
35
+ function providerConfig(env) {
36
+ const url = env.WEB_SEARCH_URL;
37
+ if (!url) return null;
38
+ return { url, key: env.WEB_SEARCH_KEY || null };
39
+ }
40
+
41
+ // Claim-relative class (ADR-0017): community content is admissible primary evidence for sentiment/
42
+ // demand claims under a checklist the tool cannot verify deterministically, so it defaults community
43
+ // hosts to `tertiary` (conservative for factual claims) and everything else to `secondary`, leaving
44
+ // the researcher to promote a community source to primary when the claim is about experience.
45
+
46
+ function classifyResult(url) {
47
+ try {
48
+ const host = new URL(url).hostname.replace(/^www\./, '');
49
+ return COMMUNITY_HOSTS.some((h) => host === h || host.endsWith('.' + h)) ? 'tertiary' : 'secondary';
50
+ } catch {
51
+ return 'tertiary';
52
+ }
53
+ }
54
+
55
+ // Confidence maps from the Admiralty grade; `high` is reserved for A1/A2/B1 (ADR-0017). A grade with
56
+ // reliability A/B or credibility 1/2 is `medium`; anything weaker is `low`.
57
+
58
+ function confidenceFromGrade(grade) {
59
+ if (HIGH_CONFIDENCE_GRADES.has(grade)) return 'high';
60
+ const reliability = grade?.[0];
61
+ const credibility = grade?.[1];
62
+ if (reliability === 'A' || reliability === 'B' || credibility === '1' || credibility === '2') return 'medium';
63
+ return 'low';
64
+ }
65
+
66
+ function degraded(reason, note) {
67
+ return { source: 'web', degraded: true, degradationReason: reason, results: [], note };
68
+ }
69
+
70
+ export async function webSearch(args = {}, { env = process.env, fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
71
+ const { query, claim, recency = null } = args;
72
+ if (!query || typeof query !== 'string') {
73
+ return { error: { code: 'INVALID_INPUT', message: 'query (string) is required' } };
74
+ }
75
+ if (!claim || typeof claim !== 'string') {
76
+ return { error: { code: 'INVALID_INPUT', message: 'claim (string) is required — it drives source classification (ADR-0017)' } };
77
+ }
78
+
79
+ const provider = providerConfig(env);
80
+ if (!provider) {
81
+ return degraded('capability-unavailable', 'No governed web search provider is configured (set WEB_SEARCH_URL). Construct will not present source or repo search as web search.');
82
+ }
83
+
84
+ let payload;
85
+ try {
86
+ const qs = `?q=${encodeURIComponent(query)}${recency ? `&recency=${encodeURIComponent(recency)}` : ''}`;
87
+ const res = await fetchImpl(provider.url + qs, { headers: provider.key ? { Authorization: `Bearer ${provider.key}` } : {} });
88
+ if (!res.ok) return degraded('server-unreachable', `Web search provider returned HTTP ${res.status}`);
89
+ payload = await res.json();
90
+ } catch (err) {
91
+ return degraded('server-unreachable', `Web search provider unreachable: ${err.message}`);
92
+ }
93
+
94
+ const items = Array.isArray(payload?.results) ? payload.results : [];
95
+ const results = items
96
+ .filter((it) => it && typeof it.url === 'string' && /^https?:\/\//.test(it.url))
97
+ .map((it) => {
98
+ const admiralty = typeof it.admiralty === 'string' && /^[A-F][1-6]$/.test(it.admiralty) ? it.admiralty : 'C3';
99
+ const date = normalizeDate(it);
100
+ const stale = date != null && (now - Date.parse(date)) > STALE_AFTER_DAYS * 86_400_000;
101
+ return {
102
+ source: 'web',
103
+ url: it.url,
104
+ title: typeof it.title === 'string' && it.title ? it.title : it.url,
105
+ snippet: typeof it.snippet === 'string' ? it.snippet : '',
106
+ class: classifyResult(it.url),
107
+ admiralty,
108
+ confidence: confidenceFromGrade(admiralty),
109
+ date,
110
+ stale,
111
+ needsGrading: !(typeof it.admiralty === 'string' && /^[A-F][1-6]$/.test(it.admiralty)),
112
+ };
113
+ });
114
+
115
+ // Order most-recent-first: fresh dated results, then undated (provider order
116
+ // kept by the stable sort), then stale ones; newest first within a tier.
117
+
118
+ const tier = (r) => (r.date && !r.stale ? 0 : r.date ? 2 : 1);
119
+ results.sort((a, b) => {
120
+ const t = tier(a) - tier(b);
121
+ if (t !== 0) return t;
122
+ if (a.date && b.date) return Date.parse(b.date) - Date.parse(a.date);
123
+ return 0;
124
+ });
125
+
126
+ return { source: 'web', degraded: false, query, claim, results, dropped: items.length - results.length };
127
+ }
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { execSync } from 'node:child_process';
7
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
8
8
  import { createInterface } from 'node:readline';
9
9
  import { homedir, platform } from 'node:os';
10
10
  import { join, dirname } from 'node:path';
@@ -441,11 +441,18 @@ export async function cmdMcpAdd(id) {
441
441
  removeCodexMcp(id);
442
442
  }
443
443
 
444
- // --- SYNC TO .ENV ---
444
+ // The credential lands in the XDG config.env, the single secret store read by
445
+ // loadConstructEnv and secret-resolver. Host MCP env blocks reference the env var
446
+ // name; config.env is what populates process.env so child MCP processes inherit the
447
+ // value (or its lazily-resolved op:// reference), so this write is load-bearing, not
448
+ // a redundant copy. config.env can hold a plaintext secret, so it is chmod 0600 to
449
+ // match credential-bootstrap; an op:// reference is preserved verbatim and resolved
450
+ // lazily rather than materialized here.
451
+
445
452
  const envPath = getUserEnvPath(homedir());
446
453
  writeEnvValues(envPath, envValues);
454
+ if (existsSync(envPath)) chmodSync(envPath, 0o600);
447
455
  console.log(`✓ Credentials synchronized to ${envPath}`);
448
- // --------------------
449
456
 
450
457
  console.log(`\n✓ ${mcp.name} successfully wired into Claude Code, OpenCode, and Codex.`);
451
458
  console.log(`✓ Setup mode: ${setupMode}`);
@@ -453,7 +460,7 @@ export async function cmdMcpAdd(id) {
453
460
  console.log(`✓ Services synchronized.`);
454
461
  console.log(`\nNext: Restart OpenCode, Claude Code, or Codex to activate the new tools.`);
455
462
  if (id === 'github' && authMode === 'oauth') {
456
- console.log(`\nAuthenticate (one-click OAuth, no token stored on disk):`);
463
+ console.log(`\nAuthenticate (one-click OAuth no PAT to paste; the host manages the token):`);
457
464
  console.log(` Claude Code: \`claude mcp login github\` · OpenCode: \`opencode mcp auth github\``);
458
465
  console.log(` VS Code / Cursor: approve the sign-in prompt on first use.`);
459
466
  console.log(` PAT fallback (headless/CI): \`construct mcp add github --token\``);
@@ -28,9 +28,18 @@ function resolveTemplateObject(input, resolvedValues, fallback) {
28
28
  );
29
29
  }
30
30
 
31
+ // A value is unsafe to write into a host MCP env block when it is an unresolved
32
+ // `__NAME__` template or a 1Password `op://` reference. The op:// form must never
33
+ // be written verbatim: a host that cannot run `op read` would pass the literal
34
+ // reference to the child process, and writing it persists secret topology on disk.
35
+
36
+ function isUnwritableEnvValue(value) {
37
+ return typeof value !== "string" || value.includes("__") || value.startsWith("op://");
38
+ }
39
+
31
40
  function stripUnresolvedValues(input) {
32
41
  return Object.fromEntries(
33
- Object.entries(input).filter(([, value]) => typeof value === "string" && !value.includes("__")),
42
+ Object.entries(input).filter(([, value]) => !isUnwritableEnvValue(value)),
34
43
  );
35
44
  }
36
45
 
@@ -52,6 +61,14 @@ const SECRET_REF = {
52
61
  opencode: (name) => `{env:${name}}`,
53
62
  };
54
63
 
64
+ // Local/stdio MCP env is materialized as resolved values rather than host env-ref
65
+ // forms. The SECRET_REF syntaxes above are verified for the remote `headers` field
66
+ // only; whether Claude / VS Code / OpenCode expand a `${VAR}`-style reference inside
67
+ // a stdio `env` block is unconfirmed, so the value-to-reference flip is deferred. The
68
+ // secret is still kept off disk by the child inheriting resolved env from the parent
69
+ // (config.env -> loadConstructEnv -> process.env); `stripUnresolvedValues` guarantees
70
+ // no unresolved template or op:// reference is persisted here.
71
+
55
72
  function buildLocalEnvironment(mcpDef, resolvedValues) {
56
73
  return stripUnresolvedValues(resolveTemplateObject(mcpDef.env ?? {}, resolvedValues));
57
74
  }