@geraldmaron/construct 1.4.0 → 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.
@@ -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
@@ -1334,7 +1334,7 @@ const ALL_TOOL_DEFS = [
1334
1334
  // host/model; the long tail is reachable, just not front-loaded.
1335
1335
 
1336
1336
  const CORE_TOOL_NAMES = new Set([
1337
- 'orchestration_policy', 'get_skill', 'get_template', 'search_skills', 'knowledge_search',
1337
+ 'orchestration_policy', 'orchestration_run', 'get_skill', 'get_template', 'search_skills', 'knowledge_search',
1338
1338
  'memory_search', 'project_context', 'summarize_diff', 'find_tool',
1339
1339
  'author_artifact', 'document_export', 'publish_run', 'artifact_workflow',
1340
1340
  'workflow_invoke', 'triage_recommend', 'orchestration_readiness',
@@ -14,6 +14,24 @@
14
14
  const COMMUNITY_HOSTS = ['reddit.com', 'stackoverflow.com', 'news.ycombinator.com', 'discord.com', 'github.com'];
15
15
  const HIGH_CONFIDENCE_GRADES = new Set(['A1', 'A2', 'B1']);
16
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
+
17
35
  function providerConfig(env) {
18
36
  const url = env.WEB_SEARCH_URL;
19
37
  if (!url) return null;
@@ -49,7 +67,7 @@ function degraded(reason, note) {
49
67
  return { source: 'web', degraded: true, degradationReason: reason, results: [], note };
50
68
  }
51
69
 
52
- export async function webSearch(args = {}, { env = process.env, fetchImpl = globalThis.fetch } = {}) {
70
+ export async function webSearch(args = {}, { env = process.env, fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
53
71
  const { query, claim, recency = null } = args;
54
72
  if (!query || typeof query !== 'string') {
55
73
  return { error: { code: 'INVALID_INPUT', message: 'query (string) is required' } };
@@ -78,6 +96,8 @@ export async function webSearch(args = {}, { env = process.env, fetchImpl = glob
78
96
  .filter((it) => it && typeof it.url === 'string' && /^https?:\/\//.test(it.url))
79
97
  .map((it) => {
80
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;
81
101
  return {
82
102
  source: 'web',
83
103
  url: it.url,
@@ -86,9 +106,22 @@ export async function webSearch(args = {}, { env = process.env, fetchImpl = glob
86
106
  class: classifyResult(it.url),
87
107
  admiralty,
88
108
  confidence: confidenceFromGrade(admiralty),
109
+ date,
110
+ stale,
89
111
  needsGrading: !(typeof it.admiralty === 'string' && /^[A-F][1-6]$/.test(it.admiralty)),
90
112
  };
91
113
  });
92
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
+
93
126
  return { source: 'web', degraded: false, query, claim, results, dropped: items.length - results.length };
94
127
  }
@@ -183,6 +183,38 @@ export function classifyResearchShape(request = '') {
183
183
  return null;
184
184
  }
185
185
 
186
+ // Live web access is distinct from research-shape: a bare "connect to the
187
+ // internet" or "fetch example.com" carries none of the comparative/landscape
188
+ // vocabulary, so without this it falls to the immediate track and the
189
+ // orchestrator — which holds no network tools — refuses instead of dispatching
190
+ // the web-capable cx-researcher. A literal URL, an explicit online/internet
191
+ // phrase, or a fetch/scrape verb paired with a web object is the signal.
192
+
193
+ const WEB_VERB = '(?:fetch|download|open|load|read|scrape|crawl|retrieve|pull|grab|access|visit|hit|reach)';
194
+ const WEB_OBJECT = '(?:url|link|web\\s?page|web\\s?site|site|page|online|internet|web|endpoint|api)';
195
+ const LIVE_WEB_ACCESS_PATTERNS = [
196
+ /https?:\/\/\S+/i,
197
+ /\bwww\.[a-z0-9-]+\.[a-z]{2,}/i,
198
+ /\b(?:connect|connecting|connection)\s+to\s+the\s+(?:internet|web|network)\b/i,
199
+ /\binternet\s+(?:access|connection|connectivity)\b/i,
200
+ /\b(?:go|get|getting|going)\s+online\b/i,
201
+ /\b(?:browse|browsing|surf)\b[\s\S]{0,20}\b(?:web|internet|site|page|url)\b/i,
202
+ /\b(?:curl|wget|ping)\b/i,
203
+ /\b(?:is|are)\b[\s\S]{0,40}\b(?:online|offline|reachable|unreachable|responding)\b/i,
204
+ new RegExp(`\\b${WEB_VERB}\\b[\\s\\S]{0,40}\\b${WEB_OBJECT}\\b`, 'i'),
205
+ new RegExp(`\\b${WEB_VERB}\\b[\\s\\S]{0,40}\\b[a-z0-9-]+\\.(?:com|org|net|io|dev|ai|gov|edu|co|app|info|xyz)\\b`, 'i'),
206
+ ];
207
+
208
+ /**
209
+ * Whether a request needs live external web/network access the orchestrator
210
+ * cannot perform itself (it is a read-only router with no network tools), so it
211
+ * must be routed to cx-researcher rather than answered immediately.
212
+ */
213
+ export function requiresLiveWebAccess(request = '') {
214
+ const text = String(request);
215
+ return LIVE_WEB_ACCESS_PATTERNS.some((pattern) => pattern.test(text));
216
+ }
217
+
186
218
  /**
187
219
  * Returns whether external research is required before scaffolding, with the
188
220
  * reason. Triggered by named entities not in the project glossary, by
@@ -193,6 +225,9 @@ export function requiresExternalResearch({ request = '', workCategory, riskFlags
193
225
  const entities = extractNamedEntities(request);
194
226
  const category = workCategory ?? classifyWorkCategory(request);
195
227
  const flags = riskFlags ?? detectRiskFlags(request);
228
+ if (requiresLiveWebAccess(request)) {
229
+ return { required: true, reason: 'web-access' };
230
+ }
196
231
  if (entities.length > 0) {
197
232
  return { required: true, reason: 'named-entities', entities };
198
233
  }
@@ -507,6 +542,7 @@ export function determineExecutionTrack({
507
542
  || isOperationsPlanningRequest(request)
508
543
  || isRdLeadRequest(request)
509
544
  || isExplorerRequest(request)
545
+ || requiresLiveWebAccess(request)
510
546
  ) {
511
547
  return EXECUTION_TRACKS.focused;
512
548
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "packageManager": "npm@11.5.1",
6
6
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
@@ -448,6 +448,13 @@ const standardConstructTools = [
448
448
  "cx_trace",
449
449
  "cx_score",
450
450
  ].join(",");
451
+
452
+ // The orchestrator's atomic contract is classify-then-dispatch: orchestration_policy
453
+ // then orchestration_run. On allowlist hosts (Claude) these must be named in the
454
+ // agent's tools list or the call is blocked, leaving the orchestrator unable to route.
455
+ // Single source of truth so every host's orchestrator grant stays in parity.
456
+
457
+ const ORCHESTRATOR_DISPATCH_TOOLS = ["orchestration_policy", "orchestration_run"];
451
458
  const managedStart = `# BEGIN ${systemName.toUpperCase()} AGENTS`;
452
459
  const managedEnd = `# END ${systemName.toUpperCase()} AGENTS`;
453
460
  const mdManagedStart = `<!-- BEGIN ${systemName.toUpperCase()} AGENTS -->`;
@@ -765,7 +772,8 @@ function orchestrationMicroPrompt(platform) {
765
772
  `You are the primary orchestrator. Before any non-trivial answer, call \`${policyTool}\` with the user's \`request\`. Do not guess agent names or workflow types.\n\n` +
766
773
  `Example — the user says "add rate limiting to the API". Your first action is a tool call, not prose:\n` +
767
774
  ` call ${policyTool} { "request": "add rate limiting to the API" }\n` +
768
- `If the route is focused/orchestrated specialist work, call \`${runTool}\` with the same request. If the route suggests a workflow such as \`research-synthesis\`, pass it as \`workflow_type\`. Do not narrate completed research unless \`${runTool}\` or evidence tools actually ran.`
775
+ `If the route is focused/orchestrated specialist work, call \`${runTool}\` with the same request. If the route suggests a workflow such as \`research-synthesis\`, pass it as \`workflow_type\`. Do not narrate completed research unless \`${runTool}\` or evidence tools actually ran.\n\n` +
776
+ `If a request needs a capability you lack — live web/network access, external data, code execution — do not refuse or tell the user to run it themselves. Route it via \`${runTool}\` to the specialist that holds the capability (web access lives with the researcher), or ask one clarifying question when the target is ambiguous.`
769
777
  );
770
778
  }
771
779
 
@@ -984,6 +992,13 @@ function claudeAgentMarkdown(entry, allEntries) {
984
992
  ...baseTools.split(",").map((t) => t.trim()),
985
993
  ...standardConstructTools.split(","),
986
994
  ]);
995
+
996
+ // Claude's tools list is a hard allowlist; the orchestrator can only route if its
997
+ // dispatch tools are named here.
998
+
999
+ if (entry.isOrchestrator) {
1000
+ for (const tool of ORCHESTRATOR_DISPATCH_TOOLS) toolSet.add(tool);
1001
+ }
987
1002
  const tools = Array.from(toolSet).filter(Boolean).join(",");
988
1003
 
989
1004
  return `---
@@ -1346,6 +1361,40 @@ When using this prompt, stay within the role above and adapt to the current repo
1346
1361
  `;
1347
1362
  }
1348
1363
 
1364
+ // VS Code reads custom agents (the renamed successor to chat modes) from
1365
+ // .github/agents/<name>.agent.md, and tool grants must use its namespaced ids:
1366
+ // <server>/* for an MCP server's tools, web/fetch for outbound web, search/read
1367
+ // for repo awareness. The Claude-format tools in .claude/agents/*.md are not
1368
+ // recognized here, so the orchestrator needs its own VS Code agent or it lists
1369
+ // in the picker with no usable tools.
1370
+
1371
+ const COPILOT_AGENT_TOOLS = [
1372
+ "construct-mcp/*",
1373
+ "web/fetch",
1374
+ "web/githubRepo",
1375
+ "search/codebase",
1376
+ "search/usages",
1377
+ "search/fileSearch",
1378
+ "read/problems",
1379
+ "edit/editFiles",
1380
+ ];
1381
+
1382
+ function copilotAgentFile(entry, allEntries) {
1383
+ const name = adapterName(entry);
1384
+ return `---
1385
+ description: ${entry.description}
1386
+ name: ${name}
1387
+ tools: ${JSON.stringify(COPILOT_AGENT_TOOLS)}
1388
+ ---
1389
+
1390
+ ${generatedMarkdownNote}
1391
+
1392
+ # ${name}
1393
+
1394
+ ${buildPrompt(entry, allEntries, "copilot")}
1395
+ `;
1396
+ }
1397
+
1349
1398
  function syncCopilot(entries, targetDir = null, wants = true) {
1350
1399
  const promptsDir = targetDir
1351
1400
  ? path.join(targetDir, ".github", "prompts")
@@ -1362,15 +1411,20 @@ function syncCopilot(entries, targetDir = null, wants = true) {
1362
1411
  sweepLegacyPrefixedFiles(promptsDir, ".prompt.md", writeEntries.map((e) => `${adapterName(e)}.prompt.md`));
1363
1412
  }
1364
1413
 
1365
- // VS Code / Copilot agent mode reads the project's `.claude/agents/*.md`
1366
- // natively (Claude-format custom agents), so Construct does NOT write a
1367
- // separate `.github/agents/` set doing so duplicated every agent in the
1368
- // picker. A pre-existing `.github/agents/` from an earlier sync is swept.
1414
+ // VS Code reads custom agents from `.github/agents/*.agent.md`. The Claude
1415
+ // tool names in `.claude/agents/*.md` are not recognized there, so the front
1416
+ // door ships as a VS Code agent with namespaced tool grants (construct-mcp/*,
1417
+ // web/fetch, search/read) selecting it in the dropdown then scopes those
1418
+ // tools in. The Claude-format set stays for Claude Code.
1369
1419
 
1370
- if (targetDir) {
1371
- const staleAgentsDir = path.join(targetDir, ".github", "agents");
1372
- if (!DRY_RUN && fs.existsSync(staleAgentsDir)) fs.rmSync(staleAgentsDir, { recursive: true, force: true });
1420
+ const agentsDir = targetDir
1421
+ ? path.join(targetDir, ".github", "agents")
1422
+ : path.join(home, ".github", "agents");
1423
+ if (!DRY_RUN && wants) mkdirp(agentsDir);
1424
+ for (const entry of writeEntries) {
1425
+ writeFile(path.join(agentsDir, `${adapterName(entry)}.agent.md`), copilotAgentFile(entry, entries), { stamp: false });
1373
1426
  }
1427
+ if (fs.existsSync(agentsDir)) removeStaleAdapters(agentsDir, ".agent.md", writeEntries);
1374
1428
 
1375
1429
  const instructionsPath = targetDir
1376
1430
  ? path.join(targetDir, ".github", "copilot-instructions.md")
@@ -1387,7 +1441,7 @@ function syncCopilot(entries, targetDir = null, wants = true) {
1387
1441
  const list = listEntries.map((e) => `- \`${adapterName(e)}\`: use \`${promptPathPrefix}/${adapterName(e)}.prompt.md\`.`).join("\n");
1388
1442
  const note = `## ${systemName.charAt(0).toUpperCase() + systemName.slice(1)} Agent Prompts
1389
1443
 
1390
- For a real multi-specialist run, Copilot agent mode calls the \`orchestration_run\` MCP tool (start the engine with \`construct dashboard\`). The prompts below are reusable role profiles for single-pass work:
1444
+ Select \`${systemName}\` from the chat mode dropdown to enter the orchestrator: describe an outcome and it classifies the request and dispatches the right specialists through the \`construct-mcp\` tools (\`orchestration_policy\` then \`orchestration_run\`). You ask for outcomes, not specialists — it routes internally. Requires the \`construct-mcp\` server (wired in \`.vscode/mcp.json\`); if its tools are unavailable, the mode cannot route and will say so rather than guess.
1391
1445
 
1392
1446
  ${list || "(no front-door prompts to surface)"}`;
1393
1447
 
@@ -1432,6 +1486,45 @@ function getVSCodeUserMcpPaths() {
1432
1486
  .filter((file) => fs.existsSync(file));
1433
1487
  }
1434
1488
 
1489
+ // A merged mcp.json preserves existing entries so user customizations survive a
1490
+ // re-sync. A construct-owned server path is a fully-resolved, non-placeholder
1491
+ // path, so the preserve rule keeps it even when it points at a different toolkit
1492
+ // root than the current one — and VS Code then launches a server that may not
1493
+ // exist. Treat a construct toolkit path outside the current root as stale so the
1494
+ // sync refreshes it; user-owned servers carry no lib/mcp toolkit path and stay.
1495
+
1496
+ export function mcpEntryPointsOutsideToolkit(entry, root) {
1497
+ const args = Array.isArray(entry?.args) ? entry.args : [];
1498
+ return args.some(
1499
+ (arg) => typeof arg === "string"
1500
+ && /\/lib\/mcp\/[a-z0-9-]+\.mjs$/.test(arg)
1501
+ && !arg.startsWith(`${root}/`),
1502
+ );
1503
+ }
1504
+
1505
+ // VS Code scans both `.github/agents` and `.claude/agents`, so the orchestrator
1506
+ // would list twice — once with VS Code tools (.github/agents) and once with
1507
+ // Claude tool names VS Code ignores (.claude/agents). `chat.agentFilesLocations`
1508
+ // is the documented lever to pin the scan; its power over the built-in
1509
+ // `.claude/agents` compatibility scan is version-dependent, so this is a
1510
+ // best-effort hint, not a guarantee. Merge only into a strictly-parseable file
1511
+ // and never overwrite an existing choice, so a commented (JSONC) or
1512
+ // user-customized settings.json is left untouched.
1513
+
1514
+ export function pinVscodeAgentLocations(targetDir) {
1515
+ if (DRY_RUN) return;
1516
+ const settingsPath = path.join(targetDir, ".vscode", "settings.json");
1517
+ let settings = {};
1518
+ if (fs.existsSync(settingsPath)) {
1519
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")) || {}; }
1520
+ catch { return; }
1521
+ }
1522
+ if (settings["chat.agentFilesLocations"]) return;
1523
+ settings["chat.agentFilesLocations"] = { ".github/agents": true, ".claude/agents": false };
1524
+ mkdirp(path.dirname(settingsPath));
1525
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1526
+ }
1527
+
1435
1528
  function syncVSCode(targetDir = null, wants = true) {
1436
1529
  const registryMcp = scopedManagedMcpDefs({ projectScope: Boolean(targetDir) });
1437
1530
  if (Object.keys(registryMcp).length === 0) return false;
@@ -1480,13 +1573,15 @@ function syncVSCode(targetDir = null, wants = true) {
1480
1573
  const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
1481
1574
  const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
1482
1575
  const transportMismatch = registryWantsCommand && existingIsRemote;
1483
- if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
1576
+ const staleToolkitPath = mcpEntryPointsOutsideToolkit(existingEntry, root);
1577
+ if (existingEntry && !hasPlaceholder && !transportMismatch && !staleToolkitPath) continue;
1484
1578
  config.servers[id] = buildClaudeMcpEntry(id, mcpDef, process.env, { host: "vscode" });
1485
1579
  }
1486
1580
  if (!DRY_RUN) {
1487
1581
  mkdirp(path.dirname(mcpPath));
1488
1582
  fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
1489
1583
  }
1584
+ pinVscodeAgentLocations(targetDir);
1490
1585
  return true;
1491
1586
  }
1492
1587
 
@@ -1504,7 +1599,8 @@ function syncVSCode(targetDir = null, wants = true) {
1504
1599
  const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
1505
1600
  const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
1506
1601
  const transportMismatch = registryWantsCommand && existingIsRemote;
1507
- if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
1602
+ const staleToolkitPath = mcpEntryPointsOutsideToolkit(existingEntry, root);
1603
+ if (existingEntry && !hasPlaceholder && !transportMismatch && !staleToolkitPath) continue;
1508
1604
  config.servers[id] = buildClaudeMcpEntry(id, mcpDef, process.env, { host: "vscode" });
1509
1605
  }
1510
1606
  if (!DRY_RUN) fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");