@aiwg/cli 2026.9.6 → 2026.9.9

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 (45) hide show
  1. package/dist/src/artifacts/index-builder.js +43 -1
  2. package/dist/src/artifacts/query-engine.js +7 -0
  3. package/dist/src/cli/handlers/help.js +7 -1
  4. package/dist/src/cli/handlers/installation.js +106 -2
  5. package/dist/src/cli/handlers/mc.js +100 -37
  6. package/dist/src/cli/handlers/ralph.js +14 -4
  7. package/dist/src/cli/handlers/refresh.js +359 -31
  8. package/dist/src/cli/handlers/repo-access.js +155 -4
  9. package/dist/src/cli/handlers/runtime-info.js +3 -0
  10. package/dist/src/cli/handlers/serve.js +21 -3
  11. package/dist/src/cli/handlers/setup.js +5 -5
  12. package/dist/src/cli/handlers/steward.js +30 -1
  13. package/dist/src/cli/handlers/use.js +123 -12
  14. package/dist/src/cli/handlers/utilities.js +26 -10
  15. package/dist/src/cli/handlers/version.js +40 -14
  16. package/dist/src/cli/handlers/workspace-context.js +8 -0
  17. package/dist/src/cli/services/deployment-verification.js +156 -7
  18. package/dist/src/cli/watch-service.js +47 -4
  19. package/dist/src/config/aiwg-config.js +95 -3
  20. package/dist/src/config/cli.js +16 -1
  21. package/dist/src/config/gitignore.js +5 -0
  22. package/dist/src/config/project-artifacts-health.mjs +15 -2
  23. package/dist/src/cost/fleet-report.js +19 -5
  24. package/dist/src/extensions/claude-hooks-installer.js +22 -6
  25. package/dist/src/extensions/project-local-doctor.js +40 -2
  26. package/dist/src/extensions/project-quickref.js +4 -0
  27. package/dist/src/installation/manager.mjs +38 -3
  28. package/dist/src/lint/runner.js +138 -0
  29. package/dist/src/mcp/helpers.mjs +56 -22
  30. package/dist/src/mcp/registry.js +32 -22
  31. package/dist/src/mcp/registry.mjs +31 -26
  32. package/dist/src/mcp/toml-editor.mjs +117 -0
  33. package/dist/src/mcp/tools/orchestration.mjs +7 -7
  34. package/dist/src/mcp/tools/subsystems.mjs +7 -7
  35. package/dist/src/memory/context-pack.js +5 -1
  36. package/dist/src/plugin/skill-command-translator.js +70 -1
  37. package/dist/src/serve/a2a-terminal-observer.js +19 -1
  38. package/dist/src/serve/mission-hitl.js +91 -0
  39. package/dist/src/sessions/import-lease.js +5 -1
  40. package/dist/src/smiths/context-pipeline/workspace-context.js +132 -6
  41. package/dist/src/testing/fixtures/test-data-factory.js +3 -3
  42. package/dist/src/writing/pattern-library.js +29 -6
  43. package/package.json +2 -1
  44. package/tools/agents/deploy-agents.mjs +91 -5
  45. package/tools/agents/providers/base.mjs +162 -6
@@ -179,13 +179,27 @@ function requestSignal(parent) {
179
179
  }
180
180
  async function openRouterGet(endpoint, key, options) {
181
181
  const fetchImpl = options.fetchImpl ?? fetch;
182
- const response = await fetchImpl(`${options.apiBaseUrl ?? OPENROUTER_API}${endpoint}`, {
183
- headers: { Authorization: `Bearer ${key}` },
184
- signal: requestSignal(options.signal),
185
- });
182
+ let response;
183
+ try {
184
+ response = await fetchImpl(`${options.apiBaseUrl ?? OPENROUTER_API}${endpoint}`, {
185
+ headers: { Authorization: `Bearer ${key}` },
186
+ signal: requestSignal(options.signal),
187
+ });
188
+ }
189
+ catch {
190
+ // Transport diagnostics can embed Authorization headers or request bodies.
191
+ throw new Error('OpenRouter request could not complete. Check connectivity, timeout, or cancellation.');
192
+ }
186
193
  if (!response.ok)
187
194
  throw new Error(`OpenRouter request failed with status ${response.status}.`);
188
- const payload = await response.json();
195
+ let payload;
196
+ try {
197
+ payload = await response.json();
198
+ }
199
+ catch {
200
+ // Decoder exceptions can quote arbitrary response content.
201
+ throw new Error('OpenRouter returned unreadable JSON. Check the API response format.');
202
+ }
189
203
  if (!payload || typeof payload !== 'object' || !payload.data)
190
204
  throw new Error('OpenRouter returned an invalid response.');
191
205
  return payload.data;
@@ -68,11 +68,17 @@ function normalizeHooks(raw) {
68
68
  return [{}, false];
69
69
  }
70
70
  /**
71
- * Detect whether the existing settings carries the AIWG signature
72
- * (any hook entry tagged `_aiwg_managed: true`). Accepts both the
71
+ * Detect whether the existing settings carries the AIWG signature — either a hook
72
+ * entry tagged `_aiwg_managed: true`, or the top-level `aiwg` stamp written when
73
+ * AIWG creates the file itself (tools/agents/providers/claude.mjs). Without the
74
+ * latter, a greenfield deploy backs up the settings.json it created seconds
75
+ * earlier and reports it as protected operator content (#2542). Accepts both the
73
76
  * current object form and the legacy array form.
74
77
  */
75
78
  function hasAiwgMarker(settings) {
79
+ const stamp = settings.aiwg;
80
+ if (stamp && typeof stamp === 'object')
81
+ return true;
76
82
  const hooksField = settings.hooks;
77
83
  if (!hooksField)
78
84
  return false;
@@ -167,7 +173,7 @@ export async function installAiwgHooks(opts) {
167
173
  const backup = `${result.settingsPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
168
174
  await fs.copyFile(result.settingsPath, backup);
169
175
  result.backupPath = backup;
170
- result.warnings.push(`Backed up pre-existing settings.json to ${backup}`);
176
+ result.warnings.push(`Backed up operator-authored settings.json to ${backup}`);
171
177
  }
172
178
  }
173
179
  catch (err) {
@@ -202,18 +208,28 @@ export async function installAiwgHooks(opts) {
202
208
  // those files no longer exist on disk after refresh, causing
203
209
  // `MODULE_NOT_FOUND` at `node:internal/modules/cjs/loader` on every
204
210
  // hook invocation. (Fixes regression report on Claude Code 2.1.157.)
211
+ // Match by `_aiwg_id` first, then by the script path. An entry invoking an
212
+ // AIWG-owned hook script without the managed tag is AIWG residue from an
213
+ // earlier install; appending a managed entry beside it registers the hook
214
+ // twice and runs it twice per event. Adopt it instead. (#2543)
205
215
  let updated = false;
206
216
  for (const group of groups) {
207
217
  if (!Array.isArray(group.hooks))
208
218
  continue;
209
219
  for (const h of group.hooks) {
210
- if (h._aiwg_id !== hookId)
220
+ const sameId = h._aiwg_id === hookId;
221
+ const sameScript = typeof h.command === 'string' && h.command.includes(script);
222
+ if (!sameId && !sameScript)
211
223
  continue;
212
- if (h.command !== command || h.type !== 'command' || h._aiwg_managed !== true) {
224
+ const adopting = !sameId && sameScript;
225
+ if (h.command !== command || h.type !== 'command' || h._aiwg_managed !== true || h._aiwg_id !== hookId) {
213
226
  h.type = 'command';
214
227
  h.command = command;
215
228
  h._aiwg_managed = true;
216
- result.warnings.push(`Refreshed stale ${event} → ${hookId} command path`);
229
+ h._aiwg_id = hookId;
230
+ result.warnings.push(adopting
231
+ ? `Adopted pre-existing untagged ${event} → ${hookId} entry instead of registering a duplicate`
232
+ : `Refreshed stale ${event} → ${hookId} command path`);
217
233
  }
218
234
  updated = true;
219
235
  }
@@ -67,7 +67,7 @@ export async function buildProjectLocalDoctorSection(opts) {
67
67
  }
68
68
  // No project-local content → no section at all
69
69
  if (discovery.isEmpty && discovery.errors.length === 0 && !quickrefAudit.exists && quickrefErrors.length === 0) {
70
- return { output: '', validationErrors: 0, denylistViolations: 0, driftCount: 0, hasFailures: false };
70
+ return { output: '', validationErrors: 0, denylistViolations: 0, driftCount: 0, undeployedCount: 0, hasFailures: false };
71
71
  }
72
72
  const lines = ['', '── Project-local artifacts ────────────────────────────────────'];
73
73
  // Counts
@@ -108,12 +108,17 @@ export async function buildProjectLocalDoctorSection(opts) {
108
108
  lines.push('');
109
109
  // Shadows + denylist
110
110
  let denylistViolations = 0;
111
+ // Bundles the resolver deliberately refused are already reported below as
112
+ // denylist violations; the undeployed check must not double-count them.
113
+ const refusedBundleIds = new Set();
111
114
  if (discovery.bundles.length > 0) {
112
115
  try {
113
116
  const upstream = await buildUpstreamRegistry({ frameworkRoot });
114
117
  const shadowResult = await resolveShadows(discovery.bundles, upstream);
115
118
  const refusals = shadowResult.resolutions.filter(r => r.verdict === 'refuse-unsafe' || r.verdict === 'refuse-phantom' || r.verdict === 'refuse-duplicate');
116
119
  denylistViolations = refusals.length;
120
+ for (const refusal of refusals)
121
+ refusedBundleIds.add(refusal.bundleId);
117
122
  if (!quiet) {
118
123
  const informational = shadowResult.shadows.filter(s => s.verdict === 'deploy-with-warning' || s.verdict === 'deploy-acknowledged');
119
124
  if (informational.length > 0) {
@@ -188,6 +193,37 @@ export async function buildProjectLocalDoctorSection(opts) {
188
193
  }
189
194
  lines.push('');
190
195
  }
196
+ // Undeployed bundles (#2503).
197
+ //
198
+ // A bundle whose deploy aborted (a bad support-asset reference, a failed CLI
199
+ // contribution) leaves no `installed` entry, so every check above — manifest
200
+ // validation, drift — silently skips it and reports a clean bill of health
201
+ // for a bundle that is not actually available. The only prior signal was a
202
+ // WARN line in `aiwg use` output, long scrolled away by the time anyone
203
+ // wonders where the skill went.
204
+ const undeployed = config
205
+ ? discovery.bundles.filter((bundle) => {
206
+ if (refusedBundleIds.has(bundle.id))
207
+ return false;
208
+ const entry = config.installed[bundle.id];
209
+ return !entry || entry.source !== 'project-local';
210
+ })
211
+ : [];
212
+ if (undeployed.length > 0) {
213
+ lines.push(` Deployment: ✗ ${undeployed.length} discovered bundle${undeployed.length === 1 ? '' : 's'} not deployed`);
214
+ for (const bundle of undeployed.slice(0, 5)) {
215
+ lines.push(` ✗ ${bundle.type}/${bundle.id} (${bundle.localPath}) — no deployment recorded`);
216
+ }
217
+ if (undeployed.length > 5)
218
+ lines.push(` + ${undeployed.length - 5} more`);
219
+ lines.push(` Run \`aiwg use ${undeployed[0].id}\` and read the output — a deploy that`);
220
+ lines.push(' fails reports the reason there.');
221
+ lines.push('');
222
+ }
223
+ else if (!quiet && config && discovery.bundles.length > 0) {
224
+ lines.push(' Deployment: ✓ all discovered bundles deployed');
225
+ lines.push('');
226
+ }
191
227
  // Provider deployment matrix
192
228
  if (!quiet && config) {
193
229
  const projectLocalEntries = Object.entries(config.installed).filter(([, e]) => e.source === 'project-local');
@@ -252,12 +288,14 @@ export async function buildProjectLocalDoctorSection(opts) {
252
288
  lines.push('');
253
289
  }
254
290
  }
255
- const hasFailures = validationErrors > 0 || denylistViolations > 0 || driftCount > 0 || gitignoredCount > 0;
291
+ const hasFailures = validationErrors > 0 || denylistViolations > 0 || driftCount > 0
292
+ || gitignoredCount > 0 || undeployed.length > 0;
256
293
  return {
257
294
  output: lines.join('\n'),
258
295
  validationErrors,
259
296
  denylistViolations,
260
297
  driftCount,
298
+ undeployedCount: undeployed.length,
261
299
  hasFailures,
262
300
  };
263
301
  }
@@ -258,6 +258,10 @@ export function renderProjectQuickref(definition) {
258
258
  '---',
259
259
  `name: ${skillName}`,
260
260
  `description: ${JSON.stringify(`Project-specific orientation for ${definition.project.name}`)}`,
261
+ // AIWG generates and deploys this skill, so it must declare AIWG ownership.
262
+ // Without it the collision scan treats every redeploy of AIWG's own artifact
263
+ // as an unowned overwrite and warns permanently (#2504).
264
+ 'namespace: aiwg',
261
265
  'kernel: true',
262
266
  'platforms: [all]',
263
267
  '---',
@@ -176,13 +176,44 @@ export function inspectInstallation(options = {}) {
176
176
  const actualRoot = canonicalPath(options.actualRoot);
177
177
  const actualMethod = options.actualMethod ?? inferInstallationMethod(actualRoot);
178
178
  const identity = options.identity ?? loadInstallationIdentity({ ...options, actualRoot });
179
- if (!identity) return { state: 'unrecorded', identity: null, actualRoot, actualMethod, drift: ['installation identity is not recorded'] };
179
+ if (!identity) {
180
+ return {
181
+ state: 'unrecorded',
182
+ identity: null,
183
+ actualRoot,
184
+ actualMethod,
185
+ frameworkRoot: actualRoot,
186
+ launcher: null,
187
+ drift: ['installation identity is not recorded'],
188
+ };
189
+ }
180
190
 
181
191
  const drift = [];
182
192
  const canonicalRoot = canonicalPath(identity.root);
193
+
194
+ // Edge/customize mode deliberately separates the *launcher* (the executable
195
+ // that ran, typically an npm-global install) from the *framework root* (the
196
+ // local clone named by `edgePath`). Comparing the launcher's package root
197
+ // against the canonical root then reports the supported configuration as
198
+ // drift, and does so only for the commands that happen to run from the
199
+ // npm-global copy — `aiwg doctor`, loaded through the redirect, saw the
200
+ // clone and reported aligned for the same workspace (#2505).
201
+ //
202
+ // The redirect is only trusted when the identity actually declares it: the
203
+ // channel is `edge` and `edgePath` resolves to the canonical root.
204
+ const edgePath = identity.edgePath ? canonicalPath(identity.edgePath) : null;
205
+ const rootsDiffer = canonicalRoot !== actualRoot;
206
+ const launcherRedirect = identity.channel === 'edge' && edgePath !== null && edgePath === canonicalRoot && rootsDiffer;
207
+ const launcher = launcherRedirect ? { root: actualRoot, method: actualMethod } : null;
208
+ // The framework root is what AIWG actually reads its corpus from, and it is
209
+ // the single value that drives `state`.
210
+ const frameworkRoot = launcherRedirect ? canonicalRoot : actualRoot;
211
+
183
212
  if (!existsSync(canonicalRoot)) drift.push(`canonical root does not exist: ${canonicalRoot}`);
184
- if (canonicalRoot !== actualRoot) drift.push(`actual root ${actualRoot} differs from canonical root ${canonicalRoot}`);
185
- if (identity.method !== actualMethod) drift.push(`actual method ${actualMethod} differs from canonical method ${identity.method}`);
213
+ if (rootsDiffer && !launcherRedirect) drift.push(`actual root ${actualRoot} differs from canonical root ${canonicalRoot}`);
214
+ if (identity.method !== actualMethod && !launcherRedirect) {
215
+ drift.push(`actual method ${actualMethod} differs from canonical method ${identity.method}`);
216
+ }
186
217
  if (identity.method !== 'web' && !identity.managerExecutable) {
187
218
  drift.push(`canonical ${identity.method} installation has no recorded manager executable`);
188
219
  }
@@ -214,6 +245,10 @@ export function inspectInstallation(options = {}) {
214
245
  canonicalRoot,
215
246
  actualRoot,
216
247
  actualMethod,
248
+ /** Where the corpus is read from. Equals actualRoot unless a launcher redirect applies. */
249
+ frameworkRoot,
250
+ /** Non-null only in edge/customize mode: the executable's own package root. */
251
+ launcher,
217
252
  drift,
218
253
  managerProbe,
219
254
  };
@@ -29,6 +29,100 @@ function parseFrontmatter(content) {
29
29
  return result;
30
30
  }
31
31
  const DEFAULT_REFERENCE_PATTERN = '\\bREF-\\d{3,}\\b';
32
+ /**
33
+ * Verification targets — things the inducting agent could have checked.
34
+ *
35
+ * Requiring one of these is what separates "the agent skipped a cheap check"
36
+ * from "the paper's own claim is unverified". Only the first is a provenance
37
+ * gap; the second is legitimate analysis, and #2523 explicitly does not ask
38
+ * agents to stop declaring limitations. Validated against a 2,544-reference
39
+ * corpus, where grammar-only matching made ~45% of hits paper-claim prose
40
+ * ("scaling behavior above 7B is unverified", "Not confirmed (34% vs 51%)").
41
+ */
42
+ /**
43
+ * Split a markdown line into clauses.
44
+ *
45
+ * Corpus prose keeps whole paragraphs, bullet bodies and changelog table rows on
46
+ * a single line, so "same line" is far too coarse a scope for relating an
47
+ * unperformed action to its target. Sentence and cell boundaries are the unit
48
+ * that actually corresponds to one statement.
49
+ */
50
+ function splitClauses(line) {
51
+ return line
52
+ .split(/(?<=[.;:!?])\s+|\s+\u2014\s+|\s+--\s+|\|/g)
53
+ .map((c) => c.trim())
54
+ .filter(Boolean);
55
+ }
56
+ export const DEFAULT_VERIFICATION_TARGETS = [
57
+ 'openreview',
58
+ '(?:acl )?anthology',
59
+ 'proceedings',
60
+ 'camera[- ]ready',
61
+ 'published version',
62
+ '\\bPMLR\\b',
63
+ '\\bDBLP\\b',
64
+ '\\bOpenAlex\\b',
65
+ 'semantic scholar',
66
+ '\\bpubpeer\\b',
67
+ '\\bunpaywall\\b',
68
+ 'retraction|correction notice|expression of concern',
69
+ 'citation (?:census|count)',
70
+ 'influential citation',
71
+ '\\bPDF\\b',
72
+ 'full[- ]text',
73
+ '\\be-?print\\b',
74
+ '(?:code|project|dataset|repository|repo)\\s+(?:page|url|link|release|availability)',
75
+ '\\bvenue\\b',
76
+ '\\bacceptance\\b',
77
+ 'source[_ ]type',
78
+ ];
79
+ /**
80
+ * Phrases that assert a check was not performed. Drawn from real induction
81
+ * output — each of these has appeared in a corpus reference doc (#2523).
82
+ * Only counted when a verification target appears on the same line.
83
+ */
84
+ export const DEFAULT_UNCERTAINTY_PATTERNS = [
85
+ '(?:was|were|is|are) not (?:retrieved|run|performed|attempted|fetched|queried|checked|acquired|probed|verified|confirmed)',
86
+ 'not (?:retrieved|run|performed|attempted|fetched|queried|checked|acquired|probed|verified|confirmed)\\b',
87
+ // Requires the action to be stated as unperformed. Without the trailing verb
88
+ // this matched bare "no search" in unrelated prose and scope statements like
89
+ // "no exhaustive census is claimed", neither of which is a skipped check.
90
+ 'no (?:\\w+[ -]){0,4}(?:quer(?:y|ies)|search|census|fetch|check|lookup|probe)(?:es|s)?\\s+(?:was |were )?(?:performed|run|attempted|made|conducted|executed)',
91
+ '\\b(?:is|remains) unverified\\b',
92
+ '\\bunconfirmed\\b',
93
+ 'rests on .{0,60} rather than an independent',
94
+ ];
95
+ export const DEFAULT_OBSTACLE_PATTERNS = [
96
+ '\\bHTTP\\s?[45]\\d{2}\\b',
97
+ '\\b(?:401|403|404|429|451|503)\\b',
98
+ 'paywall',
99
+ 'closed[- ]access',
100
+ 'requires? (?:a )?(?:credential|token|API key|subscription|login|account)',
101
+ '\\b(?:HF_TOKEN|API[_ ]KEY)\\b',
102
+ 'rate[- ]limit',
103
+ 'anti[- ]bot',
104
+ 'cloudflare',
105
+ 'captcha',
106
+ 'endpoint unknown',
107
+ 'no (?:known )?endpoint',
108
+ // Obstacle vocabulary observed in a real corpus: these are named obstacles,
109
+ // so the statement is already a real outcome rather than a silent skip.
110
+ 'proof[- ]of[- ]work',
111
+ 'challenge (?:artifact|page|response)',
112
+ 'returned challenge',
113
+ '\\bgated\\b',
114
+ 'did not render',
115
+ 'green OA',
116
+ // Explicit scope declarations: saying what is deliberately not claimed is an
117
+ // outcome, unlike omitting the check and not saying so.
118
+ 'is not (?:claimed|asserted)',
119
+ 'NOT (?:recorded|asserted) as',
120
+ 'evidence boundary',
121
+ 'completion_evidence',
122
+ 'status:\\s*(?:incomplete|blocked)',
123
+ '\\b(?:queried|fetched|checked|probed|confirmed|resolved)\\s+(?:on\\s+)?\\d{4}-\\d{2}-\\d{2}',
124
+ '\\bdeferred\\b.{0,40}\\b(?:because|since|due to)\\b',
125
+ ];
32
126
  /**
33
127
  * Build a target-wide artifact ID index once per lint run.
34
128
  *
@@ -136,6 +230,50 @@ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles, re
136
230
  }
137
231
  break;
138
232
  }
233
+ case 'unregistered-uncertainty': {
234
+ // An uncertainty written only into prose is invisible to the verification
235
+ // contract: it was never a declared check, so it never surfaces as
236
+ // `incomplete` and the "never report skipped verification as success" rule
237
+ // is never violated. Flag the bare form; accept it once a specific
238
+ // obstacle is named or an outcome is recorded (#2523).
239
+ const uncertainty = (check.uncertaintyPatterns ?? DEFAULT_UNCERTAINTY_PATTERNS)
240
+ .map((p) => new RegExp(p, 'i'));
241
+ const targets = (check.verificationTargets ?? DEFAULT_VERIFICATION_TARGETS)
242
+ .map((p) => new RegExp(p, 'i'));
243
+ const obstacle = (check.obstaclePatterns ?? DEFAULT_OBSTACLE_PATTERNS)
244
+ .map((p) => new RegExp(p, 'i'));
245
+ const within = check.obstacleWithinLines ?? 2;
246
+ const lines = content.split('\n');
247
+ for (let i = 0; i < lines.length; i++) {
248
+ // Both conditions must hold in the SAME CLAUSE: an unperformed action
249
+ // AND something the agent could have acted on. Without the target, the
250
+ // match is as likely to be the paper's own limitation, which must stay
251
+ // untouched. Without clause scoping, a long markdown line relates two
252
+ // unrelated clauses — real corpus prose puts whole paragraphs and
253
+ // changelog tables on one line, which produced most false positives.
254
+ const clause = splitClauses(lines[i]).find((c) => uncertainty.some((re) => re.test(c)) && targets.some((re) => re.test(c)));
255
+ if (!clause)
256
+ continue;
257
+ // Look in the matching line and the following `within` lines: the
258
+ // obstacle normally sits in the same sentence or the next one.
259
+ const window = lines.slice(i, i + within + 1).join(' ');
260
+ if (obstacle.some((re) => re.test(window)))
261
+ continue;
262
+ diagnostics.push({
263
+ ruleId: '',
264
+ ruleName: '',
265
+ // Left unset so the rule's declared severity applies (see runRule).
266
+ // This is a prose heuristic, so the shipped rule declares `warn`; an
267
+ // operator can raise it to error once a corpus is clean.
268
+ severity: undefined,
269
+ file: filePath,
270
+ line: i + 1,
271
+ message: `Uncertainty stated without a named obstacle or recorded outcome: ${clause.trim().slice(0, 160)}`,
272
+ fix: 'Resolve it if it costs about one request against a known endpoint, or name the specific obstacle (HTTP status, credential required, rate limited, paywalled) so it lands as incomplete/blocked rather than narrative.',
273
+ });
274
+ }
275
+ break;
276
+ }
139
277
  case 'pattern-match': {
140
278
  if (!check.pattern)
141
279
  break;
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { spawn } from 'node:child_process';
9
+ import { StringDecoder } from 'node:string_decoder';
9
10
  import fs from 'node:fs/promises';
10
11
  import path from 'node:path';
11
12
 
@@ -72,7 +73,7 @@ export async function resolveProjectAiwgDir(projectDir) {
72
73
  */
73
74
  export async function findProjectRoot(startDir = process.cwd()) {
74
75
  let currentDir = startDir;
75
- while (currentDir !== path.dirname(currentDir)) {
76
+ while (true) {
76
77
  const aiwgPath = path.join(currentDir, '.aiwg');
77
78
  const pointerPath = path.join(currentDir, PROJECT_AIWG_LOCATION_FILE);
78
79
  try {
@@ -87,7 +88,10 @@ export async function findProjectRoot(startDir = process.cwd()) {
87
88
  } catch {
88
89
  // continue up
89
90
  }
90
- currentDir = path.dirname(currentDir);
91
+ // Inspect the root candidate too, then stop instead of revisiting it.
92
+ const parentDir = path.dirname(currentDir);
93
+ if (parentDir === currentDir) break;
94
+ currentDir = parentDir;
91
95
  }
92
96
  throw new Error('No .aiwg directory or .aiwg-location pointer found. Run from an AIWG project or `aiwg new` first.');
93
97
  }
@@ -167,33 +171,56 @@ export function runAiwgCli(args, { cwd, env, timeoutMs = 120_000, input } = {})
167
171
  });
168
172
  let stdout = '';
169
173
  let stderr = '';
170
- let timedOut = false;
174
+ const stdoutDecoder = new StringDecoder('utf8');
175
+ const stderrDecoder = new StringDecoder('utf8');
176
+ let settled = false;
177
+ let killTimer;
178
+ const rejectAndTerminate = (err) => {
179
+ if (settled) return;
180
+ settled = true;
181
+ clearTimeout(timer);
182
+ // Settlement must not depend on a cooperative close event. Give the
183
+ // owned child one second to exit gracefully, then escalate cleanup.
184
+ reject(err);
185
+ killTimer = setTimeout(() => {
186
+ try { proc.kill('SIGKILL'); } catch { /* child may already have exited */ }
187
+ }, 1000);
188
+ killTimer.unref?.();
189
+ // Install cleanup first: kill() can synchronously trigger close in an adapter.
190
+ try { proc.kill('SIGTERM'); } catch { /* retain the original failure */ }
191
+ };
171
192
  const timer = setTimeout(() => {
172
- timedOut = true;
173
- proc.kill('SIGTERM');
193
+ rejectAndTerminate(new Error(`aiwg ${args[0] || ''} timed out after ${timeoutMs}ms`));
174
194
  }, timeoutMs);
175
195
 
176
- proc.stdout.on('data', (chunk) => { stdout += chunk; });
177
- proc.stderr.on('data', (chunk) => { stderr += chunk; });
196
+ proc.stdout.on('data', (chunk) => { stdout += stdoutDecoder.write(chunk); });
197
+ proc.stderr.on('data', (chunk) => { stderr += stderrDecoder.write(chunk); });
178
198
 
179
199
  proc.on('close', (code) => {
180
200
  clearTimeout(timer);
181
- if (timedOut) {
182
- reject(new Error(`aiwg ${args[0] || ''} timed out after ${timeoutMs}ms`));
183
- return;
184
- }
201
+ clearTimeout(killTimer);
202
+ if (settled) return;
203
+ settled = true;
204
+ stdout += stdoutDecoder.end();
205
+ stderr += stderrDecoder.end();
185
206
  resolve({ stdout, stderr, code: code ?? -1 });
186
207
  });
187
208
  proc.on('error', (err) => {
188
209
  clearTimeout(timer);
210
+ // A late error must not cancel cleanup of a failed, still-live child.
211
+ if (settled) return;
212
+ settled = true;
189
213
  reject(err);
190
214
  });
191
215
 
192
- if (input !== undefined) {
193
- proc.stdin.write(input);
194
- proc.stdin.end();
195
- } else {
196
- proc.stdin.end();
216
+ // Pipe errors are emitted on stdin, not on the ChildProcess. Register
217
+ // before writing so synchronous adapter events and late EPIPE are handled.
218
+ proc.stdin.on('error', rejectAndTerminate);
219
+ try {
220
+ if (input !== undefined) proc.stdin.write(input);
221
+ if (!settled) proc.stdin.end();
222
+ } catch (err) {
223
+ rejectAndTerminate(err);
197
224
  }
198
225
  });
199
226
  }
@@ -244,13 +271,20 @@ export async function loadCommandAllowList() {
244
271
  }
245
272
  }
246
273
  if (!text) {
247
- // Last-resort fallback: spawn `aiwg help` and parse slower but always works
274
+ // Installed packages need not contain TypeScript sources. Ask the CLI for
275
+ // its versioned canonical registry; human help is incomplete and contains examples.
248
276
  try {
249
- const { stdout } = await runAiwgCli(['help'], { timeoutMs: 30_000 });
250
- _commandIds = new Set(
251
- Array.from(stdout.matchAll(/^\s{4}([a-z][a-z0-9-]+)\s/gm))
252
- .map(m => m[1])
253
- );
277
+ const { stdout, code } = await runAiwgCli(['help', '--json'], { timeoutMs: 30_000 });
278
+ if (code !== 0) throw new Error('Command registry subprocess failed');
279
+ const registry = JSON.parse(stdout);
280
+ const ids = registry?.commandIds;
281
+ if (registry?.schema !== 'aiwg.command-registry.v1'
282
+ || !Array.isArray(ids) || ids.length === 0
283
+ || ids.some(id => typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id))
284
+ || new Set(ids).size !== ids.length) {
285
+ throw new Error('Invalid command registry response');
286
+ }
287
+ _commandIds = new Set(ids);
254
288
  return _commandIds;
255
289
  } catch (e) {
256
290
  _commandIds = new Set();
@@ -1,4 +1,5 @@
1
1
  import { manageOmpMcp } from './omp-config.mjs';
2
+ import { replaceServer } from './toml-editor.mjs';
2
3
  import { resolveOmpPaths } from '../providers/omp-paths.mjs';
3
4
  /**
4
5
  * MCP Server Registry
@@ -246,18 +247,30 @@ function buildServerConfig(server, provider) {
246
247
  /**
247
248
  * Build a TOML section for a server (Codex/OpenAI provider).
248
249
  */
250
+ function tomlString(value) {
251
+ if (typeof value !== 'string' || [...value].some(char => {
252
+ const point = char.codePointAt(0);
253
+ return point >= 0xd800 && point <= 0xdfff;
254
+ }))
255
+ throw new Error('TOML values must be strings containing valid Unicode scalar values');
256
+ // JSON escapes align with TOML basic strings except DEL must also be escaped.
257
+ return JSON.stringify(value).replace(/\u007f/g, '\\u007f');
258
+ }
259
+ function tomlKey(value) {
260
+ return typeof value === 'string' && /^[A-Za-z0-9_-]+$/.test(value) ? value : tomlString(value);
261
+ }
249
262
  function buildServerToml(server) {
250
263
  const lines = [];
251
- lines.push(`[mcp_servers.${server.name}]`);
264
+ lines.push(`[mcp_servers.${tomlKey(server.name)}]`);
252
265
  if (server.type === 'stdio') {
253
- lines.push(`command = "${server.command}"`);
266
+ lines.push(`command = ${tomlString(server.command)}`);
254
267
  if (server.args && server.args.length > 0) {
255
- const argsStr = server.args.map(a => `"${a}"`).join(', ');
268
+ const argsStr = server.args.map(a => tomlString(a)).join(', ');
256
269
  lines.push(`args = [${argsStr}]`);
257
270
  }
258
271
  }
259
272
  else {
260
- lines.push(`url = "${server.url}"`);
273
+ lines.push(`url = ${tomlString(server.url)}`);
261
274
  }
262
275
  lines.push(`startup_timeout_sec = 10.0`);
263
276
  lines.push(`tool_timeout_sec = 60.0`);
@@ -344,12 +357,21 @@ async function injectJson(registry, servers, configPath, provider, dryRun, resul
344
357
  existing = JSON.parse(content);
345
358
  }
346
359
  catch (error) {
360
+ if (error instanceof SyntaxError) {
361
+ throw new Error(`Refusing to overwrite malformed MCP config ${configPath}: invalid JSON`);
362
+ }
347
363
  if ((provider === 'antigravity' || provider === 'agy') && error?.code !== 'ENOENT') {
348
364
  throw new Error(`Refusing to overwrite malformed MCP config ${configPath}: ${error.message}`);
349
365
  }
366
+ if (error?.code !== 'ENOENT')
367
+ throw error;
350
368
  }
351
369
  // Determine the MCP servers key for this provider
352
370
  const mcpKey = provider === 'opencode' ? 'mcp' : 'mcpServers';
371
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
372
+ if (!isObject(existing) || (Object.hasOwn(existing, mcpKey) && !isObject(existing[mcpKey]))) {
373
+ throw new Error('MCP configuration must contain an object root and an object server map');
374
+ }
353
375
  const existingServers = existing[mcpKey] || {};
354
376
  // Build new server entries
355
377
  const newServers = { ...existingServers };
@@ -382,26 +404,17 @@ async function injectToml(registry, servers, configPath, provider, dryRun, resul
382
404
  try {
383
405
  existing = await readFile(configPath, 'utf-8');
384
406
  }
385
- catch {
386
- // File doesn't exist
407
+ catch (error) {
408
+ if (error.code !== 'ENOENT')
409
+ throw error;
387
410
  }
388
- const sectionsToAdd = [];
389
411
  for (const server of servers) {
390
- const sectionHeader = `[mcp_servers.${server.name}]`;
391
- if (existing.includes(sectionHeader)) {
392
- // Replace the existing section
393
- const sectionRegex = new RegExp(`\\[mcp_servers\\.${escapeRegex(server.name)}\\][\\s\\S]*?(?=\\n\\[|$)`);
394
- existing = existing.replace(sectionRegex, buildServerToml(server));
412
+ const edited = replaceServer(existing, server.name, buildServerToml(server));
413
+ existing = edited.text;
414
+ if (edited.alreadyPresent)
395
415
  result.alreadyPresent.push(server.name);
396
- }
397
- else {
398
- sectionsToAdd.push(buildServerToml(server));
399
- }
400
416
  result.serversInjected.push(server.name);
401
417
  }
402
- if (sectionsToAdd.length > 0) {
403
- existing = existing.trimEnd() + '\n\n' + sectionsToAdd.join('\n\n') + '\n';
404
- }
405
418
  if (!dryRun) {
406
419
  await mkdir(resolve(configPath, '..'), { recursive: true });
407
420
  await writeFile(configPath, existing, 'utf-8');
@@ -411,9 +424,6 @@ async function injectToml(registry, servers, configPath, provider, dryRun, resul
411
424
  }
412
425
  return result;
413
426
  }
414
- function escapeRegex(str) {
415
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
416
- }
417
427
  /** All supported provider names for injection */
418
428
  export const SUPPORTED_PROVIDERS = [
419
429
  'antigravity',