@dtlabs/ai-toolkit 0.13.0 → 0.14.1

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.
@@ -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,225 @@
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())) {
134
+ if (opts.schema) {
135
+ const fields = (opts.schema.required || []).join(', ') || '(see requested schema)';
136
+ throw new Error('Worker returned no structured output: ' + id + '; expected fields: ' + fields + '. The --json-schema response must populate structured_output; a report file or plain-text result is not sufficient.');
137
+ }
138
+ throw new Error('Worker returned no result: ' + id);
139
+ }
140
+ if (opts.schema) validateSchema(payload, opts.schema);
141
+ const usage = result.result.usage;
142
+ const tokens = usage && [usage.input_tokens, usage.output_tokens].every(Number.isFinite)
143
+ ? usage.input_tokens + usage.output_tokens + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0) : null;
144
+ measuredTokens = Number.isInteger(tokens) && tokens > 0 ? tokens : null;
145
+ spent += measuredTokens || 0;
146
+ tokenLedger.push({ agent: identity.nativeName, model: definition.model, phase_delta_tokens: measuredTokens });
147
+ return payload;
148
+ }, definition.model, () => measuredTokens, { agentId: id, nativeAgentName: identity.nativeName, platform: 'claude', definitionHash: identity.sha256, resolutionScope: identity.scope, toolkitVersion: identity.toolkitVersion });
149
+ }
150
+ async function parallel(fns) {
151
+ // Drain every sibling before finalizing the owner, even if one rejects.
152
+ const work = Promise.allSettled(fns.map(fn => Promise.resolve().then(fn)));
153
+ pending.add(work);
154
+ try {
155
+ const results = await work;
156
+ const failed = results.filter(r => r.status === 'rejected');
157
+ if (failed.length) throw new AggregateError(failed.map(r => r.reason), 'Parallel workers failed');
158
+ return results.map(r => r.value);
159
+ } finally { pending.delete(work); }
160
+ }
161
+ async function run(name, dir, prefix, fn) {
162
+ if (context) throw new Error('Nested workflow ownership forbidden');
163
+ if (!/^(?:FTR|ASSESS|[A-Z]+)-\d+$/.test(prefix)) throw new Error('Invalid workflow prefix');
164
+ context = { name, dir: full(dir), prefix };
165
+ fs.mkdirSync(context.dir, { recursive: true });
166
+ const key = `${name}:self:${runId}`;
167
+ const runLockPath = path.join(context.dir, prefix + '-workflow.lock');
168
+ const runLock = ledger._acquireLock(runLockPath);
169
+ try {
170
+ ledger.openOwned(context.dir, prefix, key, name, null, runId);
171
+ try {
172
+ const result = await fn();
173
+ await Promise.allSettled([...pending]);
174
+ if (!result || result.error || result.errors?.length || result.gate2_payload?.gate2_blocked) {
175
+ throw new Error('Workflow returned unsuccessful result: ' + JSON.stringify(result));
176
+ }
177
+ // All work, output validation and serialization precede the terminal commit.
178
+ const serialized = JSON.stringify(result);
179
+ const committedResult = JSON.parse(serialized);
180
+ ledger.finalizeOwned(context.dir, prefix, key, runId, 'done');
181
+ return committedResult;
182
+ } catch (error) {
183
+ await Promise.allSettled([...pending]);
184
+ try { ledger.finalizeOwned(context.dir, prefix, key, runId, 'failed', String(error)); }
185
+ catch (persistError) { throw new AggregateError([error, persistError], 'Workflow failed; ledger finalization failed'); }
186
+ throw error;
187
+ }
188
+ } finally { ledger._releaseLock(runLockPath, runLock); }
189
+ }
190
+ function feature(p) {
191
+ if (!p) throw new Error('Feature path required');
192
+ const file = full(p), dir = deriveFeatureDir(file);
193
+ const match = path.basename(dir).match(/^([A-Z]+-\d+)(?:-|$)/);
194
+ if (!match) throw new Error('Cannot derive canonical feature prefix');
195
+ read(file);
196
+ return { featurePath: file, featureDir: dir, prefix: match[1] };
197
+ }
198
+ const api = { root, runId, read, exists, write, resolve, worker, parallel, run, command, activity,
199
+ feature, parseArgs, tokenLedger, spent: () => spent,
200
+ mtime: p => fs.statSync(full(p)).mtimeMs,
201
+ json: p => JSON.parse(read(p)),
202
+ log: text => (options.log || (() => {}))(String(text)),
203
+ append: (p, text) => { fs.mkdirSync(path.dirname(full(p)), { recursive: true }); fs.appendFileSync(full(p), text); },
204
+ };
205
+ const artifacts = require('./workflow-artifacts');
206
+ api.estimates = (...args) => artifacts.estimates(api, ...args);
207
+ api.assessmentEstimates = (...args) => artifacts.assessmentEstimates(api, ...args);
208
+ api.recordAssessmentApproval = (...args) => artifacts.recordAssessmentApproval(api, ...args);
209
+ return api;
210
+ }
211
+
212
+ function validateSchema(value, schema) {
213
+ if (schema.type === 'object') {
214
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid structured object');
215
+ for (const key of schema.required || []) if (!(key in value)) throw new Error('Missing structured field: ' + key);
216
+ for (const [key, sub] of Object.entries(schema.properties || {})) if (key in value) validateSchema(value[key], sub);
217
+ } else if (schema.type === 'array') {
218
+ if (!Array.isArray(value)) throw new Error('Invalid structured array');
219
+ if (schema.items) value.forEach(v => validateSchema(v, schema.items));
220
+ } else if (schema.type && (typeof value !== schema.type || (schema.type === 'number' && !Number.isFinite(value)))) {
221
+ throw new Error('Invalid structured ' + schema.type);
222
+ }
223
+ }
224
+
225
+ 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -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 type should execute this intervention:
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).
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: gaia-validate-feature-docs
3
- description: "Validates Requirements and Tech-Spec documents against feature.md. Triggers targeted revision of failing documents if gaps are found. Input: path to feature.md"
3
+ description: "Validates Requirements and Tech-Spec documents against feature.md. Writes a validation report and returns a structured verdict; the host owns revisions. Input: path to feature.md"
4
4
  model: haiku
5
5
  tools: Read, Glob, Grep, Write
6
6
  ---
7
7
 
8
8
  # Validate Feature Docs
9
9
 
10
- A QA agent that cross-references `feature.md` against `{PREFIX}-Requirements.md` and `{PREFIX}-Tech-Spec.md`, identifies coverage gaps, and triggers targeted revisions until full coverage is achieved.
10
+ A QA agent that cross-references `feature.md` against `{PREFIX}-Requirements.md` and `{PREFIX}-Tech-Spec.md` and identifies coverage gaps in a single validation pass. The host workflow owns revisions and the three-cycle limit. Do not dispatch other agents or revise the input documents.
11
11
 
12
12
  ---
13
13
 
@@ -18,7 +18,7 @@ A QA agent that cross-references `feature.md` against `{PREFIX}-Requirements.md`
18
18
  - `feature.md` — source of truth
19
19
  - `{PREFIX}-Requirements.md`
20
20
  - `{PREFIX}-Tech-Spec.md`
21
- 3. If either output document is missing, abort and report: "Cannot validate: `{file}` not found. Run /implement-feature first."
21
+ 3. If an input document is missing, record a finding identifying it, skip checks that require it, and complete Phases 6 and 7 with `valid: false`. Never claim full coverage when an input could not be read.
22
22
 
23
23
  ---
24
24
 
@@ -101,31 +101,21 @@ Result: ✅ Full coverage — all feature claims addressed in both documents
101
101
 
102
102
  ---
103
103
 
104
- ## Phase 5 — Revision Loop
104
+ ## Phase 5 — Return Findings to the Host
105
105
 
106
- If gaps were found, trigger targeted revisions. **Only revise the documents that have gaps** — do not touch clean documents.
106
+ Collect all remaining gaps for the host's revision loop. Do not invoke agents, edit Requirements or Tech-Spec, or run a revision loop yourself.
107
107
 
108
- ### Revision instructions
108
+ Each finding must be one string prefixed with `Requirements:` or `Tech-Spec:` to identify the affected document, followed by the exact section or claim and the missing coverage. If a gap affects both documents, emit one finding per document.
109
109
 
110
- For each document with gaps, invoke the corresponding agent with a **targeted revision prompt** that includes:
111
- 1. The path to the existing document to revise (not regenerate from scratch)
112
- 2. The exact list of gaps to address
113
- 3. The instruction to revise only the affected sections
114
-
115
- ### Revision rules
116
-
117
- - **Max iterations**: 3 revision cycles. If gaps persist after 3 rounds, write the remaining gaps to the report and stop.
118
- - **After each revision**: re-run Phase 3 on the updated document to verify gaps are resolved
119
- - **Document what changed**: after each revision, note which gaps were resolved
120
- - **Never revise a document that has no gaps** — even if the other document is being revised
110
+ Use the same remaining gaps in the report and the structured verdict. Record a gap as resolved only when the current documents demonstrate that it is resolved.
121
111
 
122
112
  ---
123
113
 
124
- ## Phase 6 — Write Validation Report (MANDATORY — your final action)
114
+ ## Phase 6 — Write Validation Report (MANDATORY — before returning the verdict)
125
115
 
126
116
  **This is not optional and not the same as the Phase 4 gap report.** The coverage report you produced in Phase 4 is on-screen text; it is NOT the deliverable. Your job is not complete until the file `{PREFIX}-Validation-Report.md` exists **on disk**. You MUST call the `Write` tool to create it — even when validation is clean and zero gaps were found. Returning a summary without having called `Write` is a failure.
127
117
 
128
- Execute this phase **last, always**, regardless of outcome. Downstream agents read this file as a hard precondition; if it is missing, the entire pipeline aborts.
118
+ Execute this phase regardless of the validation outcome, then complete Phase 7. Downstream agents read this file as a hard precondition; if it is missing, the entire pipeline aborts.
129
119
 
130
120
  Write `{PREFIX}-Validation-Report.md` in the same directory:
131
121
 
@@ -154,23 +144,36 @@ Write `{PREFIX}-Validation-Report.md` in the same directory:
154
144
 
155
145
  ---
156
146
 
157
- ## Clarification Protocol
147
+ ## Phase 7 — Structured Completion (MANDATORY — your final response)
148
+
149
+ After writing the report, return a single JSON object with exactly these fields, without Markdown fences or explanatory prose:
150
+
151
+ ```json
152
+ {"valid": true, "findings": []}
153
+ ```
154
+
155
+ For remaining gaps:
158
156
 
159
- When a gap is found but the correct resolution is ambiguous (e.g., the feature.md itself is contradictory, or a gap could be filled in multiple valid ways), **stop and ask the user** before revising. Use the `AskUserQuestion` tool to present:
157
+ ```json
158
+ {"valid": false, "findings": ["Requirements: UC-01 lacks an acceptance criterion", "Tech-Spec: POST /api/users lacks a response definition"]}
159
+ ```
160
160
 
161
- 1. A clear description of the gap and why the resolution is uncertain
162
- 2. Concrete options (2–4) representing valid ways to address it
163
- 3. **Always include** an option: "Leave as open point to discuss later" — which records the gap in the Validation Report as unresolved (not a failure) and skips revision for that item
161
+ - `valid` is a boolean: `true` only when all required inputs were read, coverage is complete, and no unresolved gaps remain.
162
+ - `findings` is an array of strings, empty only for a clean validation. Missing inputs and ambiguous claims are unresolved findings.
163
+ - When the caller supplies `--json-schema`, use the runtime's structured-output mechanism to return this object in `structured_output`. Plain-text JSON alone does not satisfy that contract.
164
+ - Both deliverables are required: the report on disk **and** the structured verdict. Writing the report does not finish the task.
165
+
166
+ ## Clarification Protocol
164
167
 
165
- Do NOT guess how to fill ambiguous gaps. Ask first, then revise only with a clear direction.
168
+ If a claim is contradictory or its correct resolution is ambiguous, do not guess or request interactive input inside this worker. Describe the ambiguity in the report and the affected document's finding, with `valid: false`, so the calling orchestration can surface it to the user. An open point must not be treated as clean coverage.
166
169
 
167
170
  ---
168
171
 
169
172
  ## Guidelines
170
173
 
171
- - **Always call `Write` for the Validation Report before finishing** (Phase 6) — the on-screen gap report is not the deliverable; the file on disk is. This is your last action, clean or not.
172
- - **Do not regenerate documents from scratch** — targeted revisions only
174
+ - **Always call `Write` for the Validation Report before returning the structured verdict** (Phases 6 and 7), clean or not.
175
+ - **Validate only** — the host owns document revisions and agent dispatch
173
176
  - **Be specific about gaps** — point to the exact section, claim, and what is missing
174
- - **Stop after 3 revision cycles** — if coverage is still incomplete, report remaining gaps and exit
177
+ - **Perform one validation pass per invocation** — return remaining gaps to the host
175
178
  - **Out-of-scope items are also validated** — ensure they are not accidentally included in outputs
176
179
  - **Traceability is mandatory** — every Use Case ID in Requirements must appear in Tech-Spec