@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
@@ -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
  /**
@@ -8,12 +8,24 @@
8
8
  *
9
9
  * @issue #1374
10
10
  */
11
- import { A2AClient } from '../a2a/client.js';
11
+ import { A2AClient, A2A_HITL_PROMPT_V1 } from '../a2a/client.js';
12
12
  import { isTerminalTaskState, } from '../a2a/types.js';
13
13
  import { extractGraphMetadata } from '../flow/graph-metadata.js';
14
+ import { extractHitlEnvelope } from '../a2a/hitl.js';
14
15
  const DEFAULT_POLL_INTERVAL_MS = 1000;
15
16
  const DEFAULT_MAX_POLLS = 300;
16
17
  export async function observeA2ATerminalState(registry, executor, missionId, a2aInstanceId, initialTask, opts = {}) {
18
+ const mission = registry.getMission(missionId);
19
+ if (mission && !(mission.a2a?.taskId === initialTask.id
20
+ && mission.a2a.instanceId === a2aInstanceId)) {
21
+ mission.a2a = {
22
+ instanceId: a2aInstanceId, taskId: initialTask.id,
23
+ ...(initialTask.contextId ? { contextId: initialTask.contextId } : {}),
24
+ protocolVersion: opts.protocolVersion ?? '0.3',
25
+ ...(opts.selectedInterface ? { selectedInterface: opts.selectedInterface } : {}),
26
+ acceptedPrompts: new Set(),
27
+ };
28
+ }
17
29
  try {
18
30
  const clientOpts = {
19
31
  baseUrl: executor.transportEndpoints.rest,
@@ -21,6 +33,7 @@ export async function observeA2ATerminalState(registry, executor, missionId, a2a
21
33
  instanceId: a2aInstanceId,
22
34
  protocolVersion: opts.protocolVersion ?? '0.3',
23
35
  protocolPolicy: opts.protocolVersion ?? '0.3',
36
+ optionalExtensions: [A2A_HITL_PROMPT_V1],
24
37
  };
25
38
  if (opts.selectedInterface)
26
39
  clientOpts.selectedInterface = opts.selectedInterface;
@@ -38,6 +51,9 @@ export async function observeA2ATerminalState(registry, executor, missionId, a2a
38
51
  break;
39
52
  await sleep(opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
40
53
  task = await client.getTask(task.id);
54
+ if (task.id !== initialTask.id || task.contextId !== initialTask.contextId) {
55
+ throw new Error('A2A observer task binding changed');
56
+ }
41
57
  emitNonTerminalProgress(registry, executor.executorId, missionId, task);
42
58
  }
43
59
  registry.failMission(missionId, `A2A task ${initialTask.id} did not reach a terminal state before observer timeout`);
@@ -58,10 +74,12 @@ function emitNonTerminalProgress(registry, executorId, missionId, task) {
58
74
  return;
59
75
  }
60
76
  if (state === 'input-required') {
77
+ const prompt = extractHitlEnvelope(task);
61
78
  registry.handleEvent(makeEnvelope('mission.hitl_required', executorId, missionId, task, {
62
79
  state: 'hitl-required',
63
80
  a2a_task_id: task.id,
64
81
  summary: taskStatusSummary(task),
82
+ ...(prompt?.ok ? { hitl_id: prompt.envelope.prompt_id, hitl_prompt: prompt.envelope } : {}),
65
83
  }));
66
84
  }
67
85
  }
@@ -0,0 +1,91 @@
1
+ /** A2A approval routing for the public mission API. */
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import Ajv from 'ajv';
4
+ import { A2AClient, A2A_HITL_PROMPT_V1 } from '../a2a/client.js';
5
+ import { buildHitlResponseMessage, extractHitlEnvelope } from '../a2a/hitl.js';
6
+ export async function respondToA2AMission(registry, missionId, promptId, response, opts = {}) {
7
+ const mission = registry.getMission(missionId);
8
+ const binding = mission?.a2a;
9
+ const executor = mission && registry.getRegistration(mission.executorId);
10
+ if (!mission || !binding || !executor)
11
+ return reply(404, 'mission_binding_not_found');
12
+ if (binding.responding || binding.acceptedPrompts.has(promptId))
13
+ return reply(409, 'approval_already_submitted');
14
+ if (['done', 'failed', 'aborted'].includes(mission.state))
15
+ return reply(409, 'mission_terminal');
16
+ binding.responding = true;
17
+ try {
18
+ const client = new A2AClient({
19
+ baseUrl: executor.transportEndpoints.rest,
20
+ bearer: executor.token,
21
+ instanceId: binding.instanceId,
22
+ protocolVersion: binding.protocolVersion,
23
+ protocolPolicy: binding.protocolVersion,
24
+ optionalExtensions: [A2A_HITL_PROMPT_V1],
25
+ ...(binding.selectedInterface ? { selectedInterface: binding.selectedInterface } : {}),
26
+ ...(opts.fetch ? { fetch: opts.fetch } : {}),
27
+ });
28
+ // Re-read the owning task: cached mission events can outlive a prompt.
29
+ const task = await client.getTask(binding.taskId);
30
+ if (task.id !== binding.taskId || task.contextId !== binding.contextId)
31
+ return reply(409, 'task_binding_mismatch');
32
+ const extracted = extractHitlEnvelope(task);
33
+ if (!extracted?.ok)
34
+ return reply(409, 'no_valid_pending_approval');
35
+ const prompt = extracted.envelope;
36
+ if (prompt.prompt_id !== promptId)
37
+ return reply(409, 'approval_prompt_mismatch');
38
+ if (prompt.deadline && Date.parse(prompt.deadline) <= Date.now())
39
+ return reply(410, 'approval_expired');
40
+ // The local mission API has no authenticated operator identity. Never
41
+ // accept a caller-supplied name as authority for a restricted prompt.
42
+ if (prompt.allowed_responders && !prompt.allowed_responders.includes('any'))
43
+ return reply(403, 'authenticated_responder_required');
44
+ try {
45
+ const validate = new Ajv({ allErrors: true, strict: true }).compile(prompt.response_schema);
46
+ if ('$async' in validate && validate.$async === true)
47
+ return reply(422, 'approval_schema_unsupported');
48
+ if (!validate(response))
49
+ return reply(422, 'approval_response_invalid');
50
+ }
51
+ catch {
52
+ return reply(422, 'approval_schema_unsupported');
53
+ }
54
+ if (registry.getMission(missionId) !== mission || mission.a2a !== binding
55
+ || ['done', 'failed', 'aborted'].includes(mission.state)) {
56
+ return reply(409, 'mission_changed_during_approval');
57
+ }
58
+ const digest = createHash('sha256').update(JSON.stringify(response)).digest('hex');
59
+ const attempts = binding.attempts ??= new Map();
60
+ const previous = attempts.get(promptId);
61
+ if (previous && previous.digest !== digest)
62
+ return reply(409, 'approval_retry_payload_changed');
63
+ const attempt = previous ?? { messageId: randomUUID(), digest };
64
+ attempts.set(promptId, attempt);
65
+ const result = await client.sendMessage(buildHitlResponseMessage({
66
+ promptId, response, messageId: attempt.messageId, taskId: binding.taskId,
67
+ ...(binding.contextId ? { contextId: binding.contextId } : {}),
68
+ }));
69
+ if (result.task.id !== binding.taskId || result.task.contextId !== binding.contextId)
70
+ return reply(502, 'approval_result_binding_mismatch');
71
+ binding.acceptedPrompts.add(promptId);
72
+ // Audit correlation only; approval payloads can contain sensitive data.
73
+ registry.handleEvent({
74
+ event: 'mission.progress', executor_id: mission.executorId, mission_id: missionId,
75
+ ts: new Date().toISOString(),
76
+ data: { action: 'hitl_response_accepted', hitl_id: promptId, a2a_task_id: binding.taskId },
77
+ });
78
+ return { status: 200, body: { ok: true } };
79
+ }
80
+ catch {
81
+ // Preserve the binding for a retry; the next attempt rechecks executor state.
82
+ return reply(502, 'approval_forward_failed');
83
+ }
84
+ finally {
85
+ binding.responding = false;
86
+ }
87
+ }
88
+ function reply(status, error) {
89
+ return { status, body: { error } };
90
+ }
91
+ //# sourceMappingURL=mission-hitl.js.map
@@ -49,9 +49,12 @@ export async function acquireImportLease(databasePath, runId, options = {}) {
49
49
  heartbeatAt: observed,
50
50
  };
51
51
  await writeOwner(ownerPath, owner);
52
+ let heartbeatWrite = Promise.resolve();
52
53
  const timer = setInterval(() => {
53
54
  owner.heartbeatAt = now().toISOString();
54
- void writeOwner(ownerPath, owner).catch(() => undefined);
55
+ heartbeatWrite = heartbeatWrite
56
+ .then(() => writeOwner(ownerPath, owner))
57
+ .catch(() => undefined);
55
58
  }, heartbeatMs);
56
59
  timer.unref();
57
60
  let released = false;
@@ -63,6 +66,7 @@ export async function acquireImportLease(databasePath, runId, options = {}) {
63
66
  return;
64
67
  released = true;
65
68
  clearInterval(timer);
69
+ await heartbeatWrite;
66
70
  const current = await readOwner(ownerPath);
67
71
  if (current?.runId === owner.runId) {
68
72
  await rm(lockPath, { recursive: true, force: true });