@dtlabs/ai-toolkit 0.14.0 → 0.14.2

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 CHANGED
@@ -1,4 +1,21 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.14.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.14.1...v0.14.2) (2026-10-01)
3
+
4
+
5
+ ### Bug Fixes
6
+
7
+ * **workflows:** host persists worker-declared outputs, no Write tool on the agent ([1109fc0](https://github.com/dtlab-tech/ai-toolkit/commit/1109fc0943ad21eedbbf859a185e3a49c654c06d))
8
+ * **workflows:** host persists worker-declared outputs, no Write tool on the agent ([56f062c](https://github.com/dtlab-tech/ai-toolkit/commit/56f062c232110d2399d88ee011fa165b43336d12))
9
+ * **workflows:** host persists worker-declared outputs, no Write tool on the agent ([7c1b8dc](https://github.com/dtlab-tech/ai-toolkit/commit/7c1b8dca4ba9715809b6417f5081bcca0edb09e3))
10
+
11
+ ## [0.14.1](https://github.com/dtlab-tech/ai-toolkit/compare/v0.14.0...v0.14.1) (2026-09-30)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **workflows:** make gaia-validate-feature-docs a single-pass, host-owned validator ([2721088](https://github.com/dtlab-tech/ai-toolkit/commit/27210884375c7302cfcf84433d3da3412c857cc1))
17
+ * **workflows:** single-pass, host-owned document validator ([2b606fb](https://github.com/dtlab-tech/ai-toolkit/commit/2b606fb2d0df54e244abe473ea81b613eda30537))
18
+
2
19
  ## [0.14.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.13.0...v0.14.0) (2026-09-30)
3
20
 
4
21
 
@@ -89,6 +89,13 @@ calls, parallel rejection/drain, identity mismatch, cross-project cache leakage,
89
89
  negative semantic verdict, unknown scopes, paths with spaces and explicit approval.
90
90
 
91
91
  The live paid Claude model/permission flow has not been exercised by these tests.
92
+ Document validation requires both a written Validation Report and a structured
93
+ verdict (`valid: boolean`, `findings: string[]`). The validator performs one pass;
94
+ the host owns revisions. A report or JSON in the plain-text result cannot replace
95
+ `structured_output` from `--json-schema`. Missing structured output stops the
96
+ workflow with an error naming the worker and required fields. Regression tests
97
+ cover missing/malformed verdicts, a clean verdict without a report, and a negative
98
+ verdict followed by host revision and a clean result; model responses are mocked.
92
99
  The subprocess adapter reuses FTR-018's existing qualification; no new cross-platform
93
100
  process-supervision guarantee is claimed. On Windows, the full executor test suite
94
101
  requires Git Bash ahead of the WSL launcher in PATH. User-level installations are
@@ -8,6 +8,8 @@ const { spawnSync } = require('child_process');
8
8
  const registry = require('./agent-registry');
9
9
  const ledger = require('./execution-ledger');
10
10
  const { spawnClaudeAgent } = require('./task-executor/claude-process');
11
+ const { createEvidence } = require('./workflow-worker-evidence');
12
+ const { resolveOutputPath } = require('./workflow-worker-outputs');
11
13
  const CLI = path.join(__dirname, '..', 'bin', 'cli.js');
12
14
 
13
15
 
@@ -66,7 +68,16 @@ function deriveFeatureDir(featurePath) {
66
68
  }
67
69
 
68
70
  function createControl(options) {
69
- const root = path.resolve(options.projectDir);
71
+ if (options.workerMaxBudgetUsd != null && (!Number.isFinite(options.workerMaxBudgetUsd) || options.workerMaxBudgetUsd <= 0)) throw new Error('Worker budget must be a positive finite number');
72
+ // Windows can alias any long/dotted path segment to an 8.3 short name (e.g. the
73
+ // TEMP env var itself is often short-form: C:\Users\TOMADA~1\...). The spawned
74
+ // CLI resolves a tool call's absolute path in long form regardless, so an
75
+ // --allowedTools pattern built from a short-form root would silently never
76
+ // match — confirmed empirically (tests/manual/workflow-worker-smoke.js) against
77
+ // a real CLI run under a short-form TEMP path. realpath.native (unlike the pure-JS
78
+ // fs.realpathSync) resolves 8.3 aliases on Windows, and is a harmless plain
79
+ // symlink-resolving realpath on every other platform.
80
+ const root = fs.realpathSync.native(path.resolve(options.projectDir));
70
81
  const runId = crypto.randomUUID();
71
82
  let context, spent = 0, sequence = 0;
72
83
  const tokenLedger = [];
@@ -81,6 +92,13 @@ function createControl(options) {
81
92
  try { fs.writeFileSync(tmp, content, 'utf8'); fs.renameSync(tmp, dest); }
82
93
  finally { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); }
83
94
  };
95
+ // Validates a worker-declared (or, for a dynamic multi-file deliverable like
96
+ // intervention-documentation-standard, model-declared) output path the same way
97
+ // worker()'s single-output contract does — inside the project, outside any tool-config
98
+ // directory, never through a symlink — then writes it. The one path through which any
99
+ // LLM response ever reaches disk in this workflow runtime; see worker()'s own comment
100
+ // for why that's the host's job and never the agent's.
101
+ const writeOutput = (p, content) => write(resolveOutputPath(root, { path: p }), content);
84
102
  async function resolve(id, kind = 'agent') {
85
103
  const record = await registry.resolveAgent({ projectDir: root, homeDir: options.home,
86
104
  agentId: id, requireVerified: true });
@@ -123,22 +141,71 @@ function createControl(options) {
123
141
  return activity(opts.label || id, async () => {
124
142
  const cliArgs = ['--print', '--output-format', 'json', '--agent', identity.nativeName];
125
143
  if (opts.schema) cliArgs.push('--json-schema', JSON.stringify(opts.schema));
144
+ if (options.workerMaxBudgetUsd != null) cliArgs.push('--max-budget-usd', String(options.workerMaxBudgetUsd));
145
+ const outputs = opts.outputs || [];
146
+ for (const output of outputs) {
147
+ if (!output || typeof output.path !== 'string' || !Array.isArray(output.inputs || [])) throw new Error('Invalid worker output contract');
148
+ }
149
+ // Never ask an LLM to do I/O: a worker with a declared output has no Write tool at
150
+ // all (see its agent frontmatter) and returns the document as plain response text
151
+ // (or, under --json-schema, as a `report` field) instead. The host resolves and
152
+ // validates the real target path up front — fail before dispatch, not after paying
153
+ // for a model turn — and writes the file itself once the response is in hand. This
154
+ // sidesteps the CLI's own permission system for Write entirely, which empirically
155
+ // (tests/manual/workflow-worker-smoke.js) does not support a non-interactive mode
156
+ // that both skips prompting AND honors a scoped, per-file allow pattern: `dontAsk`
157
+ // ignores runtime-supplied allow rules for paths with no pre-existing persisted
158
+ // grant, while `acceptEdits`/`auto` grant Write (and every other tool, unscoped)
159
+ // anywhere in the project regardless of any allowlist passed alongside them.
160
+ if (outputs.length > 1) throw new Error('Worker output contract supports exactly one output today: ' + id);
161
+ const outputTarget = outputs.length ? resolveOutputPath(root, outputs[0]) : null;
162
+ const evidence = createEvidence(options, runId, identity, {
163
+ executable: options.claudePath || null, cwd: root, args: cliArgs, outputs,
164
+ });
165
+ (options.log || (() => {}))('Worker evidence: ' + evidence.file);
166
+ try {
126
167
  const result = await (options.dispatch || spawnClaudeAgent)({
127
168
  claudePath: options.claudePath, cwd: root, args: cliArgs, prompt,
128
169
  taskTimeoutMs: options.taskTimeoutMs || 1800000,
129
170
  });
171
+ // Count usage before any business/protocol check, including denied tools.
172
+ const usage = result.result?.usage;
173
+ const counts = usage && [usage.input_tokens, usage.output_tokens,
174
+ usage.cache_read_input_tokens ?? 0, usage.cache_creation_input_tokens ?? 0];
175
+ const tokens = counts && counts.every(n => Number.isInteger(n) && n >= 0)
176
+ ? counts.reduce((a, b) => a + b, 0) : null;
177
+ measuredTokens = Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
178
+ spent += measuredTokens || 0;
179
+ tokenLedger.push({ agent: identity.nativeName, model: definition.model,
180
+ phase_delta_tokens: measuredTokens, usage: usage || null, evidence_path: evidence.file });
181
+ evidence.save({ response: result });
182
+ const denials = result.result?.permission_denials;
183
+ if (Array.isArray(denials) && denials.length) {
184
+ const tools = [...new Set(denials.map(d => d.tool_name || 'unknown'))].join(', ');
185
+ throw new Error('Worker permission denied: ' + id + '; tools: ' + tools);
186
+ }
130
187
  if (result.exitCode !== 0 || result.parseError || result.timedOut ||
131
188
  !result.result || result.result.is_error !== false) throw new Error('Worker failed: ' + id);
132
189
  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);
190
+ if (payload == null || (typeof payload === 'string' && !payload.trim())) {
191
+ if (opts.schema) {
192
+ const fields = (opts.schema.required || []).join(', ') || '(see requested schema)';
193
+ 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.');
194
+ }
195
+ throw new Error('Worker returned no result: ' + id);
196
+ }
134
197
  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 });
198
+ if (outputTarget) {
199
+ const content = opts.schema ? payload.report : payload;
200
+ if (typeof content !== 'string' || !content.trim()) throw new Error('Worker output content missing or empty: ' + id);
201
+ writeOutput(outputTarget, content);
202
+ }
203
+ evidence.save({ outcome: 'done', completedAt: new Date().toISOString() });
141
204
  return payload;
205
+ } catch (error) {
206
+ evidence.save({ outcome: 'failed', error: String(error), completedAt: new Date().toISOString() });
207
+ throw new Error(error.message + '; evidence: ' + evidence.file, { cause: error });
208
+ }
142
209
  }, definition.model, () => measuredTokens, { agentId: id, nativeAgentName: identity.nativeName, platform: 'claude', definitionHash: identity.sha256, resolutionScope: identity.scope, toolkitVersion: identity.toolkitVersion });
143
210
  }
144
211
  async function parallel(fns) {
@@ -183,13 +250,20 @@ function createControl(options) {
183
250
  }
184
251
  function feature(p) {
185
252
  if (!p) throw new Error('Feature path required');
186
- const file = full(p), dir = deriveFeatureDir(file);
253
+ const file = full(p);
254
+ read(file); // confirms existence (with a clear error) before realpath.native requires it
255
+ // Canonicalize once, here, so every output path derived from featureDir downstream
256
+ // (pm-phase1.js/pm-phase2.js's `file()` helpers) shares the same 8.3-alias-free form
257
+ // as `root` — resolveOutputPath() can then do a plain, non-realpath string comparison
258
+ // instead of reconciling mismatched forms (which would have to silently follow
259
+ // symlinks to do so, defeating its own symlink-escape check).
260
+ const canonicalFile = fs.realpathSync.native(file);
261
+ const dir = deriveFeatureDir(canonicalFile);
187
262
  const match = path.basename(dir).match(/^([A-Z]+-\d+)(?:-|$)/);
188
263
  if (!match) throw new Error('Cannot derive canonical feature prefix');
189
- read(file);
190
- return { featurePath: file, featureDir: dir, prefix: match[1] };
264
+ return { featurePath: canonicalFile, featureDir: dir, prefix: match[1] };
191
265
  }
192
- const api = { root, runId, read, exists, write, resolve, worker, parallel, run, command, activity,
266
+ const api = { root, runId, read, exists, write, writeOutput, resolve, worker, parallel, run, command, activity,
193
267
  feature, parseArgs, tokenLedger, spent: () => spent,
194
268
  mtime: p => fs.statSync(full(p)).mtimeMs,
195
269
  json: p => JSON.parse(read(p)),
@@ -26,11 +26,13 @@ async function main(argv) {
26
26
  const split = argv.indexOf('--');
27
27
  if (split < 0) throw new Error('Workflow arguments must follow --');
28
28
  const options = { projectDir: process.cwd(), log: s => process.stderr.write(s + '\n') };
29
- const keys = { '--project': 'projectDir', '--home': 'home', '--claude-path': 'claudePath' };
29
+ const keys = { '--project': 'projectDir', '--home': 'home', '--claude-path': 'claudePath',
30
+ '--diagnostics-dir': 'diagnosticsDir', '--worker-max-budget-usd': 'workerMaxBudgetUsd' };
30
31
  for (let i = 2; i < split; i += 2) {
31
32
  if (!keys[argv[i]] || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error('Invalid workflow option: ' + argv[i]);
32
33
  options[keys[argv[i]]] = argv[i + 1];
33
34
  }
35
+ if (options.workerMaxBudgetUsd != null) options.workerMaxBudgetUsd = Number(options.workerMaxBudgetUsd);
34
36
  if (!options.claudePath && argv[1] !== 'am-phase2') throw new Error('--claude-path is required');
35
37
  const result = await runWorkflow(argv[1], argv.slice(split + 1), options);
36
38
  process.stdout.write(JSON.stringify(result) + '\n');
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const os = require('os');
7
+
8
+ // Do not capture the parent environment or stdin prompt. Diagnostic responses
9
+ // can contain document text; redact common credential forms before persistence.
10
+ function redact(value) {
11
+ const secretKey = /^(?:authorization|proxy.authorization|.*(?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|secret|credential))$/i;
12
+ const secrets = Object.entries(process.env)
13
+ .filter(([key, val]) => secretKey.test(key) && val && val.length >= 8)
14
+ .map(([, val]) => val);
15
+ function cleanString(text) {
16
+ for (const secret of secrets) text = text.split(secret).join('[REDACTED]');
17
+ return text
18
+ .replace(/\b(Bearer\s+)[^\s"'<>]+/gi, '$1[REDACTED]')
19
+ .replace(/\b((?:api[_-]?key|token|password|secret)\s*[=:]\s*)[^\s,"'<>]+/gi, '$1[REDACTED]')
20
+ .replace(/\bsk-[A-Za-z0-9_-]+/g, '[REDACTED]');
21
+ }
22
+ function walk(item) {
23
+ if (typeof item === 'string') {
24
+ // stdout is often a JSON string: redact credential keys there too.
25
+ try { return JSON.stringify(walk(JSON.parse(item))); } catch (_) { return cleanString(item); }
26
+ }
27
+ if (Array.isArray(item)) return item.map(walk);
28
+ if (item && typeof item === 'object') return Object.fromEntries(Object.entries(item)
29
+ .map(([key, val]) => [key, secretKey.test(key) ? '[REDACTED]' : walk(val)]));
30
+ return item;
31
+ }
32
+ return walk(value);
33
+ }
34
+
35
+ function createEvidence(options, runId, identity, invocation) {
36
+ const dir = path.join(path.resolve(options.diagnosticsDir || path.join(os.tmpdir(), 'ai-toolkit-workflows')), runId);
37
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
38
+ const file = path.join(dir, crypto.randomUUID() + '.json');
39
+ const record = { version: 1, runId, agentId: identity.agentId, definitionPath: identity.path,
40
+ definitionHash: identity.sha256, invocation, startedAt: new Date().toISOString(), outcome: 'running' };
41
+ function save(patch) {
42
+ Object.assign(record, patch);
43
+ const temp = file + '.tmp';
44
+ try {
45
+ fs.writeFileSync(temp, JSON.stringify(redact(record), null, 2) + '\n', { mode: 0o600 });
46
+ fs.renameSync(temp, file);
47
+ } finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
48
+ }
49
+ save({});
50
+ return { file, save };
51
+ }
52
+
53
+ module.exports = { createEvidence, redact };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ // Validates that a worker's declared output stays inside the project, outside any
6
+ // tool-config directory, and is never reached through a symlink/junction — then
7
+ // returns its canonical absolute path. The workflow host (never the LLM) writes the
8
+ // worker's returned content to this path with fs.writeFileSync; there is no CLI
9
+ // permission surface to construct here ("Never ask an LLM to do I/O").
10
+ //
11
+ // Assumes `root` and `output.path` already share the same 8.3-alias form (createControl
12
+ // realpath.native's `root` once at startup; feature() realpath.native's featurePath/
13
+ // featureDir the same way, and every output.path in this codebase is built from
14
+ // featureDir) — do NOT realpath-resolve anything in here to "reconcile" a mismatch: that
15
+ // would have to follow symlinks to do it, silently defeating the symlink check below.
16
+ function resolveOutputPath(root, output) {
17
+ const target = path.resolve(root, output.path);
18
+ const relative = path.relative(root, target);
19
+ if (!relative || path.isAbsolute(relative) || relative === '..' || relative.startsWith('..' + path.sep)) {
20
+ throw new Error('Worker output must be inside the project: ' + target);
21
+ }
22
+ const segments = relative.split(path.sep);
23
+ if (segments.some(s => ['.git', '.claude', '.codex'].includes(s.toLowerCase()))) {
24
+ throw new Error('Unsupported worker output path: ' + target);
25
+ }
26
+ // Never automatically write a target reached through a symlink/junction.
27
+ let cursor = root;
28
+ for (const segment of segments) {
29
+ cursor = path.join(cursor, segment);
30
+ try {
31
+ if (fs.lstatSync(cursor).isSymbolicLink()) throw new Error('Worker output traverses a symlink: ' + cursor);
32
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
33
+ }
34
+ return target;
35
+ }
36
+
37
+ module.exports = { resolveOutputPath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: gaia-concurrency-safety-assessment
3
3
  description: "Concurrency Safety Assessment — identifies race conditions, shared mutable state corruption, and non-deterministic behaviour in concurrent or event-driven systems. Language-agnostic; produces structured findings with severity, evidence, and mitigation patterns. Output: {ASSESS_PREFIX}-Concurrency-Assessment.md"
4
- tools: Read, Grep, Glob, Bash, Write
4
+ tools: Read, Grep, Glob, Bash
5
5
  model: haiku
6
6
  ---
7
7
 
@@ -120,7 +120,7 @@ Blocking: Yes / No
120
120
 
121
121
  ## Output
122
122
 
123
- Write to `{ASSESS_PREFIX}-Concurrency-Assessment.md`:
123
+ Return the assessment as your final response — you do not have a Write tool; the host persists your response verbatim to `{ASSESS_PREFIX}-Concurrency-Assessment.md`. Your entire final response must be this document's content, and nothing else (no preamble, no Markdown code fence wrapping the whole thing):
124
124
 
125
125
  ```markdown
126
126
  # Concurrency Safety Assessment — {ASSESS_PREFIX}
@@ -2,7 +2,7 @@
2
2
  name: gaia-generate-requirements
3
3
  description: "Generates a functional requirements document from a feature description. Input: path to feature.md"
4
4
  model: haiku
5
- tools: Read, Glob, Grep, Write
5
+ tools: Read, Glob, Grep
6
6
  ---
7
7
 
8
8
  # Generate Functional Requirements Document
@@ -20,21 +20,19 @@ The user provides the path to a `feature.md` file. Read it in full before procee
20
20
  3. **Identify** all functional areas described
21
21
  3. **Extract** actors, use cases, business rules, acceptance criteria
22
22
  4. **Organize** into a structured requirements document
23
- 5. **Write** the output to a requirements file in the same directory as the input feature file
23
+ 5. **Return** the complete document as your final response — you do not have a Write tool; the host persists your response to `{PREFIX}-Requirements.md` verbatim. Do not attempt to write any file yourself.
24
24
 
25
- ## Output Filename
25
+ ## Output Filename (for your reference only — you do not write this file)
26
26
 
27
27
  Extract the **feature prefix** from the folder name containing `feature.md`:
28
28
  - Folder: `FTR-001-user-management` → prefix: `FTR-001`
29
29
  - Folder: `FTR-042-Search-Engine` → prefix: `FTR-042`
30
30
 
31
- The prefix is everything up to and including the second hyphen-separated segment (pattern: `[A-Z]+-[0-9]+`).
32
-
33
- Output file: `{PREFIX}-Requirements.md` in the same directory as `feature.md`.
31
+ The prefix is everything up to and including the second hyphen-separated segment (pattern: `[A-Z]+-[0-9]+`). Use it in the document's own "Document Info" section; the host, not you, decides the actual output filename.
34
32
 
35
33
  ## Output Structure
36
34
 
37
- Generate the document in **English** following this template:
35
+ Generate the document in **English** following this template. Your entire final response must be this document's content, and nothing else (no preamble, no "here is the document", no Markdown code fence wrapping the whole thing) — the host writes your response verbatim to disk:
38
36
 
39
37
  ```markdown
40
38
  # Functional Requirements — [Feature Title]
@@ -2,7 +2,7 @@
2
2
  name: gaia-generate-tech-spec
3
3
  description: "Generates a technical specification document from a feature description and requirements. Input: path to feature.md"
4
4
  model: haiku
5
- tools: Read, Glob, Grep, Write
5
+ tools: Read, Glob, Grep
6
6
  ---
7
7
 
8
8
  # Generate Technical Specification
@@ -37,21 +37,19 @@ The tech spec MUST use the project's actual stack and patterns. Do not assume an
37
37
  3. **Read** `docs/features/REGISTRY.md` if it exists — identify features that touch the same entities, endpoints, or services as this feature. Note architectural dependencies and integration points in Section 5 (External Integrations) or Section 12 (Risks & Mitigations) of the output document. If a prior feature introduced shared infrastructure this feature reuses (e.g. auth middleware, base service, shared DTO), reference it explicitly.
38
38
  4. **Explore** the existing codebase areas affected (models, controllers/handlers, pages, services)
39
39
  4. **Design** the technical solution aligned with existing patterns from AGENTS.md
40
- 5. **Write** the output to a tech-spec file in the same directory as the input feature file
40
+ 5. **Return** the complete document as your final response — you do not have a Write tool; the host persists your response to `{PREFIX}-Tech-Spec.md` verbatim. Do not attempt to write any file yourself.
41
41
 
42
- ## Output Filename
42
+ ## Output Filename (for your reference only — you do not write this file)
43
43
 
44
44
  Extract the **feature prefix** from the folder name containing `feature.md`:
45
45
  - Folder: `FTR-001-user-management` → prefix: `FTR-001`
46
46
  - Folder: `FTR-042-Search-Engine` → prefix: `FTR-042`
47
47
 
48
- The prefix is everything up to and including the second hyphen-separated segment (pattern: `[A-Z]+-[0-9]+`).
49
-
50
- Output file: `{PREFIX}-Tech-Spec.md` in the same directory as `feature.md`.
48
+ The prefix is everything up to and including the second hyphen-separated segment (pattern: `[A-Z]+-[0-9]+`). Use it in the document's own "Document Info" section; the host, not you, decides the actual output filename.
51
49
 
52
50
  ## Output Structure
53
51
 
54
- Generate the document in **English** following this template. Adapt section content to the project's actual tech stack (from AGENTS.md):
52
+ Generate the document in **English** following this template. Adapt section content to the project's actual tech stack (from AGENTS.md). Your entire final response must be this document's content, and nothing else (no preamble, no "here is the spec", no Markdown code fence wrapping the whole thing) — the host writes your response verbatim to disk:
55
53
 
56
54
  ```markdown
57
55
  # Technical Specification — [Feature Title]
@@ -2,7 +2,7 @@
2
2
  name: gaia-generate-work-breakdown
3
3
  description: "Generates a structured work breakdown (User Stories + Tasks) from validated and approved Requirements and Tech-Spec. Input: path to feature.md"
4
4
  model: haiku
5
- tools: Read, Glob, Grep, Write
5
+ tools: Read, Glob, Grep
6
6
  ---
7
7
 
8
8
  # Generate Work Breakdown
@@ -186,9 +186,9 @@ Duration guidance:
186
186
 
187
187
  - `estimate.tokens`: integer; rough estimate of token consumption (input + output) for the agent invocation; use project norms if available, otherwise estimate based on task complexity (small: 10 000–25 000, medium: 25 000–60 000)
188
188
 
189
- ### Step 7 — Write the JSON Output
189
+ ### Step 7 — Return the JSON Output
190
190
 
191
- Write `{PREFIX}-Work-Breakdown.json` in the same directory as `feature.md`. The file must be:
191
+ Return `{PREFIX}-Work-Breakdown.json`'s content as your final response, and nothing else (no preamble, no explanatory prose, no Markdown code fence wrapping it) — you do not have a Write tool; the host persists your response verbatim to disk. The content must be:
192
192
  - Valid JSON
193
193
  - UTF-8 encoded
194
194
  - 2-space indentation
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: gaia-generic-software-assessment
3
3
  description: "Generic Software Assessment — performs a broad quality analysis across architecture, code quality, security, testability, observability, and DevOps. Reads AGENTS.md for project conventions. Output: {ASSESS_PREFIX}-Generic-Assessment.md"
4
- tools: Read, Grep, Glob, Bash, Write
4
+ tools: Read, Grep, Glob, Bash
5
5
  model: haiku
6
6
  ---
7
7
 
@@ -112,7 +112,7 @@ Report a score (1–5) for each KPI, where 5 = excellent, 1 = critical issues:
112
112
 
113
113
  ## Output
114
114
 
115
- Write the assessment to `{ASSESS_PREFIX}-Generic-Assessment.md` in the assessment directory.
115
+ Return the assessment as your final response — you do not have a Write tool; the host persists your response verbatim to `{ASSESS_PREFIX}-Generic-Assessment.md` in the assessment directory. Your entire final response must be this document's content, and nothing else (no preamble, no "here is the assessment", no Markdown code fence wrapping the whole thing):
116
116
 
117
117
  ```markdown
118
118
  # Generic Software Assessment — {ASSESS_PREFIX}
@@ -2,7 +2,7 @@
2
2
  name: gaia-intervention-documentation-standard
3
3
  description: "Intervention Documentation Standard — generates structured, self-contained intervention documents from assessment findings. Each document is independently actionable by an architect or developer agent. Input: assessment findings; Output: {ASSESS_PREFIX}-INT-NNN-{slug}.md per finding + Interventions-Index.md"
4
4
  model: sonnet
5
- tools: Read, Grep, Glob, Bash, Write
5
+ tools: Read, Grep, Glob, Bash
6
6
  ---
7
7
 
8
8
  # Intervention Documentation Standard
@@ -117,7 +117,7 @@ References to assessment findings (`{ASSESS_PREFIX}-G-NNN`), related ADRs, relat
117
117
 
118
118
  ## Interventions Index
119
119
 
120
- After writing all intervention documents, produce `{ASSESS_PREFIX}-Interventions-Index.md`:
120
+ Alongside the intervention documents, compose one more file, `{ASSESS_PREFIX}-Interventions-Index.md`:
121
121
 
122
122
  ```markdown
123
123
  # Interventions Index — {ASSESS_PREFIX}
@@ -130,3 +130,26 @@ After writing all intervention documents, produce `{ASSESS_PREFIX}-Interventions
130
130
  ```
131
131
 
132
132
  Order by criticality (CRITICAL first), then by dependency (prerequisites before dependents).
133
+
134
+ ---
135
+
136
+ ## Output Contract (MANDATORY — your final response)
137
+
138
+ You do not have a Write tool. You produce a **variable number of files** (one per intervention, plus the index) whose names you yourself decide (the `{area-slug}` in each filename), so the host cannot know their paths in advance the way it does for a single fixed deliverable — you must declare each file's path and content yourself, and the host validates and persists every one of them.
139
+
140
+ Return a single JSON object with exactly this shape, without Markdown fences or explanatory prose:
141
+
142
+ ```json
143
+ {
144
+ "files": [
145
+ { "path": "{ASSESS_PREFIX}-INT-001-sql-injection-hardening.md", "content": "# ...full document content..." },
146
+ { "path": "{ASSESS_PREFIX}-INT-002-god-class-decomposition.md", "content": "# ...full document content..." },
147
+ { "path": "{ASSESS_PREFIX}-Interventions-Index.md", "content": "# Interventions Index — {ASSESS_PREFIX}\n\n| ID | ... |\n..." }
148
+ ]
149
+ }
150
+ ```
151
+
152
+ - `files` is a non-empty array. Every intervention document you decided to write, plus the index, must appear here — there is no separate Write step and nothing you do not include in this array reaches disk.
153
+ - Each `path` is a bare filename (no directory components) — the host resolves it against the assessment output directory itself; never include `docs/`, `..`, or an absolute path.
154
+ - Each `content` is that file's complete, final content, verbatim — exactly as the host will write it byte-for-byte.
155
+ - 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.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: gaia-layered-architecture-assessment
3
3
  description: "Layered Architecture Assessment — audits a codebase for layer boundary violations, missing separation of concerns, and namespace/package misalignment. Produces a violation inventory and a recommended layer model. Language-agnostic; reads AGENTS.md for project conventions. Output: {ASSESS_PREFIX}-Layer-Assessment.md"
4
- tools: Read, Grep, Glob, Bash, Write
4
+ tools: Read, Grep, Glob, Bash
5
5
  model: haiku
6
6
  ---
7
7
 
@@ -135,7 +135,7 @@ Report the current state and recommend the appropriate option based on team size
135
135
 
136
136
  ## Output
137
137
 
138
- Write to `{ASSESS_PREFIX}-Layer-Assessment.md`:
138
+ Return the assessment as your final response — you do not have a Write tool; the host persists your response verbatim to `{ASSESS_PREFIX}-Layer-Assessment.md`. Your entire final response must be this document's content, and nothing else (no preamble, no Markdown code fence wrapping the whole thing):
139
139
 
140
140
  ```markdown
141
141
  # Layered Architecture Assessment — {ASSESS_PREFIX}
@@ -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. Returns the validation report content and a structured verdict; the host persists the report and owns revisions. Input: path to feature.md"
4
4
  model: haiku
5
- tools: Read, Glob, Grep, Write
5
+ tools: Read, Glob, Grep
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,33 +101,23 @@ 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 — Compose the Validation Report Content (MANDATORY — before returning the verdict)
125
115
 
126
- **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.
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. You do not have a Write tool — the deliverable is the `report` field of your Phase 7 structured output, which the host persists to `{PREFIX}-Validation-Report.md` verbatim. Returning a verdict without a complete `report` field 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 the persisted file as a hard precondition; if the `report` field is missing or empty, the entire pipeline aborts.
129
119
 
130
- Write `{PREFIX}-Validation-Report.md` in the same directory:
120
+ Compose `{PREFIX}-Validation-Report.md`'s full content following this template:
131
121
 
132
122
  ```markdown
133
123
  # Validation Report — {PREFIX}
@@ -154,23 +144,37 @@ 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
+ Return a single JSON object with exactly these fields, without Markdown fences or explanatory prose:
150
+
151
+ ```json
152
+ {"valid": true, "findings": [], "report": "# Validation Report — FTR-001\n\n## Summary\n..."}
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"], "report": "# Validation Report — FTR-001\n\n## Summary\n..."}
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
+ - `report` is a non-empty string: the complete Phase 6 document content, verbatim, as the host will write it byte-for-byte to `{PREFIX}-Validation-Report.md`.
164
+ - 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.
165
+ - There is only one deliverable now: the structured verdict, with `report` inside it. There is no separate Write step.
166
+
167
+ ## Clarification Protocol
164
168
 
165
- Do NOT guess how to fill ambiguous gaps. Ask first, then revise only with a clear direction.
169
+ 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
170
 
167
171
  ---
168
172
 
169
173
  ## Guidelines
170
174
 
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
175
+ - **Always include the complete report content in the `report` field of your structured verdict** (Phases 6 and 7), clean or not — there is no Write tool to fall back on.
176
+ - **Validate only** — the host owns document revisions and agent dispatch
173
177
  - **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
178
+ - **Perform one validation pass per invocation** — return remaining gaps to the host
175
179
  - **Out-of-scope items are also validated** — ensure they are not accidentally included in outputs
176
180
  - **Traceability is mandatory** — every Use Case ID in Requirements must appear in Tech-Spec
@@ -29,20 +29,35 @@ return c.run(meta.name, outputDir, prefix, async () => {
29
29
  const id = SCOPE_AGENT_MAP[area]
30
30
  groups.set(id, [...(groups.get(id) || []), area])
31
31
  }
32
+ // Neither assessor below has a Write tool: each returns its report as plain response
33
+ // text, which the host persists to its declared `outputs` path (see worker()'s own
34
+ // comment in lib/workflow-control.js for why — "Never ask an LLM to do I/O").
32
35
  const assessmentResults = await c.parallel([...groups].map(([id, areas]) => async () => {
36
+ const suffix = { 'gaia.agent.assessment.generic': 'Generic', 'gaia.agent.assessment.layered-architecture': 'Layer', 'gaia.agent.assessment.concurrency': 'Concurrency' }[id]
37
+ const output_file = `${outputDir}/${prefix}-${suffix}-Assessment.md`
33
38
  const result = await c.worker(id,
34
39
  `${targetPath} --prefix ${prefix} --output-dir ${outputDir}\n` +
35
40
  `Read-only assessment. Explicit scope: ${areas.join(', ')}. Inspect risks, evidence, severity and recommendations for EACH listed scope. ` +
36
41
  'For security inspect trust boundaries and authorization; devops: build/deploy/observability; domain-model: invariants and ownership; dependencies: versions, provenance and supply-chain risks. ' +
37
- 'Do not remediate code or dispatch remediation agents. Write your assessment report in the output directory.')
38
- const suffix = { 'gaia.agent.assessment.generic': 'Generic', 'gaia.agent.assessment.layered-architecture': 'Layer', 'gaia.agent.assessment.concurrency': 'Concurrency' }[id]
39
- const output_file = `${outputDir}/${prefix}-${suffix}-Assessment.md`
40
- if (!c.read(output_file).trim()) throw new Error('Assessment report empty: ' + output_file)
42
+ 'Do not remediate code or dispatch remediation agents. Return your assessment report as your final response.',
43
+ { outputs: [{ path: output_file }] })
41
44
  return { agent: id, scopes: areas, result, output_file }
42
45
  }))
43
- await c.worker('gaia.agent.assessment.intervention-documentation',
46
+ // intervention-documentation-standard writes a variable number of files (one per
47
+ // intervention plus an index) whose names it decides itself, so it cannot use the
48
+ // single static `outputs` contract above — it declares each {path, content} pair in
49
+ // its own structured_output, and the host validates and persists every one of them.
50
+ const docs = await c.worker('gaia.agent.assessment.intervention-documentation',
44
51
  `--prefix ${prefix} --output-dir ${outputDir} --target ${targetPath}\nUse only the reports from these assessors: ${[...groups.keys()].join(', ')}.\n` +
45
- assessmentResults.map(r => `${r.agent}: ${typeof r.result === 'string' ? r.result : JSON.stringify(r.result)}`).join('\n'))
52
+ assessmentResults.map(r => `${r.agent}: ${typeof r.result === 'string' ? r.result : JSON.stringify(r.result)}`).join('\n'),
53
+ { schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'object',
54
+ properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } } }, required: ['files'] } })
55
+ if (!docs.files.length) throw new Error('Intervention documentation produced no files')
56
+ for (const f of docs.files) {
57
+ if (!f.path || /[\\/]/.test(f.path) || f.path.includes('..')) throw new Error('Invalid intervention document path: ' + f.path)
58
+ if (!f.content || !f.content.trim()) throw new Error('Empty intervention document: ' + f.path)
59
+ c.writeOutput(`${outputDir}/${f.path}`, f.content)
60
+ }
46
61
  const metrics = c.assessmentEstimates(outputDir, prefix)
47
62
  return { prefix, output_dir: outputDir, target_path: targetPath,
48
63
  assessment_summaries: assessmentResults.map(({ agent, scopes, output_file }) => ({ agent, scopes, output_file })),
@@ -14,21 +14,24 @@ return c.run(meta.name, featureDir, prefix, async () => {
14
14
  const reqId = 'gaia.agent.planner.requirements'
15
15
  const specId = 'gaia.agent.planner.tech-spec'
16
16
  const valId = 'gaia.agent.planner.validate-feature-docs'
17
+ const output = (suffix, inputs = [featurePath]) => ({ path: file(suffix), inputs })
18
+ const requirements = () => c.worker(reqId, featurePath, { outputs: [output('Requirements')] })
19
+ const techSpec = () => c.worker(specId, featurePath, { outputs: [output('Tech-Spec', [featurePath, file('Requirements')])] })
17
20
  let changed = false
18
21
  if (force || !fresh(file('Requirements'))) {
19
- await c.worker(reqId, featurePath)
22
+ await requirements()
20
23
  if (!c.exists(file('Requirements'))) throw new Error('Requirements output missing')
21
24
  changed = true
22
25
  }
23
26
  if (force || changed || !fresh(file('Tech-Spec'), [featurePath, file('Requirements')])) {
24
- await c.worker(specId, featurePath)
27
+ await techSpec()
25
28
  if (!c.exists(file('Tech-Spec'))) throw new Error('Tech-Spec output missing')
26
29
  changed = true
27
30
  }
28
31
  let validationSummary = 'skipped (fresh)'
29
32
  if (force || changed || !fresh(file('Validation-Report'), [featurePath, file('Requirements'), file('Tech-Spec')])) {
30
33
  for (let cycle = 1; cycle <= 3; cycle++) {
31
- const result = await c.worker(valId, featurePath + '\nValidate only; the host owns the revision loop. Write the validation report and return valid plus a findings array (one string per remaining gap). Do not dispatch other agents.', { label: `validation:${cycle}`, schema: { type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array', items: { type: 'string' } } }, required: ['valid', 'findings'] } })
34
+ const result = await c.worker(valId, featurePath + '\nValidate only; the host owns the revision loop. Return valid, a findings array (one string per remaining gap), and the full validation report content. Do not dispatch other agents.', { label: `validation:${cycle}`, outputs: [output('Validation-Report', [featurePath, file('Requirements'), file('Tech-Spec')])], schema: { type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array', items: { type: 'string' } }, report: { type: 'string' } }, required: ['valid', 'findings', 'report'] } })
32
35
  const text = result.findings.join('\n')
33
36
  if (result.valid && result.findings.length === 0) {
34
37
  if (!fresh(file('Validation-Report'), [featurePath, file('Requirements'), file('Tech-Spec')])) throw new Error('Validation agent did not produce its report')
@@ -36,8 +39,8 @@ return c.run(meta.name, featureDir, prefix, async () => {
36
39
  break
37
40
  }
38
41
  if (cycle === 3) throw new Error('Validation gaps remain after 3 cycles')
39
- if (!text.includes('Tech-Spec') || text.includes('Requirements')) await c.worker(reqId, featurePath)
40
- if (!text.includes('Requirements') || text.includes('Tech-Spec')) await c.worker(specId, featurePath)
42
+ if (!text.includes('Tech-Spec') || text.includes('Requirements')) await requirements()
43
+ if (!text.includes('Requirements') || text.includes('Tech-Spec')) await techSpec()
41
44
  }
42
45
  }
43
46
  c.append(`${featureDir}/${prefix}-process-log.txt`, `[${new Date().toISOString()}] pm-phase1: ${validationSummary}; Gate 1 approval requested\n`)
@@ -14,8 +14,10 @@ return c.run(meta.name, featureDir, prefix, async () => {
14
14
  const approvals = c.read(file('Approvals.md'))
15
15
  const gate = approvals.match(/^## Gate 1\b[^\n]*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m)
16
16
  if (!gate || !/✅\s*Approved/.test(gate[1])) throw new Error('Gate 1 approval evidence missing')
17
- await c.worker('gaia.agent.planner.work-breakdown', featurePath)
18
17
  const jsonPath = file('Work-Breakdown.json')
18
+ await c.worker('gaia.agent.planner.work-breakdown', featurePath, {
19
+ outputs: [{ path: jsonPath, inputs: [featurePath, file('Requirements.md'), file('Tech-Spec.md')] }],
20
+ })
19
21
  const report = await c.activity('wb-validate', async () => {
20
22
  const res = c.command(['run-asset', 'scripts/wb-validate.js', '--project', c.root,
21
23
  '--', jsonPath, file('Requirements.md')], [0, 1])