@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.
- package/dist/src/artifacts/index-builder.js +43 -1
- package/dist/src/artifacts/query-engine.js +7 -0
- package/dist/src/cli/handlers/help.js +7 -1
- package/dist/src/cli/handlers/installation.js +106 -2
- package/dist/src/cli/handlers/mc.js +100 -37
- package/dist/src/cli/handlers/ralph.js +14 -4
- package/dist/src/cli/handlers/refresh.js +359 -31
- package/dist/src/cli/handlers/repo-access.js +155 -4
- package/dist/src/cli/handlers/runtime-info.js +3 -0
- package/dist/src/cli/handlers/serve.js +21 -3
- package/dist/src/cli/handlers/setup.js +5 -5
- package/dist/src/cli/handlers/steward.js +30 -1
- package/dist/src/cli/handlers/use.js +123 -12
- package/dist/src/cli/handlers/utilities.js +26 -10
- package/dist/src/cli/handlers/version.js +40 -14
- package/dist/src/cli/handlers/workspace-context.js +8 -0
- package/dist/src/cli/services/deployment-verification.js +156 -7
- package/dist/src/cli/watch-service.js +47 -4
- package/dist/src/config/aiwg-config.js +95 -3
- package/dist/src/config/cli.js +16 -1
- package/dist/src/config/gitignore.js +5 -0
- package/dist/src/config/project-artifacts-health.mjs +15 -2
- package/dist/src/cost/fleet-report.js +19 -5
- package/dist/src/extensions/claude-hooks-installer.js +22 -6
- package/dist/src/extensions/project-local-doctor.js +40 -2
- package/dist/src/extensions/project-quickref.js +4 -0
- package/dist/src/installation/manager.mjs +38 -3
- package/dist/src/lint/runner.js +138 -0
- package/dist/src/mcp/helpers.mjs +56 -22
- package/dist/src/mcp/registry.js +32 -22
- package/dist/src/mcp/registry.mjs +31 -26
- package/dist/src/mcp/toml-editor.mjs +117 -0
- package/dist/src/mcp/tools/orchestration.mjs +7 -7
- package/dist/src/mcp/tools/subsystems.mjs +7 -7
- package/dist/src/memory/context-pack.js +5 -1
- package/dist/src/plugin/skill-command-translator.js +70 -1
- package/dist/src/serve/a2a-terminal-observer.js +19 -1
- package/dist/src/serve/mission-hitl.js +91 -0
- package/dist/src/sessions/import-lease.js +5 -1
- package/dist/src/smiths/context-pipeline/workspace-context.js +132 -6
- package/dist/src/testing/fixtures/test-data-factory.js +3 -3
- package/dist/src/writing/pattern-library.js +29 -6
- package/package.json +2 -1
- package/tools/agents/deploy-agents.mjs +91 -5
- 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 =
|
|
291
|
+
lines.push(`command = ${tomlString(server.command)}`);
|
|
278
292
|
if (server.args && server.args.length > 0) {
|
|
279
|
-
const argsStr = server.args.map(a =>
|
|
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 =
|
|
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
|
-
|
|
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
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|