@dtlabs/ai-toolkit 0.13.0 → 0.14.0
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/CHANGELOG.md +44 -0
- package/bin/cli.js +371 -2
- package/docs/task-executor-bootstrap.md +195 -0
- package/docs/task-executor-cli.md +319 -0
- package/docs/workflow-control-plane.md +95 -0
- package/lib/execution-ledger.js +186 -4
- package/lib/task-executor/claude-process.js +563 -0
- package/lib/task-executor/git.js +1362 -0
- package/lib/task-executor/index.js +3729 -0
- package/lib/task-executor/ownership.js +590 -0
- package/lib/task-executor/plan.js +725 -0
- package/lib/task-executor/store.js +1164 -0
- package/lib/workflow-artifacts.js +102 -0
- package/lib/workflow-control.js +219 -0
- package/lib/workflow-runner.js +39 -0
- package/package.json +1 -1
- package/src/claude/agents/gaia-intervention-documentation-standard.md +9 -9
- package/src/claude/skills/assess-codebase/SKILL.md +9 -103
- package/src/claude/skills/implement-feature/SKILL.md +74 -159
- package/src/claude/workflows/am-phase1.js +43 -294
- package/src/claude/workflows/am-phase2.js +10 -164
- package/src/claude/workflows/pm-phase1.js +45 -358
- package/src/claude/workflows/pm-phase2.js +50 -571
- package/src/claude/workflows/pm-phase3.js +5 -824
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function cell(value) { return String(value ?? '—').replace(/\|/g, '\\|').replace(/[\r\n]/g, ' '); }
|
|
4
|
+
function table(headers, rows) {
|
|
5
|
+
return [headers, headers.map(() => '---'), ...rows].map(row => '| ' + row.map(cell).join(' | ') + ' |').join('\n') + '\n';
|
|
6
|
+
}
|
|
7
|
+
function pricing(control) {
|
|
8
|
+
if (!control.exists('docs/token-pricing.json')) return null;
|
|
9
|
+
const value = control.json('docs/token-pricing.json');
|
|
10
|
+
if (!value || !value.models || !Number.isFinite(value.usd_to_eur) || value.usd_to_eur <= 0) throw new Error('Invalid token pricing');
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function cost(tokens, model, rates) {
|
|
14
|
+
if (!rates || !Number.isFinite(tokens)) return null;
|
|
15
|
+
const rate = rates.models[model];
|
|
16
|
+
if (!rate || ![rate.input_per_1m_usd, rate.output_per_1m_usd].every(v => Number.isFinite(v) && v >= 0)) throw new Error('Invalid model pricing: ' + model);
|
|
17
|
+
return tokens * (0.8 * rate.input_per_1m_usd + 0.2 * rate.output_per_1m_usd) / 1000000 * rates.usd_to_eur;
|
|
18
|
+
}
|
|
19
|
+
function tokensDocument(c, prefix) {
|
|
20
|
+
const rates = pricing(c);
|
|
21
|
+
return `# Token Estimate — ${prefix}\n\nActuals for this invocation only; previous phases remain in the ledger. Missing usage is unavailable, not zero. Costs use an estimated 80/20 input/output split.\n\n` +
|
|
22
|
+
table(['Agent', 'Model', 'Tokens Actual', 'Estimated cost EUR'], c.tokenLedger.map(e => [e.agent, e.model, e.phase_delta_tokens, cost(e.phase_delta_tokens, e.model, rates)?.toFixed(4)]));
|
|
23
|
+
}
|
|
24
|
+
function estimates(c, dir, prefix, wb) {
|
|
25
|
+
if (!wb || !Array.isArray(wb.phases) || !wb.phases.length) throw new Error('Invalid Work Breakdown phases');
|
|
26
|
+
const tasks = wb.phases.flatMap(p => { if (!Array.isArray(p.tasks)) throw new Error('Invalid phase tasks'); return p.tasks; });
|
|
27
|
+
const rates = { BE: 120, TEST: 60, INFRA: 30, FE: 90, DB: 90, DevOps: 30 };
|
|
28
|
+
const domains = {};
|
|
29
|
+
for (const t of tasks) { if (!Object.hasOwn(rates, t.domain)) throw new Error('Unknown task domain'); domains[t.domain] = (domains[t.domain] || 0) + 1; }
|
|
30
|
+
const human = list => list.reduce((n, t) => n + rates[t.domain], 0);
|
|
31
|
+
const minutes = human(tasks);
|
|
32
|
+
const human_estimate = `~${Math.floor(minutes / 60)}h ${minutes % 60}min`;
|
|
33
|
+
const agentMinutes = tasks.every(t => Number.isFinite(t.estimate?.agentMinutes)) ? tasks.reduce((n, t) => n + t.estimate.agentMinutes, 0) : null;
|
|
34
|
+
const agent_estimate = agentMinutes == null ? 'unavailable' : `${agentMinutes}min (sequential estimate)`;
|
|
35
|
+
const base = `${dir}/${prefix}`;
|
|
36
|
+
const metrics = { prefix, feature_dir: dir, feature_title: wb.title || wb.feature || prefix,
|
|
37
|
+
total_tasks: tasks.length, user_stories: wb.phases.filter(p => p.id.startsWith('US-')).length,
|
|
38
|
+
domain_breakdown: Object.entries(domains).map(([d, n]) => `${d}: ${n}`).join(', '),
|
|
39
|
+
implementation_phases: wb.phases.length, human_estimate, agent_estimate,
|
|
40
|
+
work_breakdown_path: `${base}-Work-Breakdown.json`, tasks_csv_path: `${base}-Work-Breakdown.csv`,
|
|
41
|
+
effort_estimate_path: `${base}-Effort-Estimate.md`, token_estimate_path: `${base}-Token-Estimate.md` };
|
|
42
|
+
c.write(metrics.effort_estimate_path, `# Effort Estimate — ${prefix} — ${cell(metrics.feature_title)}\n\n` +
|
|
43
|
+
table(['Metric', 'Value'], [['User Stories', metrics.user_stories], ['Total tasks', tasks.length], ['Human estimate', human_estimate], ['Agent estimate', agent_estimate]]) +
|
|
44
|
+
'\n## Per-Phase Breakdown\n\n' + table(['Phase', 'Title', 'Tasks', 'Est. Human minutes', 'Actual Human', 'Actual Agent'],
|
|
45
|
+
wb.phases.map(p => [p.id, p.title, p.tasks.length, human(p.tasks), '—', '—'])));
|
|
46
|
+
c.write(metrics.token_estimate_path, tokensDocument(c, prefix));
|
|
47
|
+
return metrics;
|
|
48
|
+
}
|
|
49
|
+
function interventions(c, dir, prefix) {
|
|
50
|
+
const raw = c.read(`${dir}/${prefix}-Interventions-Index.md`);
|
|
51
|
+
const lines = raw.split(/\r?\n/).filter(l => l.trim().startsWith('|'));
|
|
52
|
+
const split = line => line.trim().replace(/^\||\|$/g, '').split(/(?<!\\)\|/).map(s => s.trim());
|
|
53
|
+
const headerAt = lines.findIndex(l => split(l).some(v => /^(Criticality|Severity)$/i.test(v)));
|
|
54
|
+
if (headerAt < 0) throw new Error('Interventions index missing Criticality column');
|
|
55
|
+
const headers = split(lines[headerAt]), severityAt = headers.findIndex(v => /^(Criticality|Severity)$/i.test(v));
|
|
56
|
+
const rows = [];
|
|
57
|
+
for (const line of lines.slice(headerAt + 1)) {
|
|
58
|
+
const values = split(line);
|
|
59
|
+
if (values.every(v => /^:?-+:?$/.test(v))) continue;
|
|
60
|
+
const id = line.match(/\bINT-\d+\b/);
|
|
61
|
+
if (!id) continue;
|
|
62
|
+
const severity = values[severityAt]?.replace(/\*/g, '').toUpperCase();
|
|
63
|
+
if (!['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].includes(severity)) throw new Error('Unknown intervention severity: ' + severity);
|
|
64
|
+
if (rows.some(r => r.id === id[0])) throw new Error('Duplicate intervention ID: ' + id[0]);
|
|
65
|
+
rows.push({ id: id[0], severity });
|
|
66
|
+
}
|
|
67
|
+
return rows;
|
|
68
|
+
}
|
|
69
|
+
function assessmentEstimates(c, dir, prefix) {
|
|
70
|
+
const rows = interventions(c, dir, prefix);
|
|
71
|
+
const severity_counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
|
72
|
+
const rates = { CRITICAL: 8, HIGH: 4, MEDIUM: 2, LOW: 1 };
|
|
73
|
+
rows.forEach(r => severity_counts[r.severity]++);
|
|
74
|
+
const hours = rows.reduce((n, r) => n + rates[r.severity], 0);
|
|
75
|
+
const base = `${dir}/${prefix}`;
|
|
76
|
+
c.write(`${base}-Token-Estimate.md`, tokensDocument(c, prefix));
|
|
77
|
+
c.write(`${base}-Effort-Estimate.md`, `# Effort Estimate — ${prefix}\n\n` +
|
|
78
|
+
table(['Severity', 'Count', 'Hours each', 'Subtotal hours'], Object.entries(severity_counts).map(([s, n]) => [s, n, rates[s], n * rates[s]])) + `\nTotal: ${hours} human hours (estimate).\n`);
|
|
79
|
+
return { severity_counts, total_interventions: rows.length, remediation_hours_est: hours,
|
|
80
|
+
interventions_index_path: `${base}-Interventions-Index.md`, effort_estimate_path: `${base}-Effort-Estimate.md`, token_estimate_path: `${base}-Token-Estimate.md` };
|
|
81
|
+
}
|
|
82
|
+
function recordAssessmentApproval(c, dir, prefix, ack, flagged) {
|
|
83
|
+
const rows = interventions(c, dir, prefix);
|
|
84
|
+
const selected = flagged === 'none' ? [] : flagged.split(',').map(s => s.trim());
|
|
85
|
+
for (const id of selected) if (!rows.some(r => r.id === id)) throw new Error('Unknown flagged intervention: ' + id);
|
|
86
|
+
const today = new Date().toISOString().slice(0, 10), approvals_path = `${dir}/${prefix}-Approvals.md`;
|
|
87
|
+
const content = `# Assessment Approvals — ${prefix}\n\n## Findings Gate Acknowledgement\n\n` +
|
|
88
|
+
table(['Field', 'Value'], [['Acknowledged by', 'Toolkit user'], ['Date', today], ['Acknowledgement', ack]]) +
|
|
89
|
+
'\n## Interventions Flagged for Feature Delivery\n\n' + table(['Intervention', 'Flagged', 'Date', 'Notes'], rows.map(r => [r.id, selected.includes(r.id) ? 'Yes' : 'No', today, selected.includes(r.id) ? '—' : 'Not selected']));
|
|
90
|
+
c.write(approvals_path, content);
|
|
91
|
+
if (c.read(approvals_path) !== content) throw new Error('Approval readback mismatch');
|
|
92
|
+
const registryPath = 'docs/assessments/registry.md';
|
|
93
|
+
const header = '# Assessment Registry\n\n' + table(['Date', 'Prefix', 'Total', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'Flagged'], []);
|
|
94
|
+
const old = c.exists(registryPath) ? c.read(registryPath) : header;
|
|
95
|
+
const row = `| ${today} | [${prefix}](${prefix}/) | ${rows.length} | ` +
|
|
96
|
+
['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].map(s => rows.filter(r => r.severity === s).length).join(' | ') + ` | ${selected.length} |`;
|
|
97
|
+
c.write(registryPath, old.split('\n').filter(l => !l.includes(`[${prefix}](`)).join('\n').trimEnd() + '\n' + row + '\n');
|
|
98
|
+
c.read(registryPath);
|
|
99
|
+
return { approvals_path, registry_updated: registryPath, flagged_count: selected.length, summary: `${prefix}: ${selected.length} interventions selected` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { estimates, assessmentEstimates, recordAssessmentApproval, interventions };
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Node host for workflow control-plane operations. Never ask an LLM to do I/O.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
const registry = require('./agent-registry');
|
|
9
|
+
const ledger = require('./execution-ledger');
|
|
10
|
+
const { spawnClaudeAgent } = require('./task-executor/claude-process');
|
|
11
|
+
const CLI = path.join(__dirname, '..', 'bin', 'cli.js');
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
// Runtime payload has no third-party dependencies. Only these two canonical
|
|
15
|
+
// scalar fields are needed; reject ambiguous/duplicate declarations.
|
|
16
|
+
function definitionFields(source) {
|
|
17
|
+
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
18
|
+
if (!match) throw new Error('Agent frontmatter missing');
|
|
19
|
+
const fields = {};
|
|
20
|
+
for (const key of ['name', 'model']) {
|
|
21
|
+
const lines = match[1].split(/\r?\n/).filter(l => l.startsWith(key + ':'));
|
|
22
|
+
if (lines.length !== 1) throw new Error('Ambiguous agent field: ' + key);
|
|
23
|
+
fields[key] = lines[0].slice(key.length + 1).trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
24
|
+
}
|
|
25
|
+
if (!/^[a-z][a-z0-9-]*$/.test(fields.name) || !['haiku', 'sonnet', 'opus'].includes(fields.model)) throw new Error('Invalid agent name/model');
|
|
26
|
+
return fields;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateIdentity(id, record, kind = 'agent') {
|
|
30
|
+
const entry = registry.CATALOG.find(e => e.agentId === id);
|
|
31
|
+
if (!entry || (entry.role === 'orchestrator') !== (kind === 'workflow') ||
|
|
32
|
+
!record || record.agentId !== id || record.status !== 'verified' ||
|
|
33
|
+
record.nativeName !== entry.nativeNames.claude ||
|
|
34
|
+
!record.path || !/^sha256:[a-f0-9]{64}$/.test(record.sha256 || '')) {
|
|
35
|
+
throw new Error('Invalid verified ' + kind + ' identity: ' + id);
|
|
36
|
+
}
|
|
37
|
+
const bytes = fs.readFileSync(record.path);
|
|
38
|
+
if ('sha256:' + crypto.createHash('sha256').update(bytes).digest('hex') !== record.sha256) {
|
|
39
|
+
throw new Error('Definition hash changed: ' + id);
|
|
40
|
+
}
|
|
41
|
+
if (kind === 'agent' && definitionFields(bytes.toString()).name !== record.nativeName) {
|
|
42
|
+
throw new Error('Agent frontmatter/nativeName mismatch: ' + id);
|
|
43
|
+
}
|
|
44
|
+
return record;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseArgs(input) {
|
|
48
|
+
if (Array.isArray(input)) return input.slice();
|
|
49
|
+
const tokens = [];
|
|
50
|
+
let value = '', quote = null, started = false;
|
|
51
|
+
for (const ch of String(input || '')) {
|
|
52
|
+
if (quote) { if (ch === quote) quote = null; else value += ch; }
|
|
53
|
+
else if (ch === '"' || ch === "'") { quote = ch; started = true; }
|
|
54
|
+
else if (/\s/.test(ch)) { if (started) { tokens.push(value); value = ''; started = false; } }
|
|
55
|
+
else { value += ch; started = true; }
|
|
56
|
+
}
|
|
57
|
+
if (quote) throw new Error('Unterminated argument quote');
|
|
58
|
+
if (started) tokens.push(value);
|
|
59
|
+
return tokens;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function deriveFeatureDir(featurePath) {
|
|
63
|
+
return /[/\\]feature\.md$/i.test(featurePath)
|
|
64
|
+
? featurePath.replace(/[/\\]feature\.md$/i, '')
|
|
65
|
+
: featurePath.replace(/[/\\]+$/, '')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createControl(options) {
|
|
69
|
+
const root = path.resolve(options.projectDir);
|
|
70
|
+
const runId = crypto.randomUUID();
|
|
71
|
+
let context, spent = 0, sequence = 0;
|
|
72
|
+
const tokenLedger = [];
|
|
73
|
+
const pending = new Set();
|
|
74
|
+
const full = p => path.resolve(root, p);
|
|
75
|
+
const read = p => fs.readFileSync(full(p), 'utf8');
|
|
76
|
+
const exists = p => { try { return fs.statSync(full(p)).isFile(); } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
|
|
77
|
+
const write = (p, content) => {
|
|
78
|
+
const dest = full(p);
|
|
79
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
80
|
+
const tmp = dest + '.tmp-' + crypto.randomUUID();
|
|
81
|
+
try { fs.writeFileSync(tmp, content, 'utf8'); fs.renameSync(tmp, dest); }
|
|
82
|
+
finally { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); }
|
|
83
|
+
};
|
|
84
|
+
async function resolve(id, kind = 'agent') {
|
|
85
|
+
const record = await registry.resolveAgent({ projectDir: root, homeDir: options.home,
|
|
86
|
+
agentId: id, requireVerified: true });
|
|
87
|
+
return validateIdentity(id, record, kind);
|
|
88
|
+
}
|
|
89
|
+
function command(argv, accepted = [0]) {
|
|
90
|
+
if (!Array.isArray(argv) || argv.some(a => typeof a !== 'string')) throw new Error('CLI argv array required');
|
|
91
|
+
if (options.home && argv[0] === 'run-asset') {
|
|
92
|
+
argv = argv.slice();
|
|
93
|
+
const split = argv.indexOf('--');
|
|
94
|
+
argv.splice(split < 0 ? argv.length : split, 0, '--home', options.home);
|
|
95
|
+
}
|
|
96
|
+
const result = spawnSync(process.execPath, [CLI, ...argv], {
|
|
97
|
+
cwd: root, encoding: 'utf8', shell: false, windowsHide: true,
|
|
98
|
+
timeout: 120000, maxBuffer: 20 * 1024 * 1024,
|
|
99
|
+
});
|
|
100
|
+
if (result.error || !Number.isInteger(result.status) || !accepted.includes(result.status)) {
|
|
101
|
+
throw new Error('Deterministic command failed: ' + (result.error?.message || result.stderr || result.status));
|
|
102
|
+
}
|
|
103
|
+
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
104
|
+
}
|
|
105
|
+
async function activity(label, fn, model, usage = () => null, metadata) {
|
|
106
|
+
if (!context) throw new Error('Workflow ownership not established');
|
|
107
|
+
const key = `${context.name}:${label}:${runId}:${++sequence}`;
|
|
108
|
+
ledger.openOwned(context.dir, context.prefix, key, context.name, model, runId, metadata);
|
|
109
|
+
try {
|
|
110
|
+
const result = await fn();
|
|
111
|
+
ledger.finalizeOwned(context.dir, context.prefix, key, runId, 'done', undefined, usage());
|
|
112
|
+
return result;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
try { ledger.finalizeOwned(context.dir, context.prefix, key, runId, 'failed', String(error), usage()); }
|
|
115
|
+
catch (persistError) { throw new AggregateError([error, persistError], 'Activity failed and ledger finalization failed'); }
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function worker(id, prompt, opts = {}) {
|
|
120
|
+
let measuredTokens = null;
|
|
121
|
+
const identity = await resolve(id);
|
|
122
|
+
const definition = definitionFields(read(identity.path));
|
|
123
|
+
return activity(opts.label || id, async () => {
|
|
124
|
+
const cliArgs = ['--print', '--output-format', 'json', '--agent', identity.nativeName];
|
|
125
|
+
if (opts.schema) cliArgs.push('--json-schema', JSON.stringify(opts.schema));
|
|
126
|
+
const result = await (options.dispatch || spawnClaudeAgent)({
|
|
127
|
+
claudePath: options.claudePath, cwd: root, args: cliArgs, prompt,
|
|
128
|
+
taskTimeoutMs: options.taskTimeoutMs || 1800000,
|
|
129
|
+
});
|
|
130
|
+
if (result.exitCode !== 0 || result.parseError || result.timedOut ||
|
|
131
|
+
!result.result || result.result.is_error !== false) throw new Error('Worker failed: ' + id);
|
|
132
|
+
const payload = opts.schema ? result.result.structured_output : result.result.result;
|
|
133
|
+
if (payload == null || (typeof payload === 'string' && !payload.trim())) throw new Error('Worker returned no result: ' + id);
|
|
134
|
+
if (opts.schema) validateSchema(payload, opts.schema);
|
|
135
|
+
const usage = result.result.usage;
|
|
136
|
+
const tokens = usage && [usage.input_tokens, usage.output_tokens].every(Number.isFinite)
|
|
137
|
+
? usage.input_tokens + usage.output_tokens + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0) : null;
|
|
138
|
+
measuredTokens = Number.isInteger(tokens) && tokens > 0 ? tokens : null;
|
|
139
|
+
spent += measuredTokens || 0;
|
|
140
|
+
tokenLedger.push({ agent: identity.nativeName, model: definition.model, phase_delta_tokens: measuredTokens });
|
|
141
|
+
return payload;
|
|
142
|
+
}, definition.model, () => measuredTokens, { agentId: id, nativeAgentName: identity.nativeName, platform: 'claude', definitionHash: identity.sha256, resolutionScope: identity.scope, toolkitVersion: identity.toolkitVersion });
|
|
143
|
+
}
|
|
144
|
+
async function parallel(fns) {
|
|
145
|
+
// Drain every sibling before finalizing the owner, even if one rejects.
|
|
146
|
+
const work = Promise.allSettled(fns.map(fn => Promise.resolve().then(fn)));
|
|
147
|
+
pending.add(work);
|
|
148
|
+
try {
|
|
149
|
+
const results = await work;
|
|
150
|
+
const failed = results.filter(r => r.status === 'rejected');
|
|
151
|
+
if (failed.length) throw new AggregateError(failed.map(r => r.reason), 'Parallel workers failed');
|
|
152
|
+
return results.map(r => r.value);
|
|
153
|
+
} finally { pending.delete(work); }
|
|
154
|
+
}
|
|
155
|
+
async function run(name, dir, prefix, fn) {
|
|
156
|
+
if (context) throw new Error('Nested workflow ownership forbidden');
|
|
157
|
+
if (!/^(?:FTR|ASSESS|[A-Z]+)-\d+$/.test(prefix)) throw new Error('Invalid workflow prefix');
|
|
158
|
+
context = { name, dir: full(dir), prefix };
|
|
159
|
+
fs.mkdirSync(context.dir, { recursive: true });
|
|
160
|
+
const key = `${name}:self:${runId}`;
|
|
161
|
+
const runLockPath = path.join(context.dir, prefix + '-workflow.lock');
|
|
162
|
+
const runLock = ledger._acquireLock(runLockPath);
|
|
163
|
+
try {
|
|
164
|
+
ledger.openOwned(context.dir, prefix, key, name, null, runId);
|
|
165
|
+
try {
|
|
166
|
+
const result = await fn();
|
|
167
|
+
await Promise.allSettled([...pending]);
|
|
168
|
+
if (!result || result.error || result.errors?.length || result.gate2_payload?.gate2_blocked) {
|
|
169
|
+
throw new Error('Workflow returned unsuccessful result: ' + JSON.stringify(result));
|
|
170
|
+
}
|
|
171
|
+
// All work, output validation and serialization precede the terminal commit.
|
|
172
|
+
const serialized = JSON.stringify(result);
|
|
173
|
+
const committedResult = JSON.parse(serialized);
|
|
174
|
+
ledger.finalizeOwned(context.dir, prefix, key, runId, 'done');
|
|
175
|
+
return committedResult;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
await Promise.allSettled([...pending]);
|
|
178
|
+
try { ledger.finalizeOwned(context.dir, prefix, key, runId, 'failed', String(error)); }
|
|
179
|
+
catch (persistError) { throw new AggregateError([error, persistError], 'Workflow failed; ledger finalization failed'); }
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
} finally { ledger._releaseLock(runLockPath, runLock); }
|
|
183
|
+
}
|
|
184
|
+
function feature(p) {
|
|
185
|
+
if (!p) throw new Error('Feature path required');
|
|
186
|
+
const file = full(p), dir = deriveFeatureDir(file);
|
|
187
|
+
const match = path.basename(dir).match(/^([A-Z]+-\d+)(?:-|$)/);
|
|
188
|
+
if (!match) throw new Error('Cannot derive canonical feature prefix');
|
|
189
|
+
read(file);
|
|
190
|
+
return { featurePath: file, featureDir: dir, prefix: match[1] };
|
|
191
|
+
}
|
|
192
|
+
const api = { root, runId, read, exists, write, resolve, worker, parallel, run, command, activity,
|
|
193
|
+
feature, parseArgs, tokenLedger, spent: () => spent,
|
|
194
|
+
mtime: p => fs.statSync(full(p)).mtimeMs,
|
|
195
|
+
json: p => JSON.parse(read(p)),
|
|
196
|
+
log: text => (options.log || (() => {}))(String(text)),
|
|
197
|
+
append: (p, text) => { fs.mkdirSync(path.dirname(full(p)), { recursive: true }); fs.appendFileSync(full(p), text); },
|
|
198
|
+
};
|
|
199
|
+
const artifacts = require('./workflow-artifacts');
|
|
200
|
+
api.estimates = (...args) => artifacts.estimates(api, ...args);
|
|
201
|
+
api.assessmentEstimates = (...args) => artifacts.assessmentEstimates(api, ...args);
|
|
202
|
+
api.recordAssessmentApproval = (...args) => artifacts.recordAssessmentApproval(api, ...args);
|
|
203
|
+
return api;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function validateSchema(value, schema) {
|
|
207
|
+
if (schema.type === 'object') {
|
|
208
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid structured object');
|
|
209
|
+
for (const key of schema.required || []) if (!(key in value)) throw new Error('Missing structured field: ' + key);
|
|
210
|
+
for (const [key, sub] of Object.entries(schema.properties || {})) if (key in value) validateSchema(value[key], sub);
|
|
211
|
+
} else if (schema.type === 'array') {
|
|
212
|
+
if (!Array.isArray(value)) throw new Error('Invalid structured array');
|
|
213
|
+
if (schema.items) value.forEach(v => validateSchema(v, schema.items));
|
|
214
|
+
} else if (schema.type && (typeof value !== schema.type || (schema.type === 'number' && !Number.isFinite(value)))) {
|
|
215
|
+
throw new Error('Invalid structured ' + schema.type);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { createControl, validateIdentity, validateSchema, parseArgs, deriveFeatureDir };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { createControl, parseArgs } = require('./workflow-control');
|
|
5
|
+
const IDS = {
|
|
6
|
+
'pm-phase1': 'gaia.orchestrator.feature.phase1',
|
|
7
|
+
'pm-phase2': 'gaia.orchestrator.feature.phase2',
|
|
8
|
+
'am-phase1': 'gaia.orchestrator.assessment.phase1',
|
|
9
|
+
'am-phase2': 'gaia.orchestrator.assessment.phase2',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
async function runWorkflow(name, args, options) {
|
|
13
|
+
if (!IDS[name]) throw new Error('Unsupported workflow: ' + name + '. For implementation use ai-toolkit tasks start/run (FTR-018).');
|
|
14
|
+
const control = createControl(options);
|
|
15
|
+
const identity = await control.resolve(IDS[name], 'workflow');
|
|
16
|
+
const source = fs.readFileSync(identity.path, 'utf8');
|
|
17
|
+
if (!/controlPlaneVersion:\s*1\b/.test(source)) throw new Error('Installed workflow does not support deterministic control-plane v1; reinstall toolkit assets.');
|
|
18
|
+
// Only verified toolkit scripts are evaluated. This host is not a security sandbox.
|
|
19
|
+
const body = source.replace(/^export const meta\s*=/m, 'const meta =');
|
|
20
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
21
|
+
return new AsyncFunction('control', 'args', body)(control, parseArgs(args));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function main(argv) {
|
|
25
|
+
if (argv[0] !== 'run') throw new Error('Usage: ai-toolkit workflow run NAME --project DIR --claude-path EXE -- ARGS');
|
|
26
|
+
const split = argv.indexOf('--');
|
|
27
|
+
if (split < 0) throw new Error('Workflow arguments must follow --');
|
|
28
|
+
const options = { projectDir: process.cwd(), log: s => process.stderr.write(s + '\n') };
|
|
29
|
+
const keys = { '--project': 'projectDir', '--home': 'home', '--claude-path': 'claudePath' };
|
|
30
|
+
for (let i = 2; i < split; i += 2) {
|
|
31
|
+
if (!keys[argv[i]] || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error('Invalid workflow option: ' + argv[i]);
|
|
32
|
+
options[keys[argv[i]]] = argv[i + 1];
|
|
33
|
+
}
|
|
34
|
+
if (!options.claudePath && argv[1] !== 'am-phase2') throw new Error('--claude-path is required');
|
|
35
|
+
const result = await runWorkflow(argv[1], argv.slice(split + 1), options);
|
|
36
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { runWorkflow, main };
|
package/package.json
CHANGED
|
@@ -89,12 +89,12 @@ Other interventions that must be completed first (blocking) or should be coordin
|
|
|
89
89
|
What to do if the intervention fails mid-way: rollback strategy, branch isolation, partial revert steps.
|
|
90
90
|
|
|
91
91
|
### 17. Suggested Agent Assignment
|
|
92
|
-
Which agent
|
|
93
|
-
- `developer-backend` — backend/infrastructure implementation
|
|
94
|
-
- `developer-frontend` — frontend/UI implementation
|
|
95
|
-
- `developer-testing` — test writing
|
|
96
|
-
- `review-solution` — post-intervention validation
|
|
97
|
-
- Specialised agent (e.g. `security-hardening`) if available in `.claude/agents/`
|
|
92
|
+
Which verified native agent should implement this intervention (recommendations only, never assessment dispatch):
|
|
93
|
+
- `gaia-developer-backend` — backend/infrastructure implementation
|
|
94
|
+
- `gaia-developer-frontend` — frontend/UI implementation
|
|
95
|
+
- `gaia-developer-testing` — test writing
|
|
96
|
+
- `gaia-review-solution` — post-intervention validation
|
|
97
|
+
- Specialised agent (e.g. `gaia-security-hardening`) if available in `.claude/agents/`
|
|
98
98
|
|
|
99
99
|
### 18. Expected Outputs
|
|
100
100
|
List of files to be created or modified. Each entry: `file path` → what changes.
|
|
@@ -124,9 +124,9 @@ After writing all intervention documents, produce `{ASSESS_PREFIX}-Interventions
|
|
|
124
124
|
|
|
125
125
|
| ID | Title | Area | Criticality | Depends on | Suggested Agent |
|
|
126
126
|
|---|---|---|---|---|---|
|
|
127
|
-
| INT-001 | ... | Security | CRITICAL | — | developer-backend |
|
|
128
|
-
| INT-002 | ... | Code Quality | HIGH | INT-003 | developer-backend |
|
|
129
|
-
| INT-003 | ... | Architecture | HIGH | — | developer-backend |
|
|
127
|
+
| INT-001 | ... | Security | CRITICAL | — | gaia-developer-backend |
|
|
128
|
+
| INT-002 | ... | Code Quality | HIGH | INT-003 | gaia-developer-backend |
|
|
129
|
+
| INT-003 | ... | Architecture | HIGH | — | gaia-developer-backend |
|
|
130
130
|
```
|
|
131
131
|
|
|
132
132
|
Order by criticality (CRITICAL first), then by dependency (prerequisites before dependents).
|
|
@@ -9,8 +9,7 @@ Orchestrates the full codebase assessment pipeline by invoking two sequential wo
|
|
|
9
9
|
(`am-phase1`, `am-phase2`), handling the Findings Gate in the main loop between the phases,
|
|
10
10
|
and recording the real token consumption at the end.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
with accurate per-agent `usage` data and their declared `model:` frontmatter honoured.
|
|
12
|
+
Run workflows through the deterministic Node host. Workflows are not agent types. Resolve the absolute Claude executable path using docs/task-executor-bootstrap.md.
|
|
14
13
|
|
|
15
14
|
---
|
|
16
15
|
|
|
@@ -25,16 +24,14 @@ found, or start at `ASSESS-001`. This prefix is used for all output files in thi
|
|
|
25
24
|
|
|
26
25
|
Invoke the `am-phase1` workflow:
|
|
27
26
|
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
prompt: <path> [--scope=<areas>] --prefix ASSESS-NNN [--force]
|
|
27
|
+
```bash
|
|
28
|
+
ai-toolkit workflow run am-phase1 --project . --claude-path "{ABSOLUTE_CLAUDE_EXE}" -- "<path>" [--scope=<areas>] --prefix ASSESS-NNN
|
|
31
29
|
```
|
|
32
30
|
|
|
33
31
|
If no path was provided by the user, use `.` (current working directory).
|
|
34
32
|
Always pass `--prefix ASSESS-NNN` with the prefix determined in Step 1.
|
|
35
33
|
|
|
36
|
-
Wait for
|
|
37
|
-
(format: `subagent_tokens: N`). Extract from the result:
|
|
34
|
+
Wait for exit 0, then parse stdout JSON. A nonzero exit is a HARD STOP; do not present a successful Findings Gate. The host owns ledger transitions. Extract from the result:
|
|
38
35
|
- `prefix` — confirmed assessment prefix
|
|
39
36
|
- `output_dir` — path to `docs/assessments/ASSESS-NNN/`
|
|
40
37
|
- `assessment_summaries` — list of `{ agent, output_file }` for each completed assessment
|
|
@@ -46,8 +43,7 @@ Wait for the workflow to complete. Capture its full result, including the `<usag
|
|
|
46
43
|
- `token_estimate_path` — path to `{PREFIX}-Token-Estimate.md`
|
|
47
44
|
- `errors` — any agent failures during phase 1
|
|
48
45
|
|
|
49
|
-
If
|
|
50
|
-
Partial assessments are acceptable — present the Findings Gate with available data.
|
|
46
|
+
If any worker fails, stop. Partial assessments must not be represented as a completed workflow.
|
|
51
47
|
|
|
52
48
|
---
|
|
53
49
|
|
|
@@ -112,9 +108,8 @@ Capture the flagged identifiers (as a comma-separated string, or "none").
|
|
|
112
108
|
|
|
113
109
|
Invoke the `am-phase2` workflow:
|
|
114
110
|
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
prompt: --prefix {prefix} --output-dir {output_dir} --flagged {flagged_ids} --ack "{acknowledgement_text}"
|
|
111
|
+
```bash
|
|
112
|
+
ai-toolkit workflow run am-phase2 --project . -- "--prefix" "{prefix}" --output-dir "{output_dir}" --flagged "{flagged_ids}" --ack "{acknowledgement_text}"
|
|
118
113
|
```
|
|
119
114
|
|
|
120
115
|
Where `{flagged_ids}` is the comma-separated list of INT-NNN identifiers (or "none").
|
|
@@ -126,95 +121,6 @@ Wait for the workflow to complete. Extract from the result:
|
|
|
126
121
|
|
|
127
122
|
---
|
|
128
123
|
|
|
129
|
-
## Step 5 —
|
|
130
|
-
|
|
131
|
-
From the `<usage>` block of the am-phase1 result, read:
|
|
132
|
-
- `subagent_tokens` — total tokens consumed by the am-phase1 workflow
|
|
133
|
-
- `duration_ms` — wall-clock duration of am-phase1
|
|
134
|
-
|
|
135
|
-
If the `<usage>` block is missing: log warning
|
|
136
|
-
`"am-phase1 produced no <usage> block; orchestrator token data unavailable"`
|
|
137
|
-
and set `actual_tokens = "N/A"`.
|
|
138
|
-
|
|
139
|
-
Read `{token_estimate_path}`. Append the following in order:
|
|
140
|
-
|
|
141
|
-
### 5a — Orchestrator row (for use in 5b only)
|
|
142
|
-
|
|
143
|
-
Compute values:
|
|
144
|
-
- Estimated tokens: 80,000 (baseline from estimation model)
|
|
145
|
-
- Estimated cost: $0.4320 — `80,000 × $0.005400 / 1,000` using sonnet blended rate
|
|
146
|
-
- Actual tokens: `subagent_tokens` from `<usage>` block; `"N/A"` if missing
|
|
147
|
-
- Actual cost: `(actual_tokens / 1,000) × 0.005400` at 4 decimal places; `"N/A"` if unavailable
|
|
148
|
-
|
|
149
|
-
### 5b — Actuals vs Estimate section
|
|
150
|
-
|
|
151
|
-
Append a horizontal rule (`---`) followed by:
|
|
152
|
-
|
|
153
|
-
```markdown
|
|
154
|
-
## Actuals vs Estimate
|
|
155
|
-
|
|
156
|
-
| Agent | Task/Scope | Model | Est. tokens | Actual tokens | Delta | Est. cost ($) | Actual cost ($) | Duration |
|
|
157
|
-
|-------|------------|-------|-------------|---------------|-------|---------------|-----------------|----------|
|
|
158
|
-
| {agent} | {scope} | {model} | {est} | {actual} | {±delta} | {est_cost} | {actual_cost} | {Xmin Ys} |
|
|
159
|
-
...
|
|
160
|
-
| am-phase1 (orchestrator) | — | sonnet | 80,000 | {actual} | ±{delta} | $0.4320 | ${actual_cost} | {duration} |
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
Column rules:
|
|
164
|
-
- **Delta**: `actual_tokens − est_tokens`; show as `+N` or `−N`. Show `"N/A"` if either missing.
|
|
165
|
-
- **Duration**: convert `duration_ms` to `Xmin Ys` where available; use `"—"` where not.
|
|
166
|
-
- Rows with `"N/A"` actual tokens are still shown but excluded from aggregate calculations.
|
|
167
|
-
|
|
168
|
-
### 5c — Estimation accuracy by agent type (conditional)
|
|
169
|
-
|
|
170
|
-
If 2+ distinct model tiers appear with non-N/A actuals, append:
|
|
171
|
-
|
|
172
|
-
```markdown
|
|
173
|
-
## Estimation accuracy by agent type
|
|
174
|
-
|
|
175
|
-
| Model | Count | Avg est. tokens | Avg actual tokens | Avg delta | Trend |
|
|
176
|
-
|-------|-------|-----------------|-------------------|-----------|-------|
|
|
177
|
-
| {model} | {N} | {avg_est} | {avg_actual} | {avg_delta} | {trend} |
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
- One row per model tier (e.g., `haiku`, `sonnet`)
|
|
181
|
-
- Exclude N/A rows from averages
|
|
182
|
-
- **Trend**: `"over-target"` if avg delta > +20% of avg est; `"under-target"` if < −20%; else `"on-target"`
|
|
183
|
-
|
|
184
|
-
### 5d — Grand Total section
|
|
185
|
-
|
|
186
|
-
Update the Grand Total section: replace `"partial — updated at pipeline end"` with Final values:
|
|
187
|
-
|
|
188
|
-
```markdown
|
|
189
|
-
## Grand Total (Final)
|
|
190
|
-
|
|
191
|
-
| Metric | Estimated | Actual | Delta | Delta % |
|
|
192
|
-
|--------|-----------|--------|-------|---------|
|
|
193
|
-
| Total tokens (all agents) | {sum_est} | {sum_actual} | ±{delta} | {delta_pct} |
|
|
194
|
-
| Total cost ($) | ${sum_est_cost} | ${sum_actual_cost} | ±${delta_cost} | {delta_cost_pct} |
|
|
195
|
-
| Total wall-clock | — | {Xmin Ys} | — | — |
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
- **Delta %**: `(delta / estimated) × 100` at 1 decimal place; `"N/A"` if estimated is 0
|
|
199
|
-
- Sum all agents' tokens and costs; exclude `"N/A"` rows from sums
|
|
200
|
-
- Cost: 2 decimal places for totals; 4 decimal places for per-row costs
|
|
201
|
-
- The "Remediation" section contains a static note — leave it as-is
|
|
202
|
-
- Wall-clock: convert `duration_ms` from am-phase1 `<usage>` to minutes/seconds
|
|
203
|
-
|
|
204
|
-
---
|
|
205
|
-
|
|
206
|
-
## Step 6 — Report to user
|
|
207
|
-
|
|
208
|
-
After writing to the Token Estimate file, report:
|
|
209
|
-
|
|
210
|
-
```
|
|
211
|
-
Assessment pipeline complete.
|
|
212
|
-
Token estimate + actuals → {token_estimate_path}
|
|
213
|
-
Approvals → {approvals_path}
|
|
214
|
-
Process log → {output_dir}/{prefix}-process-log.txt
|
|
215
|
-
|
|
216
|
-
{summary from am-phase2}
|
|
217
|
-
```
|
|
124
|
+
## Step 5 — Report results
|
|
218
125
|
|
|
219
|
-
|
|
220
|
-
note this in the report but do not halt — the skill has completed its work.
|
|
126
|
+
Use the deterministic Token-Estimate and Effort-Estimate files returned by am-phase1, and the summary returned by am-phase2. Worker usage is in token_ledger; missing usage remains unavailable. Do not invent an orchestrator usage block or overwrite the host-generated metrics.
|