@geraldmaron/construct 1.3.2 → 1.4.0
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 +2 -1
- package/bin/construct +61 -15
- package/lib/a11y-audit.mjs +104 -0
- package/lib/artifact-completion-states.mjs +35 -0
- package/lib/artifact-completion.mjs +66 -0
- package/lib/artifact-gate-levels.mjs +69 -0
- package/lib/artifact-manifest.mjs +21 -0
- package/lib/artifact-release-gate.mjs +19 -1
- package/lib/artifact-workflow.mjs +16 -0
- package/lib/brand-contrast.mjs +60 -0
- package/lib/brand-tokens.mjs +14 -0
- package/lib/cli-commands.mjs +5 -3
- package/lib/codex-config.mjs +7 -1
- package/lib/deck-export-pptx.mjs +223 -18
- package/lib/diagram-quality.mjs +161 -0
- package/lib/document-export.mjs +1 -1
- package/lib/env-config.mjs +8 -8
- package/lib/export-validate.mjs +106 -0
- package/lib/gates-audit.mjs +34 -0
- package/lib/health-check.mjs +23 -9
- package/lib/hooks/session-start.mjs +2 -1
- package/lib/init-unified.mjs +5 -3
- package/lib/mcp/server.mjs +52 -2
- package/lib/mcp/tool-recovery.mjs +56 -0
- package/lib/mcp/tools/web-search.mjs +94 -0
- package/lib/mcp-manager.mjs +11 -4
- package/lib/mcp-platform-config.mjs +18 -1
- package/lib/models/provider-poll.mjs +32 -6
- package/lib/orchestration/readiness.mjs +183 -0
- package/lib/output-quality.mjs +90 -0
- package/lib/pixel-regression.mjs +172 -0
- package/lib/providers/op-run.mjs +6 -5
- package/lib/providers/secret-audit-wiring.mjs +32 -0
- package/lib/providers/secret-resolver.mjs +90 -20
- package/lib/publish.mjs +64 -1
- package/lib/render-evidence.mjs +55 -0
- package/lib/render-pipeline.mjs +136 -0
- package/lib/service-manager.mjs +31 -10
- package/lib/templates/doc-presentation.mjs +24 -0
- package/lib/tracking-surfaces.mjs +36 -0
- package/lib/visual-review.mjs +70 -0
- package/package.json +1 -1
- package/specialists/artifact-manifest.json +18 -0
- package/specialists/artifact-manifest.schema.json +46 -2
- package/templates/distribution/construct-brand.typ +1 -2
- package/templates/distribution/construct-deck.html +34 -34
- package/templates/distribution/construct-pdf.typ +1 -1
- 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
|
+
}
|
package/lib/gates-audit.mjs
CHANGED
|
@@ -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}`);
|
package/lib/health-check.mjs
CHANGED
|
@@ -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
|
-
*
|
|
300
|
-
*
|
|
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 -
|
|
304
|
-
* @returns {Promise<Record<string, string>>} raw values
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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;
|
|
@@ -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 });
|
package/lib/init-unified.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
}
|
package/lib/mcp/server.mjs
CHANGED
|
@@ -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
|
|
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
|
|
@@ -1306,7 +1337,7 @@ const CORE_TOOL_NAMES = new Set([
|
|
|
1306
1337
|
'orchestration_policy', '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,94 @@
|
|
|
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
|
+
function providerConfig(env) {
|
|
18
|
+
const url = env.WEB_SEARCH_URL;
|
|
19
|
+
if (!url) return null;
|
|
20
|
+
return { url, key: env.WEB_SEARCH_KEY || null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Claim-relative class (ADR-0017): community content is admissible primary evidence for sentiment/
|
|
24
|
+
// demand claims under a checklist the tool cannot verify deterministically, so it defaults community
|
|
25
|
+
// hosts to `tertiary` (conservative for factual claims) and everything else to `secondary`, leaving
|
|
26
|
+
// the researcher to promote a community source to primary when the claim is about experience.
|
|
27
|
+
|
|
28
|
+
function classifyResult(url) {
|
|
29
|
+
try {
|
|
30
|
+
const host = new URL(url).hostname.replace(/^www\./, '');
|
|
31
|
+
return COMMUNITY_HOSTS.some((h) => host === h || host.endsWith('.' + h)) ? 'tertiary' : 'secondary';
|
|
32
|
+
} catch {
|
|
33
|
+
return 'tertiary';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Confidence maps from the Admiralty grade; `high` is reserved for A1/A2/B1 (ADR-0017). A grade with
|
|
38
|
+
// reliability A/B or credibility 1/2 is `medium`; anything weaker is `low`.
|
|
39
|
+
|
|
40
|
+
function confidenceFromGrade(grade) {
|
|
41
|
+
if (HIGH_CONFIDENCE_GRADES.has(grade)) return 'high';
|
|
42
|
+
const reliability = grade?.[0];
|
|
43
|
+
const credibility = grade?.[1];
|
|
44
|
+
if (reliability === 'A' || reliability === 'B' || credibility === '1' || credibility === '2') return 'medium';
|
|
45
|
+
return 'low';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function degraded(reason, note) {
|
|
49
|
+
return { source: 'web', degraded: true, degradationReason: reason, results: [], note };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function webSearch(args = {}, { env = process.env, fetchImpl = globalThis.fetch } = {}) {
|
|
53
|
+
const { query, claim, recency = null } = args;
|
|
54
|
+
if (!query || typeof query !== 'string') {
|
|
55
|
+
return { error: { code: 'INVALID_INPUT', message: 'query (string) is required' } };
|
|
56
|
+
}
|
|
57
|
+
if (!claim || typeof claim !== 'string') {
|
|
58
|
+
return { error: { code: 'INVALID_INPUT', message: 'claim (string) is required — it drives source classification (ADR-0017)' } };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const provider = providerConfig(env);
|
|
62
|
+
if (!provider) {
|
|
63
|
+
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.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let payload;
|
|
67
|
+
try {
|
|
68
|
+
const qs = `?q=${encodeURIComponent(query)}${recency ? `&recency=${encodeURIComponent(recency)}` : ''}`;
|
|
69
|
+
const res = await fetchImpl(provider.url + qs, { headers: provider.key ? { Authorization: `Bearer ${provider.key}` } : {} });
|
|
70
|
+
if (!res.ok) return degraded('server-unreachable', `Web search provider returned HTTP ${res.status}`);
|
|
71
|
+
payload = await res.json();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return degraded('server-unreachable', `Web search provider unreachable: ${err.message}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const items = Array.isArray(payload?.results) ? payload.results : [];
|
|
77
|
+
const results = items
|
|
78
|
+
.filter((it) => it && typeof it.url === 'string' && /^https?:\/\//.test(it.url))
|
|
79
|
+
.map((it) => {
|
|
80
|
+
const admiralty = typeof it.admiralty === 'string' && /^[A-F][1-6]$/.test(it.admiralty) ? it.admiralty : 'C3';
|
|
81
|
+
return {
|
|
82
|
+
source: 'web',
|
|
83
|
+
url: it.url,
|
|
84
|
+
title: typeof it.title === 'string' && it.title ? it.title : it.url,
|
|
85
|
+
snippet: typeof it.snippet === 'string' ? it.snippet : '',
|
|
86
|
+
class: classifyResult(it.url),
|
|
87
|
+
admiralty,
|
|
88
|
+
confidence: confidenceFromGrade(admiralty),
|
|
89
|
+
needsGrading: !(typeof it.admiralty === 'string' && /^[A-F][1-6]$/.test(it.admiralty)),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return { source: 'web', degraded: false, query, claim, results, dropped: items.length - results.length };
|
|
94
|
+
}
|
package/lib/mcp-manager.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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]) =>
|
|
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
|
}
|