@aiwg/cli 2026.9.5 → 2026.9.7

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 (33) hide show
  1. package/dist/src/cli/handlers/help.js +7 -1
  2. package/dist/src/cli/handlers/installation.js +4 -0
  3. package/dist/src/cli/handlers/mc.js +13 -20
  4. package/dist/src/cli/handlers/ralph.js +14 -4
  5. package/dist/src/cli/handlers/refresh.js +298 -30
  6. package/dist/src/cli/handlers/runtime-info.js +3 -0
  7. package/dist/src/cli/handlers/serve.js +21 -3
  8. package/dist/src/cli/handlers/use.js +114 -8
  9. package/dist/src/cli/handlers/utilities.js +26 -10
  10. package/dist/src/cli/services/deployment-verification.js +117 -1
  11. package/dist/src/cli/watch-service.js +47 -4
  12. package/dist/src/config/project-artifacts-health.mjs +15 -2
  13. package/dist/src/cost/fleet-report.js +19 -5
  14. package/dist/src/extensions/project-local-doctor.js +40 -2
  15. package/dist/src/extensions/project-quickref.js +4 -0
  16. package/dist/src/installation/manager.mjs +38 -3
  17. package/dist/src/mcp/helpers.mjs +56 -22
  18. package/dist/src/mcp/registry.js +32 -22
  19. package/dist/src/mcp/registry.mjs +31 -26
  20. package/dist/src/mcp/toml-editor.mjs +117 -0
  21. package/dist/src/mcp/tools/orchestration.mjs +7 -7
  22. package/dist/src/mcp/tools/subsystems.mjs +7 -7
  23. package/dist/src/memory/context-pack.js +5 -1
  24. package/dist/src/plugin/skill-command-translator.js +70 -1
  25. package/dist/src/serve/a2a-terminal-observer.js +19 -1
  26. package/dist/src/serve/mission-hitl.js +91 -0
  27. package/dist/src/sessions/import-lease.js +5 -1
  28. package/dist/src/smiths/context-pipeline/workspace-context.js +81 -5
  29. package/dist/src/testing/fixtures/test-data-factory.js +3 -3
  30. package/dist/src/writing/pattern-library.js +29 -6
  31. package/package.json +3 -1
  32. package/tools/agents/deploy-agents.mjs +87 -5
  33. package/tools/agents/providers/base.mjs +61 -2
@@ -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',
@@ -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 (Runtime ESM)
@@ -269,18 +270,31 @@ function buildServerConfig(server, provider) {
269
270
  }
270
271
  }
271
272
 
273
+ function tomlString(value) {
274
+ if (typeof value !== 'string' || [...value].some(char => {
275
+ const point = char.codePointAt(0);
276
+ return point >= 0xd800 && point <= 0xdfff;
277
+ })) throw new Error('TOML values must be strings containing valid Unicode scalar values');
278
+ // JSON escapes align with TOML basic strings except DEL must also be escaped.
279
+ return JSON.stringify(value).replace(/\u007f/g, '\\u007f');
280
+ }
281
+
282
+ function tomlKey(value) {
283
+ return typeof value === 'string' && /^[A-Za-z0-9_-]+$/.test(value) ? value : tomlString(value);
284
+ }
285
+
272
286
  function buildServerToml(server) {
273
287
  const lines = [];
274
- lines.push(`[mcp_servers.${server.name}]`);
288
+ lines.push(`[mcp_servers.${tomlKey(server.name)}]`);
275
289
 
276
290
  if (server.type === 'stdio') {
277
- lines.push(`command = "${server.command}"`);
291
+ lines.push(`command = ${tomlString(server.command)}`);
278
292
  if (server.args && server.args.length > 0) {
279
- const argsStr = server.args.map(a => `"${a}"`).join(', ');
293
+ const argsStr = server.args.map(a => tomlString(a)).join(', ');
280
294
  lines.push(`args = [${argsStr}]`);
281
295
  }
282
296
  } else {
283
- lines.push(`url = "${server.url}"`);
297
+ lines.push(`url = ${tomlString(server.url)}`);
284
298
  }
285
299
 
286
300
  lines.push(`startup_timeout_sec = 10.0`);
@@ -344,12 +358,20 @@ async function injectJson(registry, servers, configPath, provider, dryRun, resul
344
358
  const content = await readFile(configPath, 'utf-8');
345
359
  existing = JSON.parse(content);
346
360
  } catch (error) {
361
+ if (error instanceof SyntaxError) {
362
+ throw new Error(`Refusing to overwrite malformed MCP config ${configPath}: invalid JSON`);
363
+ }
347
364
  if (normalizeRuntimeProviderId(provider) === 'antigravity' && error?.code !== 'ENOENT') {
348
365
  throw new Error(`Refusing to overwrite malformed MCP config ${configPath}: ${error.message}`);
349
366
  }
367
+ if (error?.code !== 'ENOENT') throw error;
350
368
  }
351
369
 
352
370
  const mcpKey = getMcpInjectionDefinition(provider)?.serversKey || '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
  const newServers = { ...existingServers };
355
377
 
@@ -381,30 +403,17 @@ async function injectToml(registry, servers, configPath, provider, dryRun, resul
381
403
  let existing = '';
382
404
  try {
383
405
  existing = await readFile(configPath, 'utf-8');
384
- } catch {
385
- // File doesn't exist
406
+ } catch (error) {
407
+ if (error.code !== 'ENOENT') throw error;
386
408
  }
387
409
 
388
- const sectionsToAdd = [];
389
-
390
410
  for (const server of servers) {
391
- const sectionHeader = `[mcp_servers.${server.name}]`;
392
- if (existing.includes(sectionHeader)) {
393
- const sectionRegex = new RegExp(
394
- `\\[mcp_servers\\.${escapeRegex(server.name)}\\][\\s\\S]*?(?=\\n\\[|$)`,
395
- );
396
- existing = existing.replace(sectionRegex, buildServerToml(server));
397
- result.alreadyPresent.push(server.name);
398
- } else {
399
- sectionsToAdd.push(buildServerToml(server));
400
- }
411
+ const edited = replaceServer(existing, server.name, buildServerToml(server));
412
+ existing = edited.text;
413
+ if (edited.alreadyPresent) result.alreadyPresent.push(server.name);
401
414
  result.serversInjected.push(server.name);
402
415
  }
403
416
 
404
- if (sectionsToAdd.length > 0) {
405
- existing = existing.trimEnd() + '\n\n' + sectionsToAdd.join('\n\n') + '\n';
406
- }
407
-
408
417
  if (!dryRun) {
409
418
  await mkdir(resolve(configPath, '..'), { recursive: true });
410
419
  await writeFile(configPath, existing, 'utf-8');
@@ -417,8 +426,4 @@ async function injectToml(registry, servers, configPath, provider, dryRun, resul
417
426
  return result;
418
427
  }
419
428
 
420
- function escapeRegex(str) {
421
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
422
- }
423
-
424
429
  export const SUPPORTED_PROVIDERS = listMcpInjectProviderIds();
@@ -0,0 +1,117 @@
1
+ // Pure source-range editing; provider filesystem access belongs to the caller.
2
+ import { parseTOML } from 'toml-eslint-parser';
3
+
4
+ const keys = node => node.key.keys.map(key => key.type === 'TOMLBare' ? key.name : key.value);
5
+ const starts = (path, prefix) => prefix.every((key, index) => path[index] === key);
6
+ function parse(text) {
7
+ try { return parseTOML(text, { tomlVersion: '1.0.0' }); }
8
+ catch { throw new Error('Invalid TOML configuration; no changes made'); }
9
+ }
10
+
11
+ export function replaceServer(text, name, section) {
12
+ const ast = parse(text);
13
+ const target = ['mcp_servers', name];
14
+ const replacement = parse(section).body[0].body;
15
+ if (replacement.length !== 1 || replacement[0].type !== 'TOMLTable' ||
16
+ replacement[0].resolvedKey.length !== 2 || !starts(replacement[0].resolvedKey, target)) {
17
+ throw new Error('Invalid replacement server definition');
18
+ }
19
+ const inline = '{ ' + replacement[0].body.map(node => section.slice(...node.range)).join(', ') + ' }';
20
+ const encodedName = section.slice(...replacement[0].key.keys[1].range);
21
+ const edits = [];
22
+ let present = false;
23
+ let placed = false;
24
+ const edit = (range, value = '') => edits.push({ start: range[0], end: range[1], value });
25
+
26
+ function inspectValue(node, path) {
27
+ if (path[0] === 'mcp_servers') {
28
+ if (path.length <= 2 && node.type !== 'TOMLInlineTable') {
29
+ throw new Error('MCP configuration and server entries must be TOML tables');
30
+ }
31
+ if (starts(path, target)) present = true;
32
+ }
33
+ if (node.type === 'TOMLInlineTable') {
34
+ for (const entry of node.body) inspectValue(entry.value, [...path, ...keys(entry)]);
35
+ }
36
+ }
37
+ for (const node of ast.body[0].body) {
38
+ if (node.type === 'TOMLTable') {
39
+ const path = node.resolvedKey;
40
+ if (path[0] === 'mcp_servers' &&
41
+ (typeof path[1] === 'number' || typeof path[2] === 'number')) {
42
+ throw new Error('MCP configuration and server entries must not be TOML arrays of tables');
43
+ }
44
+ if (starts(path, target)) present = true;
45
+ for (const entry of node.body) inspectValue(entry.value, [...path, ...keys(entry)]);
46
+ } else inspectValue(node.value, keys(node));
47
+ }
48
+
49
+ const selectedTables = ast.body[0].body.filter(node => node.type === 'TOMLTable' && starts(node.resolvedKey, target));
50
+ if (selectedTables.length === 1 && text.slice(...selectedTables[0].range) === section) {
51
+ return { text, alreadyPresent: true };
52
+ }
53
+ if (selectedTables.length === 1 && selectedTables[0].resolvedKey.length === 2) {
54
+ const [start, end] = selectedTables[0].range;
55
+ if (!ast.comments.some(comment => comment.range[0] >= start && comment.range[0] < end)) {
56
+ const output = text.slice(0, start) + section + text.slice(end);
57
+ parse(output);
58
+ return { text: output, alreadyPresent: true };
59
+ }
60
+ }
61
+
62
+ function editInlineMap(node) {
63
+ const entries = node.body;
64
+ const selected = entries.map((entry, index) => keys(entry)[0] === name ? index : -1).filter(index => index >= 0);
65
+ if (selected.length === 0) {
66
+ edit([node.range[1] - 1, node.range[1] - 1], `${entries.length ? ', ' : ''}${encodedName} = ${inline}`);
67
+ } else {
68
+ const first = selected[0];
69
+ if (keys(entries[first]).length === 1) edit(entries[first].value.range, inline);
70
+ else edit(entries[first].range, `${encodedName} = ${inline}`);
71
+ // Keep the first selected entry as the replacement anchor. Remove each
72
+ // subsequent contiguous run together with one separator, never a neighbor.
73
+ for (let cursor = 1; cursor < selected.length;) {
74
+ const start = selected[cursor];
75
+ let end = start;
76
+ while (cursor + 1 < selected.length && selected[cursor + 1] === end + 1) {
77
+ cursor++;
78
+ end++;
79
+ }
80
+ if (end + 1 < entries.length) edit([entries[start].range[0], entries[end + 1].range[0]]);
81
+ else edit([entries[start - 1].range[1], entries[end].range[1]]);
82
+ cursor++;
83
+ }
84
+ }
85
+ placed = true;
86
+ }
87
+ function planEntry(entry, base) {
88
+ const path = [...base, ...keys(entry)];
89
+ if (path.length === 1 && path[0] === 'mcp_servers') {
90
+ editInlineMap(entry.value);
91
+ } else if (starts(path, target)) {
92
+ if (path.length === 2) {
93
+ edit(entry.value.range, inline);
94
+ placed = true;
95
+ } else edit(entry.range);
96
+ }
97
+ }
98
+ for (const node of ast.body[0].body) {
99
+ if (node.type !== 'TOMLTable') { planEntry(node, []); continue; }
100
+ if (starts(node.resolvedKey, target)) {
101
+ const closing = ast.tokens.filter(token => token.range[0] >= node.key.range[1] && token.value === ']');
102
+ const last = closing[node.kind === 'array' ? 1 : 0];
103
+ if (!last) throw new Error('Invalid TOML table range');
104
+ edit([node.range[0], last.range[1]]);
105
+ for (const entry of node.body) edit(entry.range);
106
+ } else for (const entry of node.body) planEntry(entry, node.resolvedKey);
107
+ }
108
+ edits.sort((a, b) => a.start - b.start || a.end - b.end);
109
+ for (let index = 1; index < edits.length; index++) {
110
+ if (edits[index].start < edits[index - 1].end) throw new Error('Overlapping TOML edit ranges');
111
+ }
112
+ let output = text;
113
+ for (const change of edits.reverse()) output = output.slice(0, change.start) + change.value + output.slice(change.end);
114
+ if (!placed) output += `${output.endsWith('\n') ? '' : '\n'}\n${section}\n`;
115
+ parse(output);
116
+ return { text: output, alreadyPresent: present };
117
+ }
@@ -235,13 +235,13 @@ export function registerMissionToolset(server) {
235
235
  session_id: z.string().describe('Existing Mission Control session id from mc-start / mc-list'),
236
236
  objective: z.string().describe('Mission objective'),
237
237
  completion: z.string().describe('Measurable completion criterion'),
238
- max_iterations: z.number().int().positive().optional().describe('Ralph iteration cap'),
239
- max_total_tokens: z.number().int().positive().optional().describe('Hard cumulative token ceiling'),
240
- max_output_tokens: z.number().int().positive().optional().describe('Hard cumulative output-token ceiling'),
241
- max_tool_calls: z.number().int().positive().optional().describe('Hard cumulative tool-call ceiling'),
242
- max_total_cost: z.number().positive().optional().describe('Hard cumulative provider-reported spend ceiling'),
243
- max_wall_clock_minutes: z.number().positive().optional().describe('Hard cumulative runtime ceiling'),
244
- exploration_quota: z.number().int().positive().optional().describe('Require structural variant after this many flat cycles (off unless declared; no default K)'),
238
+ max_iterations: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional().describe('Ralph iteration cap'),
239
+ max_total_tokens: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional().describe('Hard cumulative token ceiling'),
240
+ max_output_tokens: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional().describe('Hard cumulative output-token ceiling'),
241
+ max_tool_calls: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional().describe('Hard cumulative tool-call ceiling'),
242
+ max_total_cost: z.number().positive().finite().optional().describe('Hard cumulative provider-reported spend ceiling'),
243
+ max_wall_clock_minutes: z.number().positive().finite().optional().describe('Hard cumulative runtime ceiling'),
244
+ exploration_quota: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional().describe('Require structural variant after this many flat cycles (off unless declared; no default K)'),
245
245
  budget_stop_policy: z.enum(['completion-wins', 'budget-wins']).optional().describe('Stop semantics when the completing iteration crosses a ceiling (default: completion-wins)'),
246
246
  project_dir: z.string().optional().describe('Project directory for CLI dispatch'),
247
247
  confirmed: z.boolean().default(false).describe('Required for durable/long-running mission dispatch'),
@@ -413,13 +413,13 @@ function registerMcToolset(server) {
413
413
  session_id: z.string().describe('Session id'),
414
414
  objective: z.string().describe('Mission objective'),
415
415
  completion: z.string().optional().describe('Completion criteria'),
416
- max_iterations: z.number().int().positive().optional().describe('Ralph iteration cap'),
417
- max_total_tokens: z.number().int().positive().optional().describe('Hard cumulative token ceiling'),
418
- max_output_tokens: z.number().int().positive().optional().describe('Hard cumulative output-token ceiling'),
419
- max_tool_calls: z.number().int().positive().optional().describe('Hard cumulative tool-call ceiling'),
420
- max_total_cost: z.number().positive().optional().describe('Hard cumulative provider-reported spend ceiling'),
421
- max_wall_clock_minutes: z.number().positive().optional().describe('Hard cumulative runtime ceiling'),
422
- exploration_quota: z.number().int().positive().optional().describe('Require structural variant after this many flat cycles (off unless declared; no default K)'),
416
+ max_iterations: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional().describe('Ralph iteration cap'),
417
+ max_total_tokens: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional().describe('Hard cumulative token ceiling'),
418
+ max_output_tokens: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional().describe('Hard cumulative output-token ceiling'),
419
+ max_tool_calls: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional().describe('Hard cumulative tool-call ceiling'),
420
+ max_total_cost: z.number().positive().finite().optional().describe('Hard cumulative provider-reported spend ceiling'),
421
+ max_wall_clock_minutes: z.number().positive().finite().optional().describe('Hard cumulative runtime ceiling'),
422
+ exploration_quota: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional().describe('Require structural variant after this many flat cycles (off unless declared; no default K)'),
423
423
  budget_stop_policy: z.enum(['completion-wins', 'budget-wins']).optional().describe('Stop semantics when the completing iteration crosses a ceiling (default: completion-wins)'),
424
424
  },
425
425
  buildArgs: ({
@@ -265,6 +265,7 @@ export function buildContextPack(task, candidates, options = {}) {
265
265
  };
266
266
  }
267
267
  export function buildWorkspaceContextPack(projectRoot, task, options = {}) {
268
+ const started = performance.now();
268
269
  if (!task.trim())
269
270
  throw new Error('context task must be nonblank');
270
271
  const root = realpathSync(projectRoot);
@@ -277,6 +278,9 @@ export function buildWorkspaceContextPack(projectRoot, task, options = {}) {
277
278
  return indexed.length > 0 ? indexed : wikiCandidates(root, taskTerms, maxFiles);
278
279
  })(),
279
280
  ];
280
- return buildContextPack(task, candidates, { ...options, maxFiles });
281
+ const pack = buildContextPack(task, candidates, { ...options, maxFiles });
282
+ // Workspace callers need retrieval plus assembly latency, not assembly alone.
283
+ pack.metrics.elapsedMs = Number((performance.now() - started).toFixed(3));
284
+ return pack;
281
285
  }
282
286
  //# sourceMappingURL=context-pack.js.map
@@ -11,8 +11,63 @@
11
11
  * @implements .aiwg/architecture/adr-skills-canonical-extension-type.md
12
12
  * @issue #550
13
13
  */
14
+ import { createHash } from 'node:crypto';
14
15
  import fs from 'fs/promises';
15
16
  import path from 'path';
17
+ /**
18
+ * Ownership signal for generated command files (#2507).
19
+ *
20
+ * Command wrappers were written as bare files: no `aiwg:managed` marker and no
21
+ * `.aiwg-manifest.json` entry. AIWG could neither count them as deployed nor
22
+ * recognise them as its own, so a later run reported the wrappers it had just
23
+ * written as unmanaged artifacts the operator should delete. They now carry the
24
+ * same signals as any other deployed artifact, tagged `skill-command` so the
25
+ * flat-command prune leaves them to the skills prune that governs their source.
26
+ */
27
+ const MANAGED_SIDECAR = '.aiwg-manifest.json';
28
+ const MANAGED_MARKER_PATTERN = /^(?:<!--\s*aiwg:managed\s|#\s*aiwg:managed\s)/m;
29
+ function addManagedMarker(content, version, source) {
30
+ if (MANAGED_MARKER_PATTERN.test(content))
31
+ return content;
32
+ if (content.startsWith('---\n')) {
33
+ return content.replace(/^---\n/, `---\n# aiwg:managed v${version} ${source}\n`);
34
+ }
35
+ return `<!-- aiwg:managed v${version} ${source} -->\n${content}`;
36
+ }
37
+ /**
38
+ * Merge generated command entries into a directory's managed sidecar.
39
+ *
40
+ * Best-effort: a translation that cannot record ownership still produced a
41
+ * usable command file, so a sidecar failure must not fail the deploy.
42
+ */
43
+ async function recordManagedCommands(targetDir, entries, version, source) {
44
+ if (entries.length === 0)
45
+ return;
46
+ const sidecarPath = path.join(targetDir, MANAGED_SIDECAR);
47
+ let sidecar = { managed: {} };
48
+ try {
49
+ const parsed = JSON.parse(await fs.readFile(sidecarPath, 'utf-8'));
50
+ if (parsed && typeof parsed === 'object' && parsed.managed)
51
+ sidecar = parsed;
52
+ }
53
+ catch {
54
+ // No sidecar yet, or unreadable — start a fresh managed map.
55
+ }
56
+ for (const entry of entries) {
57
+ sidecar.managed[entry.filename] = {
58
+ hash: `sha256:${createHash('sha256').update(entry.content).digest('hex')}`,
59
+ source,
60
+ version,
61
+ kind: 'skill-command',
62
+ };
63
+ }
64
+ try {
65
+ await fs.writeFile(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`, 'utf-8');
66
+ }
67
+ catch {
68
+ // Non-fatal — the command files themselves are already written.
69
+ }
70
+ }
16
71
  // ============================================
17
72
  // Provider Configuration
18
73
  // ============================================
@@ -280,6 +335,9 @@ export async function translateSkillsToCommands(skillsDir, options) {
280
335
  errors: [],
281
336
  totalProcessed: 0,
282
337
  };
338
+ // Ownership records for the commands this call writes (#2507).
339
+ const managedEntries = [];
340
+ const promptEntries = [];
283
341
  // Check if this provider needs commands. nameFilter overrides the
284
342
  // provider gating: when an operator passes an explicit filter (e.g. Claude
285
343
  // flow→command emission per PUW-015 #1116), they're opting in to selective
@@ -328,7 +386,7 @@ export async function translateSkillsToCommands(skillsDir, options) {
328
386
  continue;
329
387
  }
330
388
  // Generate command content
331
- const commandContent = generateCommandContent(skillName, frontmatter, body, options.provider);
389
+ const commandContent = addManagedMarker(generateCommandContent(skillName, frontmatter, body, options.provider), options.deployVersion ?? 'unknown', 'bundled');
332
390
  const commandFilename = `${skillName}.md`;
333
391
  const translated = {
334
392
  sourcePath: skillMdPath,
@@ -353,7 +411,9 @@ export async function translateSkillsToCommands(skillsDir, options) {
353
411
  const promptPath = path.join(promptsDir, `${skillName}.prompt.md`);
354
412
  await fs.mkdir(promptsDir, { recursive: true });
355
413
  await fs.writeFile(promptPath, commandContent, 'utf-8');
414
+ promptEntries.push({ filename: `${skillName}.prompt.md`, content: commandContent });
356
415
  }
416
+ managedEntries.push({ filename: commandFilename, content: commandContent });
357
417
  }
358
418
  result.translated.push(translated);
359
419
  if (options.verbose) {
@@ -373,6 +433,15 @@ export async function translateSkillsToCommands(skillsDir, options) {
373
433
  }
374
434
  }
375
435
  }
436
+ if (!options.dryRun) {
437
+ const version = options.deployVersion ?? 'unknown';
438
+ await recordManagedCommands(options.targetDir, managedEntries, version, 'bundled');
439
+ if (promptEntries.length > 0) {
440
+ const projectRoot = options.projectPath
441
+ ?? path.dirname(path.dirname(options.targetDir));
442
+ await recordManagedCommands(path.join(projectRoot, '.github', 'prompts'), promptEntries, version, 'bundled');
443
+ }
444
+ }
376
445
  return result;
377
446
  }
378
447
  /**