From f5146579e54212bd536ef8fce1af4b2ec49525a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 07:33:53 +0800 Subject: [PATCH 01/73] fix(skills): point skel references at ${CLAUDE_PLUGIN_ROOT}/skel, drop local-machine paths setup SKILL.md referenced the developer machine's plugin checkout for the customizations starter pack and ship-check.py scaffold, so first-run setup failed on every external install. Both now resolve from the packaged skel/ directory; the ship-check copy gains an existence guard so an unset CLAUDE_PLUGIN_ROOT degrades to a no-op instead of a hard cp error. execute-task prose pointer updated to match. Co-Authored-By: Claude Fable 5 (cherry picked from commit f5df447dd3051344fbb11f687f73733d274d8db1) --- skills/execute-task/SKILL.md | 2 +- skills/setup/SKILL.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/skills/execute-task/SKILL.md b/skills/execute-task/SKILL.md index 9247d0c..22c1804 100644 --- a/skills/execute-task/SKILL.md +++ b/skills/execute-task/SKILL.md @@ -256,7 +256,7 @@ The termination sequence is strict — three steps, in order, no shortcuts: false-positive matches by log-watchers. The ship-check script is deterministic. Its gates are documented at the -top of `~/Shade_Gen/Projects/prd-taskmaster-plugin/.atlas-ai-skel/ship-check.py`: +top of `${CLAUDE_PLUGIN_ROOT}/skel/ship-check.py` (copied to `.atlas-ai/ship-check.py` at setup): - Gate 1: `pipeline.json current_phase == "EXECUTE"` - Gate 2: every `master.tasks[].status == "done"` diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 4c6b36c..372b9db 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -83,7 +83,7 @@ no recovery path. This step ensures the file exists BEFORE execute-task ever runs: ```bash -PLUGIN_SKEL="$HOME/Shade_Gen/Projects/prd-taskmaster-plugin/.atlas-ai-skel/customizations" +PLUGIN_SKEL="${CLAUDE_PLUGIN_ROOT}/skel/customizations" mkdir -p .atlas-ai/customizations if [ ! -f .atlas-ai/customizations/system-prompt-template.md ]; then if [ -d "$PLUGIN_SKEL" ]; then @@ -102,8 +102,8 @@ file simply must exist. Also scaffold `.atlas-ai/ship-check.py` if it doesn't already exist: ```bash -if [ ! -f .atlas-ai/ship-check.py ]; then - cp "$HOME/Shade_Gen/Projects/prd-taskmaster-plugin/.atlas-ai-skel/ship-check.py" .atlas-ai/ship-check.py +if [ ! -f .atlas-ai/ship-check.py ] && [ -f "${CLAUDE_PLUGIN_ROOT}/skel/ship-check.py" ]; then + cp "${CLAUDE_PLUGIN_ROOT}/skel/ship-check.py" .atlas-ai/ship-check.py chmod +x .atlas-ai/ship-check.py fi ``` From b41aa2a80b60fe31988558b66c5beda81a7fdd88 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 07:34:42 +0800 Subject: [PATCH 02/73] chore(pack): per-dir .npmignore for Python bytecode + tarball hygiene test npm 11 ignores the root .npmignore inside files[]-allowlisted directories, so 24 __pycache__/*.pyc entries were shipping in the tarball. Per-dir .npmignore files in mcp-server/ and prd_taskmaster/ do the real filtering; the root file documents the trap defensively. New test asserts the npm pack --dry-run manifest stays free of bytecode and marketplace.json. Imported-From: prd-taskmaster-plugin@v5-final (f140490) Co-Authored-By: Claude Fable 5 (cherry picked from commit 82ac1b0bd148a69554ed60c1b29a50feb42bd889) --- .npmignore | 19 +++++++++++++++++++ mcp-server/.npmignore | 2 ++ prd_taskmaster/.npmignore | 2 ++ tests/plugin/test_packaging_identity.py | 25 +++++++++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 .npmignore create mode 100644 mcp-server/.npmignore create mode 100644 prd_taskmaster/.npmignore diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..27aeefb --- /dev/null +++ b/.npmignore @@ -0,0 +1,19 @@ +# Root .npmignore — Python bytecode + dev-loopback marketplace patterns. +# +# IMPORTANT (npm 11 behavior): when package.json `files` lists a directory +# like "mcp-server/" as an allowlist, the root .npmignore is NOT honored +# for files INSIDE that directory. Filters must live in per-subdirectory +# .npmignore files to actually take effect. +# +# The root entries below are kept defensively for: +# - future npm versions that may honor root .npmignore with directory files +# - fallback if a subdir .npmignore is ever accidentally deleted +# - self-documentation of the filtering intent +# +# The ACTUAL working filters live at: +# - mcp-server/.npmignore (excludes __pycache__, *.pyc) +# - prd_taskmaster/.npmignore (excludes __pycache__, *.pyc) +# - .claude-plugin/.npmignore (excludes marketplace.json dev config) +**/__pycache__ +*.pyc +.claude-plugin/marketplace.json diff --git a/mcp-server/.npmignore b/mcp-server/.npmignore new file mode 100644 index 0000000..8d35cb3 --- /dev/null +++ b/mcp-server/.npmignore @@ -0,0 +1,2 @@ +__pycache__ +*.pyc diff --git a/prd_taskmaster/.npmignore b/prd_taskmaster/.npmignore new file mode 100644 index 0000000..8d35cb3 --- /dev/null +++ b/prd_taskmaster/.npmignore @@ -0,0 +1,2 @@ +__pycache__ +*.pyc diff --git a/tests/plugin/test_packaging_identity.py b/tests/plugin/test_packaging_identity.py index 598af98..3cba879 100644 --- a/tests/plugin/test_packaging_identity.py +++ b/tests/plugin/test_packaging_identity.py @@ -1,4 +1,8 @@ import json +import shutil +import subprocess + +import pytest from pathlib import Path @@ -55,3 +59,24 @@ def test_product_spec_marks_backend_abstraction_shipped_v41(): assert "auto|taskmaster|native" in content assert "detect/init/parse_prd/expand/rate" in content assert "agent_action_required" in content + + +@pytest.mark.skipif(shutil.which("npm") is None, reason="npm not available") +def test_npm_pack_excludes_python_bytecode(): + # npm 11 does not honor the root .npmignore inside `files[]`-allowlisted + # directories; per-subdir .npmignore files carry the real filtering. This + # test guards the tarball against __pycache__/*.pyc regressions. + result = subprocess.run( + ["npm", "pack", "--dry-run", "--json"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, result.stderr + + manifest = json.loads(result.stdout) + paths = [entry["path"] for entry in manifest[0]["files"]] + offenders = [p for p in paths if ".pyc" in p or "__pycache__" in p] + assert offenders == [] + assert not any("marketplace.json" in p for p in paths) From 2c6b76c4052651a6a63df5861fd92e6aea64934b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 09:01:24 +0800 Subject: [PATCH 03/73] test(skill): track dual-op-table contract from 7d0436b restructure Same repair as main's 8533244, isolated from the identity changes so the v4.0.0 tag tests green. Co-Authored-By: Claude Fable 5 --- tests/plugin/test_skill_files.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/plugin/test_skill_files.py b/tests/plugin/test_skill_files.py index acc843c..8f86d9c 100644 --- a/tests/plugin/test_skill_files.py +++ b/tests/plugin/test_skill_files.py @@ -80,19 +80,24 @@ def test_setup_skill_frames_taskmaster_install_as_backend_unlock(): def test_orchestrator_skill_defines_normative_backend_operations(): content = (REPO_ROOT / "SKILL.md").read_text() - assert "## Backend operations" in content + assert "## Engine operations" in content assert "This table is normative — instruction sites reference operations by name." in content - assert "| Operation | Command (both backends) | Notes |" in content - for operation, command in ( - ("init", "script.py init-project"), - ("parse-prd", "script.py parse-prd"), - ("rate", "script.py rate"), - ("expand", "script.py expand"), - ("next", "script.py next-task"), - ("set-status", "script.py set-status"), + assert "| Operation | MCP tool (MCP-mode) | script.py (CLI-mode / fallback) |" in content + for operation, mcp_tool, script_cmd in ( + ("init", "init_project", "init-project"), + ("parse-prd", "parse_prd", "parse-prd"), + ("rate", "rate_tasks", "rate"), + ("expand", "expand_tasks", "expand"), + ("next", "next_task", "next-task"), + ("set-status", "set_task_status", "set-status"), ): - assert f"| `{operation}` | `{command}" in content, f"missing backend op row: {operation}" - assert "next/set-status are engine-native under every backend" in content + assert f"| `{operation}` | `{mcp_tool}` | `{script_cmd}" in content, ( + f"missing engine op row: {operation}" + ) + # normalize hard wraps — the sentence spans a line break in the doc + flat = " ".join(content.split()) + assert "`next`/`set-status` are engine-native under every backend" in flat + assert "## Script/agent-only operations" in content def test_orchestrator_skill_documents_feedback_debrief(): From 9da61f7e98ff01e747e1c76f73441a2aa4da4669 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 09:35:34 +0800 Subject: [PATCH 04/73] feat(release): npm-auth preflight fails closed before publish (#3) Co-Authored-By: Claude Fable 5 --- RELEASE.md | 74 +++++++++++++++ package.json | 3 +- scripts/check-npm-auth.mjs | 155 ++++++++++++++++++++++++++++++++ scripts/check-npm-auth.test.mjs | 94 +++++++++++++++++++ 4 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 RELEASE.md create mode 100644 scripts/check-npm-auth.mjs create mode 100644 scripts/check-npm-auth.test.mjs diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..041ed61 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,74 @@ +# Release process + +This package ships to npm as `prd-taskmaster`. Releases are gated so that an +**auth failure is caught in one second, before any expensive tag/build/publish work** — +not after the build is half-done and `npm publish` hangs on an interactive web-auth wall. + +## Release gate order + +Run the gates in this exact order. Each one fails closed (non-zero exit) and stops the release: + +1. **`release:preflight` (npm auth)** — `npm run release:preflight` + - Confirms a usable npm token exists **before** anything else. + - Runs `scripts/check-npm-auth.mjs`. If no token is found it exits `1` immediately, + *before any network call*, so it can never stall on a browser login. +2. **version-sync** — `npm run version:check` + - `scripts/check-version-sync.js` — every version source-of-truth must agree + (`package.json`, `.claude-plugin/plugin.json`, `prd_taskmaster/__init__.py`). +3. **tag** — `git tag vX.Y.Z && git push --tags` +4. **publish** — `npm publish` + +### Automatic enforcement via `prepublishOnly` + +`npm publish` runs the package's `prepublishOnly` script first. It now runs **auth, then +version-sync**: + +```json +"prepublishOnly": "node scripts/check-npm-auth.mjs && node scripts/check-version-sync.js" +``` + +So even if you forget to run `release:preflight` by hand, `npm publish` will refuse to +proceed when npm auth is missing/expired — auth is checked **before** the publish does any +real work. This is the fix for the recurring "publish stalls on a browser web-auth timeout" +failure: you now fail fast at the gate instead of mid-publish. + +## Auth: token path vs web-auth path + +`scripts/check-npm-auth.mjs` looks for a token in two places (pure, offline detection): + +### (a) Token path — preferred (non-interactive, no browser) + +Either of: + +- Set an environment variable: + ```bash + export NPM_TOKEN= + ``` + (The operator keeps an npm token in **Bitwarden** — retrieve it from there.) +- Or add a line to `~/.npmrc`: + ``` + //registry.npmjs.org/:_authToken= + ``` + +Then re-run `npm run release:preflight` to confirm. + +If a token is present, the preflight also runs `npm whoami` as a **soft** confirmation — +a network failure there is only a warning (the token may still be valid), and it never +blocks. The hard fail-closed behaviour only triggers when **no token at all** is found. + +### (b) Web-auth path — only if no token is available + +Run `npm login` in the operator's **real desktop browser** (already logged in, on their +own residential IP). Do **not** drive the login URL in an automated/headless browser — that +advertises automation and trips Cloudflare/Turnstile "Just a moment" challenges. Approve the +sign-in in one click, then re-run `npm run release:preflight`. + +## One-shot release checklist + +```bash +npm run release:preflight # 1. auth — fails closed if no token +npm run version:check # 2. versions agree +pytest tests/ # (sanity — full suite stays green) +git tag vX.Y.Z && git push --tags # 3. tag +npm publish # 4. publish (re-runs auth + version-sync via prepublishOnly) +``` diff --git a/package.json b/package.json index b5bbbf6..6bc8afd 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ } }, "scripts": { - "prepublishOnly": "node scripts/check-version-sync.js", + "prepublishOnly": "node scripts/check-npm-auth.mjs && node scripts/check-version-sync.js", + "release:preflight": "node scripts/check-npm-auth.mjs", "version:check": "node scripts/check-version-sync.js", "postinstall": "pip install -r mcp-server/requirements.txt 2>/dev/null || echo 'WARN: Python MCP deps not installed. MCP tools will not start. Run: pip install -r node_modules/prd-taskmaster/mcp-server/requirements.txt'", "test": "pytest tests/", diff --git a/scripts/check-npm-auth.mjs b/scripts/check-npm-auth.mjs new file mode 100644 index 0000000..8861642 --- /dev/null +++ b/scripts/check-npm-auth.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * Release-auth preflight — FAILS CLOSED before any expensive tag/build/publish work. + * + * The recurring failure this guards: `npm publish` stalls on a browser web-auth + * timeout because nobody checked that npm auth was actually valid FIRST. Wired ahead + * of version-sync in `prepublishOnly`, this refuses to start a publish when no usable + * npm token is present — so the operator finds out in one second, not after the tag/ + * build is half-done and the publish hangs on an interactive login. + * + * Design: the detection logic (`resolveNpmAuth`) is a PURE function with no network + * and no filesystem access, so it is unit-testable offline. The CLI wrapper reads the + * real environment + `~/.npmrc`, and only AFTER confirming a token exists does it + * optionally probe the network via `npm whoami` (soft — a network failure is a warning, + * never a crash, and never blocks the no-token fail-closed exit). + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const REGISTRY_AUTH_LINE = "//registry.npmjs.org/:_authToken="; + +/** + * Pure auth-detection. No network, no filesystem — everything is passed in. + * + * @param {{ env?: Record, npmrc?: string }} input + * env — a process.env-like object; NPM_TOKEN (or NODE_AUTH_TOKEN) means a token. + * npmrc — the raw text of an .npmrc file (may be empty/undefined). + * @returns {{ ok: boolean, method: ("NPM_TOKEN"|"npmrc-token"|null), why: string }} + */ +export function resolveNpmAuth({ env = {}, npmrc = "" } = {}) { + // 1) Environment token wins — this is how CI and the Bitwarden-token path inject auth. + const envToken = env.NPM_TOKEN || env.NODE_AUTH_TOKEN; + if (typeof envToken === "string" && envToken.trim() !== "") { + return { + ok: true, + method: "NPM_TOKEN", + why: "NPM_TOKEN (or NODE_AUTH_TOKEN) is set in the environment.", + }; + } + + // 2) An //registry.npmjs.org/:_authToken=... line in the npmrc string. + if (typeof npmrc === "string" && npmrc.length > 0) { + const hasAuthToken = npmrc + .split(/\r?\n/) + .map((line) => line.trim()) + .some( + (line) => + !line.startsWith("#") && + !line.startsWith(";") && + line.includes(REGISTRY_AUTH_LINE) && + // require a non-empty value after the '=' + line.slice(line.indexOf(REGISTRY_AUTH_LINE) + REGISTRY_AUTH_LINE.length).trim() !== "" + ); + if (hasAuthToken) { + return { + ok: true, + method: "npmrc-token", + why: `Found '${REGISTRY_AUTH_LINE}...' in the provided .npmrc.`, + }; + } + } + + // 3) Nothing usable. + return { + ok: false, + method: null, + why: "No NPM_TOKEN in the environment and no //registry.npmjs.org/:_authToken line in ~/.npmrc.", + }; +} + +/** Read a file as text, returning "" if it does not exist / is unreadable. */ +function readNpmrc(npmrcPath) { + try { + return fs.readFileSync(npmrcPath, "utf8"); + } catch { + return ""; + } +} + +/** The FAIL banner — documents both remediation paths. Returns the string for testability. */ +function failMessage() { + return [ + "✖ FAIL: npm release-auth preflight — no usable npm auth found.", + " Refusing to start publish work (tag/build/version-sync) with no valid token.", + "", + " Two ways to fix this BEFORE retrying the release:", + "", + " (a) Token path (preferred — non-interactive, no browser):", + " • export NPM_TOKEN= (the operator keeps an npm token in Bitwarden),", + " OR", + " • add this line to ~/.npmrc:", + ` ${REGISTRY_AUTH_LINE}`, + " then re-run: npm run release:preflight", + "", + " (b) Web-auth path (only if no token is available):", + " • run `npm login` in the operator's REAL browser (their own desktop browser,", + " already logged in on their residential IP — not an automated/headless one,", + " which trips Cloudflare/Turnstile). Complete the one-click approval, then", + " re-run: npm run release:preflight", + ].join("\n"); +} + +function main() { + const env = process.env; + const npmrcPath = path.join(os.homedir(), ".npmrc"); + const npmrc = readNpmrc(npmrcPath); + + const result = resolveNpmAuth({ env, npmrc }); + + if (!result.ok) { + // FAIL CLOSED — exit BEFORE any network call (no `npm whoami`), so this can never hang. + console.error(failMessage()); + console.error(`\n detail: ${result.why}`); + process.exit(1); + } + + // A token exists. Report HOW we found it. + const where = + result.method === "NPM_TOKEN" + ? "environment (NPM_TOKEN / NODE_AUTH_TOKEN)" + : `npmrc (${npmrcPath})`; + console.log(`✓ npm token present via ${result.method} — ${where}.`); + + // OPTIONAL network confirmation. Soft: a failure here is a warning, never a crash, + // because the token may be valid even when whoami can't reach the registry (offline, + // proxy, registry blip). The fail-closed guarantee above does not depend on this. + try { + const who = execFileSync("npm", ["whoami"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 15000, + }).trim(); + console.log(`✓ PASS: authenticated to npm registry as '${who}'.`); + } catch (err) { + const reason = (err && (err.stderr || err.message) ? String(err.stderr || err.message) : "") + .trim() + .split(/\r?\n/)[0]; + console.warn( + "⚠ WARN: token present but `npm whoami` could not confirm it" + + (reason ? ` (${reason})` : "") + + ".\n Treating as a soft warning — proceeding. If the publish 401s, the token may be expired/invalid." + ); + console.log("✓ PASS (soft): token present; whoami unverified."); + } +} + +// Only run main() when executed directly, so tests can import resolveNpmAuth without +// triggering the CLI side effects (reading ~/.npmrc, process.exit, network). +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/scripts/check-npm-auth.test.mjs b/scripts/check-npm-auth.test.mjs new file mode 100644 index 0000000..045e4cc --- /dev/null +++ b/scripts/check-npm-auth.test.mjs @@ -0,0 +1,94 @@ +// Run with: node --test scripts/check-npm-auth.test.mjs +// +// Covers the pure resolveNpmAuth detection (offline, no network) and the CLI +// fail-closed guarantee: the no-token path must exit non-zero WITHOUT touching +// the network (no `npm whoami`), so it can never hang on a publish web-auth wall. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { resolveNpmAuth } from "./check-npm-auth.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SCRIPT = path.join(__dirname, "check-npm-auth.mjs"); + +test("resolveNpmAuth: NPM_TOKEN present → ok, method NPM_TOKEN", () => { + const r = resolveNpmAuth({ env: { NPM_TOKEN: "npm_abc123" }, npmrc: "" }); + assert.equal(r.ok, true); + assert.equal(r.method, "NPM_TOKEN"); +}); + +test("resolveNpmAuth: NODE_AUTH_TOKEN present → ok, method NPM_TOKEN", () => { + const r = resolveNpmAuth({ env: { NODE_AUTH_TOKEN: "npm_ci_token" }, npmrc: "" }); + assert.equal(r.ok, true); + assert.equal(r.method, "NPM_TOKEN"); +}); + +test("resolveNpmAuth: npmrc with _authToken → ok, method npmrc-token", () => { + const npmrc = "//registry.npmjs.org/:_authToken=npm_fromfile\n"; + const r = resolveNpmAuth({ env: {}, npmrc }); + assert.equal(r.ok, true); + assert.equal(r.method, "npmrc-token"); +}); + +test("resolveNpmAuth: neither → not ok, method null", () => { + const r = resolveNpmAuth({ env: {}, npmrc: "" }); + assert.equal(r.ok, false); + assert.equal(r.method, null); +}); + +test("resolveNpmAuth: empty NPM_TOKEN string is not a token", () => { + const r = resolveNpmAuth({ env: { NPM_TOKEN: " " }, npmrc: "" }); + assert.equal(r.ok, false); + assert.equal(r.method, null); +}); + +test("resolveNpmAuth: commented-out _authToken line does not count", () => { + const npmrc = "# //registry.npmjs.org/:_authToken=npm_disabled\n"; + const r = resolveNpmAuth({ env: {}, npmrc }); + assert.equal(r.ok, false); +}); + +test("resolveNpmAuth: _authToken line with empty value does not count", () => { + const npmrc = "//registry.npmjs.org/:_authToken=\n"; + const r = resolveNpmAuth({ env: {}, npmrc }); + assert.equal(r.ok, false); +}); + +test("resolveNpmAuth: env token wins even when npmrc has none", () => { + const r = resolveNpmAuth({ env: { NPM_TOKEN: "npm_env" }, npmrc: "registry=https://x\n" }); + assert.equal(r.ok, true); + assert.equal(r.method, "NPM_TOKEN"); +}); + +test("CLI: no-token path exits non-zero with a FAIL message and does not hang", () => { + // Temp HOME with an EMPTY .npmrc, env scrubbed of any npm token. + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "npmauth-test-")); + fs.writeFileSync(path.join(tmpHome, ".npmrc"), "", "utf8"); + + const env = { ...process.env }; + delete env.NPM_TOKEN; + delete env.NODE_AUTH_TOKEN; + env.HOME = tmpHome; + env.USERPROFILE = tmpHome; // Windows-safe, harmless on POSIX + + const res = spawnSync(process.execPath, [SCRIPT], { + env, + encoding: "utf8", + timeout: 20000, // if it ever hangs on network, the test fails loudly instead + }); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + + assert.equal(res.error, undefined, `spawn must not error/timeout: ${res.error}`); + assert.equal(res.status, 1, "no-token CLI must exit 1 (fail closed)"); + const out = `${res.stdout}\n${res.stderr}`; + assert.match(out, /FAIL/, "must print a FAIL message"); + // Prove it exited BEFORE any whoami network probe. + assert.doesNotMatch(out, /authenticated to npm registry/, "must not have reached whoami"); +}); From 28cc54aa375e2c323bd5711e8af9710355381a3d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:02:46 +0800 Subject: [PATCH 05/73] docs(atlas-engine): spec + implementation plan for hybrid provider + setup layer Sub-project #1 of removing the task-master dependency: a 3-tier hybrid provider resolver (keyless CLI-agent -> raw-key API -> agent-plan), the net-new cli_agent.py, an engine config block, and a setup wizard. Spec + plan both independently reviewed (code citations verified). Co-Authored-By: Claude Fable 5 --- ...6-15-atlas-engine-hybrid-provider-setup.md | 335 ++ ...6-15-atlas-engine-hybrid-provider-setup.md | 4614 +++++++++++++++++ 2 files changed, 4949 insertions(+) create mode 100644 docs/design/2026-06-15-atlas-engine-hybrid-provider-setup.md create mode 100644 docs/plans/2026-06-15-atlas-engine-hybrid-provider-setup.md diff --git a/docs/design/2026-06-15-atlas-engine-hybrid-provider-setup.md b/docs/design/2026-06-15-atlas-engine-hybrid-provider-setup.md new file mode 100644 index 0000000..b198b29 --- /dev/null +++ b/docs/design/2026-06-15-atlas-engine-hybrid-provider-setup.md @@ -0,0 +1,335 @@ +# Atlas Engine — Hybrid Provider + Setup Layer + +**Status:** Design approved (pending written-spec review) +**Date:** 2026-06-15 +**Owner:** Hayden +**Sub-project:** #1 of the "remove task-master, build the Atlas engine" initiative +**Surfaces:** CLI (`atlas setup`, `script.py`) and Claude Code skill (`/atlas`) + +--- + +## 1. Context & Problem + +Task generation feels slow ("20+ minutes, not parallel") even though parallel +expansion code exists. The root cause (see the full audit: +`docs/audit` / dashboard `cloud.atlas-ai.au/s/AwWXlLaV6gT5DzB`) is **not** missing +parallelism — it is a credential gate: + +- `NativeBackend.expand()` fans every task over a `ThreadPoolExecutor` + (`backend.py:444-451`) — but only runs when `llm_client.discover_key()` finds a + raw API key (`backend.py:430`). +- In the common interactive Claude Code case there is **no `ANTHROPIC_API_KEY`**, + so generation falls back to `agent_action_required` (`backend.py:433-435`) and the + orchestrating session expands tasks one conversational turn at a time. That is the + slow run. +- `TaskMasterBackend` papers over this by shelling out to the external `task-master` + npm binary (`tm_parallel.py`), which is a dependency we want to delete. + +**Goal:** make the native engine the sole generator, remove the `task-master-ai` +dependency, and make parallel generation work **with zero API key** by adding a +keyless CLI-agent provider — fronted by a setup wizard that beats +`task-master models --setup`. + +### The one finding that shapes everything + +There are exactly two provider paths today: **raw-key HTTP API** and **agent-plan +fallback**. There is **no in-process "shell out to `claude -p` for structured JSON"** +path. The only code that drives a model CLI is `tm_parallel.py`, and it drives the +`task-master` binary, not `claude`/`codex`/`gemini`. **The keyless CLI-agent provider +is the net-new component** that fills the gap between the two existing paths. + +Verified the mechanism exists: `claude --print --output-format json` + +`claude --json-schema `; `codex exec` (non-interactive); `gemini -p`. All three +CLIs are on PATH in the target runtime. + +--- + +## 2. Decisions (locked) + +1. **Provider strategy = HYBRID, 3-tier:** keyless CLI-agent → raw-key API → + agent-plan floor. +2. **When BOTH a raw key and a CLI exist:** the **setup wizard asks once** + ("free-but-slower keyless, or paid-but-faster key, as primary?") and writes + `keyless_default`. **No global default is imposed.** (Resolves the key-vs-CLI + contradiction.) +3. **claude-code is primary keyless;** codex/gemini are **fallback-only** until their + schema-less JSON parse-failure rate is measured. +4. **`--json-schema` when available**, graceful demote to prompt + `_extract_json`. +5. **Spawn-probe result cached** (TTL 900s, invalidated on first spawn failure) to kill + the per-call 60s probe cost. +6. **Concurrency:** sub-project #1 only exposes the `engine.concurrency` hook; + **sub-project #2 owns the RAM-aware clamp + wave feedback.** +7. **`grokCli` stub dropped** (wired nowhere); may be added as a 4th keyless CLI later. +8. **File formats unchanged:** `.taskmaster/config.json` and `tasks.json` stay; only the + `task-master` *binary* dependency is removed. Existing projects migrate with zero file + changes. +9. **`validate_setup` is refactored, not reused verbatim.** Its checks 1–2 (`binary`, + `version`) are hard-coded to the `task-master` binary (`TASKMASTER_MIN_VERSION="0.43.0"`, + fix hint `npm install -g task-master-ai`, `mode_recommend.py`). Since §9 deletes that + binary, those two checks must become no-ops (or be dropped) when `provider_mode != plan_only`, + so the keyless engine does not fail its own validator on the dependency we are removing. + Only checks 5–6 (`provider_main`/`provider_research`) are credential/CLI-aware and reused + as-is. + +**Open questions blocking implementation:** none. + +--- + +## 3. Architecture — 3-tier hybrid resolver + +``` +resolve_provider(role, op_class) ── per role (main / fallback / research), at gen time + │ + 1 KEYLESS CLI-AGENT ★ NEW: prd_taskmaster/cli_agent.py ★ + │ claude -p "" --output-format json --json-schema + │ (uses existing Claude Code session auth — no API key, free, N-parallel) + │ codex exec / gemini -p for the others + │ + 2 RAW-KEY API (existing llm_client.generate_json — unchanged) + │ ANTHROPIC_API_KEY / OPENAI — in-process parallel HTTP, paid + │ + 3 AGENT-PLAN FLOOR (existing _agent_*_action packets — never deleted) + hands a parallel fan-out plan to the orchestrating session +``` + +Precedence order between tier 1 and tier 2 is governed per-project by +`keyless_default` (set by the wizard, decision #2). The CLI-agent worker drops into the +existing `ThreadPoolExecutor` (`backend.py:444`), so **parallel-by-default is inherited +for free** — N concurrent `claude -p` children run exactly like N concurrent HTTP calls. + +--- + +## 4. Config schema — keep / extend + +### Keep as-is (no renames; migration-safe) +- **`.taskmaster/config.json`** — role models `main`/`fallback`/`research`, each + `{provider, modelId, maxTokens, temperature, baseURL?}` (`config.json:2-46`). +- **`.atlas-ai/fleet.json`** — `max_concurrency`, `routing`, `token_economy`, `backend`, + `escalation` (`fleet.py:43-49`, `load_fleet_config` `fleet.py:75-135`). +- **`.atlas-ai/config/atlas.json`** — `/customise-workflow` token-economy override. + +### ADD — `engine` block in `fleet.json` (additive, all defaulted) + +```jsonc +{ + "engine": { + "provider_mode": "hybrid", // hybrid | api_only | cli_only | plan_only + "keyless_default": null, // null until wizard asks; true=CLI-first, false=key-first + "cli_agent": { + "structured_json": "auto", // auto = claude --json-schema if supported, else prompt+extract + "probe_cache_ttl_s": 900, + "per_call_timeout_s": 180, + "max_inflight": null // null = inherit max_concurrency + }, + "concurrency": { + "structured_gen": null, // null = inherit max_concurrency / tm_concurrency profile + "ram_aware": false // reserved for sub-project #2 + } + } +} +``` + +- `provider_mode` replaces the legacy `backend` (auto/taskmaster/native) knob once + TaskMaster is deleted; it is provider-strategy, not backend-binary. +- `keyless_default` encodes decision #2. `null` means "wizard hasn't asked yet" — the + resolver treats `null` as keyless-first (free) until the user states otherwise. This is the + **behavioral default of an unset flag, not a persisted global choice** — decision #2's "no + global default imposed" stands: the persisted value is only ever written by the wizard. + +--- + +## 5. Provider resolution order (exact) + +Unify the today-split decision (`discover_key()` + the `agent_action_required` fallback) +into one resolver `resolve_provider(role, op_class) -> ProviderHandle`. + +**For `main`/`fallback` (structured generation), with `keyless_default` truthy/null:** + +1. **CLI-agent** if `provider_mode ∈ {hybrid, cli_only}` AND the role's provider is a + spawning CLI (`claude-code`/`codex-cli`/`gemini-cli`, `_SPAWNING_PROVIDERS` + `providers.py:111`) AND *usable* (binary on PATH **and** `_probe_spawn()` true, + cached). +2. **Raw-key API** if `provider_mode ∈ {hybrid, api_only}` AND `discover_key()` returns + creds (`llm_client.py:44-67`). +3. **Agent-plan fan-out** — always the floor. + +With `keyless_default=false`, swap (1) and (2). + +**For `research`:** explicit chain — local Perplexity proxy → Perplexity MCP → +`PERPLEXITY_API_KEY` → CLI-agent. Research stays off the **strict-JSON** CLI path (free proxy +returns prose, `llm_client.py:6-8`); when the CLI-agent is the research tier it runs in +**prose mode** (no `--json-schema`), and its output is normalized downstream — research never +requires strict JSON. + +### "Usable" — single source of truth + +Promote `_provider_usable()` (`providers.py:73-99`) to the runtime gate, extended with the +spawn probe: + +| Provider | Usable when | +|---|---| +| `anthropic` / `openai` / `perplexity` | respective API key present | +| `claude-code` | `which("claude")` **and** `_probe_spawn("claude-code")` | +| `codex-cli` / `gemini-cli` | `which(bin)` **and** `_probe_spawn` | +| unknown (openrouter/ollama/…) | assumed usable — never clobber user choice | + +The spawn-probe extension is the critical fix: a `claude-code` provider inside a nested +Claude session may be refused (`providers.py:108-110`, gh #11); resolution must +**empirically demote** rather than fail. + +--- + +## 6. The keyless CLI-agent provider (net-new) + +New module `prd_taskmaster/cli_agent.py`: +`generate_json_via_cli(provider, prompt, *, system, schema_hint, model, op_class, task_id, timeout)` +— the structured-JSON twin of `llm_client.generate_json()`, shelling out to a host CLI +with its existing session auth (no API key). + +```python +# claude-code: hard structured output (preferred — flag verified) +[claude, "-p", prompt, "--output-format", "json", "--json-schema", schema_json] +# parse stdout JSON envelope; .result IS the model JSON. + +# codex-cli: non-interactive exec, prompt-carried schema + extraction +[codex, "exec", "--skip-git-repo-check", "-"] # prompt on stdin; _extract_json on stdout + +# gemini-cli: +[gemini, "-p", prompt] # prompt+schema; _extract_json on stdout +``` + +**Reuse, don't reinvent:** +- `_extract_json()` (`llm_client.py:79-124`) verbatim for codex/gemini and as the + fallback when `--json-schema` is unavailable. +- The retry policy from `generate_json` (`llm_client.py:204-254`): one parse-retry with + the error fed back. +- `_probe_spawn()` (`providers.py:127-146`) as the pre-flight gate, cached. The cache is + **per-process** (a module-level dict keyed by provider) — sufficient to dedupe the probe + across the in-process `ThreadPoolExecutor` fan-out, which is the case the acceptance + criteria target. Cross-invocation dedup (separate `script.py` runs) is out of scope for #1; + if needed later, back it with an on-disk TTL file. Invalidate the entry on the first spawn + failure regardless of TTL. +- Telemetry via `append_telemetry()` with `backend="native-cli"` (new value alongside + `native-api` / `taskmaster-api`) so the economy report covers the keyless path. + +**Where it plugs in** — in `NativeBackend.parse_prd/expand/rate`, replace the bare +`if not discover_key(): return agent_action_required` with: + +``` +handle = resolve_provider(role, op_class) +if handle.kind == "api": result = llm_client.generate_json(...) +elif handle.kind == "cli": result = cli_agent.generate_json_via_cli(handle.provider, ...) +else: return {"ok": False, "agent_action_required": _agent_*_action(...)} +``` + +The agent-plan return is preserved as the floor — never deleted. + +--- + +## 7. Setup wizard UX — "better than `task-master models --setup`" + +task-master makes you manually pick a model per role and validates keys *late* (a typo +dies at `parse-prd`). Ours keeps zero-config as the default and adds an optional guided +layer that explains every auto-decision. + +**`atlas setup` (new CLI verb) / `/atlas setup` (skill):** + +1. **Detect & recommend** (always runs, no prompts) — reuse `run_detect_providers()` + (`providers.py:374`) + `detect_capabilities()` (`mode_recommend.py:217`): + ``` + Atlas detected: claude ✓ codex ✓ gemini ✗ ANTHROPIC_API_KEY ✗ PERPLEXITY proxy ✓ + Recommended (zero-config, keyless): + main claude-code/sonnet ← free via your Claude session, no API key + fallback codex-cli/gpt-5.2-codex ← separate quota pool, runs in parallel + research perplexity-api-free ← local proxy on :8765 + [Enter] accept [c] customise [k] add an API key [v] validate only + ``` +2. **Accept (default)** — `run_configure_providers()` (`providers.py:241`) repair-on-detect; + only rewrites empty/unusable stock defaults, never clobbers user choices. +3. **Customise (`c`)** — task-master-style per-role picker with availability + reason + annotations (greyed-out = unusable here, with fix hint). +4. **Add key (`k`)** — prompt + write to `.env` via `_ensure_env_entry`. If a key is added + **and** a CLI exists, **ask the decision-#2 question** and set `keyless_default`. +5. **Validate (`v`, the differentiator)** — `validate_setup()` (`mode_recommend.py:367`, + 6 credential-aware checks) **plus a live one-token probe** per chosen provider + (`claude -p ok` / `codex --version` / 1-token HTTP ping). Surfaces a real 401/ENOENT + *before* the pipeline. Exposed standalone as `atlas setup --validate`. + +**Surfaces:** `atlas setup` (interactive), `atlas setup --yes` (accept recommendation, +for CI/dispatch), `atlas setup --validate` (dry-run gate). Skill drives the wizard via +`engine_preflight`/`configure_providers`/`validate_setup` MCP tools; Python owns the logic. + +--- + +## 8. Reuse vs build (~70% reuse) + +**Build new:** `cli_agent.py`; `resolve_provider()` unified resolver; the `engine` config +block; `atlas setup` wizard steps 3–5; spawn-probe caching. + +**Reuse as-is:** `discover_key` (extend ordering), `generate_json` + retry + `_extract_json`, +`_http_call`, `_provider_usable` (+ spawn probe), `_probe_spawn`/`_SPAWNING_PROVIDERS`, +`_desired_*_model` repair ladder, `KNOWN_STOCK_TASKMASTER_DEFAULTS` non-clobber, +`run_configure_providers`/`run_detect_providers`, `validate_setup`, `detect_capabilities`, +economy presets + telemetry, `NativeBackend` `ThreadPoolExecutor` fan-out, `_agent_*_action` +plan packets. + +--- + +## 9. Migration — deleting `TaskMasterBackend` + `tm_parallel.py` + +1. **Reach parity** — `cli_agent.py` covers the one capability only `TaskMasterBackend` + had: keyless generation without a raw key. Once `NativeBackend.expand` runs keyless and + already fans out in-process, `tm_parallel` has no job. +2. **Flip the default** — `get_backend` (`backend.py:855-867`): `backend="auto"` resolves to + `NativeBackend` unconditionally. Keep `backend="taskmaster"` for one deprecation release. +3. **Golden-parity gate** — run `AI-golden-parity-refactor`: capture task-graph outputs from + the TaskMaster path on sample PRDs, prove `NativeBackend`+`cli_agent` produces equivalent + graphs. Only intended diffs allowed. +4. **Delete** — `TaskMasterBackend`, `tm_parallel.py` (652 lines), `taskmaster.py` binary + detection, the `task-master-ai` install in `skills/setup`, and + `backend_detect`/`init_taskmaster`/`tm_parallel_expand` MCP tools. `BACKEND_CHOICES` → `{native}`. + Keep `KNOWN_STOCK_TASKMASTER_DEFAULTS` (still repairs legacy config files on read). +5. **Keep the file format** — `.taskmaster/config.json` + `tasks.json` unchanged. + +Net deletions: ~837 lines + the npm install. The agent-plan floor and `NativeBackend` survive. + +--- + +## 10. Acceptance criteria + +- [ ] In a clean project with **no API key** but `claude` on PATH, `parse_prd` and + `expand_tasks` complete via the CLI-agent path, in parallel, producing a valid task graph + (no `agent_action_required` fallback). +- [ ] Telemetry rows for that run show `backend="native-cli"`. +- [ ] With both a key and a CLI present, the wizard asks the keyless/paid question and the + written `keyless_default` is honored by `resolve_provider`. +- [ ] `atlas setup --validate` reports a real auth failure (e.g. bad key) before the pipeline. +- [ ] `_probe_spawn` is invoked at most once per provider per `probe_cache_ttl_s`. +- [ ] Golden-parity: native+cli_agent task graphs match the TaskMaster path on sample PRDs + (only intended diffs). +- [ ] `task-master` binary is **not** required for any generation operation. + +--- + +## 11. Out of scope (later sub-projects) + +- **#2** — RAM-aware concurrency + wave-by-wave feedback UI (the box). #1 only exposes the + `engine.concurrency` hook. +- **#4** — pluggable real research (Perplexity MCP / research subagent) to close + task-master's one genuine quality advantage. +- Full removal of `task-master` happens at the *end* of #1 (step 9), gated on golden parity. + +--- + +## 12. Key files + +- `prd_taskmaster/providers.py` — detection/repair/usability/spawn-probe +- `prd_taskmaster/llm_client.py` — API path + `discover_key` + `_extract_json` (reuse) +- `prd_taskmaster/backend.py` — `NativeBackend` (extend), `TaskMasterBackend` (delete), agent packets +- `prd_taskmaster/tm_parallel.py` — npm-dependent parallel expand (delete target, 652 lines) +- `prd_taskmaster/fleet.py` — fleet.json loader (add `engine` block) +- `prd_taskmaster/economy.py` — presets + telemetry (reuse) +- `prd_taskmaster/mode_recommend.py` — `validate_setup` + `detect_capabilities` (wizard) +- `prd_taskmaster/batch.py` — `run_engine_preflight` batched detector +- `skills/setup/SKILL.md` — DETECT-FIRST skill surface to extend +- **NEW:** `prd_taskmaster/cli_agent.py` — keyless CLI-agent structured-JSON provider diff --git a/docs/plans/2026-06-15-atlas-engine-hybrid-provider-setup.md b/docs/plans/2026-06-15-atlas-engine-hybrid-provider-setup.md new file mode 100644 index 0000000..2be9c8b --- /dev/null +++ b/docs/plans/2026-06-15-atlas-engine-hybrid-provider-setup.md @@ -0,0 +1,4614 @@ +# Atlas Engine — Hybrid Provider + Setup Layer — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the native Atlas engine generate tasks in parallel with zero API key by adding a keyless CLI-agent provider (`claude -p`/`codex`/`gemini`), a 3-tier hybrid resolver, an `engine` config block, and a setup wizard — the foundation for removing the `task-master` dependency. + +**Architecture:** A single `resolve_provider(role)` (new `provider_resolver.py`) chooses per role between three tiers — keyless CLI-agent (new `cli_agent.py`, the net-new piece), raw-key HTTP API (existing `llm_client`), and the agent-plan floor (existing `_agent_*_action`). `NativeBackend` dispatches on the returned `ProviderHandle`; the CLI path drops into the existing `ThreadPoolExecutor` so parallelism is inherited for free. A setup wizard recommends a working keyless default and live-validates it. + +**Tech Stack:** Python 3 (stdlib only — `subprocess`, `urllib`, `concurrent.futures`), pytest, argparse CLI, the local `claude`/`codex`/`gemini` CLIs. + +**Spec:** `docs/design/2026-06-15-atlas-engine-hybrid-provider-setup.md` (reviewer-approved). + +--- + +## Execution order & prerequisites + +Build the chunks **in order**. Dependencies: + +| Chunk | Builds | Depends on | +|------|--------|-----------| +| 1 | `engine` config block + `fleet.engine_config()` | — | +| 2 | `cli_agent.py` keyless provider | (telemetry/extractor reuse only) | +| 3 | probe cache + `provider_resolver.py` | 1 | +| 4 | wire resolver into `NativeBackend` | 1, 2, 3 (hard prerequisite) | +| 5 | setup wizard + `validate_setup` refactor | 1, 3 | +| 6 | flip `auto`→native + golden-parity gate → delete task-master | 1–5 (deletion gated on parity GREEN) | + +**Process:** fresh subagent per Task, TDD red→green→commit, two-stage review. Chunk 6's physical deletion of `TaskMasterBackend`/`tm_parallel.py` must NOT run until the golden-parity task in Chunk 6 passes. + +--- + + +## Chunk 1: engine config block + +This chunk adds the `engine` block to the `fleet.json` schema and an `engine_config(cfg=None)` accessor. It is pure parsing/defaults — no provider behavior. The accessor returns the fully-defaulted engine block; `load_fleet_config` merges the engine block into its returned config so downstream chunks can read `cfg["engine"]`. Malformed values fall back silently, matching the rest of the loader. + +### Task 1: `engine_config(cfg=None)` accessor with all defaults + +The accessor is the single source of truth for engine defaults. Given `None` it returns the pure defaults; given a raw config dict it merges any valid `engine` sub-keys over the defaults, ignoring malformed values (exactly like `load_fleet_config`'s field-by-field validation). `engine_config` accepts *either* a raw loaded JSON dict (`{"engine": {...}}`) *or* a top-level dict that already carries an `engine` key — it reads `cfg.get("engine")` in both cases, so it is safe to call on the output of `load_fleet_config`. + +**Files:** +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/fleet.py` — add `DEFAULT_ENGINE_CONFIG`, `PROVIDER_MODE_CHOICES`, `STRUCTURED_JSON_CHOICES` constants after `BACKEND_CHOICES` (line 51); add `engine_config()` function after `_atlas_config_economy()` (after line 72, before `load_fleet_config` at line 75). +- Test: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_engine_config.py` (new file). + +- [ ] **Step 1: Write the failing test** + +Create `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_engine_config.py`: + +```python +"""Atlas hybrid provider: engine config block defaults + merge (Chunk 1).""" + +import json + +import pytest + +from prd_taskmaster.fleet import engine_config, load_fleet_config + + +# ─── engine_config() pure defaults ─────────────────────────────────────────── + +def test_engine_config_none_returns_full_defaults(): + eng = engine_config(None) + assert eng["provider_mode"] == "hybrid" + assert eng["keyless_default"] is None + assert eng["cli_agent"]["structured_json"] == "auto" + assert eng["cli_agent"]["probe_cache_ttl_s"] == 900 + assert eng["cli_agent"]["per_call_timeout_s"] == 180 + assert eng["cli_agent"]["max_inflight"] is None + assert eng["concurrency"]["structured_gen"] is None + assert eng["concurrency"]["ram_aware"] is False + + +def test_engine_config_no_arg_returns_full_defaults(): + # Called with no argument at all (cfg defaults to None). + eng = engine_config() + assert eng["provider_mode"] == "hybrid" + assert eng["keyless_default"] is None + assert eng["concurrency"]["ram_aware"] is False + + +def test_engine_config_returns_fresh_copy_not_shared_mutable(): + a = engine_config(None) + a["provider_mode"] = "MUTATED" + a["cli_agent"]["probe_cache_ttl_s"] = -999 + b = engine_config(None) + assert b["provider_mode"] == "hybrid" + assert b["cli_agent"]["probe_cache_ttl_s"] == 900 + + +# ─── engine_config() merges valid overrides ────────────────────────────────── + +def test_engine_config_merges_valid_top_level_values(): + raw = {"engine": {"provider_mode": "cli_only", "keyless_default": True}} + eng = engine_config(raw) + assert eng["provider_mode"] == "cli_only" + assert eng["keyless_default"] is True + # untouched keys keep defaults + assert eng["cli_agent"]["structured_json"] == "auto" + assert eng["concurrency"]["ram_aware"] is False + + +def test_engine_config_keyless_default_false_is_honored(): + eng = engine_config({"engine": {"keyless_default": False}}) + assert eng["keyless_default"] is False + + +def test_engine_config_merges_valid_cli_agent_values(): + raw = {"engine": {"cli_agent": { + "structured_json": "schema", + "probe_cache_ttl_s": 60, + "per_call_timeout_s": 30, + "max_inflight": 4, + }}} + eng = engine_config(raw) + assert eng["cli_agent"]["structured_json"] == "schema" + assert eng["cli_agent"]["probe_cache_ttl_s"] == 60 + assert eng["cli_agent"]["per_call_timeout_s"] == 30 + assert eng["cli_agent"]["max_inflight"] == 4 + + +def test_engine_config_merges_valid_concurrency_values(): + raw = {"engine": {"concurrency": {"structured_gen": 8, "ram_aware": True}}} + eng = engine_config(raw) + assert eng["concurrency"]["structured_gen"] == 8 + assert eng["concurrency"]["ram_aware"] is True + + +# ─── engine_config() ignores malformed values (silent fallback) ────────────── + +def test_engine_config_malformed_provider_mode_falls_back(): + eng = engine_config({"engine": {"provider_mode": "warp_drive"}}) + assert eng["provider_mode"] == "hybrid" + + +def test_engine_config_malformed_keyless_default_falls_back(): + # Only true/false/None are valid; a string is malformed -> default None. + eng = engine_config({"engine": {"keyless_default": "yes"}}) + assert eng["keyless_default"] is None + + +def test_engine_config_malformed_structured_json_falls_back(): + eng = engine_config({"engine": {"cli_agent": {"structured_json": "telepathy"}}}) + assert eng["cli_agent"]["structured_json"] == "auto" + + +def test_engine_config_malformed_ints_fall_back(): + raw = {"engine": {"cli_agent": { + "probe_cache_ttl_s": "soon", # not an int + "per_call_timeout_s": 0, # < 1, invalid + "max_inflight": -3, # < 1, invalid (None stays valid via default) + }}} + eng = engine_config(raw) + assert eng["cli_agent"]["probe_cache_ttl_s"] == 900 + assert eng["cli_agent"]["per_call_timeout_s"] == 180 + assert eng["cli_agent"]["max_inflight"] is None + + +def test_engine_config_bool_is_not_accepted_as_int(): + # bool is a subclass of int in Python; ttl must reject True/False. + eng = engine_config({"engine": {"cli_agent": {"probe_cache_ttl_s": True}}}) + assert eng["cli_agent"]["probe_cache_ttl_s"] == 900 + + +def test_engine_config_malformed_ram_aware_falls_back(): + eng = engine_config({"engine": {"concurrency": {"ram_aware": "true"}}}) + assert eng["concurrency"]["ram_aware"] is False + + +def test_engine_config_malformed_structured_gen_falls_back(): + eng = engine_config({"engine": {"concurrency": {"structured_gen": "lots"}}}) + assert eng["concurrency"]["structured_gen"] is None + + +def test_engine_config_non_dict_engine_block_falls_back(): + assert engine_config({"engine": "broken"})["provider_mode"] == "hybrid" + assert engine_config({"engine": ["not", "a", "dict"]})["provider_mode"] == "hybrid" + assert engine_config({"engine": 42})["provider_mode"] == "hybrid" + + +def test_engine_config_non_dict_cli_agent_falls_back(): + eng = engine_config({"engine": {"cli_agent": "nope"}}) + assert eng["cli_agent"]["probe_cache_ttl_s"] == 900 + + +def test_engine_config_non_dict_concurrency_falls_back(): + eng = engine_config({"engine": {"concurrency": 5}}) + assert eng["concurrency"]["ram_aware"] is False + + +def test_engine_config_missing_engine_key_returns_defaults(): + eng = engine_config({"max_concurrency": 7}) + assert eng["provider_mode"] == "hybrid" + + +def test_engine_config_non_dict_cfg_returns_defaults(): + assert engine_config("garbage")["provider_mode"] == "hybrid" + assert engine_config(42)["provider_mode"] == "hybrid" + assert engine_config([])["provider_mode"] == "hybrid" +``` + +- [ ] **Step 2: Run it, expect FAIL** + +``` +python3 -m pytest tests/core/test_engine_config.py -v +``` + +Expected: collection or every test errors with `ImportError: cannot import name 'engine_config' from 'prd_taskmaster.fleet'`. (Run from `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public`.) + +- [ ] **Step 3: Minimal implementation** + +In `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/fleet.py`, add the constants immediately after `BACKEND_CHOICES = {"auto", "taskmaster", "native"}` (line 51): + +```python +# ─── Atlas hybrid provider: engine config block (Chunk 1) ───────────────────── +PROVIDER_MODE_CHOICES = {"hybrid", "api_only", "cli_only", "plan_only"} +STRUCTURED_JSON_CHOICES = {"auto", "schema", "prompt"} + +DEFAULT_ENGINE_CONFIG = { + "provider_mode": "hybrid", # hybrid | api_only | cli_only | plan_only + "keyless_default": None, # null until wizard asks; True=CLI-first, False=key-first + "cli_agent": { + "structured_json": "auto", # auto | schema | prompt + "probe_cache_ttl_s": 900, + "per_call_timeout_s": 180, + "max_inflight": None, # null -> inherit max_concurrency + }, + "concurrency": { + "structured_gen": None, # null -> inherit max_concurrency + "ram_aware": False, # reserved for sub-project #2 + }, +} +``` + +Then add the accessor function immediately after `_atlas_config_economy()` (after line 72, the `return val if isinstance(val, str) else None` line, before `def load_fleet_config`): + +```python +def _is_pos_int(value): + """True for a real positive int, excluding bool (bool subclasses int).""" + return isinstance(value, int) and not isinstance(value, bool) and value >= 1 + + +def engine_config(cfg=None): + """Merged `engine` block with all defaults applied (Chunk 1). + + Accepts a raw fleet.json dict OR the output of `load_fleet_config` (both + carry an `engine` key after this change). Returns a fresh dict every call. + Malformed values fall back silently, exactly like `load_fleet_config`. + """ + eng = { + "provider_mode": DEFAULT_ENGINE_CONFIG["provider_mode"], + "keyless_default": DEFAULT_ENGINE_CONFIG["keyless_default"], + "cli_agent": dict(DEFAULT_ENGINE_CONFIG["cli_agent"]), + "concurrency": dict(DEFAULT_ENGINE_CONFIG["concurrency"]), + } + if not isinstance(cfg, dict): + return eng + raw = cfg.get("engine") + if not isinstance(raw, dict): + return eng + + mode = raw.get("provider_mode") + if mode in PROVIDER_MODE_CHOICES: + eng["provider_mode"] = mode + + keyless = raw.get("keyless_default") + # Only an explicit bool is persisted; None means "wizard hasn't asked". + if isinstance(keyless, bool): + eng["keyless_default"] = keyless + + cli = raw.get("cli_agent") + if isinstance(cli, dict): + sj = cli.get("structured_json") + if sj in STRUCTURED_JSON_CHOICES: + eng["cli_agent"]["structured_json"] = sj + ttl = cli.get("probe_cache_ttl_s") + if _is_pos_int(ttl): + eng["cli_agent"]["probe_cache_ttl_s"] = ttl + timeout = cli.get("per_call_timeout_s") + if _is_pos_int(timeout): + eng["cli_agent"]["per_call_timeout_s"] = timeout + inflight = cli.get("max_inflight") + if _is_pos_int(inflight): + eng["cli_agent"]["max_inflight"] = inflight + + conc = raw.get("concurrency") + if isinstance(conc, dict): + sg = conc.get("structured_gen") + if _is_pos_int(sg): + eng["concurrency"]["structured_gen"] = sg + if isinstance(conc.get("ram_aware"), bool): + eng["concurrency"]["ram_aware"] = conc["ram_aware"] + + return eng +``` + +- [ ] **Step 4: Run, expect PASS** + +``` +python3 -m pytest tests/core/test_engine_config.py -v +``` + +Expected: all 19 tests in the file pass (`19 passed`). No other test files touched. + +- [ ] **Step 5: Commit** + +``` +git add prd_taskmaster/fleet.py tests/core/test_engine_config.py +git commit -m "$(cat <<'EOF' +feat(engine): add engine_config accessor with hybrid-provider defaults + +Chunk 1 of the Atlas hybrid provider + setup layer. Pure parsing/defaults: +engine.provider_mode / keyless_default / cli_agent / concurrency, all +defaulted, malformed values fall back silently like load_fleet_config. +No behavior yet — resolver/cli_agent land in later chunks. + +Co-Authored-By: Claude Fable 5 +EOF +)" +``` + +### Task 2: `load_fleet_config` merges the `engine` block into its returned config + +Downstream chunks read `cfg["engine"]` off the loaded config. This task makes `load_fleet_config` always include a fully-defaulted `engine` key, populated from the raw file via `engine_config`. Reuses the accessor — no duplicate validation logic (DRY). + +**Files:** +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/fleet.py` — in `load_fleet_config` (lines 75-135): seed `cfg["engine"]` with pure defaults at the initial-dict (lines 83-89), and re-merge from the parsed raw file just before `return cfg` (line 135). +- Test: append to `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_engine_config.py`. + +- [ ] **Step 1: Write the failing test** + +Append to `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_engine_config.py`: + +```python +# ─── load_fleet_config merges the engine block ─────────────────────────────── + +def test_load_fleet_config_has_engine_defaults_without_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + cfg = load_fleet_config() + assert cfg["engine"]["provider_mode"] == "hybrid" + assert cfg["engine"]["keyless_default"] is None + assert cfg["engine"]["cli_agent"]["probe_cache_ttl_s"] == 900 + assert cfg["engine"]["concurrency"]["ram_aware"] is False + + +def test_load_fleet_config_merges_engine_from_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text(json.dumps({ + "max_concurrency": 5, + "engine": { + "provider_mode": "cli_only", + "keyless_default": True, + "cli_agent": {"per_call_timeout_s": 45}, + "concurrency": {"ram_aware": True}, + }, + })) + cfg = load_fleet_config() + # legacy keys still work + assert cfg["max_concurrency"] == 5 + # engine merged + assert cfg["engine"]["provider_mode"] == "cli_only" + assert cfg["engine"]["keyless_default"] is True + assert cfg["engine"]["cli_agent"]["per_call_timeout_s"] == 45 + # untouched engine sub-keys keep defaults + assert cfg["engine"]["cli_agent"]["probe_cache_ttl_s"] == 900 + assert cfg["engine"]["concurrency"]["ram_aware"] is True + + +def test_load_fleet_config_engine_malformed_file_falls_back(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text("{not json") + cfg = load_fleet_config() + assert cfg["engine"]["provider_mode"] == "hybrid" + assert cfg["engine"]["keyless_default"] is None + + +def test_load_fleet_config_engine_invalid_values_ignored(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text(json.dumps({ + "engine": { + "provider_mode": "warp_drive", # invalid -> hybrid + "keyless_default": "yes", # invalid -> None + "cli_agent": {"probe_cache_ttl_s": 0}, # invalid -> 900 + "concurrency": {"structured_gen": -1}, # invalid -> None + }, + })) + cfg = load_fleet_config() + assert cfg["engine"]["provider_mode"] == "hybrid" + assert cfg["engine"]["keyless_default"] is None + assert cfg["engine"]["cli_agent"]["probe_cache_ttl_s"] == 900 + assert cfg["engine"]["concurrency"]["structured_gen"] is None + + +def test_load_fleet_config_engine_block_independent_per_call(tmp_path, monkeypatch): + # Mutating one returned engine block must not bleed into the next call. + monkeypatch.chdir(tmp_path) + a = load_fleet_config() + a["engine"]["cli_agent"]["probe_cache_ttl_s"] = -1 + b = load_fleet_config() + assert b["engine"]["cli_agent"]["probe_cache_ttl_s"] == 900 + + +def test_engine_config_accepts_loaded_config(tmp_path, monkeypatch): + # engine_config() called on load_fleet_config() output is idempotent. + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text(json.dumps({ + "engine": {"provider_mode": "api_only"}, + })) + cfg = load_fleet_config() + eng = engine_config(cfg) + assert eng["provider_mode"] == "api_only" + assert eng["cli_agent"]["structured_json"] == "auto" +``` + +- [ ] **Step 2: Run it, expect FAIL** + +``` +python3 -m pytest tests/core/test_engine_config.py -v -k "load_fleet_config or accepts_loaded_config" +``` + +Expected: the 6 new `load_fleet_config`/`accepts_loaded_config` tests FAIL with `KeyError: 'engine'` (the loaded config has no `engine` key yet). Task-1 tests still pass. + +- [ ] **Step 3: Minimal implementation** + +In `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/fleet.py`, seed the engine defaults in the initial `cfg` dict inside `load_fleet_config`. Change the block at lines 83-89: + +```python + cfg = { + "max_concurrency": DEFAULT_FLEET_CONFIG["max_concurrency"], + "routing": dict(DEFAULT_ROUTING), + "experimental_backends": DEFAULT_FLEET_CONFIG["experimental_backends"], + "token_economy": _atlas_config_economy() or DEFAULT_FLEET_CONFIG["token_economy"], + "backend": DEFAULT_FLEET_CONFIG["backend"], + } +``` + +to: + +```python + cfg = { + "max_concurrency": DEFAULT_FLEET_CONFIG["max_concurrency"], + "routing": dict(DEFAULT_ROUTING), + "experimental_backends": DEFAULT_FLEET_CONFIG["experimental_backends"], + "token_economy": _atlas_config_economy() or DEFAULT_FLEET_CONFIG["token_economy"], + "backend": DEFAULT_FLEET_CONFIG["backend"], + "engine": engine_config(None), + } +``` + +Then, at the end of `load_fleet_config`, replace the final `return cfg` (line 135) with a re-merge from the parsed `raw` file: + +```python + cfg["engine"] = engine_config(raw) + + return cfg +``` + +Note: `raw` is the parsed JSON dict that is in scope at the point of the final return (the function only reaches there after the `isinstance(raw, dict)` guard at lines 97-98). The `engine_config(None)` seed at the top covers the early-return paths (no file / unreadable / not-a-dict), so every return path now carries a valid `engine` key. + +- [ ] **Step 4: Run, expect PASS** + +``` +python3 -m pytest tests/core/test_engine_config.py -v +``` + +Expected: all tests pass (`25 passed`). Then confirm no regression in the existing loader suite: + +``` +python3 -m pytest tests/core/test_fleet_config.py -v +``` + +Expected: `8 passed` (unchanged — the existing tests assert specific keys, none of which is `engine`, so the additive key does not break them). + +- [ ] **Step 5: Commit** + +``` +git add prd_taskmaster/fleet.py tests/core/test_engine_config.py +git commit -m "$(cat <<'EOF' +feat(engine): merge engine block into load_fleet_config output + +load_fleet_config now always returns a fully-defaulted cfg["engine"], merged +from .atlas-ai/fleet.json via engine_config(). Every return path (no file, +malformed, valid) carries a valid engine key so downstream chunks can read +cfg["engine"] unconditionally. Existing fleet-config tests unchanged. + +Co-Authored-By: Claude Fable 5 +EOF +)" +``` + +--- + +**Chunk 1 done.** Exports added to `prd_taskmaster/fleet.py`: `PROVIDER_MODE_CHOICES`, `STRUCTURED_JSON_CHOICES`, `DEFAULT_ENGINE_CONFIG`, `engine_config(cfg=None)`, and `load_fleet_config(...)["engine"]`. These are the interface later chunks consume — `provider_resolver.resolve_provider` reads `fleet_config["engine"]["provider_mode"]` / `["keyless_default"]`, and `cli_agent` reads `["cli_agent"]["probe_cache_ttl_s"]` / `["per_call_timeout_s"]` / `["structured_json"]`. No provider behavior is introduced here. + + +--- + + +## Chunk 2: cli_agent keyless provider + +**Goal:** New module `prd_taskmaster/cli_agent.py` exposing `generate_json_via_cli(provider, prompt, ...)` — the structured-JSON twin of `llm_client.generate_json()` that shells out to a host model CLI (`claude` / `codex` / `gemini`) using its own session auth (no API key). It reuses `llm_client._extract_json`, applies one parse-retry, emits one `backend="native-cli"` telemetry row per attempt, and raises `CliAgentError(kind, message)` with `kind ∈ {no_cli, spawn_refused, timeout, invalid_json, nonzero_exit}`. + +**Contract (shared interface §3):** +```python +def generate_json_via_cli(provider, prompt, *, system="", schema_hint="", model=None, + op_class="structured_gen", task_id=None, timeout=180, + structured_json="auto") -> dict +``` +- `claude`: `claude -p --output-format json [--json-schema ]` → stdout is a JSON envelope; `.result` is the model output. When `structured_json="prompt"` or `"auto"` and no schema is supplied, schema flag is omitted and the envelope `.result` is run through `_extract_json`. +- `codex`: `codex exec --skip-git-repo-check -` with prompt on **stdin**; `_extract_json` on stdout. +- `gemini`: `gemini -p `; `_extract_json` on stdout. +- All `subprocess.run` calls are **mocked** in tests (capture argv/stdin, feed canned stdout/returncode). No real CLI is ever spawned; no network. + +**Design notes baked into the implementation (read before writing code):** +- The `claude --output-format json` envelope is a JSON object whose `result` field carries the model's textual answer. We parse the envelope first; if `--json-schema` was passed, `result` should already be a JSON string/object — we still funnel it through `_extract_json` so a stringified-JSON `result` is normalized uniformly. If the envelope itself is unparseable, that is an `invalid_json` (subject to the one parse-retry). +- "Schema path vs prompt+extract path" (acceptance): schema path = claude WITH `--json-schema`; prompt+extract = codex/gemini (always) and claude when `structured_json != "schema"` or no schema given. +- One parse-retry mirrors `generate_json` (`llm_client.py:246-252`): on first `_extract_json` → `None`, append the corrective instruction to the prompt and respawn **once**; a second failure raises `CliAgentError("invalid_json", ...)`. +- `subprocess.run(..., timeout=timeout)` raising `TimeoutExpired` → `CliAgentError("timeout", ...)`. A non-zero `returncode` → `CliAgentError("nonzero_exit", ...)`. `which(cli)` missing → `CliAgentError("no_cli", ...)`. `OSError`/`FileNotFoundError` on spawn → `CliAgentError("spawn_refused", ...)`. +- Telemetry: one row per spawn attempt via `economy.append_telemetry({...})` with `backend="native-cli"`, fields mirroring `llm_client._telemetry` shape (`ts, op_class, task_id, model, backend, exit, wall_ms, escalated:False, parse_retry, http_status:None`). `exit=0` on parse success, `exit=1` otherwise. Token counts are unavailable from the CLIs so are omitted (matches the no-usage telemetry case in `test_telemetry_rows_written`). + +--- + +### Task 1: Module skeleton — `CliAgentError`, argv builder, telemetry helper + +**Files:** +- Create: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/cli_agent.py` (lines 1–80) +- Test: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_cli_agent.py` + +- [ ] **Step 1: Write the failing test** + +Create `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_cli_agent.py`: + +```python +"""Chunk 2: cli_agent — keyless CLI-agent structured-JSON provider. + +Hermetic: every subprocess.run is monkeypatched (no real claude/codex/gemini), +no network. Telemetry asserted by chdir-ing into tmp_path and reading the +.atlas-ai/telemetry.jsonl the module appends to. +""" + +import json +import subprocess + +import pytest + +from prd_taskmaster import cli_agent as C + + +# ── A reusable fake for subprocess.run ─────────────────────────────────────── +class FakeCompleted: + """Mimics subprocess.CompletedProcess just enough for cli_agent.""" + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def make_runner(scripted): + """Return a fake subprocess.run that records calls and replays `scripted` + (a list of FakeCompleted or Exception instances), one per invocation.""" + calls = [] + seq = list(scripted) + + def fake_run(argv, *args, **kwargs): + calls.append({"argv": argv, "kwargs": kwargs}) + item = seq.pop(0) + if isinstance(item, Exception): + raise item + return item + + fake_run.calls = calls + return fake_run + + +# ── Task 1: error shape + argv builders ────────────────────────────────────── + +def test_cli_agent_error_has_kind(): + err = C.CliAgentError("timeout", "boom") + assert err.kind == "timeout" + assert "boom" in str(err) + assert isinstance(err, Exception) + + +def test_build_argv_claude_schema_path(): + argv, stdin = C._build_argv( + "claude-code", "/bin/claude", "PROMPT", schema_hint='{"type":"object"}', + structured_json="auto", + ) + assert argv == ["/bin/claude", "-p", "PROMPT", "--output-format", "json", + "--json-schema", '{"type":"object"}'] + assert stdin is None + + +def test_build_argv_claude_prompt_path_when_no_schema(): + argv, stdin = C._build_argv( + "claude-code", "/bin/claude", "PROMPT", schema_hint="", + structured_json="auto", + ) + assert argv == ["/bin/claude", "-p", "PROMPT", "--output-format", "json"] + assert stdin is None + + +def test_build_argv_claude_prompt_mode_forces_no_schema(): + argv, _ = C._build_argv( + "claude-code", "/bin/claude", "PROMPT", schema_hint='{"x":1}', + structured_json="prompt", + ) + assert "--json-schema" not in argv + + +def test_build_argv_codex_uses_stdin(): + argv, stdin = C._build_argv( + "codex-cli", "/bin/codex", "PROMPT", schema_hint="", structured_json="auto", + ) + assert argv == ["/bin/codex", "exec", "--skip-git-repo-check", "-"] + assert stdin == "PROMPT" + + +def test_build_argv_gemini(): + argv, stdin = C._build_argv( + "gemini-cli", "/bin/gemini", "PROMPT", schema_hint="", structured_json="auto", + ) + assert argv == ["/bin/gemini", "-p", "PROMPT"] + assert stdin is None + + +def test_build_argv_unknown_provider_raises_no_cli(): + with pytest.raises(C.CliAgentError) as ei: + C._build_argv("openrouter", "/bin/x", "P", schema_hint="", structured_json="auto") + assert ei.value.kind == "no_cli" +``` + +- [ ] **Step 2: Run it, expect FAIL** + +``` +cd /home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +python3 -m pytest tests/core/test_cli_agent.py -v +``` +Expected: collection error / `ModuleNotFoundError: No module named 'prd_taskmaster.cli_agent'` (or, once the file exists but is empty, `AttributeError: module 'prd_taskmaster.cli_agent' has no attribute 'CliAgentError'`). + +- [ ] **Step 3: Minimal implementation** + +Create `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/cli_agent.py`: + +```python +"""Keyless CLI-agent structured-JSON provider (sub-project #1, Chunk 2). + +The structured-JSON twin of llm_client.generate_json(): instead of an HTTP call +against a raw API key, it shells out to a host model CLI (claude / codex / gemini) +using that CLI's own session auth — free, no API key, runs N-parallel inside the +existing NativeBackend ThreadPoolExecutor exactly like N concurrent HTTP calls. + +Reuses llm_client._extract_json verbatim and mirrors generate_json's ONE +parse-retry. Emits one telemetry row per spawn attempt with backend="native-cli". +""" + +import shutil +import subprocess +import time + +from prd_taskmaster.economy import append_telemetry +from prd_taskmaster.llm_client import _extract_json + +# provider -> CLI binary name (mirrors providers._SPAWN_PROBE_CLI; kept local to +# avoid a hard import cycle and because cli_agent must run even if probe is stubbed). +_CLI_FOR_PROVIDER = {"claude-code": "claude", "codex-cli": "codex", "gemini-cli": "gemini"} + +_RETRY_INSTRUCTION = ( + "\nYour previous output failed json.loads. Return ONLY the JSON, no prose, no fences." +) + + +class CliAgentError(Exception): + """kind in {"no_cli", "spawn_refused", "timeout", "invalid_json", "nonzero_exit"}.""" + + def __init__(self, kind, message): + super().__init__(message) + self.kind = kind + + +def _build_argv(provider, binary, prompt, *, schema_hint, structured_json): + """Return (argv, stdin_text). stdin_text is None unless the CLI takes the + prompt on stdin (codex). Raises CliAgentError('no_cli') for unknown providers.""" + p = str(provider or "").lower() + if p == "claude-code": + argv = [binary, "-p", prompt, "--output-format", "json"] + # Schema path: only when a schema is supplied AND prompt-mode not forced. + if schema_hint and structured_json != "prompt": + argv += ["--json-schema", schema_hint] + return argv, None + if p == "codex-cli": + return [binary, "exec", "--skip-git-repo-check", "-"], prompt + if p == "gemini-cli": + return [binary, "-p", prompt], None + raise CliAgentError("no_cli", f"provider {provider!r} is not a spawning CLI agent") + + +def _telemetry(op_class, task_id, model, exit_code, start, parse_retry): + """One native-cli telemetry row. Mirrors llm_client._telemetry minus usage + tokens (the CLIs do not surface token counts) and http_status (None).""" + from datetime import datetime, timezone + + return append_telemetry({ + "ts": datetime.now(timezone.utc).isoformat(), + "op_class": op_class, + "task_id": task_id, + "model": model, + "backend": "native-cli", + "exit": exit_code, + "wall_ms": int((time.monotonic() - start) * 1000), + "escalated": False, + "parse_retry": parse_retry, + "http_status": None, + }) +``` + +- [ ] **Step 4: Run, expect PASS** + +``` +cd /home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +python3 -m pytest tests/core/test_cli_agent.py -v +``` +Expected: `7 passed` (the Task-1 tests). No subprocess is touched yet. + +- [ ] **Step 5: Commit** + +``` +cd /home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +git add prd_taskmaster/cli_agent.py tests/core/test_cli_agent.py +git commit -m "feat(cli-agent): CliAgentError + per-provider argv builder + +New keyless CLI-agent module skeleton: error shape with .kind, argv/stdin +builder for claude/codex/gemini, native-cli telemetry helper. Schema path +(claude --json-schema) vs prompt path selection. subprocess fully mocked. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: `_run_once` — spawn one attempt, parse stdout, classify failures + +**Files:** +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/cli_agent.py` (append `_parse_claude_envelope`, `_run_once`; ~lines 81–150) +- Test: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_cli_agent.py` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_cli_agent.py`: + +```python +# ── Task 2: _run_once spawn + parse + failure classification ────────────────── + +def test_run_once_claude_envelope_result_is_json_string(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + envelope = json.dumps({"type": "result", "result": '{"tasks": [1, 2]}'}) + fake = make_runner([FakeCompleted(returncode=0, stdout=envelope)]) + monkeypatch.setattr(C.subprocess, "run", fake) + out = C._run_once( + "claude-code", "/bin/claude", "PROMPT", schema_hint="", structured_json="auto", + model="sonnet", op_class="structured_gen", task_id=3, timeout=180, + ) + assert out == {"tasks": [1, 2]} + # captured argv is the claude prompt path + assert fake.calls[0]["argv"][:2] == ["/bin/claude", "-p"] + + +def test_run_once_claude_envelope_result_is_object(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + envelope = json.dumps({"result": {"tasks": []}}) + fake = make_runner([FakeCompleted(returncode=0, stdout=envelope)]) + monkeypatch.setattr(C.subprocess, "run", fake) + out = C._run_once( + "claude-code", "/bin/claude", "P", schema_hint="", structured_json="auto", + model="sonnet", op_class="structured_gen", task_id=None, timeout=180, + ) + assert out == {"tasks": []} + + +def test_run_once_codex_extract_from_fenced_stdout(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + stdout = "Here you go:\n```json\n{\"ok\": true}\n```\n" + fake = make_runner([FakeCompleted(returncode=0, stdout=stdout)]) + monkeypatch.setattr(C.subprocess, "run", fake) + out = C._run_once( + "codex-cli", "/bin/codex", "P", schema_hint="", structured_json="auto", + model="gpt-5.2-codex", op_class="structured_gen", task_id=None, timeout=180, + ) + assert out == {"ok": True} + # codex carries the prompt on stdin, not argv + assert fake.calls[0]["kwargs"].get("input") == "P" + + +def test_run_once_invalid_json_returns_none(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + fake = make_runner([FakeCompleted(returncode=0, stdout="totally not json")]) + monkeypatch.setattr(C.subprocess, "run", fake) + out = C._run_once( + "gemini-cli", "/bin/gemini", "P", schema_hint="", structured_json="auto", + model=None, op_class="structured_gen", task_id=None, timeout=180, + ) + assert out is None # signals the caller to do its one parse-retry + + +def test_run_once_nonzero_exit_raises(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + fake = make_runner([FakeCompleted(returncode=2, stdout="", stderr="bad flag")]) + monkeypatch.setattr(C.subprocess, "run", fake) + with pytest.raises(C.CliAgentError) as ei: + C._run_once( + "claude-code", "/bin/claude", "P", schema_hint="", structured_json="auto", + model=None, op_class="structured_gen", task_id=None, timeout=180, + ) + assert ei.value.kind == "nonzero_exit" + assert "bad flag" in str(ei.value) + + +def test_run_once_timeout_raises(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + fake = make_runner([subprocess.TimeoutExpired(cmd="claude", timeout=180)]) + monkeypatch.setattr(C.subprocess, "run", fake) + with pytest.raises(C.CliAgentError) as ei: + C._run_once( + "claude-code", "/bin/claude", "P", schema_hint="", structured_json="auto", + model=None, op_class="structured_gen", task_id=None, timeout=180, + ) + assert ei.value.kind == "timeout" + + +def test_run_once_oserror_raises_spawn_refused(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + fake = make_runner([OSError("nested spawn refused")]) + monkeypatch.setattr(C.subprocess, "run", fake) + with pytest.raises(C.CliAgentError) as ei: + C._run_once( + "claude-code", "/bin/claude", "P", schema_hint="", structured_json="auto", + model=None, op_class="structured_gen", task_id=None, timeout=180, + ) + assert ei.value.kind == "spawn_refused" + + +def test_run_once_emits_one_native_cli_telemetry_row(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + envelope = json.dumps({"result": '{"ok": true}'}) + fake = make_runner([FakeCompleted(returncode=0, stdout=envelope)]) + monkeypatch.setattr(C.subprocess, "run", fake) + C._run_once( + "claude-code", "/bin/claude", "P", schema_hint="", structured_json="auto", + model="sonnet", op_class="structured_gen", task_id=9, timeout=180, + ) + rows = [json.loads(l) for l in + (tmp_path / ".atlas-ai" / "telemetry.jsonl").read_text().splitlines()] + assert len(rows) == 1 + assert rows[0]["backend"] == "native-cli" + assert rows[0]["exit"] == 0 + assert rows[0]["task_id"] == 9 + assert rows[0]["http_status"] is None + assert "tokens_in" not in rows[0] + + +def test_run_once_invalid_json_telemetry_exit_1(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + fake = make_runner([FakeCompleted(returncode=0, stdout="nope")]) + monkeypatch.setattr(C.subprocess, "run", fake) + C._run_once( + "gemini-cli", "/bin/gemini", "P", schema_hint="", structured_json="auto", + model=None, op_class="structured_gen", task_id=None, timeout=180, + ) + rows = [json.loads(l) for l in + (tmp_path / ".atlas-ai" / "telemetry.jsonl").read_text().splitlines()] + assert rows[0]["exit"] == 1 + assert rows[0]["parse_retry"] is False +``` + +- [ ] **Step 2: Run it, expect FAIL** + +``` +cd /home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +python3 -m pytest tests/core/test_cli_agent.py -v -k "run_once" +``` +Expected: `AttributeError: module 'prd_taskmaster.cli_agent' has no attribute '_run_once'` for all `run_once` tests. + +- [ ] **Step 3: Minimal implementation** + +Append to `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/cli_agent.py`: + +```python +def _parse_claude_envelope(stdout): + """claude --output-format json prints a JSON envelope; the model answer is in + `.result` (a JSON string when --json-schema was used, else free text). Funnel + `.result` through _extract_json so a stringified-JSON result is normalized. + Return parsed JSON, or None to signal a parse failure (one retry upstream).""" + envelope = _extract_json(stdout) + if isinstance(envelope, dict) and "result" in envelope: + result = envelope["result"] + if isinstance(result, (dict, list)): + return result + if isinstance(result, str): + return _extract_json(result) + return None + # No envelope (or non-dict): treat the whole stdout as the payload. + return envelope + + +def _run_once(provider, binary, prompt, *, schema_hint, structured_json, + model, op_class, task_id, timeout, parse_retry=False): + """Spawn the CLI once, parse stdout into JSON. Returns the parsed dict/list, + or None on a parse failure (caller decides whether to retry). Raises + CliAgentError for timeout / nonzero_exit / spawn_refused. Emits exactly one + native-cli telemetry row for this attempt.""" + argv, stdin_text = _build_argv( + provider, binary, prompt, schema_hint=schema_hint, structured_json=structured_json, + ) + start = time.monotonic() + try: + completed = subprocess.run( + argv, + input=stdin_text, + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + _telemetry(op_class, task_id, model, 1, start, parse_retry) + raise CliAgentError("timeout", f"{binary} exceeded {timeout}s timeout") + except OSError as exc: + _telemetry(op_class, task_id, model, 1, start, parse_retry) + raise CliAgentError("spawn_refused", f"{binary} could not spawn: {exc}") + + if completed.returncode != 0: + _telemetry(op_class, task_id, model, 1, start, parse_retry) + detail = (completed.stderr or completed.stdout or "").strip()[:400] + raise CliAgentError("nonzero_exit", f"{binary} exit {completed.returncode}: {detail}") + + if str(provider).lower() == "claude-code": + result = _parse_claude_envelope(completed.stdout) + else: + result = _extract_json(completed.stdout) + + _telemetry(op_class, task_id, model, 0 if result is not None else 1, start, parse_retry) + return result +``` + +- [ ] **Step 4: Run, expect PASS** + +``` +cd /home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +python3 -m pytest tests/core/test_cli_agent.py -v +``` +Expected: all Task-1 + Task-2 tests pass (16 passed). + +- [ ] **Step 5: Commit** + +``` +cd /home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +git add prd_taskmaster/cli_agent.py tests/core/test_cli_agent.py +git commit -m "feat(cli-agent): _run_once spawn + claude envelope parse + failure kinds + +One spawn attempt: claude .result envelope unwrap (reuses _extract_json), +codex/gemini direct extract. Classifies TimeoutExpired->timeout, +OSError->spawn_refused, nonzero rc->nonzero_exit. Emits one native-cli +telemetry row (exit 0/1) per attempt. All subprocess mocked. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: `generate_json_via_cli` — public entry, no-cli gate, one parse-retry + +**Files:** +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/cli_agent.py` (append public fn; ~lines 151–210) +- Test: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_cli_agent.py` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_cli_agent.py`: + +```python +# ── Task 3: generate_json_via_cli public entry ─────────────────────────────── + +def test_generate_json_via_cli_happy_path(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + envelope = json.dumps({"result": '{"tasks": [{"id": 1}]}'}) + fake = make_runner([FakeCompleted(returncode=0, stdout=envelope)]) + monkeypatch.setattr(C.subprocess, "run", fake) + out = C.generate_json_via_cli("claude-code", "Make tasks", task_id=1) + assert out == {"tasks": [{"id": 1}]} + assert len(fake.calls) == 1 # one spawn, no retry needed + + +def test_generate_json_via_cli_no_cli_when_binary_missing(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: None) + with pytest.raises(C.CliAgentError) as ei: + C.generate_json_via_cli("claude-code", "P") + assert ei.value.kind == "no_cli" + + +def test_generate_json_via_cli_parse_retry_succeeds(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + # First spawn: garbage; second spawn (the one retry): valid. + fake = make_runner([ + FakeCompleted(returncode=0, stdout="garbage no json"), + FakeCompleted(returncode=0, stdout='{"ok": true}'), + ]) + monkeypatch.setattr(C.subprocess, "run", fake) + out = C.generate_json_via_cli("gemini-cli", "P") + assert out == {"ok": True} + assert len(fake.calls) == 2 + # The retry prompt must carry the corrective instruction. + retry_prompt = fake.calls[1]["argv"][2] # gemini -p + assert "ONLY the JSON" in retry_prompt + + +def test_generate_json_via_cli_invalid_json_after_retry_raises(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + fake = make_runner([ + FakeCompleted(returncode=0, stdout="nope"), + FakeCompleted(returncode=0, stdout="still nope"), + ]) + monkeypatch.setattr(C.subprocess, "run", fake) + with pytest.raises(C.CliAgentError) as ei: + C.generate_json_via_cli("gemini-cli", "P") + assert ei.value.kind == "invalid_json" + assert len(fake.calls) == 2 # exactly one retry, no third spawn + + +def test_generate_json_via_cli_schema_hint_appended_to_prompt_when_no_schema_flag(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + fake = make_runner([FakeCompleted(returncode=0, stdout='{"ok": 1}')]) + monkeypatch.setattr(C.subprocess, "run", fake) + # gemini has no schema flag -> schema_hint must be folded into the prompt text. + C.generate_json_via_cli("gemini-cli", "Base", schema_hint='{"type":"object"}') + sent_prompt = fake.calls[0]["argv"][2] + assert "Base" in sent_prompt + assert '{"type":"object"}' in sent_prompt + + +def test_generate_json_via_cli_claude_schema_uses_flag_not_prompt(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + fake = make_runner([FakeCompleted(returncode=0, stdout=json.dumps({"result": "{}"}))]) + monkeypatch.setattr(C.subprocess, "run", fake) + C.generate_json_via_cli("claude-code", "Base", schema_hint='{"type":"object"}', + structured_json="schema") + argv = fake.calls[0]["argv"] + assert "--json-schema" in argv + # schema goes via the flag, so the prompt slot stays the bare prompt + assert argv[2] == "Base" + + +def test_generate_json_via_cli_two_telemetry_rows_on_retry(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + fake = make_runner([ + FakeCompleted(returncode=0, stdout="bad"), + FakeCompleted(returncode=0, stdout='{"ok": true}'), + ]) + monkeypatch.setattr(C.subprocess, "run", fake) + C.generate_json_via_cli("gemini-cli", "P", task_id=5) + rows = [json.loads(l) for l in + (tmp_path / ".atlas-ai" / "telemetry.jsonl").read_text().splitlines()] + assert len(rows) == 2 + assert rows[0]["exit"] == 1 and rows[0]["parse_retry"] is False + assert rows[1]["exit"] == 0 and rows[1]["parse_retry"] is True + assert all(r["backend"] == "native-cli" for r in rows) +``` + +- [ ] **Step 2: Run it, expect FAIL** + +``` +cd /home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +python3 -m pytest tests/core/test_cli_agent.py -v -k "generate_json_via_cli" +``` +Expected: `AttributeError: module 'prd_taskmaster.cli_agent' has no attribute 'generate_json_via_cli'`. + +- [ ] **Step 3: Minimal implementation** + +Append to `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/cli_agent.py`: + +```python +def _has_schema_flag(provider): + """Only claude exposes a native --json-schema flag today; codex/gemini fold + the schema into the prompt text.""" + return str(provider or "").lower() == "claude-code" + + +def generate_json_via_cli(provider, prompt, *, system="", schema_hint="", model=None, + op_class="structured_gen", task_id=None, timeout=180, + structured_json="auto"): + """Structured-JSON generation by shelling out to a keyless host CLI. + + Mirrors llm_client.generate_json: builds the full prompt (system + schema for + CLIs without a schema flag), spawns once, and on a parse failure respawns ONCE + with the corrective instruction. Raises CliAgentError(kind, message) with + kind in {no_cli, spawn_refused, timeout, invalid_json, nonzero_exit}. One + telemetry row (backend=native-cli) per spawn attempt. + """ + cli = _CLI_FOR_PROVIDER.get(str(provider or "").lower()) + if not cli: + raise CliAgentError("no_cli", f"provider {provider!r} is not a spawning CLI agent") + binary = shutil.which(cli) + if not binary: + raise CliAgentError("no_cli", f"{cli} binary not on PATH") + + # Assemble the prompt the model sees. The CLI takes no separate system slot, + # so prepend system. Fold the schema into the prompt only when the CLI has no + # native schema flag (codex/gemini) or prompt-mode is forced for claude. + base_prompt = prompt + if system: + base_prompt = system + "\n\n" + base_prompt + use_schema_flag = _has_schema_flag(provider) and structured_json != "prompt" + if schema_hint and not use_schema_flag: + base_prompt += "\n\nReturn ONLY valid JSON matching:\n" + schema_hint + + flag_schema = schema_hint if use_schema_flag else "" + + attempt_prompt = base_prompt + parse_retry = False + while True: + result = _run_once( + provider, binary, attempt_prompt, + schema_hint=flag_schema, structured_json=structured_json, + model=model, op_class=op_class, task_id=task_id, timeout=timeout, + parse_retry=parse_retry, + ) + if result is not None: + return result + if parse_retry: + raise CliAgentError("invalid_json", "CLI output failed JSON parsing after one retry") + parse_retry = True + attempt_prompt = base_prompt + _RETRY_INSTRUCTION +``` + +- [ ] **Step 4: Run, expect PASS** + +``` +cd /home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +python3 -m pytest tests/core/test_cli_agent.py -v +``` +Expected: all tests pass (~23 passed). Then confirm no regression in the reused modules: +``` +python3 -m pytest tests/core/test_llm_client.py tests/core/test_economy.py -q +``` +Expected: existing suites still green (the new module only imports `_extract_json` + `append_telemetry`, no behavior change). + +- [ ] **Step 5: Commit** + +``` +cd /home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +git add prd_taskmaster/cli_agent.py tests/core/test_cli_agent.py +git commit -m "feat(cli-agent): generate_json_via_cli public entry with one parse-retry + +Resolves binary via shutil.which (no_cli when absent), folds system+schema into +the prompt for codex/gemini, uses claude --json-schema flag in schema mode, and +mirrors generate_json's single parse-retry (corrective instruction fed back). +Two telemetry rows on a retried call. Closes the keyless structured-JSON gap. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: Full-suite guard + idempotency sweep + +**Files:** +- Test: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_cli_agent.py` (no new code — verification gate only) + +- [ ] **Step 1: Write the failing test** — N/A (gate task; the contract is already covered). Add one belt-and-braces test that the module never touches the network and never spawns when the provider is plan/api-only-shaped: + +Append to `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_cli_agent.py`: + +```python +# ── Task 4: guardrails ─────────────────────────────────────────────────────── + +def test_generate_json_via_cli_api_provider_is_no_cli(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + # an API/plan provider name must never reach a spawn — it is no_cli. + monkeypatch.setattr(C.subprocess, "run", + lambda *a, **k: pytest.fail("must not spawn for api provider")) + for provider in ("anthropic", "openai", "perplexity", ""): + with pytest.raises(C.CliAgentError) as ei: + C.generate_json_via_cli(provider, "P") + assert ei.value.kind == "no_cli" + + +def test_codex_prompt_carried_on_stdin_not_argv(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + fake = make_runner([FakeCompleted(returncode=0, stdout='{"ok": true}')]) + monkeypatch.setattr(C.subprocess, "run", fake) + C.generate_json_via_cli("codex-cli", "SECRET PROMPT", schema_hint='{"x":1}') + argv = fake.calls[0]["argv"] + assert "SECRET PROMPT" not in " ".join(argv) # never on the command line + assert "SECRET PROMPT" in fake.calls[0]["kwargs"]["input"] # on stdin + assert '{"x":1}' in fake.calls[0]["kwargs"]["input"] # schema folded into stdin +``` + +- [ ] **Step 2: Run it, expect FAIL/PASS** — these tests should PASS immediately if Task 1–3 are correct (they assert already-implemented behavior). Run to confirm: + +``` +cd /home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +python3 -m pytest tests/core/test_cli_agent.py -v -k "no_cli or stdin" +``` +Expected: 2 passed. (If `test_codex_prompt_carried_on_stdin_not_argv` fails, the schema-fold or stdin wiring from Task 3 regressed — fix there, not here.) + +- [ ] **Step 3: Minimal implementation** — none required; this task is a guard. If the stdin test fails, the bug is that `codex` schema folding must go through `base_prompt` (already does), so re-verify Task 3's `base_prompt` assembly. + +- [ ] **Step 4: Run, expect PASS** — full module + the reused-module regression set + the native-backend suite (which Chunk 6 will wire into, so it must stay green now): + +``` +cd /home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +python3 -m pytest tests/core/test_cli_agent.py tests/core/test_llm_client.py tests/core/test_economy.py tests/core/test_native_backend.py -q +``` +Expected: all green. The cli_agent module is standalone (only `_extract_json` + `append_telemetry` imports), so the other suites are unaffected. + +- [ ] **Step 5: Commit** + +``` +cd /home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +git add tests/core/test_cli_agent.py +git commit -m "test(cli-agent): guardrails — api providers never spawn; codex prompt on stdin + +Locks two safety invariants: api/plan provider names short-circuit to no_cli +before any subprocess.run, and the codex prompt+schema travel on stdin (never +on the argv command line). Completes Chunk 2 — keyless CLI-agent provider. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +**Chunk 2 done-when:** `generate_json_via_cli` returns parsed JSON for claude (schema + envelope path), codex (stdin + extract), and gemini (prompt + extract); raises `CliAgentError` with the correct `kind` for `no_cli`/`spawn_refused`/`timeout`/`nonzero_exit`/`invalid_json`; does exactly one parse-retry; writes one `backend="native-cli"` telemetry row per spawn; and every test mocks `subprocess.run` (no real CLI, no network). Downstream chunks consume it as `cli_agent.generate_json_via_cli(handle.provider, prompt, system=..., schema_hint=..., model=handle.model, op_class=op_class, task_id=..., timeout=engine_cfg["cli_agent"]["per_call_timeout_s"], structured_json=engine_cfg["cli_agent"]["structured_json"])` — the shared-interface §3 signature. + + +--- + + +## Chunk 3: probe cache + provider_resolver + +This chunk adds the per-process spawn-probe cache to `providers.py` and builds the net-new `provider_resolver.py` module — the single decision point that picks CLI vs API vs plan-floor per role, honoring `provider_mode` and `keyless_default` exactly as the contract specifies. No subprocess or network is ever touched in these tests: every external fact (`discover_key`, `_probe_spawn_cached`, `_read_taskmaster_model`, `_provider_usable` inputs) is monkeypatched. + +**Dependencies:** Chunk 1 must have landed `fleet.engine_config(cfg=None) -> dict` (the merged `engine` block). This chunk imports and calls it. If running this chunk before chunk 1, the resolver tests that pass an explicit `fleet_config` dict will still pass because `engine_config` accepts a pre-loaded cfg; the no-arg path is covered by chunk 1's tests. + +--- + +### Task 1: `providers._probe_spawn_cached(provider, ttl_s)` — per-process TTL cache with failure invalidation + +**Files:** +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/providers.py` (add a module-level `_PROBE_CACHE` dict + `_probe_spawn_cached` after `_probe_spawn`, ends at line 146; insert at line 147) +- Test: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_providers_probe_cache.py` (new) + +The cache must: (a) call `_probe_spawn` at most once per provider within `ttl_s`; (b) re-probe after the TTL elapses; (c) **invalidate immediately on a `False` result** regardless of TTL, so a transient spawn refusal never sticks for 900s. Time is read via `time.monotonic()` so the tests control the clock by monkeypatching `time.monotonic`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/core/test_providers_probe_cache.py +"""Per-process spawn-probe cache: _probe_spawn at most once per provider per TTL, +invalidated on the first False result. No real subprocess is ever spawned — +_probe_spawn itself is monkeypatched and time.monotonic is driven by the test.""" + +import prd_taskmaster.providers as providers + + +def _reset_cache(): + providers._PROBE_CACHE.clear() + + +def test_cached_hit_calls_probe_once_within_ttl(monkeypatch): + _reset_cache() + calls = {"n": 0} + + def fake_probe(provider): + calls["n"] += 1 + return True + + clock = {"t": 1000.0} + monkeypatch.setattr(providers, "_probe_spawn", fake_probe) + monkeypatch.setattr(providers.time, "monotonic", lambda: clock["t"]) + + assert providers._probe_spawn_cached("claude-code", 900) is True + clock["t"] = 1500.0 # 500s later, still inside the 900s TTL + assert providers._probe_spawn_cached("claude-code", 900) is True + assert calls["n"] == 1 # second call served from cache + + +def test_reprobes_after_ttl_expires(monkeypatch): + _reset_cache() + calls = {"n": 0} + + def fake_probe(provider): + calls["n"] += 1 + return True + + clock = {"t": 1000.0} + monkeypatch.setattr(providers, "_probe_spawn", fake_probe) + monkeypatch.setattr(providers.time, "monotonic", lambda: clock["t"]) + + assert providers._probe_spawn_cached("claude-code", 900) is True + clock["t"] = 1000.0 + 901.0 # just past TTL + assert providers._probe_spawn_cached("claude-code", 900) is True + assert calls["n"] == 2 # TTL expired -> re-probed + + +def test_false_result_is_not_cached(monkeypatch): + _reset_cache() + calls = {"n": 0} + results = iter([False, True]) # first probe refuses, second succeeds + + def fake_probe(provider): + calls["n"] += 1 + return next(results) + + clock = {"t": 1000.0} + monkeypatch.setattr(providers, "_probe_spawn", fake_probe) + monkeypatch.setattr(providers.time, "monotonic", lambda: clock["t"]) + + assert providers._probe_spawn_cached("claude-code", 900) is False + # Same instant, well inside TTL — a cached False would skip the probe. + assert providers._probe_spawn_cached("claude-code", 900) is True + assert calls["n"] == 2 # the False was never cached + + +def test_distinct_providers_are_cached_independently(monkeypatch): + _reset_cache() + seen = [] + + def fake_probe(provider): + seen.append(provider) + return True + + monkeypatch.setattr(providers, "_probe_spawn", fake_probe) + monkeypatch.setattr(providers.time, "monotonic", lambda: 1000.0) + + assert providers._probe_spawn_cached("claude-code", 900) is True + assert providers._probe_spawn_cached("codex-cli", 900) is True + assert providers._probe_spawn_cached("claude-code", 900) is True + assert seen == ["claude-code", "codex-cli"] # claude-code second call cached +``` + +- [ ] **Step 2: Run it, expect FAIL** + +``` +python3 -m pytest tests/core/test_providers_probe_cache.py -v +``` + +Expected: collection/attribute errors — `AttributeError: module 'prd_taskmaster.providers' has no attribute '_PROBE_CACHE'` (and `_probe_spawn_cached`). All four tests FAIL/ERROR. + +- [ ] **Step 3: Minimal implementation** + +First add the `time` import. The top of `providers.py` currently imports `argparse, os, shutil, subprocess` (lines 3-6). Add `time` alongside them: + +```python +import argparse +import os +import shutil +import subprocess +import time +from pathlib import Path +``` + +Then insert the cache + accessor immediately after `_probe_spawn` (after line 146, before `_resolve_configure_profile`): + +```python +# Per-process spawn-probe cache. Keyed by provider -> (monotonic_ts, result). +# Dedupes the 60s probe across the in-process ThreadPoolExecutor fan-out (the +# acceptance-criteria case). Cross-process dedup is out of scope (#1). A False +# result is NEVER stored: a transient nested-spawn refusal must not pin the +# provider off the free path for the whole TTL. +_PROBE_CACHE: dict[str, tuple[float, bool]] = {} + + +def _probe_spawn_cached(provider: object, ttl_s: int) -> bool: + """Cached _probe_spawn: at most one real probe per provider per ttl_s. + True results are cached for ttl_s; False results invalidate the entry so + the next call re-probes (empirical demote, not a sticky failure).""" + key = str(provider or "").lower() + now = time.monotonic() + entry = _PROBE_CACHE.get(key) + if entry is not None: + ts, result = entry + if now - ts < ttl_s: + return result + result = _probe_spawn(provider) + if result: + _PROBE_CACHE[key] = (now, result) + else: + _PROBE_CACHE.pop(key, None) + return result +``` + +- [ ] **Step 4: Run, expect PASS** + +``` +python3 -m pytest tests/core/test_providers_probe_cache.py -v +``` + +Expected: `4 passed`. + +- [ ] **Step 5: Commit** + +``` +git add prd_taskmaster/providers.py tests/core/test_providers_probe_cache.py +git commit -m "feat(providers): per-process spawn-probe cache with failure invalidation + +_probe_spawn_cached(provider, ttl_s): module-level dict keyed by provider, +time.monotonic TTL, True cached for ttl_s, False never cached so a transient +nested-spawn refusal re-probes instead of pinning off the free path. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: `provider_resolver.ProviderHandle` + plan-floor resolution (the always-true tier) + +**Files:** +- Create: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/provider_resolver.py` (new) +- Test: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_provider_resolver.py` (new) + +Start with the dataclass and the universal floor: when nothing else is usable, `resolve_provider` returns a `plan` handle. This pins the contract's `ProviderHandle` shape and the "plan floor always" invariant before any tier logic exists. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/core/test_provider_resolver.py +"""resolve_provider precedence. Every external fact is monkeypatched — no +subprocess, no network, no real config file is read. + +Knobs under test (all default-applied by fleet.engine_config): + provider_mode : hybrid | api_only | cli_only | plan_only + keyless_default : True/null -> CLI-first ; False -> key-first ; floor always +""" + +import prd_taskmaster.provider_resolver as pr +from prd_taskmaster.provider_resolver import ProviderHandle + + +def _engine(provider_mode="hybrid", keyless_default=None, ttl_s=900): + """A fleet_config dict whose engine block is what the resolver reads.""" + return { + "engine": { + "provider_mode": provider_mode, + "keyless_default": keyless_default, + "cli_agent": {"probe_cache_ttl_s": ttl_s}, + } + } + + +def _patch(monkeypatch, *, role_provider="claude-code", role_model="sonnet", + usable=True, probe=True, key=None): + """Wire the four facts resolve_provider consults.""" + monkeypatch.setattr( + pr, "_read_taskmaster_model", + lambda role: {"provider": role_provider, "modelId": role_model}, + ) + monkeypatch.setattr(pr, "_provider_usable", lambda *a, **k: usable) + monkeypatch.setattr(pr, "_probe_spawn_cached", lambda provider, ttl_s: probe) + monkeypatch.setattr(pr, "discover_key", lambda: key) + + +def test_handle_is_frozen_dataclass(): + h = ProviderHandle(kind="plan", provider="", role="main", model=None, reason="x") + assert (h.kind, h.provider, h.role, h.model, h.reason) == ("plan", "", "main", None, "x") + try: + h.kind = "cli" + assert False, "ProviderHandle must be frozen" + except Exception: + pass + + +def test_no_cli_no_key_falls_to_plan_floor(monkeypatch): + # claude-code role, but spawn probe refuses AND no API key -> plan floor. + _patch(monkeypatch, usable=True, probe=False, key=None) + h = pr.resolve_provider("main", fleet_config=_engine()) + assert h.kind == "plan" + assert h.provider == "" + assert h.role == "main" + + +def test_plan_only_mode_always_returns_plan(monkeypatch): + # Even with a perfectly usable CLI and a key, plan_only forces the floor. + _patch(monkeypatch, usable=True, probe=True, key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(provider_mode="plan_only")) + assert h.kind == "plan" +``` + +- [ ] **Step 2: Run it, expect FAIL** + +``` +python3 -m pytest tests/core/test_provider_resolver.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'prd_taskmaster.provider_resolver'`. All tests ERROR at import. + +- [ ] **Step 3: Minimal implementation** + +Create the module with the full final resolver (it is small; writing the complete precedence now avoids churn, and Task 3/4 only add tests against it). Note the imports are bound at module scope so tests `monkeypatch.setattr(pr, "", ...)` over them. + +```python +# prd_taskmaster/provider_resolver.py +"""Single decision point: pick the provider TIER for one role at gen time. + +Three kinds, in contract precedence (keyless_default truthy/null): + 1. cli — provider_mode in {hybrid, cli_only} AND role provider is a spawning + CLI AND usable (_provider_usable) AND _probe_spawn_cached() true. + 2. api — provider_mode in {hybrid, api_only} AND discover_key() returns creds. + 3. plan — always the floor; returned when nothing above is usable. +keyless_default=False swaps tiers 1 and 2. provider_mode=plan_only short-circuits +to plan; cli_only never falls through to api; api_only never tries the CLI. + +Nothing here spawns a process or hits the network: it consults + - _read_taskmaster_model(role) -> the role's configured provider + - _provider_usable(...) -> credential/CLI presence + - _probe_spawn_cached(provider,ttl) -> empirical nested-spawn check (cached) + - discover_key() -> raw-key API creds +The cli_agent / llm_client actually run the chosen tier downstream. +""" + +import os +import shutil +from dataclasses import dataclass + +from prd_taskmaster.fleet import engine_config +from prd_taskmaster.lib import _read_taskmaster_model +from prd_taskmaster.llm_client import discover_key +from prd_taskmaster.providers import ( + _SPAWNING_PROVIDERS, + _provider_usable, + _probe_spawn_cached, +) + + +@dataclass(frozen=True) +class ProviderHandle: + kind: str # "cli" | "api" | "plan" + provider: str # e.g. "claude-code", "anthropic", "" + role: str # "main" | "fallback" | "research" + model: str | None + reason: str # human-readable why this tier (telemetry/logs) + + +def _usability_facts() -> dict: + """The keyword args _provider_usable expects. discover_key is consulted + separately for the api tier; here we only need CLI/key presence flags.""" + return { + "has_claude": shutil.which("claude") is not None, + "has_codex": shutil.which("codex") is not None, + "has_anthropic_key": bool(os.environ.get("ANTHROPIC_API_KEY")), + "has_openai_key": bool(os.environ.get("OPENAI_API_KEY")), + "has_perplexity_key": bool(os.environ.get("PERPLEXITY_API_KEY")), + } + + +def _try_cli(role: str, provider: str, model, ttl_s: int) -> ProviderHandle | None: + """CLI tier: spawning provider, usable, and spawn probe passes (cached).""" + if provider not in _SPAWNING_PROVIDERS: + return None + if not _provider_usable(provider, **_usability_facts()): + return None + if not _probe_spawn_cached(provider, ttl_s): + return None + return ProviderHandle( + kind="cli", provider=provider, role=role, model=model, + reason=f"keyless CLI-agent ({provider}) usable + spawn-probe ok", + ) + + +def _try_api(role: str, model) -> ProviderHandle | None: + """Raw-key API tier: discover_key found credentials.""" + creds = discover_key() + if not creds: + return None + return ProviderHandle( + kind="api", provider=creds.get("provider", ""), role=role, model=model, + reason=f"raw-key API ({creds.get('provider', '')}) via discover_key", + ) + + +def _plan_floor(role: str, model, reason: str) -> ProviderHandle: + return ProviderHandle(kind="plan", provider="", role=role, model=model, reason=reason) + + +def resolve_provider(role, op_class="structured_gen", *, fleet_config=None) -> ProviderHandle: + """Resolve the provider tier for one role. See module docstring for precedence.""" + engine = engine_config(fleet_config) + mode = engine.get("provider_mode", "hybrid") + keyless = engine.get("keyless_default") # True | False | None + ttl_s = (engine.get("cli_agent") or {}).get("probe_cache_ttl_s", 900) + + role_cfg = _read_taskmaster_model(role) or {} + provider = str(role_cfg.get("provider", "")).lower() + model = role_cfg.get("modelId") + + if mode == "plan_only": + return _plan_floor(role, model, "provider_mode=plan_only") + + cli_allowed = mode in {"hybrid", "cli_only"} + api_allowed = mode in {"hybrid", "api_only"} + + # keyless_default False -> key-first; True/None -> CLI-first. + cli_first = keyless is not False + + if cli_first: + order = [ + ("cli", lambda: _try_cli(role, provider, model, ttl_s) if cli_allowed else None), + ("api", lambda: _try_api(role, model) if api_allowed else None), + ] + else: + order = [ + ("api", lambda: _try_api(role, model) if api_allowed else None), + ("cli", lambda: _try_cli(role, provider, model, ttl_s) if cli_allowed else None), + ] + + for _name, attempt in order: + handle = attempt() + if handle is not None: + return handle + + return _plan_floor(role, model, f"no usable CLI/API tier (mode={mode})") +``` + +- [ ] **Step 4: Run, expect PASS** + +``` +python3 -m pytest tests/core/test_provider_resolver.py -v +``` + +Expected: `3 passed`. + +- [ ] **Step 5: Commit** + +``` +git add prd_taskmaster/provider_resolver.py tests/core/test_provider_resolver.py +git commit -m "feat(resolver): ProviderHandle + resolve_provider 3-tier precedence + +New prd_taskmaster/provider_resolver.py. frozen ProviderHandle(kind/provider/ +role/model/reason). resolve_provider consults engine_config + _read_taskmaster_ +model + _provider_usable + _probe_spawn_cached + discover_key; plan floor always. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: CLI-first and key-first precedence (`keyless_default` truthy/null vs false) + +**Files:** +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_provider_resolver.py` (append tests; resolver already complete) +- Test: same file + +Locks the contract's core swap: with a spawning CLI usable AND a key present, the tier chosen depends entirely on `keyless_default`. + +- [ ] **Step 1: Write the failing test** (append to `test_provider_resolver.py`) + +```python +def test_cli_first_when_keyless_default_null(monkeypatch): + # Both a usable CLI and a key exist; keyless_default unset (None) -> CLI wins. + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(keyless_default=None)) + assert h.kind == "cli" + assert h.provider == "claude-code" + assert h.model == "sonnet" + + +def test_cli_first_when_keyless_default_true(monkeypatch): + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(keyless_default=True)) + assert h.kind == "cli" + + +def test_key_first_when_keyless_default_false(monkeypatch): + # Same facts, keyless_default False -> API wins despite the usable CLI. + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(keyless_default=False)) + assert h.kind == "api" + assert h.provider == "anthropic" + + +def test_key_first_demotes_to_cli_when_no_key(monkeypatch): + # keyless_default False but no key present -> still falls to the usable CLI, + # not the plan floor (api tier missing -> next tier in order). + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, key=None) + h = pr.resolve_provider("main", fleet_config=_engine(keyless_default=False)) + assert h.kind == "cli" +``` + +- [ ] **Step 2: Run it, expect FAIL... or PASS** + +``` +python3 -m pytest tests/core/test_provider_resolver.py -v -k "keyless or key_first" +``` + +Because the resolver was written complete in Task 2, these four assertions should already pass. If any FAIL, that is a real precedence bug in Task 2's code — fix the `cli_first`/`order` logic before proceeding. Expected after a correct Task 2: `4 passed`. + +- [ ] **Step 3: Minimal implementation** — none required (resolver complete). If Task 2 had a precedence bug, the fix lives in the `cli_first = keyless is not False` line and the `order` lists. + +- [ ] **Step 4: Run, expect PASS** + +``` +python3 -m pytest tests/core/test_provider_resolver.py -v +``` + +Expected: `7 passed`. + +- [ ] **Step 5: Commit** + +``` +git add tests/core/test_provider_resolver.py +git commit -m "test(resolver): keyless_default flips CLI-first <-> key-first tier order + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: mode gating (`api_only`, `cli_only`) + spawn-refused demote + +**Files:** +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_provider_resolver.py` (append) +- Test: same file + +Locks the remaining contract branches: `api_only` never touches the CLI, `cli_only` never falls to API, and a usable-but-spawn-refused CLI demotes correctly to the next allowed tier. + +- [ ] **Step 1: Write the failing test** (append) + +```python +def test_api_only_ignores_usable_cli(monkeypatch): + # CLI is usable, but api_only must use the key and never the CLI. + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(provider_mode="api_only")) + assert h.kind == "api" + + +def test_api_only_with_no_key_falls_to_plan(monkeypatch): + # api_only + no key -> CLI tier is not allowed, so plan floor (not cli). + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, key=None) + h = pr.resolve_provider("main", fleet_config=_engine(provider_mode="api_only")) + assert h.kind == "plan" + + +def test_cli_only_ignores_key(monkeypatch): + # cli_only with a usable CLI uses it even though a key exists. + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(provider_mode="cli_only")) + assert h.kind == "cli" + + +def test_cli_only_with_refused_spawn_falls_to_plan(monkeypatch): + # cli_only + spawn refused -> api tier not allowed, so plan floor (not api), + # even with a key present. + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=False, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(provider_mode="cli_only")) + assert h.kind == "plan" + + +def test_spawn_refused_demotes_to_api_in_hybrid(monkeypatch): + # hybrid, CLI usable per config but _probe_spawn_cached refuses -> demote to + # the key API tier (the nested-claude gh#11 case). This is the core demote. + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=False, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine()) # hybrid, keyless null + assert h.kind == "api" + assert h.provider == "anthropic" + assert "spawn" not in h.reason or h.kind == "api" # reason reflects api tier + + +def test_non_spawning_role_provider_skips_cli_tier(monkeypatch): + # Role provider is a raw-key provider (anthropic), not a spawning CLI. The + # CLI tier is skipped on provider identity; with a key it resolves to api. + _patch(monkeypatch, role_provider="anthropic", role_model="claude-sonnet-4-20250514", + usable=True, probe=True, key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine()) + assert h.kind == "api" + + +def test_unusable_cli_demotes_to_api(monkeypatch): + # Spawning provider in config, probe would pass, but _provider_usable is + # False (e.g. binary absent) -> demote to api. + _patch(monkeypatch, role_provider="codex-cli", usable=False, probe=True, + key={"provider": "anthropic"}) + h = pr.resolve_provider("fallback", fleet_config=_engine()) + assert h.kind == "api" + assert h.role == "fallback" +``` + +- [ ] **Step 2: Run it, expect PASS** (resolver complete; these exercise existing branches) + +``` +python3 -m pytest tests/core/test_provider_resolver.py -v -k "only or refused or non_spawning or unusable" +``` + +Expected: all pass. If `test_spawn_refused_demotes_to_api_in_hybrid` or `test_api_only_with_no_key_falls_to_plan` FAIL, the demote/mode-gating logic in Task 2 is wrong — the bug is in the per-tier `if cli_allowed`/`if api_allowed` guards inside `order`, not the tests. + +- [ ] **Step 3: Minimal implementation** — none expected. If a mode-gating test fails, confirm `_try_cli`/`_try_api` are only invoked when `cli_allowed`/`api_allowed` (the `lambda: ... if else None` wrappers in `order`). + +- [ ] **Step 4: Run the full module, expect PASS** + +``` +python3 -m pytest tests/core/test_provider_resolver.py -v +``` + +Expected: `14 passed`. + +- [ ] **Step 5: Commit** + +``` +git add tests/core/test_provider_resolver.py +git commit -m "test(resolver): mode gating (api_only/cli_only) + spawn-refused demote + +Covers: api_only ignores usable CLI; api_only+no-key -> plan; cli_only ignores +key; cli_only+refused -> plan; hybrid spawn-refused demotes to api (gh#11); +non-spawning role provider skips CLI tier; unusable CLI demotes to api. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 5: Full-suite regression gate (no real spawn / network leaked) + +**Files:** +- Test: both new test files + the existing core suite + +Confirms the probe cache and resolver did not regress sibling modules and — critically — that no test in this chunk spawns a real `claude`/`codex` child or opens a socket. + +- [ ] **Step 1: Write the failing test** — none new. This is a verification gate, not a TDD step. + +- [ ] **Step 2: Run the two new files + a sanity grep** + +``` +python3 -m pytest tests/core/test_providers_probe_cache.py tests/core/test_provider_resolver.py -v +``` + +Expected: `18 passed` (4 + 14). + +- [ ] **Step 3: Run the full core suite** + +``` +python3 -m pytest tests/core/ -q +``` + +Expected: all prior tests still pass plus the 18 new ones; no errors importing `provider_resolver` (catches a broken chunk-1 `engine_config` contract early). + +- [ ] **Step 4: Prove no real subprocess/network was used** + +``` +python3 -m pytest tests/core/test_providers_probe_cache.py tests/core/test_provider_resolver.py -v -p no:cacheprovider 2>&1 | grep -iE "real|spawn|connect|timeout" || echo "NO REAL EXTERNAL CALLS" +``` + +Expected: `NO REAL EXTERNAL CALLS` (every external fact was monkeypatched; `_probe_spawn` itself is patched in the cache tests, and the resolver never calls subprocess directly). + +- [ ] **Step 5: Commit (only if any incidental fix was needed; otherwise skip)** + +``` +git add -A +git commit -m "test(chunk3): green full core suite with probe cache + resolver + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +**Chunk 3 done.** Net new: `_PROBE_CACHE` + `_probe_spawn_cached` in `providers.py`; `prd_taskmaster/provider_resolver.py` (`ProviderHandle` + `resolve_provider`). Consumes chunk 1's `fleet.engine_config`. Produces the `ProviderHandle` that chunk's NativeBackend wiring (contract §6) branches on. Total ~330 lines incl. tests. + + +--- + + +## Chunk 4: wire resolver into NativeBackend + +> **PREREQUISITE: Chunks 1, 2 and 3 MUST be merged before any step in this chunk runs.** Step 3 of Task 4.1 adds the imports at `backend.py:15` (`from prd_taskmaster import cli_agent …` and `from prd_taskmaster.provider_resolver import resolve_provider`), so the module will not import until those modules exist. The red/green TDD steps below are **NOT** standalone-runnable without chunks 1/2/3 on the branch — the "expect FAIL" step fails on a missing attribute, not on an ImportError, only because those imports are already present. + +**Dependencies (built by sibling chunks):** +- **Chunk 1:** `fleet.engine_config(config)` (the contract accessor the new `_cli_timeout` / `_cli_structured_mode` helpers call) **and** the defaulted `engine.cli_agent` block it returns (`["cli_agent"]["per_call_timeout_s"]`, `["cli_agent"]["structured_json"]`). These helpers are dead without Chunk 1 supplying `fleet.engine_config` + the defaulted `engine.cli_agent` config. +- **Chunk 2:** `cli_agent.generate_json_via_cli(provider, prompt, *, system, schema_hint, model, op_class, task_id, timeout, structured_json) -> dict` and `cli_agent.CliAgentError(kind, message)` — the CLI generation path every `cli`-kind branch dispatches to. +- **Chunk 3:** `from prd_taskmaster.provider_resolver import resolve_provider, ProviderHandle` — `resolve_provider(role)` returns a frozen `ProviderHandle` (`.kind/.provider/.role/.model/.reason`); `kind` is one of `api`/`cli`/`plan`. + +**Goal:** Replace the three `if not llm_client.discover_key(): return {…agent_action_required…}` gates in `NativeBackend.parse_prd` / `expand` / `rate` (`backend.py:344-665`) with a single `resolve_provider(role)` dispatch. Three outcomes per handle: + +- `handle.kind == "api"` → the existing `llm_client.generate_json` path, unchanged. +- `handle.kind == "cli"` → `cli_agent.generate_json_via_cli(handle.provider, …)` (chunk 1 module). +- `handle.kind == "plan"` → the existing `_agent_*_action(...)` floor packet, never deleted. + +The `ThreadPoolExecutor` fan-out (`backend.py:444`) **stays exactly as it is**; the CLI path is only a new branch *inside the per-packet worker* `_expand_packet`. The resolved handle is computed once in `expand()` and threaded into each worker so all N `claude -p` children run concurrently like N HTTP calls. + +**Dependencies (already built by sibling chunks — assume importable):** +- Chunk 3: `from prd_taskmaster.provider_resolver import resolve_provider, ProviderHandle` (frozen dataclass with `.kind/.provider/.role/.model/.reason`). +- Chunk 1: `from prd_taskmaster import cli_agent` exposing `generate_json_via_cli(provider, prompt, *, system, schema_hint, model, op_class, task_id, timeout, structured_json) -> dict` and `cli_agent.CliAgentError(kind, message)`. + +> **Conventions:** tests in `tests/core/test_native_backend.py`; import `from prd_taskmaster import …`; run `python3 -m pytest tests/core/test_native_backend.py -v`; never spawn a real `claude`/`codex` — monkeypatch `resolve_provider` and `cli_agent.generate_json_via_cli`. Every existing test in that file must still pass (they monkeypatch `discover_key` → truthy, which must keep routing to the `api` branch). + +--- + +### Task 4.1: Thread a resolved handle into parse_prd + +Make `parse_prd` resolve the provider for role `"main"` and dispatch. `api` keeps the current behaviour; `cli` calls `cli_agent`; `plan` returns the existing parse floor. + +**Files:** +- Modify: `prd_taskmaster/backend.py` — imports (L15), `parse_prd` (L344-419) +- Test: `tests/core/test_native_backend.py` + +Steps: + +- [ ] **Step 1: Write the failing tests** — append to `tests/core/test_native_backend.py`: + +```python +def _stub_handle(kind, provider="", role="main", model=None, reason="test"): + from prd_taskmaster.provider_resolver import ProviderHandle + return ProviderHandle(kind=kind, provider=provider, role=role, model=model, reason=reason) + + +def test_parse_prd_cli_kind_drives_cli_agent(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend, TASKS_SCHEMA_HINT + + monkeypatch.chdir(tmp_path) + prd = tmp_path / "prd.md" + prd.write_text("# PRD\n\nREQ-001: Build native backend.") + # Even if a key existed, cli kind must win — prove the resolver, not discover_key, decides. + monkeypatch.setattr(llm_client, "discover_key", lambda: {"provider": "anthropic", "key": "k"}) + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("cli", "claude-code", role) + ) + + cli_calls = [] + + def fake_cli(provider, prompt, **kwargs): + cli_calls.append({"provider": provider, "prompt": prompt, **kwargs}) + return _valid_tasks(2) + + monkeypatch.setattr(backend_mod.cli_agent, "generate_json_via_cli", fake_cli) + # If the api path were taken this would blow up the test. + monkeypatch.setattr( + llm_client, "generate_json", lambda *a, **k: pytest.fail("api path taken on cli kind") + ) + + result = NativeBackend().parse_prd(prd, 2, tag="native-tag") + + assert result["ok"] is True + assert result["task_count"] == 2 + assert result["backend"] == "native" + assert result["ai"] == "cli" + assert len(cli_calls) == 1 + assert cli_calls[0]["provider"] == "claude-code" + assert cli_calls[0]["model"] is None # plan-kind handle has model=None; contract must thread it through + assert cli_calls[0]["schema_hint"] == TASKS_SCHEMA_HINT + assert cli_calls[0]["op_class"] == "structured_gen" + written = json.loads((tmp_path / ".taskmaster" / "tasks" / "tasks.json").read_text()) + assert [task["id"] for task in written["native-tag"]["tasks"]] == [1, 2] + + +def test_parse_prd_plan_kind_returns_agent_action_required(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend, TASKS_SCHEMA_HINT + + monkeypatch.chdir(tmp_path) + prd = tmp_path / "prd.md" + prd.write_text("# PRD\n") + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("plan", role=role) + ) + monkeypatch.setattr( + backend_mod.cli_agent, + "generate_json_via_cli", + lambda *a, **k: pytest.fail("cli path taken on plan kind"), + ) + + result = NativeBackend().parse_prd(prd, 1, tag="master") + + assert result["ok"] is False + assert result["agent_action_required"]["op"] == "parse_prd" + assert result["agent_action_required"]["schema_hint"] == TASKS_SCHEMA_HINT + + +def test_parse_prd_cli_agent_error_falls_back_to_plan(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend + + monkeypatch.chdir(tmp_path) + prd = tmp_path / "prd.md" + prd.write_text("# PRD\n") + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("cli", "claude-code", role) + ) + + def boom(provider, prompt, **kwargs): + raise backend_mod.cli_agent.CliAgentError("spawn_refused", "nested claude refused") + + monkeypatch.setattr(backend_mod.cli_agent, "generate_json_via_cli", boom) + + result = NativeBackend().parse_prd(prd, 1, tag="master") + + assert result["ok"] is False + # CLI failure must demote to the plan floor, not hard-error. + assert result["agent_action_required"]["op"] == "parse_prd" +``` + +- [ ] **Step 2: Run it, expect FAIL** — + `python3 -m pytest tests/core/test_native_backend.py -v -k "parse_prd_cli_kind or parse_prd_plan_kind or parse_prd_cli_agent_error"` + Expected: collection error or failures — `AttributeError: module 'prd_taskmaster.backend' has no attribute 'resolve_provider'` (and `cli_agent`). The existing dispatch still keys off `discover_key`, so the new branches don't exist yet. + +- [ ] **Step 3: Minimal implementation** — wire the imports and refactor `parse_prd`. + + In `backend.py` extend the import block (L15) so `resolve_provider` and `cli_agent` are module attributes (monkeypatch-able): + +```python +from prd_taskmaster import cli_agent, fleet, llm_client, parallel, taskmaster, tm_parallel +from prd_taskmaster.provider_resolver import resolve_provider +``` + + Replace the head of `parse_prd` (current L344-350, the `if not llm_client.discover_key(): return {…}` block) with a resolve-and-dispatch that reads the PRD first, then routes. Replace **L344-419** with: + +```python + def parse_prd(self, prd_path, num_tasks, tag=None) -> dict: + handle = resolve_provider("main") + if handle.kind == "plan": + return { + "ok": False, + "agent_action_required": _agent_parse_action(prd_path, num_tasks, tag), + } + + path = Path(prd_path) + try: + prd_text = path.read_text() + except OSError as exc: + return {"ok": False, "error": f"failed to read PRD: {exc}", "prd_path": str(path)} + + config = fleet.load_fleet_config() + profile = economy_profile(config) + tier = profile.get("structured_gen_start", "standard") + prompt = ( + f"Parse this PRD into exactly {num_tasks} TaskMaster-compatible tasks.\n" + f"Target tag: {tag or parallel.current_tag(None)}.\n" + "Return only the tasks JSON object.\n\n" + f"PRD PATH: {path}\n" + f"PRD:\n{prd_text}" + ) + system = ( + "You are the prd-taskmaster native backend. Generate strict JSON for " + "the Native Mode tasks.json path." + ) + + telemetry_ref = None + if handle.kind == "cli": + try: + candidate = cli_agent.generate_json_via_cli( + handle.provider, + prompt, + system=system, + schema_hint=TASKS_SCHEMA_HINT, + model=handle.model, + op_class="structured_gen", + timeout=_cli_timeout(config), + structured_json=_cli_structured_mode(config), + ) + except cli_agent.CliAgentError: + return { + "ok": False, + "agent_action_required": _agent_parse_action(prd_path, num_tasks, tag), + } + ai_label = "cli" + else: + try: + generated = llm_client.generate_json( + prompt, + system=system, + schema_hint=TASKS_SCHEMA_HINT, + tier=tier, + op_class="structured_gen", + return_telemetry_ref=True, + ) + except llm_client.LLMError as exc: + if exc.kind == "no_key": + return { + "ok": False, + "agent_action_required": _agent_parse_action(prd_path, num_tasks, tag), + } + return {"ok": False, "error": str(exc), "kind": exc.kind, "backend": "native"} + if isinstance(generated, tuple) and len(generated) == 2: + candidate, telemetry_ref = generated + else: + candidate = generated + ai_label = "api" + + try: + tasks, validation = _validate_task_candidate(candidate) + except CommandError as exc: + result = {"ok": False, "error": exc.message, "backend": "native"} + result.update(exc.extra) + return result + except Exception as exc: + return {"ok": False, "error": str(exc), "backend": "native"} + + try: + resolved = _write_tasks_into_tag(tasks, tag) + except Exception as exc: + return {"ok": False, "error": f"failed to write tasks: {exc}", "backend": "native"} + + result = { + "ok": True, + "task_count": len(tasks), + "tag": resolved, + "backend": "native", + "ai": ai_label, + "validation": validation, + } + if telemetry_ref is not None: + result["telemetry_ref"] = telemetry_ref + return result +``` + + Add two small config helpers near the top of the module body (just after `_report_candidates`, ~L301), reading the shared `engine` block via the contract accessor `fleet.engine_config`: + +```python +def _cli_timeout(config: dict | None = None) -> int: + return int(fleet.engine_config(config)["cli_agent"]["per_call_timeout_s"]) + + +def _cli_structured_mode(config: dict | None = None) -> str: + return str(fleet.engine_config(config)["cli_agent"]["structured_json"]) +``` + +- [ ] **Step 4: Run, expect PASS** — + `python3 -m pytest tests/core/test_native_backend.py -v -k "parse_prd"` + Expected: all `parse_prd*` pass, **including** the pre-existing `test_parse_prd_validates_and_writes_tagged_tasks`, `test_parse_prd_success_echoes_telemetry_reference`, and `test_parse_prd_invalid_candidate_returns_error_without_overwrite` — those monkeypatch `discover_key` truthy, and the real `resolve_provider` must return an `api` handle for them (chunk 3 returns `api` when `discover_key()` is truthy and no usable CLI). If chunk 3 isn't merged yet, those three will route through `resolve_provider`; ensure chunk 3 is on the branch first. + +- [ ] **Step 5: Commit** — + `git add prd_taskmaster/backend.py tests/core/test_native_backend.py` + `git commit -m "feat(backend): route parse_prd through resolve_provider (api/cli/plan)"` (append the Co-Authored-By trailer) + +--- + +### Task 4.2: Branch _expand_packet on the resolved handle (preserve fan-out) + +`expand()` resolves the handle **once** for role `"main"`, decides plan-vs-generate, then fans every packet over the unchanged `ThreadPoolExecutor`. The per-packet worker `_expand_packet` gains a `handle` arg and routes to `cli_agent` when `handle.kind == "cli"`. Parallelism is preserved because each worker independently shells out. + +**Files:** +- Modify: `prd_taskmaster/backend.py` — `expand` (L421-479), `_expand_packet` (L481-559) +- Test: `tests/core/test_native_backend.py` + +Steps: + +- [ ] **Step 1: Write the failing tests** — append: + +```python +def test_expand_cli_kind_drives_cli_agent_and_produces_graph(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend + + monkeypatch.chdir(tmp_path) + tasks_path = _seed_project(tmp_path, [_pending_task()]) + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("cli", "claude-code", role) + ) + monkeypatch.setattr( + llm_client, "generate_json", lambda *a, **k: pytest.fail("api path taken on cli kind") + ) + + cli_calls = [] + + def fake_cli(provider, prompt, **kwargs): + cli_calls.append({"provider": provider, **kwargs}) + return { + "id": 1, + "complexityScore": 8, + "recommendedSubtasks": 2, + "reasoning": "Needs careful backend integration.", + "researchNotes": "Reuse parallel.apply_results for the merge.", + "subtasks": [ + {"title": "Write expansion test", "description": "Cover merge.", + "details": "Assert once.", "dependencies": []}, + {"title": "Implement expansion", "description": "Apply packet.", + "details": "CLI path.", "dependencies": [1]}, + ], + } + + monkeypatch.setattr(backend_mod.cli_agent, "generate_json_via_cli", fake_cli) + + result = NativeBackend().expand(tag="master") + + assert result["ok"] is True + assert result["applied"] == [1] + assert result["failed"] == [] + assert result["ai"] == "cli" + assert len(cli_calls) == 1 + assert cli_calls[0]["provider"] == "claude-code" + merged = json.loads(tasks_path.read_text()) + titles = [s["title"] for s in merged["master"]["tasks"][0]["subtasks"]] + assert titles == ["Write expansion test", "Implement expansion"] + + +def test_expand_plan_kind_returns_agent_action_required(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend + + monkeypatch.chdir(tmp_path) + _seed_project(tmp_path, [_pending_task()]) + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("plan", role=role) + ) + monkeypatch.setattr( + backend_mod.cli_agent, + "generate_json_via_cli", + lambda *a, **k: pytest.fail("cli path taken on plan kind"), + ) + + result = NativeBackend().expand(tag="master") + + assert result["ok"] is False + assert result["agent_action_required"]["op"] == "expand" + assert result["agent_action_required"]["packets"] + + +def test_expand_cli_kind_fans_out_in_parallel(tmp_path, monkeypatch): + """Three packets must be in flight concurrently on the cli path.""" + import threading + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend + + monkeypatch.chdir(tmp_path) + _seed_project(tmp_path, [_pending_task(1), _pending_task(2), _pending_task(3)]) + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("cli", "claude-code", role) + ) + # Force >=3 workers regardless of profile defaults. + monkeypatch.setattr(backend_mod, "_native_concurrency", lambda n, c, p: max(n, 3)) + + barrier = threading.Barrier(3, timeout=5) + + def fake_cli(provider, prompt, **kwargs): + # If fan-out were serial, the 2nd/3rd never arrive and this times out. + barrier.wait() + tid = kwargs["task_id"] + return { + "id": tid, + "complexityScore": 5, + "recommendedSubtasks": 2, + "reasoning": "parallel proof", + "researchNotes": "n/a", + "subtasks": [ + {"title": "a", "description": "x", "details": "y", "dependencies": []}, + {"title": "b", "description": "x", "details": "y", "dependencies": [1]}, + ], + } + + monkeypatch.setattr(backend_mod.cli_agent, "generate_json_via_cli", fake_cli) + + result = NativeBackend().expand(tag="master") + + assert result["ok"] is True + assert sorted(result["applied"]) == [1, 2, 3] + assert result["ai"] == "cli" +``` + +- [ ] **Step 2: Run it, expect FAIL** — + `python3 -m pytest tests/core/test_native_backend.py -v -k "expand_cli or expand_plan"` + Expected: `AttributeError: module 'prd_taskmaster.backend' has no attribute 'resolve_provider'`, and the parallel test would hang/timeout on the barrier under the unmodified serial-via-api code (it never calls `cli_agent`). All three fail. + +- [ ] **Step 3: Minimal implementation** — replace the `discover_key` gate in `expand` and add the `handle` plumb-through + cli branch in `_expand_packet`. + + In `expand` (L421-479), replace the gate block (current L430-435) and the future-submission line (L446) and the trailing `ai` (L478). The full replacement for **L421-479**: + +```python + def expand(self, task_ids=None, research=True, tag=None) -> dict: + try: + resolved, pending = _pending_tasks(tag, task_ids) + packets = parallel.build_packets(pending, missing_only=True) + except SystemExit as exc: + return {"ok": False, "error": f"failed to load tasks: {exc}", "backend": "native"} + except Exception as exc: + return {"ok": False, "error": str(exc), "backend": "native"} + + handle = resolve_provider("main") + if handle.kind == "plan": + return { + "ok": False, + "tag": resolved, + "agent_action_required": _agent_expand_action(resolved, task_ids, packets), + } + if not packets: + return {"ok": True, "tag": resolved, "applied": [], "failed": [], "results": []} + + config = fleet.load_fleet_config() + profile = economy_profile(config) + workers = _native_concurrency(len(packets), config, profile) + outcomes = [] + + with ThreadPoolExecutor(max_workers=workers) as executor: + futures = [ + executor.submit(self._expand_packet, packet, profile, research, handle, config) + for packet in packets + ] + for future in as_completed(futures): + outcomes.append(future.result()) + + outcomes.sort(key=lambda item: str(item.get("task_id"))) + results = [item["result"] for item in outcomes if item.get("ok")] + failed = [item["task_id"] for item in outcomes if not item.get("ok")] + + if results: + try: + applied = parallel.apply_results(results, tag=resolved) + except SystemExit as exc: + return {"ok": False, "error": f"failed to apply results: {exc}", "backend": "native"} + except Exception as exc: + return {"ok": False, "error": str(exc), "backend": "native"} + else: + applied = { + "ok": False, + "tag": resolved, + "applied": [], + "report": None, + "needs_more_subtasks": [], + } + + return { + **applied, + "ok": bool(applied.get("ok")) and not failed, + "failed": failed, + "results": outcomes, + "backend": "native", + "ai": handle.kind, + } +``` + + Then update `_expand_packet` to accept the `handle` and `config`, and add a `cli` short-circuit at the top of the generate logic. The CLI path does **not** run the api tier-escalation ladder (escalation = swap to a more capable API tier, which is meaningless for a single CLI); on `cli` it does one call and emits its own telemetry inside `generate_json_via_cli` (chunk 1 contract item 3). Replace the signature and the first generate block of **L481-510**: + +```python + def _expand_packet( + self, packet: dict, profile: dict, research: bool, handle: Any, config: dict | None = None + ) -> dict: + task_id = packet.get("id") + start_tier = profile.get("structured_gen_start", "standard") + prompt = packet.get("prompt", "") + if not research: + prompt += "\n\nDo not perform external research; decompose structurally from the task text." + system = ( + "You are the prd-taskmaster native backend expansion engine. Return " + "one strict JSON result object for parallel.apply_results." + ) + + if handle.kind == "cli": + try: + result = cli_agent.generate_json_via_cli( + handle.provider, + prompt, + system=system, + schema_hint=PARALLEL_RESULT_SCHEMA_HINT, + model=handle.model, + op_class="structured_gen", + task_id=task_id, + timeout=_cli_timeout(config), + structured_json=_cli_structured_mode(config), + ) + except cli_agent.CliAgentError as exc: + return { + "ok": False, + "task_id": task_id, + "error": str(exc), + "kind": exc.kind, + "escalated": False, + } + return self._packet_success(packet, result, escalated=False) + + try: + result = llm_client.generate_json( + prompt, + system=system, + schema_hint=PARALLEL_RESULT_SCHEMA_HINT, + tier=start_tier, + op_class="structured_gen", + task_id=task_id, + ) + return self._packet_success(packet, result, escalated=False) + except llm_client.LLMError as exc: + if exc.kind != "invalid_json": + return { + "ok": False, + "task_id": task_id, + "error": str(exc), + "kind": exc.kind, + "escalated": False, + } +``` + + Everything below that (the escalation block L512-559) is unchanged — it is only reached on the `api` path now, since the `cli` branch returns first. + +- [ ] **Step 4: Run, expect PASS** — + `python3 -m pytest tests/core/test_native_backend.py -v -k "expand"` + Expected: new `expand_cli_*` / `expand_plan_*` pass AND the pre-existing `test_expand_builds_packets_escalates_invalid_json_and_merges_once` passes (it monkeypatches `discover_key` truthy → real `resolve_provider` returns `api` → escalation ladder still exercised, `tier` calls still `["standard", "capable"]`). + +- [ ] **Step 5: Commit** — + `git add prd_taskmaster/backend.py tests/core/test_native_backend.py` + `git commit -m "feat(backend): cli-kind expansion worker inside the ThreadPoolExecutor fan-out"` (Co-Authored-By trailer) + +--- + +### Task 4.3: Route rate through resolve_provider + +`rate` is single-shot like `parse_prd`. Resolve role `"main"`, dispatch api/cli/plan. + +**Files:** +- Modify: `prd_taskmaster/backend.py` — `rate` (L580-665) +- Test: `tests/core/test_native_backend.py` + +Steps: + +- [ ] **Step 1: Write the failing tests** — append: + +```python +def _complexity_payload(): + return { + "complexityAnalysis": [ + { + "taskId": 1, + "taskTitle": "Task 1", + "complexityScore": 5, + "recommendedSubtasks": 3, + "expansionPrompt": "Expand Task 1", + "reasoning": "Moderate implementation work.", + } + ] + } + + +def test_rate_cli_kind_drives_cli_agent(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend + + monkeypatch.chdir(tmp_path) + _seed_project(tmp_path, [_pending_task()]) + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("cli", "claude-code", role) + ) + monkeypatch.setattr( + llm_client, "generate_json", lambda *a, **k: pytest.fail("api path taken on cli kind") + ) + + cli_calls = [] + + def fake_cli(provider, prompt, **kwargs): + cli_calls.append({"provider": provider, **kwargs}) + return _complexity_payload() + + monkeypatch.setattr(backend_mod.cli_agent, "generate_json_via_cli", fake_cli) + + result = NativeBackend().rate(tag="master") + + assert result["ok"] is True + assert result["ai"] == "cli" + assert result["complexityAnalysis"][0]["taskId"] == 1 + assert len(cli_calls) == 1 + assert cli_calls[0]["provider"] == "claude-code" + report = tmp_path / ".taskmaster" / "reports" / "task-complexity-report.json" + assert report.is_file() + + +def test_rate_plan_kind_returns_agent_action_required(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend + + monkeypatch.chdir(tmp_path) + _seed_project(tmp_path, [_pending_task()]) + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("plan", role=role) + ) + monkeypatch.setattr( + backend_mod.cli_agent, + "generate_json_via_cli", + lambda *a, **k: pytest.fail("cli path taken on plan kind"), + ) + + result = NativeBackend().rate(tag="master") + + assert result["ok"] is False + assert result["agent_action_required"]["op"] == "rate" + assert "scoring_rubric" in result["agent_action_required"] +``` + +- [ ] **Step 2: Run it, expect FAIL** — + `python3 -m pytest tests/core/test_native_backend.py -v -k "rate_cli or rate_plan"` + Expected: `AttributeError: module 'prd_taskmaster.backend' has no attribute 'resolve_provider'` — the `rate` gate still keys off `discover_key`; both fail. + +- [ ] **Step 3: Minimal implementation** — replace the gate in `rate`. Replace **L580-625** (from the `def rate` head through the `except llm_client.LLMError` block): + +```python + def rate(self, tag=None, research=True) -> dict: + try: + resolved, tasks = _load_tasks(tag) + except SystemExit as exc: + return {"ok": False, "error": f"failed to load tasks: {exc}", "backend": "native"} + except Exception as exc: + return {"ok": False, "error": str(exc), "backend": "native"} + + summaries = _task_summaries(tasks) + handle = resolve_provider("main") + if handle.kind == "plan": + return { + "ok": False, + "tag": resolved, + "agent_action_required": _agent_rate_action(resolved, summaries), + } + + config = fleet.load_fleet_config() + profile = economy_profile(config) + tier = profile.get("structured_gen_start", "standard") + prompt = ( + "Score these TaskMaster tasks and return a TaskMaster-compatible " + "complexity report.\n" + f"Research enabled: {bool(research)}\n" + f"Scoring rubric: {COMPLEXITY_SCORING_RUBRIC}\n\n" + f"TASK SUMMARIES:\n{json.dumps(summaries, indent=2, default=str)}" + ) + system = ( + "You are the prd-taskmaster native backend complexity engine. Return " + "strict JSON in TaskMaster complexity report format." + ) + if handle.kind == "cli": + try: + candidate = cli_agent.generate_json_via_cli( + handle.provider, + prompt, + system=system, + schema_hint=COMPLEXITY_REPORT_SCHEMA_HINT, + model=handle.model, + op_class="structured_gen", + timeout=_cli_timeout(config), + structured_json=_cli_structured_mode(config), + ) + except cli_agent.CliAgentError: + return { + "ok": False, + "tag": resolved, + "agent_action_required": _agent_rate_action(resolved, summaries), + } + ai_label = "cli" + else: + try: + candidate = llm_client.generate_json( + prompt, + system=system, + schema_hint=COMPLEXITY_REPORT_SCHEMA_HINT, + tier=tier, + op_class="structured_gen", + ) + except llm_client.LLMError as exc: + if exc.kind == "no_key": + return { + "ok": False, + "tag": resolved, + "agent_action_required": _agent_rate_action(resolved, summaries), + } + return {"ok": False, "error": str(exc), "kind": exc.kind, "backend": "native"} + ai_label = "api" +``` + + Then update the success return at the **end of `rate`** (current L657-665) to emit the resolved label — change `"ai": "api"` to `"ai": ai_label`: + +```python + return { + "ok": True, + "tag": resolved, + "report": str(report_path), + "complexityAnalysis": analysis, + "raw": report, + "backend": "native", + "ai": ai_label, + } +``` + + (The `analysis` extraction, `report` build, and `write_atomic` block at L627-656 are unchanged.) + +- [ ] **Step 4: Run, expect PASS** — + `python3 -m pytest tests/core/test_native_backend.py -v -k "rate"` + Expected: new `rate_cli_*` / `rate_plan_*` pass AND pre-existing `test_rate_writes_taskmaster_report_from_batched_generation` passes (discover_key truthy → api handle → unchanged behaviour, `ai == "api"`). + +- [ ] **Step 5: Commit** — + `git add prd_taskmaster/backend.py tests/core/test_native_backend.py` + `git commit -m "feat(backend): route rate through resolve_provider (api/cli/plan)"` (Co-Authored-By trailer) + +--- + +### Task 4.4: Reconcile the legacy no-key test + full backend regression + +The pre-existing `test_no_key_operations_return_agent_action_required` (test_native_backend.py:298-323) monkeypatches `discover_key → None` and asserts all three ops return the plan floor. With chunk 3 merged, `resolve_provider` returns a `plan` handle when no key AND no usable CLI — so the test passes unchanged in a CI box with no `claude` on PATH. To make it deterministic regardless of the host (a dev box may have `claude` installed → resolver would pick `cli`), pin the resolver to `plan` explicitly. + +**Files:** +- Modify: `tests/core/test_native_backend.py` — `test_no_key_operations_return_agent_action_required` (L298-323) +- Test: same file + full suite + +Steps: + +- [ ] **Step 1: Make the legacy test host-independent** — add a resolver pin at the top of `test_no_key_operations_return_agent_action_required`, right after the `discover_key` monkeypatch (L305): + +```python + from prd_taskmaster import backend as backend_mod + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("plan", role=role) + ) +``` + + This guarantees the plan floor is exercised whether or not a CLI is on PATH, matching the test's intent (no-key → plan). + +- [ ] **Step 2: Run the whole native-backend file, expect PASS** — + `python3 -m pytest tests/core/test_native_backend.py -v` + Expected: every test green — the 8 original tests plus the 8 new dispatch tests (parse cli/plan/error, expand cli/plan/parallel, rate cli/plan). + +- [ ] **Step 3: Run the adjacent backend regression, expect PASS** — + `python3 -m pytest tests/core/test_backend.py tests/core/test_native_backend.py -v` + Expected: no regressions in the `TaskMasterBackend`/`get_backend` seam — Chunk 4 only touched `NativeBackend` methods and added two module-level helpers + two imports. + +- [ ] **Step 4: Full suite sanity, expect PASS** — + `python3 -m pytest tests/ -q` + Expected: `passed` with no new failures attributable to backend wiring. (If `provider_resolver` or `cli_agent` aren't yet merged from chunks 1/3, this surfaces an ImportError at `backend.py` import time — that is the integration gate; rebase chunks 1+3 first.) + +- [ ] **Step 5: Commit** — + `git add tests/core/test_native_backend.py` + `git commit -m "test(backend): pin resolver to plan in legacy no-key test for host independence"` (Co-Authored-By trailer) + +--- + +**Chunk 4 invariants verified by these tests:** +- (a) `cli` kind drives `cli_agent.generate_json_via_cli` and produces a valid graph — `test_parse_prd_cli_kind_drives_cli_agent`, `test_expand_cli_kind_drives_cli_agent_and_produces_graph`, `test_rate_cli_kind_drives_cli_agent`. +- (b) `plan` kind still returns `agent_action_required` — the three `*_plan_kind_*` tests + the pinned legacy test. +- (c) parallel fan-out still occurs for `cli` kind — `test_expand_cli_kind_fans_out_in_parallel` (a 3-way `threading.Barrier` deadlocks unless all three CLI workers run concurrently inside the unchanged `ThreadPoolExecutor`). +- All existing backend tests preserved: untouched assertions still hold because a truthy `discover_key` resolves to an `api` handle (chunk 3), keeping the api branch byte-identical to today. + + +--- + + +## Chunk 5: setup wizard + +Builds `prd_taskmaster/setup_wizard.py` (`run_setup`, `cmd_setup`), wires an `atlas setup` +CLI verb with `--yes` / `--validate`, and refactors `mode_recommend.validate_setup` so its +task-master binary/version checks (checks 1–2) go advisory when `provider_mode != "plan_only"` +(contract item 8). Depends on chunk 1's `fleet.engine_config()` accessor and a +`fleet.save_engine_config()` persister; depends on chunk 4's `resolve_provider`/`ProviderHandle` +ONLY transitively — the wizard never imports the resolver, it reads/writes config + runs a +live probe, so this chunk is independently testable with the other chunks stubbed. + +> **Contract dependency:** this chunk calls `fleet.engine_config(cfg=None) -> dict` (returns the +> merged engine block with all defaults) and `fleet.save_engine_config(updates: dict) -> dict` +> (deep-merges `updates` into `fleet.json["engine"]`, writes atomically, returns the new merged +> block). Both are delivered by Chunk 1. If Chunk 1 is not yet merged when you start, add the two +> shims below to `fleet.py` first (they match Chunk 1's contract exactly and Chunk 1 will replace +> them — coordinate on the merge): +> +> ```python +> # fleet.py — Chunk-1 contract shims (remove once Chunk 1 lands the real versions) +> _ENGINE_DEFAULTS = { +> "provider_mode": "hybrid", +> "keyless_default": None, +> "cli_agent": {"structured_json": "auto", "probe_cache_ttl_s": 900, +> "per_call_timeout_s": 180, "max_inflight": None}, +> "concurrency": {"structured_gen": None, "ram_aware": False}, +> } +> +> def engine_config(cfg=None) -> dict: +> import copy +> merged = copy.deepcopy(_ENGINE_DEFAULTS) +> raw = (cfg or load_fleet_config()).get("engine") if isinstance(cfg, dict) else None +> if raw is None and FLEET_CONFIG_PATH.is_file(): +> try: +> raw = json.loads(FLEET_CONFIG_PATH.read_text()).get("engine") +> except (json.JSONDecodeError, OSError): +> raw = None +> if isinstance(raw, dict): +> for k, v in raw.items(): +> if isinstance(v, dict) and isinstance(merged.get(k), dict): +> merged[k].update(v) +> elif k in merged: +> merged[k] = v +> return merged +> +> def save_engine_config(updates: dict) -> dict: +> path = FLEET_CONFIG_PATH +> path.parent.mkdir(parents=True, exist_ok=True) +> try: +> doc = json.loads(path.read_text()) if path.is_file() else {} +> except (json.JSONDecodeError, OSError): +> doc = {} +> if not isinstance(doc, dict): +> doc = {} +> engine = doc.get("engine") if isinstance(doc.get("engine"), dict) else {} +> for k, v in updates.items(): +> if isinstance(v, dict) and isinstance(engine.get(k), dict): +> engine[k].update(v) +> else: +> engine[k] = v +> doc["engine"] = engine +> tmp = path.with_suffix(".json.tmp") +> tmp.write_text(json.dumps(doc, indent=2)) +> tmp.replace(path) +> return engine_config(doc) +> ``` + +--- + +### Task 1: Refactor `validate_setup` — task-master checks go advisory in non-plan_only mode + +The keyless engine must not fail its own validator on the `task-master` binary it is removing. +Checks 1 (`binary`) and 2 (`version`) become `severity: "advisory"` (excluded from +`critical_failures`) when `provider_mode != "plan_only"`. In `plan_only` mode they keep current +behavior. `validate_setup` gains a `provider_mode` parameter (default read from +`fleet.engine_config()`) so tests inject it without a config file. + +**Files:** +- Modify: `prd_taskmaster/mode_recommend.py` (signature at line 367; check 1 at lines 404–413; check 2 at lines 417–428; aggregation at lines 563–582; add import) +- Test: `tests/core/test_mode_recommend_validate.py` (Create) + +- [ ] **Step 1: Write the failing test** + + Create `tests/core/test_mode_recommend_validate.py`: + ```python + """validate_setup: task-master binary/version checks are advisory in hybrid mode.""" + import json + + import pytest + + from prd_taskmaster import mode_recommend + + + def _no_taskmaster(monkeypatch): + """No task-master binary on PATH, no claude/codex either.""" + monkeypatch.setattr(mode_recommend.shutil, "which", lambda name: None) + + def _seed_config(tmp_path, main_provider="claude-code"): + tm = tmp_path / ".taskmaster" + tm.mkdir(parents=True, exist_ok=True) + (tm / "config.json").write_text(json.dumps({ + "models": { + "main": {"provider": main_provider, "modelId": "sonnet"}, + "research": {"provider": "perplexity", "modelId": "sonar"}, + "fallback": {"provider": "codex-cli", "modelId": "gpt-5.2-codex"}, + } + })) + + + def test_hybrid_mode_does_not_hard_fail_on_missing_taskmaster(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _seed_config(tmp_path) + _no_taskmaster(monkeypatch) + # claude usable so provider_main passes; only the task-master checks would fail. + monkeypatch.setattr(mode_recommend.shutil, "which", + lambda name: "/usr/bin/claude" if name == "claude" else None) + + result = mode_recommend.validate_setup(provider_mode="hybrid") + + binary = next(c for c in result["checks"] if c["id"] == "binary") + version = next(c for c in result["checks"] if c["id"] == "version") + assert binary["severity"] == "advisory" + assert version["severity"] == "advisory" + # binary/version are NOT in critical_failures even though they "failed" + assert not binary["passed"] + assert result["critical_failures"] == 0 + assert result["ready"] is True + + + def test_plan_only_mode_still_hard_fails_on_missing_taskmaster(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _seed_config(tmp_path) + _no_taskmaster(monkeypatch) + + result = mode_recommend.validate_setup(provider_mode="plan_only") + + binary = next(c for c in result["checks"] if c["id"] == "binary") + assert binary.get("severity") != "advisory" + assert not binary["passed"] + assert result["critical_failures"] >= 1 + assert result["ready"] is False + + + def test_default_provider_mode_reads_engine_config_hybrid(tmp_path, monkeypatch): + """No explicit provider_mode → engine_config() default 'hybrid' → advisory.""" + monkeypatch.chdir(tmp_path) + _seed_config(tmp_path) + monkeypatch.setattr(mode_recommend.shutil, "which", + lambda name: "/usr/bin/claude" if name == "claude" else None) + + result = mode_recommend.validate_setup() # no arg → engine_config default + + binary = next(c for c in result["checks"] if c["id"] == "binary") + assert binary["severity"] == "advisory" + assert result["ready"] is True + ``` + +- [ ] **Step 2: Run it, expect FAIL** + ``` + python3 -m pytest tests/core/test_mode_recommend_validate.py -v + ``` + Expected: `TypeError: validate_setup() got an unexpected keyword argument 'provider_mode'` + for all three tests (the parameter does not exist yet). + +- [ ] **Step 3: Minimal implementation** + + In `prd_taskmaster/mode_recommend.py`, add the engine_config import after the existing + `from prd_taskmaster.providers import (...)` block (around line 25): + ```python + from prd_taskmaster.fleet import engine_config + ``` + + Change the signature (line 367) from: + ```python + def validate_setup() -> dict: + ``` + to: + ```python + def validate_setup(provider_mode: str | None = None) -> dict: + ``` + + Immediately inside the function (just before `checks = []` at line 384) add: + ```python + if provider_mode is None: + provider_mode = engine_config().get("provider_mode", "hybrid") + # When the engine is NOT plan_only it no longer depends on the task-master + # binary (sub-project #1 removes it), so its presence/version is advisory, + # not a critical gate. plan_only keeps the binary as a hard requirement. + taskmaster_advisory = provider_mode != "plan_only" + ``` + + Replace check 1 (the `binary` check, lines 404–413) with: + ```python + checks.append({ + "id": "binary", + "name": "task-master CLI installed", + "passed": bool(cli_path), + "detail": ( + f"Found at {cli_path} (version {cli_version})" if cli_path + else ( + "Not found in PATH (advisory: engine no longer requires it)" + if taskmaster_advisory else "Not found in PATH" + ) + ), + "fix": ( + None if cli_path or taskmaster_advisory + else "npm install -g task-master-ai" + ), + **({"severity": "advisory"} if taskmaster_advisory else {}), + }) + ``` + + Replace check 2 (the `version` check, lines 417–428) with: + ```python + version_info = _check_taskmaster_version(cli_path) + checks.append({ + "id": "version", + "name": f"task-master version >= {TASKMASTER_MIN_VERSION}", + "passed": version_info["supported"], + "detail": ( + f"detected {version_info['detected_version']} (min {TASKMASTER_MIN_VERSION})" + if version_info.get("detected_version") + else "version not detectable" + ), + "fix": ( + None if version_info["supported"] or taskmaster_advisory + else "npm install -g task-master-ai@latest" + ), + "severity": "advisory" if taskmaster_advisory else "warning", + }) + ``` + + Update the aggregation (lines 563–567). The `critical_failures` filter already excludes + `severity == "warning"`; extend it to exclude `"advisory"` too: + ```python + # Aggregate — neither "warning" nor "advisory" failures are "critical" + _non_critical = {"warning", "advisory"} + critical_failures = [ + c for c in checks + if not c["passed"] and c.get("severity") not in _non_critical + ] + all_passed = len(critical_failures) == 0 + ``` + +- [ ] **Step 4: Run, expect PASS** + ``` + python3 -m pytest tests/core/test_mode_recommend_validate.py -v + ``` + Expected: `3 passed`. Then guard the existing suite: + ``` + python3 -m pytest tests/core/ -q -k "validate or capabilit or prerelaunch" + ``` + Expected: all pass (the `provider_mode=None` default + `engine_config()` "hybrid" is the new + behavior; if a legacy test asserted `ready is False` on missing task-master it must pass + `provider_mode="plan_only"` — update it in this commit). + +- [ ] **Step 5: Commit** + ``` + git add prd_taskmaster/mode_recommend.py tests/core/test_mode_recommend_validate.py + git commit -m "$(cat <<'EOF' + refactor(validate): task-master binary/version checks advisory off plan_only + + Contract item 8: the keyless hybrid engine no longer depends on the task-master + binary, so its presence/version is advisory (excluded from critical_failures) + when provider_mode != plan_only. plan_only keeps the hard gate. + + Co-Authored-By: Claude Fable 5 + EOF + )" + ``` + +--- + +### Task 2: `setup_wizard.run_setup` — Detect&Recommend + the recommendation panel + +`run_setup(accept_default=False, validate_only=False)` first builds a recommendation by reusing +`run_detect_providers()` + `detect_capabilities()`, renders a human panel into `result["panel"]` +(list of lines), and returns it. This task delivers ONLY the detect/recommend slice and the +panel; accept/customise/add-key/validate land in Tasks 3–4. `run_setup` is non-interactive in +this task (no `input()`), driven by flags — interactivity is layered behind a guarded `_prompt` +in Task 3. + +**Files:** +- Create: `prd_taskmaster/setup_wizard.py` +- Test: `tests/core/test_setup_wizard.py` (Create) + +- [ ] **Step 1: Write the failing test** + + Create `tests/core/test_setup_wizard.py`: + ```python + """Setup wizard: detect+recommend panel, accept, add-key, validate.""" + import json + + import pytest + + from prd_taskmaster import setup_wizard + + + def _stub_detectors(monkeypatch, *, claude=True, codex=True, gemini=False, + anthropic_key=False, perplexity_proxy=True): + """Stub run_detect_providers + detect_capabilities so the panel is deterministic.""" + providers = { + "main": {"provider": "claude-code" if claude else "anthropic", + "status": "detected", "source": "claude CLI"}, + "fallback": {"provider": "codex-cli" if codex else "claude-code", + "status": "detected", "source": "codex CLI"}, + "research": {"provider": "perplexity-api-free" if perplexity_proxy else "claude-code", + "status": "detected", "source": "proxy"}, + } + monkeypatch.setattr(setup_wizard, "run_detect_providers", + lambda: {"ok": True, "providers": providers}) + caps = { + "ok": True, "tier": "free", + "recommended_mode": "C", "recommended_reason": "Plan + Ralph Loop", + "capabilities": {"codex-cli": codex, "gemini-cli": gemini}, + "has_external_ai_tools": codex or gemini, + } + monkeypatch.setattr(setup_wizard, "detect_capabilities", lambda: caps) + # PATH-based presence flags used by the env-detection line. + def fake_which(name): + return { + "claude": "/usr/bin/claude" if claude else None, + "codex": "/usr/bin/codex" if codex else None, + "gemini": "/usr/bin/gemini" if gemini else None, + }.get(name) + monkeypatch.setattr(setup_wizard.shutil, "which", fake_which) + if anthropic_key: + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + else: + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + + def test_recommend_panel_lists_each_role_with_reason(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch) + monkeypatch.setattr(setup_wizard, "_validate", lambda mode: {"ok": True, "ready": True, "checks": []}) + + result = setup_wizard.run_setup(accept_default=True) + + panel = "\n".join(result["panel"]) + assert "Atlas detected" in panel + assert "claude ✓" in panel + assert "codex ✓" in panel + assert "gemini ✗" in panel + assert "main" in panel and "claude-code" in panel + assert "fallback" in panel and "codex-cli" in panel + assert "research" in panel + assert result["recommendation"]["main"]["provider"] == "claude-code" + assert result["ok"] is True + ``` + +- [ ] **Step 2: Run it, expect FAIL** + ``` + python3 -m pytest tests/core/test_setup_wizard.py -v + ``` + Expected: `ModuleNotFoundError: No module named 'prd_taskmaster.setup_wizard'`. + +- [ ] **Step 3: Minimal implementation** + + Create `prd_taskmaster/setup_wizard.py`: + ```python + """Setup wizard — `atlas setup`. + + Beats `task-master models --setup`: zero-config recommendation by default, an + optional guided layer that explains every auto-decision, and a live one-token + probe per chosen provider BEFORE the pipeline runs (the differentiator). + + Steps: Detect&Recommend → Accept → Customise → Add-key (writes + engine.keyless_default after asking) → Validate. + + Pure-core `run_setup()` returns a dict (never exits); `cmd_setup` is the CLI + wrapper. Interactivity is fully guarded behind `accept_default` / `validate_only` + so the function is non-interactive under --yes and in tests. + """ + from __future__ import annotations + + import os + import shutil + import subprocess + + from prd_taskmaster import fleet + from prd_taskmaster.lib import _ensure_env_entry # used in Task 4 + from pathlib import Path # used in Task 4 + from prd_taskmaster.mode_recommend import detect_capabilities, validate_setup + from prd_taskmaster.providers import run_configure_providers, run_detect_providers + + # one-token live-probe commands per provider kind (Task 5) + _PROBE_CMD = { + "claude-code": ["claude", "-p", "ok"], + "codex-cli": ["codex", "--version"], + "gemini-cli": ["gemini", "--version"], + } + _PROBE_TIMEOUT = 60 + + + def _env_flag(name: str) -> bool: + return bool(os.environ.get(name)) + + + def _detect_line() -> str: + """`Atlas detected: claude ✓ codex ✓ gemini ✗ ANTHROPIC_API_KEY ✗ ...`""" + def mark(ok: bool) -> str: + return "✓" if ok else "✗" + claude = shutil.which("claude") is not None + codex = shutil.which("codex") is not None + gemini = shutil.which("gemini") is not None + akey = _env_flag("ANTHROPIC_API_KEY") + pkey = _env_flag("PERPLEXITY_API_KEY") or _env_flag("PERPLEXITY_API_BASE_URL") + return ( + f"Atlas detected: claude {mark(claude)} codex {mark(codex)} " + f"gemini {mark(gemini)} ANTHROPIC_API_KEY {mark(akey)} " + f"PERPLEXITY {mark(pkey)}" + ) + + + _ROLE_REASON = { + "claude-code": "free via your Claude session, no API key", + "codex-cli": "separate quota pool, runs in parallel", + "gemini-cli": "separate quota pool", + "anthropic": "paid Anthropic API key", + "perplexity-api-free": "local proxy on :8765", + "perplexity": "Perplexity API key", + } + + + def _recommend() -> dict: + """Reuse the zero-config detectors and shape a per-role recommendation.""" + detected = run_detect_providers().get("providers", {}) + caps = detect_capabilities() + recommendation = {} + for role in ("main", "fallback", "research"): + entry = detected.get(role, {}) + provider = entry.get("provider", "") + recommendation[role] = { + "provider": provider, + "modelId": entry.get("modelId"), + "source": entry.get("source", "-"), + "reason": _ROLE_REASON.get(provider, entry.get("source", "")), + } + return {"recommendation": recommendation, "capabilities": caps} + + + def _panel(recommendation: dict, caps: dict) -> list[str]: + lines = [_detect_line(), ""] + lines.append("Recommended (zero-config, keyless):") + for role in ("main", "fallback", "research"): + rec = recommendation[role] + model = rec.get("modelId") or "" + label = f"{rec['provider']}/{model}".rstrip("/") + lines.append(f" {role:<9} {label:<28} ← {rec['reason']}") + lines.append( + f"Tier: {caps.get('tier', 'free')} — {caps.get('recommended_reason', '')}" + ) + lines.append("[Enter] accept [c] customise [k] add an API key [v] validate only") + return lines + + + def _validate(mode: str | None) -> dict: + """Indirection so tests can stub the heavy validate path. Calls the + refactored validate_setup with the resolved provider_mode.""" + return validate_setup(provider_mode=mode) + + + def run_setup(accept_default: bool = False, validate_only: bool = False) -> dict: + """Drive the wizard. Returns a dict; never exits, never raises on the + happy path. Non-interactive when accept_default or validate_only is set.""" + rec = _recommend() + recommendation = rec["recommendation"] + caps = rec["capabilities"] + panel = _panel(recommendation, caps) + + result = { + "ok": True, + "panel": panel, + "recommendation": recommendation, + "tier": caps.get("tier", "free"), + } + # Accept / customise / add-key / validate are layered in Tasks 3–4. + return result + ``` + +- [ ] **Step 4: Run, expect PASS** + ``` + python3 -m pytest tests/core/test_setup_wizard.py -v + ``` + Expected: `1 passed`. + +- [ ] **Step 5: Commit** + ``` + git add prd_taskmaster/setup_wizard.py tests/core/test_setup_wizard.py + git commit -m "$(cat <<'EOF' + feat(setup): wizard detect+recommend panel (step 1) + + run_setup() reuses run_detect_providers + detect_capabilities to render a + per-role recommendation panel with reasons. Non-interactive scaffold; accept/ + add-key/validate land next. + + Co-Authored-By: Claude Fable 5 + EOF + )" + ``` + +--- + +### Task 3: Accept + Validate steps (+ live one-token probe) and `--yes` non-interactivity + +`run_setup` now: on `accept_default` runs `run_configure_providers()` (the repair-on-detect +Accept step) and then the Validate step; `validate_only` runs ONLY validate. Validate = +`validate_setup()` + a **live one-token probe** per chosen spawning provider. `--yes` proves +non-interactive: no `input()` is ever called when `accept_default=True`. + +**Files:** +- Modify: `prd_taskmaster/setup_wizard.py` (extend `run_setup`; add `_live_probe`, `_run_validate_step`) +- Test: `tests/core/test_setup_wizard.py` (append) + +- [ ] **Step 1: Write the failing test** + + Append to `tests/core/test_setup_wizard.py`: + ```python + def test_yes_is_non_interactive_and_configures(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch) + called = {"configure": 0, "input": 0} + monkeypatch.setattr(setup_wizard, "run_configure_providers", + lambda *a, **k: called.__setitem__("configure", called["configure"] + 1) or + {"ok": True, "changed": ["main"], "models": {}}) + monkeypatch.setattr(setup_wizard, "_validate", + lambda mode: {"ok": True, "ready": True, "checks": []}) + monkeypatch.setattr(setup_wizard, "_live_probe", lambda provider: {"provider": provider, "ok": True}) + # any input() call must blow the test up + def boom(*a, **k): + called["input"] += 1 + raise AssertionError("input() called under --yes") + monkeypatch.setattr("builtins.input", boom) + + result = setup_wizard.run_setup(accept_default=True) + + assert called["configure"] == 1 + assert called["input"] == 0 + assert result["accepted"] is True + assert result["validation"]["ready"] is True + + + def test_validate_surfaces_forced_auth_failure(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch) + # validate_setup passes, but the LIVE probe of the chosen provider fails (401/ENOENT). + monkeypatch.setattr(setup_wizard, "_validate", + lambda mode: {"ok": True, "ready": True, "checks": []}) + + def fake_run(cmd, **kw): + class R: + returncode = 1 + stdout = "" + stderr = "Error: 401 invalid x-api-key" + return R() + monkeypatch.setattr(setup_wizard.subprocess, "run", fake_run) + + result = setup_wizard.run_setup(validate_only=True) + + assert result["validation"]["ready"] is False # live probe demotes readiness + probes = result["validation"]["live_probes"] + assert any(p["ok"] is False and "401" in (p.get("error") or "") for p in probes) + + + def test_validate_only_does_not_configure(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch) + monkeypatch.setattr(setup_wizard, "run_configure_providers", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("configure under --validate"))) + monkeypatch.setattr(setup_wizard, "_validate", lambda mode: {"ok": True, "ready": True, "checks": []}) + monkeypatch.setattr(setup_wizard, "_live_probe", lambda provider: {"provider": provider, "ok": True}) + + result = setup_wizard.run_setup(validate_only=True) + assert result.get("accepted") is not True + assert result["validation"]["ready"] is True + ``` + +- [ ] **Step 2: Run it, expect FAIL** + ``` + python3 -m pytest tests/core/test_setup_wizard.py -v -k "non_interactive or forced_auth or validate_only" + ``` + Expected: `KeyError: 'accepted'` / `KeyError: 'validation'` — `run_setup` does not yet add + those keys or run the steps. + +- [ ] **Step 3: Minimal implementation** + + In `prd_taskmaster/setup_wizard.py`, add these helpers above `run_setup`: + ```python + def _live_probe(provider: str) -> dict: + """One-token liveness probe for a chosen provider. Surfaces a real + 401/ENOENT BEFORE the pipeline. Spawning CLIs only; API/proxy providers + are validated by validate_setup's credential checks, so they pass here.""" + cmd = _PROBE_CMD.get(provider) + if not cmd: + return {"provider": provider, "ok": True, "skipped": "no live probe for this provider"} + binary = shutil.which(cmd[0]) + if not binary: + return {"provider": provider, "ok": False, "error": f"{cmd[0]} not found in PATH"} + probe = [binary, *cmd[1:]] + try: + proc = subprocess.run(probe, capture_output=True, text=True, timeout=_PROBE_TIMEOUT) + except (subprocess.TimeoutExpired, OSError) as exc: + return {"provider": provider, "ok": False, "error": f"probe failed: {exc}"} + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "").strip().splitlines() + return {"provider": provider, "ok": False, "error": err[-1] if err else f"exit {proc.returncode}"} + return {"provider": provider, "ok": True} + + + def _run_validate_step(recommendation: dict, mode: str | None) -> dict: + """validate_setup (credential-aware checks) PLUS a live one-token probe per + chosen provider. A failed live probe demotes `ready` to False — that is the + 'surfaces a real 401 before the pipeline' differentiator.""" + base = _validate(mode) + probed = set() + live_probes = [] + for role in ("main", "fallback", "research"): + provider = recommendation.get(role, {}).get("provider", "") + if provider in _PROBE_CMD and provider not in probed: + probed.add(provider) + live_probes.append(_live_probe(provider)) + live_ok = all(p["ok"] for p in live_probes) + ready = bool(base.get("ready")) and live_ok + return {**base, "ready": ready, "live_probes": live_probes} + ``` + + Replace the tail of `run_setup` (everything after `result = {...}` … `return result`) with: + ```python + mode = fleet.engine_config().get("provider_mode", "hybrid") + + if validate_only: + result["validation"] = _run_validate_step(recommendation, mode) + return result + + if accept_default: + configured = run_configure_providers() + result["accepted"] = True + result["configured"] = configured + result["validation"] = _run_validate_step(recommendation, mode) + return result + + # Interactive branch is layered in Task 4 (Customise / Add-key prompts). + result["validation"] = _run_validate_step(recommendation, mode) + return result + ``` + +- [ ] **Step 4: Run, expect PASS** + ``` + python3 -m pytest tests/core/test_setup_wizard.py -v + ``` + Expected: `4 passed`. + +- [ ] **Step 5: Commit** + ``` + git add prd_taskmaster/setup_wizard.py tests/core/test_setup_wizard.py + git commit -m "$(cat <<'EOF' + feat(setup): Accept + Validate steps with live one-token probe + + --yes runs configure-providers + validate non-interactively (no input()). + --validate runs validate_setup PLUS a live claude -p / codex --version probe + per chosen provider, demoting `ready` on a real 401/ENOENT before the pipeline. + + Co-Authored-By: Claude Fable 5 + EOF + )" + ``` + +--- + +### Task 4: Add-key step writes `engine.keyless_default` (decision #2 question) + +The interactive Add-key step prompts for a key, writes it to `.env` via `_ensure_env_entry`, +then — **only when both a key was added AND a spawning CLI exists** — asks the decision-#2 +question ("free-but-slower keyless, or paid-but-faster key, as primary?") and persists +`engine.keyless_default` via `fleet.save_engine_config`. Driven by injected callbacks so it is +fully testable without real stdin. + +**Files:** +- Modify: `prd_taskmaster/setup_wizard.py` (add `add_key`) +- Test: `tests/core/test_setup_wizard.py` (append) + +- [ ] **Step 1: Write the failing test** + + Append to `tests/core/test_setup_wizard.py`: + ```python + def test_add_key_writes_env_and_keyless_flag_when_cli_present(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch, claude=True) # a spawning CLI exists + # user supplies a key, then answers "paid" (key as primary) -> keyless_default False + result = setup_wizard.add_key( + var="ANTHROPIC_API_KEY", + ask_value=lambda: "sk-newkey", + ask_keyless=lambda: False, + ) + env_text = (tmp_path / ".env").read_text() + assert 'ANTHROPIC_API_KEY="sk-newkey"' in env_text + engine = setup_wizard.fleet.engine_config() + assert engine["keyless_default"] is False + assert result["keyless_default"] is False + assert result["asked_keyless"] is True + + + def test_add_key_keyless_true_when_user_chooses_keyless(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch, claude=True) + setup_wizard.add_key( + var="ANTHROPIC_API_KEY", + ask_value=lambda: "sk-newkey", + ask_keyless=lambda: True, + ) + assert setup_wizard.fleet.engine_config()["keyless_default"] is True + + + def test_add_key_does_not_ask_keyless_without_cli(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch, claude=False, codex=False, gemini=False) + def must_not_ask(): + raise AssertionError("asked keyless question with no CLI present") + result = setup_wizard.add_key( + var="ANTHROPIC_API_KEY", + ask_value=lambda: "sk-newkey", + ask_keyless=must_not_ask, + ) + assert result["asked_keyless"] is False + # flag stays null (unset) — no global default imposed + assert setup_wizard.fleet.engine_config()["keyless_default"] is None + + + def test_add_key_blank_value_is_noop(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch, claude=True) + result = setup_wizard.add_key( + var="ANTHROPIC_API_KEY", + ask_value=lambda: " ", + ask_keyless=lambda: True, + ) + assert result["ok"] is False + assert not (tmp_path / ".env").exists() or 'ANTHROPIC_API_KEY' not in (tmp_path / ".env").read_text() + assert result["asked_keyless"] is False + ``` + +- [ ] **Step 2: Run it, expect FAIL** + ``` + python3 -m pytest tests/core/test_setup_wizard.py -v -k add_key + ``` + Expected: `AttributeError: module 'prd_taskmaster.setup_wizard' has no attribute 'add_key'`. + +- [ ] **Step 3: Minimal implementation** + + Add to `prd_taskmaster/setup_wizard.py`: + ```python + def _has_spawning_cli() -> bool: + return any(shutil.which(b) for b in ("claude", "codex", "gemini")) + + + def add_key(var: str, ask_value, ask_keyless) -> dict: + """Add-key step (decision #2). + + `ask_value()` returns the raw key string (e.g. an input() call). + `ask_keyless()` returns True if the user wants the FREE keyless CLI as + primary, False if the PAID key. Both are injected so the step is testable. + + Writes the key to .env (via _ensure_env_entry — non-secret local append), + then — only when a key was added AND a spawning CLI exists — asks the + keyless/paid question and persists engine.keyless_default. With no CLI the + question is meaningless (only one path exists) so the flag stays unset + (null) — no global default imposed (decision #2).""" + value = (ask_value() or "").strip() + if not value: + return {"ok": False, "reason": "no key entered", "asked_keyless": False, + "keyless_default": fleet.engine_config().get("keyless_default")} + + changed = _ensure_env_entry(Path(".env"), var, value) + + asked = False + keyless_default = fleet.engine_config().get("keyless_default") + if _has_spawning_cli(): + asked = True + # True → keyless CLI primary → keyless_default True + # False → paid key primary → keyless_default False + keyless_default = bool(ask_keyless()) + fleet.save_engine_config({"keyless_default": keyless_default}) + + return { + "ok": True, + "env_changed": changed, + "var": var, + "asked_keyless": asked, + "keyless_default": keyless_default, + } + ``` + + Wire `add_key` into the interactive branch of `run_setup` (replace the + `# Interactive branch is layered in Task 4` block). Interactivity reads a single choice via + an injectable `choose` callback (default `input`), so it remains test-safe: + ```python + # Interactive: present the panel, read a one-char choice. The default + # (Enter / 'a') accepts. 'k' adds a key + asks the decision-#2 question. + choice = (choose() or "").strip().lower() + if choice in ("", "a", "accept"): + configured = run_configure_providers() + result["accepted"] = True + result["configured"] = configured + elif choice in ("k", "key"): + result["add_key"] = add_key( + var="ANTHROPIC_API_KEY", + ask_value=lambda: choose("Paste API key: "), + ask_keyless=lambda: (choose( + "Primary provider? [k]eyless (free) / [p]aid key: ").strip().lower() + not in ("p", "paid")), + ) + configured = run_configure_providers() + result["accepted"] = True + result["configured"] = configured + elif choice in ("c", "customise", "customize"): + # Customise = repair-on-detect for now (task-master-style picker is a + # later enhancement); run_configure_providers never clobbers user choices. + result["configured"] = run_configure_providers() + result["accepted"] = True + result["validation"] = _run_validate_step(recommendation, mode) + return result + ``` + + Update the `run_setup` signature to accept the injectable prompt: + ```python + def run_setup(accept_default: bool = False, validate_only: bool = False, choose=None) -> dict: + ``` + and at the top of the function: + ```python + if choose is None: + choose = input + ``` + +- [ ] **Step 4: Run, expect PASS** + ``` + python3 -m pytest tests/core/test_setup_wizard.py -v + ``` + Expected: `8 passed`. + +- [ ] **Step 5: Commit** + ``` + git add prd_taskmaster/setup_wizard.py tests/core/test_setup_wizard.py + git commit -m "$(cat <<'EOF' + feat(setup): Add-key step asks decision-#2 question, writes keyless_default + + When a key is added AND a spawning CLI exists, the wizard asks once + (keyless-free vs paid-key as primary) and persists engine.keyless_default via + save_engine_config. No CLI → no question, flag stays null (no global default). + + Co-Authored-By: Claude Fable 5 + EOF + )" + ``` + +--- + +### Task 5: `cmd_setup` + the `atlas setup` CLI subcommand (`--yes`, `--validate`) + +Add `cmd_setup` to `setup_wizard.py` and wire the `setup` subparser + DISPATCH entry in +`cli.py`, matching the file's conventions (`sub.add_parser`, `DISPATCH[...]`, `emit`-style +output). The command prints the panel lines to stderr-free stdout-readable form and emits the +result JSON; exit code reflects validation readiness so CI can gate on it. + +**Files:** +- Modify: `prd_taskmaster/setup_wizard.py` (add `cmd_setup`) +- Modify: `prd_taskmaster/cli.py` (import line 10-area; subparser after line 184; DISPATCH after line 361) +- Test: `tests/core/test_setup_wizard.py` (append a CLI-level test using the `run_cli` shim) + +- [ ] **Step 1: Write the failing test** + + Append to `tests/core/test_setup_wizard.py`: + ```python + import os + import subprocess as _sp + import sys + from pathlib import Path as _Path + + REPO_ROOT = _Path(__file__).resolve().parents[2] + SCRIPT = REPO_ROOT / "script.py" + + + def _clean_env(tmp_path): + env = os.environ.copy() + for k in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "PERPLEXITY_API_KEY"): + env.pop(k, None) + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + env["PATH"] = str(bin_dir) + env["HOME"] = str(tmp_path / "home") + return env + + + def test_cli_setup_validate_runs_and_emits_json(tmp_path): + """`script.py setup --validate` exits cleanly and emits a validation block. + No claude/codex on PATH → live probes are skipped/absent, validate runs.""" + env = _clean_env(tmp_path) + proc = _sp.run( + [sys.executable, str(SCRIPT), "setup", "--validate"], + capture_output=True, text=True, cwd=str(tmp_path), env=env, + ) + # exit code mirrors readiness; with no project it is not ready -> exit 1. + assert proc.returncode in (0, 1), proc.stderr + payload = json.loads(proc.stdout) + assert "validation" in payload + assert "panel" in payload + assert isinstance(payload["panel"], list) + + + def test_cli_setup_subcommand_registered(): + """`setup` is a real subcommand (argparse help lists it).""" + proc = _sp.run( + [sys.executable, str(SCRIPT), "--help"], + capture_output=True, text=True, cwd=str(REPO_ROOT), + ) + assert "setup" in proc.stdout + ``` + +- [ ] **Step 2: Run it, expect FAIL** + ``` + python3 -m pytest tests/core/test_setup_wizard.py -v -k "cli_setup" + ``` + Expected: `setup` is not a registered subcommand → argparse exits 2 with + `invalid choice: 'setup'`; `json.loads` raises / assertion fails. + +- [ ] **Step 3: Minimal implementation** + + Add `cmd_setup` to the end of `prd_taskmaster/setup_wizard.py`: + ```python + import json + import sys + + + def cmd_setup(args) -> None: + """CLI wrapper for `atlas setup`. Emits the result JSON; exit code mirrors + validation readiness (0 = ready, 1 = not ready) so CI / dispatch can gate.""" + result = run_setup( + accept_default=bool(getattr(args, "yes", False)), + validate_only=bool(getattr(args, "validate", False)), + ) + print(json.dumps(result, indent=2, default=str)) + validation = result.get("validation") or {} + ready = validation.get("ready", True) + sys.exit(0 if result.get("ok") and ready else 1) + ``` + + In `prd_taskmaster/cli.py`, add the import (after line 11, + `from prd_taskmaster.providers import ...`): + ```python + from prd_taskmaster.setup_wizard import cmd_setup + ``` + + Add the subparser (after the `detect-capabilities` parser, line 184): + ```python + # setup — guided provider/setup wizard (better than task-master models --setup) + p = sub.add_parser("setup", help="Guided provider setup wizard (detect, recommend, validate)") + p.add_argument("--yes", action="store_true", help="Accept the recommendation non-interactively (CI/dispatch)") + p.add_argument("--validate", action="store_true", help="Dry-run gate: validate_setup + a live one-token probe per provider") + ``` + + Add the DISPATCH entry (in the `DISPATCH` dict, after `"detect-capabilities": cmd_detect_capabilities,`): + ```python + "setup": cmd_setup, + ``` + +- [ ] **Step 4: Run, expect PASS** + ``` + python3 -m pytest tests/core/test_setup_wizard.py -v + ``` + Expected: all setup-wizard tests pass (10 total). Then the full CLI suite: + ``` + python3 -m pytest tests/core/test_cli.py tests/core/test_setup_wizard.py -q + ``` + Expected: all pass. + +- [ ] **Step 5: Commit** + ``` + git add prd_taskmaster/setup_wizard.py prd_taskmaster/cli.py tests/core/test_setup_wizard.py + git commit -m "$(cat <<'EOF' + feat(cli): wire `atlas setup` verb (--yes, --validate) + + cmd_setup drives run_setup and exits non-zero when validation is not ready so + CI/dispatch can gate on `atlas setup --validate`. Subparser + DISPATCH follow + cli.py conventions. + + Co-Authored-By: Claude Fable 5 + EOF + )" + ``` + +--- + +### Task 6: Full-suite regression guard + +Confirm the chunk integrates without breaking sibling chunks or the existing suite. + +**Files:** none (verification only). + +- [ ] **Step 1:** Run the chunk's tests plus every test that touches providers/validate/CLI: + ``` + python3 -m pytest tests/core/test_setup_wizard.py tests/core/test_mode_recommend_validate.py \ + tests/core/test_cli.py tests/core/test_dogfood_fixes.py tests/core/test_prerelaunch_p0_fixes.py \ + tests/core/test_fleet_config.py -v + ``` + Expected: all pass. +- [ ] **Step 2:** Run the entire suite for safety: + ``` + python3 -m pytest tests/core/ -q + ``` + Expected: green (any failure here is a Chunk-1 `engine_config`/`save_engine_config` + integration gap — coordinate with Chunk 1, do NOT paper over it in `setup_wizard.py`). +- [ ] **Step 3: Commit (only if anything was touched to integrate)** — otherwise skip. If a + legacy `validate_setup()` caller needed `provider_mode="plan_only"`, fix it here: + ``` + git add -A + git commit -m "$(cat <<'EOF' + test(setup): full-suite regression guard for the setup-wizard chunk + + Co-Authored-By: Claude Fable 5 + EOF + )" + ``` +``` + +I have written Chunk 5 grounded in the real source. Key load-bearing facts I verified and built against: + +- **`validate_setup` (mode_recommend.py:367)** takes no args today; check 1 (`binary`, L404-413), check 2 (`version`, L417-428), aggregation (L563-582) already excludes `severity == "warning"` — so my refactor only needs to add `"advisory"` to the exclusion set and gate it on `provider_mode != "plan_only"`. I added a `provider_mode` param defaulting to `fleet.engine_config()["provider_mode"]`. +- **`run_detect_providers` / `detect_capabilities` / `run_configure_providers`** return the exact dict shapes the panel/accept steps consume (`{"ok", "providers": {main/fallback/research}}`, the caps dict, the configure result). +- **`_ensure_env_entry(env_path, key, value)` (lib.py:275)** returns `True` when changed and appends a quoted entry — the Add-key step uses it verbatim. +- **`emit`/`fail`/`CommandError` (lib.py:33-56)** and the `cli.py` `sub.add_parser(...)` + `DISPATCH` dict + `main()` dispatch pattern (cli.py:142-397) — the `setup` subparser and `cmd_setup` follow them. +- **Contract dependency surfaced explicitly:** `fleet.engine_config()` and `fleet.save_engine_config()` do **not** exist yet (grep returned nothing) — they are Chunk 1's deliverable. I included drop-in contract-matching shims so this chunk is buildable/testable standalone, with a coordination note for the merge. + +Plan file to be created by the executing engineer: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/setup_wizard.py` (new), with modifications to `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/mode_recommend.py` and `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/cli.py`, and tests at `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_setup_wizard.py` and `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_mode_recommend_validate.py`. + + +--- + + +## Chunk 6: migration: flip default + parity gate + +This chunk completes sub-project #1: it flips `get_backend("auto")` to resolve to `NativeBackend` unconditionally (keeping `backend="taskmaster"` working for one deprecation release behind a `DeprecationWarning`), then erects a **golden-parity gate** (a verification TASK referencing the `AI-golden-parity-refactor` skill) that must pass GREEN before the **final** task physically deletes `TaskMasterBackend`, `tm_parallel.py`, and the three TaskMaster MCP tools. + +> **Gating rule (read this first):** Tasks 1–3 land independently. **Task 3 (parity) is a hard gate on Task 4 (deletion).** Do NOT start Task 4 — do not delete a single line of `TaskMasterBackend` / `tm_parallel.py` / the MCP tools — until Task 3's parity run is committed GREEN with the diff artifact checked in. The plan marks the exact gate command. This is the migration order from spec §9 (flip → parity gate → delete). + +**Dependency on prior chunks:** this chunk assumes Chunks 1–5 have landed (`engine` config block in `fleet.py`, `provider_resolver.resolve_provider`, `cli_agent.generate_json_via_cli`, the `NativeBackend.parse_prd/expand/rate` rewiring to the resolver, and the probe cache). If those are not yet merged, Task 3's "native+cli_agent" leg cannot pass — which is exactly why Task 4's deletion is gated on it. + +--- + +### Task 1: Flip `get_backend("auto")` → `NativeBackend` unconditionally + deprecate `"taskmaster"` + +Spec §9.2. Today `get_backend` with `backend="auto"` constructs a `TaskMasterBackend`, calls `.detect()`, and returns it when the `task-master` binary is available — preferring the external binary. We flip the default so `"auto"` always returns `NativeBackend`, and we keep the explicit `backend="taskmaster"` opt-in alive for **one** deprecation release, emitting a `DeprecationWarning`. The `TaskMasterBackend` class is **not** deleted here (that is gated Task 4). + +**Files:** +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/backend.py` (`get_backend`, L855-867) — replace the auto-detect body +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/backend.py` (add `import warnings` near top, L3-12 import block) +- Test: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_backend_migration.py` (new) + +Steps: + +- [ ] **Step 1: Write the failing test** + +Create `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_backend_migration.py`: + +```python +"""Migration tests: backend='auto' resolves to NativeBackend unconditionally, +backend='taskmaster' still works for one deprecation release with a warning. + +Spec: docs/design/2026-06-15-atlas-engine-hybrid-provider-setup.md §9.2 +""" + +import warnings + +import pytest + +from prd_taskmaster import backend as backend_mod +from prd_taskmaster.backend import ( + NativeBackend, + TaskMasterBackend, + get_backend, +) + + +def test_auto_resolves_native_even_when_taskmaster_binary_present(monkeypatch): + """The migration's core invariant: 'auto' is NativeBackend even when the + task-master binary is on PATH and detect() reports it available. + + We monkeypatch TaskMasterBackend.detect to claim availability; the old + code would have returned the TaskMasterBackend in that case. Post-flip it + must NOT — 'auto' returns NativeBackend unconditionally. + """ + monkeypatch.setattr( + TaskMasterBackend, + "detect", + lambda self: {"name": "taskmaster", "available": True, "ai_ops": True}, + ) + be = get_backend({"backend": "auto"}) + assert isinstance(be, NativeBackend) + assert be.name == "native" + + +def test_auto_resolves_native_when_taskmaster_binary_absent(monkeypatch): + monkeypatch.setattr( + TaskMasterBackend, + "detect", + lambda self: {"name": "taskmaster", "available": False, "ai_ops": False}, + ) + be = get_backend({"backend": "auto"}) + assert isinstance(be, NativeBackend) + + +def test_missing_backend_key_defaults_to_native(monkeypatch): + """An empty/legacy config (no 'backend' key) defaults to 'auto' -> Native.""" + monkeypatch.setattr( + TaskMasterBackend, + "detect", + lambda self: {"name": "taskmaster", "available": True}, + ) + be = get_backend({}) + assert isinstance(be, NativeBackend) + + +def test_explicit_native_returns_native(): + be = get_backend({"backend": "native"}) + assert isinstance(be, NativeBackend) + + +def test_explicit_taskmaster_still_works_but_warns(): + """backend='taskmaster' is honored for ONE deprecation release, with a + DeprecationWarning so dispatch logs surface the impending removal.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + be = get_backend({"backend": "taskmaster"}) + assert isinstance(be, TaskMasterBackend) + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + assert any("taskmaster" in str(w.message).lower() for w in caught) + + +def test_get_backend_does_not_call_taskmaster_detect_on_auto(monkeypatch): + """Regression guard: the old auto path constructed a TaskMasterBackend and + called .detect(). The flip must NOT touch TaskMasterBackend at all on auto — + no binary probe cost, no import-time spawn.""" + called = {"detect": False} + + def boom(self): + called["detect"] = True + return {"available": True} + + monkeypatch.setattr(TaskMasterBackend, "detect", boom) + get_backend({"backend": "auto"}) + assert called["detect"] is False +``` + +- [ ] **Step 2: Run it, expect FAIL** + +``` +python3 -m pytest tests/core/test_backend_migration.py -v +``` + +Expected: `test_auto_resolves_native_even_when_taskmaster_binary_present` FAILS with `AssertionError: assert isinstance(, NativeBackend)` (old code returns the TaskMasterBackend when detect reports available); `test_explicit_taskmaster_still_works_but_warns` FAILS because no `DeprecationWarning` is emitted; `test_get_backend_does_not_call_taskmaster_detect_on_auto` FAILS (`assert called["detect"] is False` — old code calls detect on auto). + +- [ ] **Step 3: Minimal implementation** + +In `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/backend.py`, add `import warnings` to the stdlib import block (alphabetical, after `import time` on L8): + +```python +import time +import warnings +``` + +Replace `get_backend` (L855-867) in its entirety with: + +```python +def get_backend(cfg=None) -> Backend: + config = fleet.load_fleet_config() if cfg is None else cfg + backend = config.get("backend", "auto") if isinstance(config, dict) else "auto" + + if backend == "taskmaster": + # Deprecated path: kept for ONE release so existing fleet.json files with + # an explicit "backend": "taskmaster" do not hard-break on upgrade. The + # TaskMaster binary + this branch are deleted in the gated migration task + # (spec §9.4) once golden parity is green. + warnings.warn( + "backend='taskmaster' is deprecated and will be removed in the next " + "release; the native engine is now the sole generator. Remove the " + "'backend' key from .atlas-ai/fleet.json (or set it to 'native') to " + "silence this warning.", + DeprecationWarning, + stacklevel=2, + ) + return TaskMasterBackend(_FACTORY_TOKEN) + + # backend == "native" OR "auto" (the default): the native engine is the sole + # generator. 'auto' no longer probes for the task-master binary — it resolves + # to NativeBackend unconditionally (spec §9.2). + return NativeBackend() +``` + +- [ ] **Step 4: Run, expect PASS** + +``` +python3 -m pytest tests/core/test_backend_migration.py -v +``` + +Expected: 6 passed. Then run the existing backend suites to catch regressions in callers that relied on the old auto-detect: + +``` +python3 -m pytest tests/core/test_backend.py tests/core/test_native_backend.py -v +``` + +Expected: most pass, but **`test_backend_factory_precedence_and_auto_detection` (tests/core/test_backend.py:106-125) WILL FAIL** — it asserts the **old** behavior: after `_write_fake_taskmaster`, `get_backend({"backend":"auto"})` returns a `TaskMasterBackend` (L123-125, `auto = get_backend({"backend": "auto"}); assert isinstance(auto, TaskMasterBackend)`). That assertion encodes the behavior we are intentionally changing. Update that test: change its final two lines so `auto` is expected to be `NativeBackend` even with the fake binary present, and add a one-line comment `# flipped: spec §9.2 — auto is always native`. Concretely, replace L123-125: + +```python + _write_fake_taskmaster(tmp_path / "bin") + auto = get_backend({"backend": "auto"}) + assert isinstance(auto, TaskMasterBackend) +``` +with: +```python + # flipped: spec §9.2 — auto is always native, even with the task-master binary present + _write_fake_taskmaster(tmp_path / "bin") + auto = get_backend({"backend": "auto"}) + assert isinstance(auto, NativeBackend) +``` + +The rest of `test_backend.py` (the explicit `backend="taskmaster"` / `backend="native"` assertions in that same test, and the other TaskMasterBackend tests) still pass under Task 1 — `"taskmaster"` is still honored. Do not weaken the new `auto→native` invariant to satisfy a stale assertion. (Those `backend="taskmaster"`-coupled tests are dealt with separately in Task 4, once the class is actually deleted.) + +- [ ] **Step 5: Commit** + +``` +git add prd_taskmaster/backend.py tests/core/test_backend_migration.py tests/core/test_backend.py +git commit -m "feat(backend): flip get_backend('auto') to NativeBackend; deprecate backend='taskmaster' + +auto no longer probes for the task-master binary — native is the sole +generator (spec §9.2). backend='taskmaster' still works for one release +behind a DeprecationWarning. Updated test_backend_factory_precedence_and_auto_detection +to expect auto->native. TaskMasterBackend class deletion is gated on golden +parity (Task 4). + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: Add the golden-parity capture harness (script, no deletion) + +Spec §9.3 + the `AI-golden-parity-refactor` skill. This task builds the **reusable harness** that captures task-graph outputs from both backends on 2-3 sample PRDs and diffs them. It captures the TaskMaster-path golden **now** (while `TaskMasterBackend` still exists) so Task 3 can prove the native+cli_agent path produces equivalent graphs. This task writes only the harness + fixtures; the actual pass/fail gate is Task 3. + +> **Critical correctness note (the disk-vs-result bug):** `NativeBackend.parse_prd` (backend.py:409-419) and `TaskMasterBackend.parse_prd` (backend.py:735-738) BOTH return `{"ok": ..., "task_count": N, "tag": ..., "backend": ...}` with **no `"tasks"` key and no `"raw"` key** — the generated tasks are written to `.taskmaster/tasks/tasks.json` on disk (the binary writes them; `NativeBackend` calls `_write_tasks_into_tag`). So the harness must **read the task graph from disk** via `parallel.load_tagged` + `parallel.get_tasks` after `parse_prd` returns — it cannot pull tasks out of the result dict. Because both backends write to the SAME `.taskmaster/tasks/tasks.json` path, each backend leg must run in its **own temp cwd + tag** so the two legs don't overwrite each other's `tasks.json`. The unit test below locks this contract so the bug is caught in CI, not only at Task-3 runtime. + +**Files:** +- Create: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/parity/fixtures/prd_cli_tool.md` (sample PRD 1) +- Create: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/parity/fixtures/prd_web_api.md` (sample PRD 2) +- Create: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/parity/fixtures/prd_data_pipeline.md` (sample PRD 3) +- Create: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/parity/golden_parity.py` (harness: capture + normalize + diff) +- Test: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_golden_parity_harness.py` (unit tests for the normalizer/differ + the disk-read extraction path — pure, no model calls) + +Steps: + +- [ ] **Step 1: Write the failing test** + +Create the fixtures first (tiny but real PRDs — these are inputs, not asserted output). Example for `tests/parity/fixtures/prd_cli_tool.md`: + +```markdown +# PRD: line-count CLI + +## Goal +Build `lc`, a CLI that counts lines, words, and bytes in files. + +## Requirements +- `lc ` prints lines, words, bytes for one file. +- `lc ` prints per-file rows plus a total row. +- `--lines-only` flag suppresses word/byte columns. +- Reads stdin when no path is given. + +## Acceptance +- Output matches `wc` byte-for-byte on the test corpus. +- Exit 1 with a stderr message on a missing file. +``` + +(Author `prd_web_api.md` and `prd_data_pipeline.md` similarly — each ~10-15 lines, one clear goal, 4-5 requirements, an acceptance section. These exist to exercise generation, not to be asserted.) + +Now create `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_golden_parity_harness.py` — unit tests for the **pure** normalizer/differ AND the disk-read task-extraction path (no model, no subprocess): + +```python +"""Unit tests for the golden-parity harness normalizer + differ + extractor. + +These are PURE-function tests — they do NOT call any model, backend, or +subprocess. They lock two contracts: + 1. the harness compares the STRUCTURE of two task graphs (parity-relevant + shape) and not volatile fields; + 2. the harness extracts tasks from DISK (.taskmaster/tasks/tasks.json) after + parse_prd, NOT from the parse_prd result dict — which carries only + {ok, task_count, tag, backend} and has NO "tasks" key (backend.py:409-419, + 735-738). Test #2 is the regression guard for the disk-vs-result bug. + +Spec: docs/design/2026-06-15-atlas-engine-hybrid-provider-setup.md §9.3 +Skill: AI-golden-parity-refactor +""" + +import json +from pathlib import Path + +from tests.parity.golden_parity import ( + diff_graphs, + extract_graph_from_disk, + normalize_graph, +) + + +def _graph(*titles): + return { + "tasks": [ + { + "id": i + 1, + "title": t, + "description": f"desc {t}", + "details": "volatile per-run details that must be ignored", + "testStrategy": "volatile too", + "status": "pending", + "dependencies": [], + "priority": "high", + "subtasks": [], + } + for i, t in enumerate(titles) + ] + } + + +def test_normalize_keeps_structural_fields_drops_volatile(): + norm = normalize_graph(_graph("Set up project", "Write tests")) + assert norm == { + "task_count": 2, + "tasks": [ + {"id": 1, "title": "Set up project", "dependencies": [], "subtask_count": 0, "priority": "high"}, + {"id": 2, "title": "Write tests", "dependencies": [], "subtask_count": 0, "priority": "high"}, + ], + } + # details / testStrategy / description must NOT appear — they are prose that + # legitimately differs run-to-run and is not a parity signal. + assert "details" not in norm["tasks"][0] + assert "description" not in norm["tasks"][0] + + +def test_diff_identical_graphs_is_clean(): + a = normalize_graph(_graph("A", "B")) + b = normalize_graph(_graph("A", "B")) + result = diff_graphs(a, b) + assert result["parity"] is True + assert result["diffs"] == [] + + +def test_diff_reports_task_count_mismatch(): + a = normalize_graph(_graph("A", "B")) + b = normalize_graph(_graph("A")) + result = diff_graphs(a, b) + assert result["parity"] is False + assert any("task_count" in d for d in result["diffs"]) + + +def test_diff_reports_dependency_shape_change(): + g1 = _graph("A", "B") + g2 = _graph("A", "B") + g2["tasks"][1]["dependencies"] = [1] + result = diff_graphs(normalize_graph(g1), normalize_graph(g2)) + assert result["parity"] is False + assert any("dependencies" in d for d in result["diffs"]) + + +def test_diff_honors_intended_whitelist(): + """A pre-declared intended diff (e.g. a deliberate title rephrase on task 2) + is allowed and does NOT fail parity — per the skill, declare the whitelist + BEFORE running.""" + a = normalize_graph(_graph("A", "B")) + g2 = _graph("A", "B-renamed") + b = normalize_graph(g2) + result = diff_graphs(a, b, intended={"tasks[1].title"}) + assert result["parity"] is True + assert result["intended_applied"] == ["tasks[1].title"] + + +def test_extract_reads_tasks_from_disk_not_from_parse_result(tmp_path, monkeypatch): + """REGRESSION GUARD for the disk-vs-result bug: parse_prd returns a dict with + {ok, task_count} and NO "tasks"/"raw" key (backend.py:409-419, 735-738). + extract_graph_from_disk must read the graph from .taskmaster/tasks/tasks.json + via parallel.load_tagged + parallel.get_tasks — NOT from the result dict. + + We simulate a completed parse: write a realistic parse_prd-shaped result dict + (no "tasks" key) AND a tasks.json on disk, then assert the extractor returns + the DISK tasks and would have returned nothing useful from the result dict. + """ + monkeypatch.chdir(tmp_path) + tm = tmp_path / ".taskmaster" / "tasks" + tm.mkdir(parents=True) + (tmp_path / ".taskmaster" / "state.json").write_text(json.dumps({"currentTag": "master"})) + disk_tasks = [ + {"id": 1, "title": "From disk A", "dependencies": [], "priority": "high", "subtasks": []}, + {"id": 2, "title": "From disk B", "dependencies": [1], "priority": "medium", "subtasks": []}, + ] + (tm / "tasks.json").write_text(json.dumps({"master": {"tasks": disk_tasks}}, indent=2)) + + # Exactly the shape both backends return — NO "tasks", NO "raw". + parse_result = {"ok": True, "task_count": 2, "tag": "master", "backend": "native", "ai": "api"} + assert "tasks" not in parse_result and "raw" not in parse_result # the trap + + graph = extract_graph_from_disk(parse_result) + assert [t["title"] for t in graph["tasks"]] == ["From disk A", "From disk B"] + # And the normalized shape reflects the on-disk dependency edge, proving we + # did not silently fall back to an empty list from the result dict. + norm = normalize_graph(graph) + assert norm["task_count"] == 2 + assert norm["tasks"][1]["dependencies"] == [1] +``` + +- [ ] **Step 2: Run it, expect FAIL** + +``` +python3 -m pytest tests/core/test_golden_parity_harness.py -v +``` + +Expected: collection error / `ModuleNotFoundError: No module named 'tests.parity.golden_parity'` (the harness does not exist yet). If `tests/` lacks an `__init__.py`, the import `from tests.parity.golden_parity import ...` will fail — create empty `tests/__init__.py` and `tests/parity/__init__.py` so the package import resolves (confirm with `ls tests/__init__.py`; if the repo runs pytest in rootdir-import mode without packages, instead import as `from parity.golden_parity import ...` and add `tests/` to `pythonpath` in `pyproject.toml`/`pytest.ini` — match whatever the existing `tests/core/*` files do). + +- [ ] **Step 3: Minimal implementation** + +Create `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/parity/__init__.py` (empty) and `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/parity/golden_parity.py`: + +```python +"""Golden-parity harness for the TaskMaster -> native+cli_agent migration. + +Captures task-graph outputs from each backend on the sample PRDs in +fixtures/, normalizes them to a structural shape (dropping volatile prose), +and diffs them. Only diffs NOT in the pre-declared `intended` whitelist fail +parity. + +IMPORTANT: parse_prd does NOT return the task graph in its result dict — both +NativeBackend.parse_prd (backend.py:409-419) and TaskMasterBackend.parse_prd +(backend.py:735-738) return {ok, task_count, tag, backend, ...} with no "tasks" +key; the tasks are written to .taskmaster/tasks/tasks.json. So capture reads the +graph from DISK via parallel.load_tagged + parallel.get_tasks AFTER parse_prd. +Each backend leg runs in its OWN temp cwd + tag so the two legs do not overwrite +each other's tasks.json. + +This is the binary acceptance gate referenced by the migration deletion task. +Skill: AI-golden-parity-refactor. Spec: §9.3. + +Usage (capture + gate, run from repo root): + python3 -m tests.parity.golden_parity capture --backend taskmaster --out golden/tm + python3 -m tests.parity.golden_parity capture --backend native --out golden/native + python3 -m tests.parity.golden_parity gate --gold golden/tm --new golden/native +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tempfile +from pathlib import Path + +FIXTURES = Path(__file__).parent / "fixtures" +SAMPLE_PRDS = ["prd_cli_tool.md", "prd_web_api.md", "prd_data_pipeline.md"] + +# Pre-declared intended-diff whitelist (skill: declare BEFORE running). +# Each entry is a "tasks[]." path that is allowed to differ between +# the TaskMaster golden and the native+cli_agent output. Start EMPTY — every +# real diff must be consciously promoted here with a one-line justification. +INTENDED_DIFFS: set[str] = set() + + +def extract_graph_from_disk(parse_result: dict | None = None, tag: str | None = None) -> dict: + """Read the task graph that parse_prd wrote to .taskmaster/tasks/tasks.json. + + parse_result is accepted (and may be inspected for {ok}) but its body is NOT + the source of tasks — parse_prd returns only {ok, task_count, tag, ...} with + no "tasks"/"raw" key. The authoritative graph is the on-disk tasks.json for + the current (or given) tag, read via parallel.load_tagged + parallel.get_tasks. + Imported lazily so the pure differ tests do not drag in backend deps. + """ + from prd_taskmaster import parallel + + resolved = tag if tag is not None else ( + parse_result.get("tag") if isinstance(parse_result, dict) and parse_result.get("tag") else None + ) + resolved = parallel.current_tag(resolved) + raw, tag_key = parallel.load_tagged(resolved) + tasks = parallel.get_tasks(raw, tag_key) + return {"tasks": tasks} + + +def normalize_graph(graph: dict) -> dict: + """Reduce a parse_prd/expand task graph to its parity-relevant structure. + + Keeps: task_count, and per-task {id, title, dependencies, subtask_count, + priority}. Drops: details/testStrategy/description (volatile prose), + status (always 'pending' at gen time), and subtask internals (structural + count is the parity signal, not generated subtask prose). + """ + tasks = graph.get("tasks", []) or [] + norm_tasks = [] + for t in tasks: + norm_tasks.append( + { + "id": t.get("id"), + "title": t.get("title", ""), + "dependencies": sorted(t.get("dependencies", []) or []), + "subtask_count": len(t.get("subtasks", []) or []), + "priority": t.get("priority", ""), + } + ) + return {"task_count": len(norm_tasks), "tasks": norm_tasks} + + +def diff_graphs(gold: dict, new: dict, intended: set[str] | None = None) -> dict: + """Structural diff. Returns {parity: bool, diffs: [str], intended_applied: [str]}. + + A diff path in `intended` is recorded in intended_applied and does NOT + count against parity (skill: only explicitly-intended diffs allowed). + """ + intended = intended or set() + diffs: list[str] = [] + intended_applied: list[str] = [] + + if gold.get("task_count") != new.get("task_count"): + diffs.append( + f"task_count: gold={gold.get('task_count')} new={new.get('task_count')}" + ) + + g_tasks = gold.get("tasks", []) + n_tasks = new.get("tasks", []) + for idx in range(max(len(g_tasks), len(n_tasks))): + g = g_tasks[idx] if idx < len(g_tasks) else None + n = n_tasks[idx] if idx < len(n_tasks) else None + if g is None or n is None: + diffs.append(f"tasks[{idx}]: present in only one graph") + continue + for field in ("title", "dependencies", "subtask_count", "priority"): + if g.get(field) != n.get(field): + path = f"tasks[{idx}].{field}" + if path in intended: + intended_applied.append(path) + else: + diffs.append(f"{path}: gold={g.get(field)!r} new={n.get(field)!r}") + + return { + "parity": not diffs, + "diffs": diffs, + "intended_applied": intended_applied, + } + + +def _capture(backend_name: str, out_dir: Path) -> int: + """Run parse_prd on each sample PRD via the named backend; write normalized + graphs to out_dir/.json. + + Each PRD runs in its OWN isolated temp cwd + per-PRD tag so the two backend + legs (which both write the SAME .taskmaster/tasks/tasks.json path) never + overwrite each other. The graph is read from DISK after parse_prd via + extract_graph_from_disk — parse_prd's result dict has no "tasks" key. + + Imported lazily so the pure differ tests do not drag in backend/model deps.""" + from prd_taskmaster.backend import NativeBackend, TaskMasterBackend, _FACTORY_TOKEN + + out_dir = out_dir.resolve() + out_dir.mkdir(parents=True, exist_ok=True) + if backend_name == "taskmaster": + be = TaskMasterBackend(_FACTORY_TOKEN) + elif backend_name == "native": + be = NativeBackend() + else: + print(f"unknown backend: {backend_name}", file=sys.stderr) + return 2 + + rc = 0 + cwd0 = Path.cwd() + for prd in SAMPLE_PRDS: + prd_path = (FIXTURES / prd).resolve() + stem = Path(prd).stem + tag = f"parity_{backend_name}_{stem}" + # Isolated workdir per leg+PRD: parse_prd writes .taskmaster/tasks/tasks.json + # relative to cwd, so distinct cwds keep the two backend legs from clobbering. + with tempfile.TemporaryDirectory(prefix=f"parity_{backend_name}_") as work: + os.chdir(work) + try: + be.init_project() + # point state at this PRD's tag so load_tagged resolves it + state = Path(".taskmaster") / "state.json" + state.parent.mkdir(parents=True, exist_ok=True) + state.write_text(json.dumps({"currentTag": tag})) + result = be.parse_prd(str(prd_path), num_tasks=8, tag=tag) + if not result.get("ok"): + print(f"CAPTURE FAIL {backend_name}/{prd}: {result}", file=sys.stderr) + rc = 1 + continue + # Read the graph from DISK (result dict has no "tasks" key). + graph = extract_graph_from_disk(result, tag=tag) + finally: + os.chdir(cwd0) + norm = normalize_graph(graph) + (out_dir / f"{stem}.json").write_text( + json.dumps(norm, indent=2, sort_keys=True) + ) + print(f"captured {backend_name}/{prd}: {norm['task_count']} tasks") + return rc + + +def _gate(gold_dir: Path, new_dir: Path) -> int: + """Diff every captured PRD graph; print a report; return 0 iff full parity.""" + overall = True + report = [] + for prd in SAMPLE_PRDS: + stem = Path(prd).stem + gold = json.loads((gold_dir / f"{stem}.json").read_text()) + new = json.loads((new_dir / f"{stem}.json").read_text()) + res = diff_graphs(gold, new, intended=INTENDED_DIFFS) + report.append((stem, res)) + if not res["parity"]: + overall = False + + print("=== GOLDEN PARITY REPORT ===") + for stem, res in report: + status = "PARITY_OK" if res["parity"] else "PARITY_FAIL" + print(f"[{status}] {stem}") + for d in res["diffs"]: + print(f" DIFF: {d}") + for i in res["intended_applied"]: + print(f" intended (allowed): {i}") + print("=== %s ===" % ("ALL_PARITY_OK" if overall else "PARITY_FAILED")) + return 0 if overall else 1 + + +def main(argv=None) -> int: + p = argparse.ArgumentParser() + sub = p.add_subparsers(dest="cmd", required=True) + cap = sub.add_parser("capture") + cap.add_argument("--backend", required=True, choices=["taskmaster", "native"]) + cap.add_argument("--out", required=True, type=Path) + gate = sub.add_parser("gate") + gate.add_argument("--gold", required=True, type=Path) + gate.add_argument("--new", required=True, type=Path) + args = p.parse_args(argv) + if args.cmd == "capture": + return _capture(args.backend, args.out) + return _gate(args.gold, args.new) + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +Create empty `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/__init__.py` if it does not already exist. + +- [ ] **Step 4: Run, expect PASS** + +``` +python3 -m pytest tests/core/test_golden_parity_harness.py -v +``` + +Expected: 6 passed (5 pure normalizer/differ tests + `test_extract_reads_tasks_from_disk_not_from_parse_result`, the disk-vs-result regression guard). The harness's `capture`/`gate` CLI subcommands are exercised live in Task 3 (they need a real backend run); here we prove the pure normalizer/differ contract AND that task extraction reads from disk, not from the result dict. + +- [ ] **Step 5: Commit** + +``` +git add tests/parity/ tests/__init__.py tests/core/test_golden_parity_harness.py +git commit -m "test(parity): golden-parity harness + sample PRD fixtures + +Normalizer reduces a task graph to its structural shape (drops volatile +prose); differ gates on a pre-declared intended-diff whitelist (skill: +AI-golden-parity-refactor). Capture reads the graph from DISK +(.taskmaster/tasks/tasks.json via parallel.load_tagged/get_tasks) because +parse_prd returns {ok, task_count} with no 'tasks' key; each backend leg runs +in its own temp cwd+tag. A unit test feeds a realistic parse_prd-shaped result +dict through the extractor to catch the disk-vs-result bug in CI. capture/gate +CLI subcommands feed the deletion gate (Task 3). No production code touched. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: Run the golden-parity gate — capture TaskMaster golden, prove native+cli_agent parity + +Spec §9.3 + acceptance criterion "Golden-parity: native+cli_agent task graphs match the TaskMaster path on sample PRDs (only intended diffs)." This is the **gate** that unlocks Task 4. It is a verification TASK, not a code change: it runs the Task-2 harness end-to-end against both backends and commits the artifacts (golden capture + native capture + the GREEN gate report). Per the skill, **re-verify the diff yourself — do not trust a `PARITY_OK` string; run the diff.** + +> This task requires a runtime where the `task-master` binary AND a `claude`/`codex`/`gemini` CLI (or a raw API key) are present, so both legs of the capture actually generate graphs. Run it in the target dispatch runtime, NOT in a bare unit-test sandbox. Generation calls a real model — this is the ONE place in the chunk that does, and it is a one-time capture, not a unit test. + +**Files:** +- Create: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/parity/golden/tm/*.json` (TaskMaster-path captures — committed artifact) +- Create: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/parity/golden/native/*.json` (native+cli_agent captures — committed artifact) +- Create: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/parity/golden/PARITY_REPORT.txt` (the gate output — committed artifact) +- Test: parity gate is the harness `gate` subcommand (exit 0) + +Steps: + +- [ ] **Step 1: Write the failing test (the gate invocation itself is the test)** + +The gate is the command below. Before running it, **declare the intended-diff whitelist**. With both legs feeding the SAME `normalize_graph` (titles/deps/counts only, prose dropped), the expectation is `INTENDED_DIFFS = set()` (empty) — i.e. byte-identical structure. If a legitimate intended diff exists (e.g. native deliberately emits a different `priority` heuristic), add it to `INTENDED_DIFFS` in `golden_parity.py` **now**, with a one-line `# justification` comment, BEFORE capturing — never after seeing the diff. + +- [ ] **Step 2: Capture both legs, run the gate, expect FAIL-or-PASS surfaced** + +From repo root, in the runtime that has both backends. Each capture runs each PRD in its own isolated temp cwd+tag and reads the resulting graph from disk (per the Task-2 harness), so the two legs do not collide on `.taskmaster/tasks/tasks.json`: + +``` +# Golden: capture from the TaskMaster path WHILE IT STILL EXISTS. +python3 -m tests.parity.golden_parity capture --backend taskmaster --out tests/parity/golden/tm + +# New: capture from native+cli_agent (Chunks 1-5 must be merged for the keyless leg). +python3 -m tests.parity.golden_parity capture --backend native --out tests/parity/golden/native + +# The gate: +python3 -m tests.parity.golden_parity gate \ + --gold tests/parity/golden/tm --new tests/parity/golden/native \ + | tee tests/parity/golden/PARITY_REPORT.txt +echo "GATE_EXIT=$?" +``` + +Expected on the FIRST run: very possibly `PARITY_FAILED` with `GATE_EXIT=1`, listing concrete `DIFF:` lines (e.g. a task-count delta, a dependency-shape change, a title rephrase). That is the gate doing its job. Per the skill's pitfalls: do not paper over a diff — for each one decide (a) it is an UNINTENDED behavior regression → fix the native/cli_agent path (a Chunk 1-5 bug) and re-capture, or (b) it is a genuinely INTENDED behavior change → add the exact `tasks[i].field` path to `INTENDED_DIFFS` with justification, and re-run the gate. + +- [ ] **Step 3: Drive to GREEN, then self-verify the diff by hand** + +Iterate Step 2 until the gate prints `=== ALL_PARITY_OK ===` and `GATE_EXIT=0`. Then re-verify the gate **yourself** (skill step 5 — don't trust the string). The committed captures under `tests/parity/golden/{tm,native}/` are the already-normalized graphs that the harness read from disk and wrote out; diff them directly: + +``` +# Independent re-derivation: diff the committed normalized captures directly. +for f in tests/parity/golden/tm/*.json; do + base=$(basename "$f") + diff <(python3 -c "import json,sys;print(json.dumps(json.load(open('$f')),sort_keys=True,indent=2))") \ + <(python3 -c "import json,sys;print(json.dumps(json.load(open('tests/parity/golden/native/$base')),sort_keys=True,indent=2))") \ + && echo "BYTE_IDENTICAL $base" || echo "INSPECT $base (expected only declared intended diffs)" +done +``` + +Expected: `BYTE_IDENTICAL` for every PRD when `INTENDED_DIFFS` is empty; for any PRD reported as `INSPECT`, eyeball that the ONLY differing fields are exactly the declared whitelist paths — nothing else. Sanity-check the output shape too (the skill's pitfall: an empty diff that tested nothing — which, given the disk-vs-result bug we fixed, is exactly the false-pass to guard against). Confirm each capture file actually has `task_count > 0` and non-empty `tasks` (i.e. the disk read actually found generated tasks, not an empty graph from a result dict that never carried them): + +``` +python3 -c "import json,glob; [print(p, json.load(open(p))['task_count']) for p in glob.glob('tests/parity/golden/*/*.json')]" +``` + +Expected: every file reports a non-zero task_count (proves the on-disk graph was actually read and generation actually ran — not an empty-vs-empty false pass arising from reading tasks out of the result dict instead of disk). + +- [ ] **Step 4: Confirm the gate is GREEN and reproducible** + +Re-run the gate one final time against the committed artifacts to confirm determinism of the gate itself (the normalizer/differ are pure, so re-gating committed captures must be stable): + +``` +python3 -m tests.parity.golden_parity gate --gold tests/parity/golden/tm --new tests/parity/golden/native; echo "GATE_EXIT=$?" +``` + +Expected: `=== ALL_PARITY_OK ===`, `GATE_EXIT=0`. + +- [ ] **Step 5: Commit the GREEN gate artifact (this commit is the unlock token for Task 4)** + +``` +git add tests/parity/golden/ tests/parity/golden_parity.py +git commit -m "test(parity): GREEN golden-parity gate — native+cli_agent matches TaskMaster path + +Captured task graphs from both backends on 3 sample PRDs (read from disk per +leg, isolated temp cwd+tag); structural diff is clean (intended-diff whitelist: +). Gate re-verified by hand (byte-identical +captures, non-empty task_counts). This unlocks the gated deletion task (Task 4): +TaskMaster removal is now safe. + +Co-Authored-By: Claude Fable 5 " +``` + +> If parity CANNOT be reached after fixing genuine regressions (a real capability the native path lacks vs TaskMaster), **STOP** — do NOT proceed to Task 4, do NOT delete TaskMaster. Surface the un-closable gap to the orchestrator. The whole point of the gate is that deletion is conditional on it. + +--- + +### Task 4: GATED DELETION — remove `TaskMasterBackend`, `tm_parallel.py`, the 3 MCP tools, the install step + +> **DO NOT START until Task 3 is committed GREEN.** The unlock condition is a literal git check, run as the first sub-step. Spec §9.4. Physical deletion of: `TaskMasterBackend` (backend.py L668-852), `tm_parallel.py` (652 lines) + its `cli.py` command registrations AND argparse subparsers, the `backend_detect`/`init_taskmaster`/`tm_parallel_expand` MCP tools (server.py), the `task-master-ai` install in `skills/setup/SKILL.md`, and `BACKEND_CHOICES` → `{"native"}`. This ALSO requires deleting/rewriting the `TaskMasterBackend`-coupled tests in `tests/core/test_backend.py` (they import `TaskMasterBackend` and exercise `get_backend({"backend":"taskmaster"})`). **Keep** `KNOWN_STOCK_TASKMASTER_DEFAULTS` (still repairs legacy config on read), the `_detect_taskmaster_method` function in `lib.py` (still used by `preflight.py` + `capabilities.py` — verified by grep), `taskmaster.py` itself (still used by `cmd_init_taskmaster` + file-format support per spec §9.5), and the `.taskmaster/config.json` + `tasks.json` file formats (spec §9.5). + +**Files:** +- Delete: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/tm_parallel.py` +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/backend.py` (remove `TaskMasterBackend` L668-852; remove `_binary_or_raise` L151-155; remove `tm_parallel` AND `taskmaster` from import L15; remove `_detect_taskmaster_method` from the lib import L17 — all grep-verified orphaned; drop the `"taskmaster"` early-return body in `get_backend`) +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/fleet.py` (`BACKEND_CHOICES` L51; `DEFAULT_FLEET_CONFIG` backend default L48) +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/prd_taskmaster/cli.py` (remove `tm_parallel` import L20; remove the 4 `tm-*` `add_parser` subparser blocks L262-282; remove the 4 `tm-*` COMMANDS entries L374-377) +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/mcp-server/server.py` (remove `init_taskmaster` L93-96, `tm_parallel_expand` L117-123, `backend_detect` L164-172; remove `tm_parallel as TMP` import L33) +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/skills/setup/SKILL.md` (remove the `npm install -g task-master-ai` block L54-55) +- Modify: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_backend.py` (delete/rewrite the TaskMasterBackend-coupled tests — see Step 3.7) +- Test: `/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public/tests/core/test_backend_migration.py` (extend — assert the deleted surface is gone) + +Steps: + +- [ ] **Step 1: Write the failing test (assert the surface is GONE) + verify the gate unlock** + +First, the literal unlock check — this MUST pass before any deletion: + +``` +git log --oneline -50 | grep -q "GREEN golden-parity gate" && echo "GATE_UNLOCKED" || echo "BLOCKED: parity gate not committed green — STOP" +``` + +Expected: `GATE_UNLOCKED`. If `BLOCKED`, stop and return to Task 3. + +Then append to `tests/core/test_backend_migration.py`: + +```python +def test_taskmaster_backend_is_removed(): + """Post-deletion: TaskMasterBackend no longer exists in the backend module.""" + from prd_taskmaster import backend as backend_mod + + assert not hasattr(backend_mod, "TaskMasterBackend") + + +def test_tm_parallel_module_is_removed(): + import importlib + + with pytest.raises(ModuleNotFoundError): + importlib.import_module("prd_taskmaster.tm_parallel") + + +def test_backend_choices_is_native_only(): + from prd_taskmaster import fleet + + assert fleet.BACKEND_CHOICES == {"native"} + + +def test_taskmaster_backend_request_falls_back_to_native(recwarn): + """An old fleet.json still pinned to backend='taskmaster' must NOT crash now + that the class is gone — it resolves to native with a deprecation warning.""" + from prd_taskmaster.backend import NativeBackend, get_backend + + be = get_backend({"backend": "taskmaster"}) + assert isinstance(be, NativeBackend) + assert any(issubclass(w.category, DeprecationWarning) for w in recwarn.list) +``` + +Note: `test_explicit_taskmaster_still_works_but_warns` from Task 1 asserted `isinstance(be, TaskMasterBackend)` — that assertion is now intentionally obsolete. **Delete that one test** and rely on `test_taskmaster_backend_request_falls_back_to_native` (the deprecation release is over; the class is gone, the warning + native fallback remains so legacy configs don't hard-crash). Also remove the now-dead `TaskMasterBackend` symbol from the top-of-file import in `test_backend_migration.py` (it is no longer importable) — keep only `NativeBackend` and `get_backend`. + +- [ ] **Step 2: Run it, expect FAIL** + +``` +python3 -m pytest tests/core/test_backend_migration.py -v +``` + +Expected: the 4 new tests FAIL (`TaskMasterBackend` still present; `tm_parallel` still importable; `BACKEND_CHOICES == {"auto","taskmaster","native"}`). + +- [ ] **Step 3: Minimal implementation (the deletions)** + +1. Delete the file: +``` +git rm prd_taskmaster/tm_parallel.py +``` + +2. `backend.py` — edit imports. L15 from: +```python +from prd_taskmaster import fleet, llm_client, parallel, taskmaster, tm_parallel +``` +to: +```python +from prd_taskmaster import fleet, llm_client, parallel +``` +(both `taskmaster` and `tm_parallel` become orphaned in backend.py once `TaskMasterBackend` + `_binary_or_raise` are gone — verified by grep: the only `taskmaster.` refs in backend.py were `taskmaster._find_binary()` at L152 inside `_binary_or_raise` and `taskmaster.init_taskmaster()` at L699 inside the class). And L17 from: +```python +from prd_taskmaster.lib import CommandError, _detect_taskmaster_method, now_iso +``` +to: +```python +from prd_taskmaster.lib import CommandError, now_iso +``` +(`_detect_taskmaster_method`'s only backend.py use is L676 inside `TaskMasterBackend.detect`; it is still defined in `lib.py` and still imported by `preflight.py` + `capabilities.py` — leave the function in `lib.py`, only drop the now-unused backend.py import). + +Delete the entire `class TaskMasterBackend(Backend):` block (L668-852). Delete the now-orphaned `_binary_or_raise()` helper (L151-155 — grep-verified: its only callers were L702/L754/L819, all inside the deleted class). Update `get_backend` — remove the `if backend == "taskmaster": ... return TaskMasterBackend(...)` early-return body but KEEP the deprecation-warning fall-through to native: +```python +def get_backend(cfg=None) -> Backend: + config = fleet.load_fleet_config() if cfg is None else cfg + backend = config.get("backend", "auto") if isinstance(config, dict) else "auto" + + if backend == "taskmaster": + # The taskmaster backend was removed; a legacy fleet.json pinned to it + # resolves to native (the sole generator) rather than crashing. + warnings.warn( + "backend='taskmaster' has been removed; using the native engine. " + "Remove the 'backend' key from .atlas-ai/fleet.json.", + DeprecationWarning, + stacklevel=2, + ) + return NativeBackend() +``` +Then confirm zero remaining references in backend.py before moving on: `grep -n "taskmaster\b\|tm_parallel\|_binary_or_raise\|_detect_taskmaster_method" prd_taskmaster/backend.py` — the only surviving hits should be `prd_taskmaster.__version__` (line ~312, a different module attr, NOT the `taskmaster` submodule) and any `KNOWN_STOCK_TASKMASTER_DEFAULTS`/config-repair string literals. If a real `taskmaster.` submodule call survives, it was missed — handle it before deleting the import. + +3. `fleet.py` — L51: +```python +BACKEND_CHOICES = {"native"} +``` +And L48 in `DEFAULT_FLEET_CONFIG`, change `"backend": "auto"` to `"backend": "native"` (auto now means native; keep `"auto"` accepted in `get_backend` for back-compat but stop emitting it as the default). + +4. `cli.py` — remove `tm_parallel` from the import L20: +```python +from prd_taskmaster import fleet, parallel, task_state +``` +Delete the 4 `tm-*` argparse subparser blocks (L262-282: the `tm-parallel`/`tm-plan`/`tm-run`/`tm-harvest` `sub.add_parser(...)` registrations) AND the 4 corresponding `COMMANDS` entries (L374-377: `tm-parallel`/`tm-plan`/`tm-run`/`tm-harvest`). Both halves must go — deleting only the COMMANDS dict entries leaves dangling subparsers that reference `tm_parallel.cmd_*` and break import. + +5. `mcp-server/server.py` — delete the `from prd_taskmaster import tm_parallel as TMP` import (L33), and delete the three tool functions: `init_taskmaster` (L93-96), `tm_parallel_expand` (L117-123), `backend_detect` (L164-172). Grep for any remaining `TM.init_taskmaster` / `TMP.` / `CLI.run_backend_detect` references and remove their now-orphaned helper imports if unused. + +6. `skills/setup/SKILL.md` — delete L54-55: +``` +TaskMaster is optional. Installing task-master-ai unlocks the TaskMaster backend: + npm install -g task-master-ai +``` +Replace with a one-line note that the native engine needs no external binary (and, if a CLI agent is the keyless path, that `claude`/`codex`/`gemini` on PATH is sufficient — cross-ref Chunk 7's setup wizard). + +7. `tests/core/test_backend.py` — this file imports `TaskMasterBackend` and exercises `get_backend({"backend":"taskmaster"})` across many tests; with the class deleted, those tests fail to import/collect. Delete or rewrite them: +- **`test_backend_factory_precedence_and_auto_detection` (L106-125):** it imports `TaskMasterBackend` and asserts `get_backend({"backend":"taskmaster"})` is a `TaskMasterBackend` (L111-114) and (after Task 1's edit) `auto→Native`. Rewrite: drop the `TaskMasterBackend` import and the explicit-taskmaster `isinstance(..., TaskMasterBackend)` block; assert `get_backend({"backend":"taskmaster"})` now returns `NativeBackend` (with a `DeprecationWarning`), keep the `native` and `auto→Native` assertions. +- **`test_backend_detect_shape_and_version_gate` (L128-141):** exercises the TaskMaster `detect()` version gate — **delete** (no TaskMaster backend to detect). +- **`test_parse_prd_runs_taskmaster_and_counts_tasks_json` (L144-157):** TaskMaster `parse_prd` — **delete**. +- **`test_expand_delegates_to_tm_parallel_for_more_than_three_pending` (L160-178):** imports `tm_parallel` and patches `run_tm_parallel` — **delete** (module gone). +- **`test_expand_serial_branch_runs_binary_and_appends_telemetry` (L180-204):** TaskMaster serial expand via binary — **delete**. +- **`test_rate_reads_report_file_not_stdout` (L207-227):** TaskMaster `rate()` — **delete**. +- **`test_taskmaster_responses_carry_backend_identity` (L245-266):** TaskMaster backend-identity — **delete**. +- **`test_expand_serial_degrades_to_structural_on_research_failure` (L271-291)** and **`test_parse_prd_zero_tasks_is_failure` (L294-319):** these exercise TaskMaster-binary P0-3 / P1-1 paths — **delete** (the native backend has its own coverage in `test_native_backend.py`; these binary-path behaviors no longer exist). +- **`test_load_fleet_config_backend_key_validates_silently` (L230-242):** does NOT touch `TaskMasterBackend` (only `load_fleet_config`), but L234 asserts `load_fleet_config()["backend"] == "auto"` for an empty config — after the fleet.py default flip to `"native"` (Step 3.3), update that assertion to `== "native"`. Keep the test otherwise; it still validates the `"broken" → default` repair path (update its final assertion to the new default too). +- The fake-binary helpers `_write_fake_taskmaster` / `_isolate(..., with_binary=...)` and the `_seed_tasks` helper become unused once the above are deleted — remove `_write_fake_taskmaster` and the `with_binary` plumbing if nothing else references them (grep first). `_seed_tasks` may still be referenced by surviving/rewritten tests; keep it only if so. + +- [ ] **Step 4: Run, expect PASS — and run the FULL suite + parity gate to confirm no orphaned references** + +``` +python3 -m pytest tests/core/test_backend_migration.py tests/core/test_backend.py -v +python3 -m pytest tests/ -q +``` +Expected: migration tests pass; the rewritten `test_backend.py` collects and passes (no `ImportError: cannot import name 'TaskMasterBackend'`, no `ModuleNotFoundError: prd_taskmaster.tm_parallel`); full suite green. Then prove nothing references the deleted symbols: +``` +grep -rn "tm_parallel\|TaskMasterBackend\|backend_detect\|init_taskmaster\|tm_parallel_expand\|_binary_or_raise" prd_taskmaster/ mcp-server/ skills/ --include='*.py' --include='*.md' | grep -v "KNOWN_STOCK_TASKMASTER_DEFAULTS\|taskmaster.init_taskmaster\|# " ; echo "EXIT=$?" +``` +Expected: no live code references (grep exit 1 / `EXIT=1`). Note `taskmaster.py`'s own `init_taskmaster()` function (called by `cmd_init_taskmaster`) is KEPT — the filter excludes it; if the grep surfaces a hit, confirm it is that kept function, not the deleted MCP tool. Re-confirm the MCP server still imports: +``` +python3 -c "import importlib.util,sys; sys.path.insert(0,'mcp-server'); importlib.import_module('server') if importlib.util.find_spec('server') else None" 2>&1 || python3 mcp-server/server.py --help 2>&1 | head -1 +``` +Expected: no `ImportError`. Finally, re-run the parity gate one more time against the committed captures to confirm deletion did not disturb the native path: +``` +python3 -m tests.parity.golden_parity gate --gold tests/parity/golden/tm --new tests/parity/golden/native; echo "GATE_EXIT=$?" +``` +Expected: `=== ALL_PARITY_OK ===`, `GATE_EXIT=0` (the committed golden captures still validate; the TaskMaster golden is a frozen artifact, not regenerated — note the `taskmaster` capture leg is no longer runnable post-deletion, which is exactly why the golden is a committed frozen artifact). + +- [ ] **Step 5: Commit** + +``` +git add prd_taskmaster/backend.py prd_taskmaster/fleet.py prd_taskmaster/cli.py mcp-server/server.py skills/setup/SKILL.md tests/core/test_backend_migration.py tests/core/test_backend.py +git commit -m "feat(backend)!: delete TaskMasterBackend + tm_parallel.py + 3 MCP tools (gated on green parity) + +Native engine is the sole generator. Removes: +- TaskMasterBackend (backend.py), _binary_or_raise helper, tm_parallel.py (652 lines) +- backend.py imports of taskmaster, tm_parallel, _detect_taskmaster_method (orphaned) +- backend_detect / init_taskmaster / tm_parallel_expand MCP tools +- tm-parallel/tm-plan/tm-run/tm-harvest CLI commands (argparse subparsers + COMMANDS) +- npm install -g task-master-ai from skills/setup +- BACKEND_CHOICES -> {native} +- TaskMasterBackend-coupled tests in tests/core/test_backend.py +Keeps: taskmaster.py (cmd_init_taskmaster + file formats), lib._detect_taskmaster_method +(preflight/capabilities), .taskmaster/config.json + tasks.json formats, +KNOWN_STOCK_TASKMASTER_DEFAULTS config repair. Legacy backend='taskmaster' configs +fall back to native + warn. Unlocked by the committed GREEN golden-parity gate. + +BREAKING CHANGE: task-master binary is no longer required or supported. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +**Chunk 6 done when:** `get_backend("auto")` returns `NativeBackend` even with the `task-master` binary present (Task 1, monkeypatched, with `test_backend_factory_precedence_and_auto_detection` updated); the golden-parity harness (reading task graphs from disk, isolated per leg) + GREEN gate artifact are committed (Tasks 2-3); and the gated deletion of `TaskMasterBackend` + `_binary_or_raise` + `tm_parallel.py` + the 3 MCP tools + the `tm-*` CLI surface + the install step + the TaskMasterBackend-coupled `test_backend.py` tests has landed with the full suite + parity gate green (Task 4). Acceptance criteria satisfied: "Golden-parity: native+cli_agent task graphs match the TaskMaster path on sample PRDs" and "`task-master` binary is not required for any generation operation." + + +--- From af259132bf549ebb06c00a1007dfd03335ec0988 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:05:32 +0800 Subject: [PATCH 06/73] feat(engine): add engine_config accessor with hybrid-provider defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 1 of the Atlas hybrid provider + setup layer. Pure parsing/defaults: engine.provider_mode / keyless_default / cli_agent / concurrency, all defaulted, malformed values fall back silently like load_fleet_config. No behavior yet — resolver/cli_agent land in later chunks. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/fleet.py | 78 ++++++++++++++++ tests/core/test_engine_config.py | 149 +++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 tests/core/test_engine_config.py diff --git a/prd_taskmaster/fleet.py b/prd_taskmaster/fleet.py index f83507e..f6f8ff9 100644 --- a/prd_taskmaster/fleet.py +++ b/prd_taskmaster/fleet.py @@ -50,6 +50,25 @@ BACKEND_CHOICES = {"auto", "taskmaster", "native"} +# ─── Atlas hybrid provider: engine config block (Chunk 1) ───────────────────── +PROVIDER_MODE_CHOICES = {"hybrid", "api_only", "cli_only", "plan_only"} +STRUCTURED_JSON_CHOICES = {"auto", "schema", "prompt"} + +DEFAULT_ENGINE_CONFIG = { + "provider_mode": "hybrid", # hybrid | api_only | cli_only | plan_only + "keyless_default": None, # null until wizard asks; True=CLI-first, False=key-first + "cli_agent": { + "structured_json": "auto", # auto | schema | prompt + "probe_cache_ttl_s": 900, + "per_call_timeout_s": 180, + "max_inflight": None, # null -> inherit max_concurrency + }, + "concurrency": { + "structured_gen": None, # null -> inherit max_concurrency + "ram_aware": False, # reserved for sub-project #2 + }, +} + ATLAS_CONFIG_PATH = Path(".atlas-ai") / "config" / "atlas.json" @@ -72,6 +91,65 @@ def _atlas_config_economy() -> str | None: return val if isinstance(val, str) else None +def _is_pos_int(value): + """True for a real positive int, excluding bool (bool subclasses int).""" + return isinstance(value, int) and not isinstance(value, bool) and value >= 1 + + +def engine_config(cfg=None): + """Merged `engine` block with all defaults applied (Chunk 1). + + Accepts a raw fleet.json dict OR the output of `load_fleet_config` (both + carry an `engine` key after this change). Returns a fresh dict every call. + Malformed values fall back silently, exactly like `load_fleet_config`. + """ + eng = { + "provider_mode": DEFAULT_ENGINE_CONFIG["provider_mode"], + "keyless_default": DEFAULT_ENGINE_CONFIG["keyless_default"], + "cli_agent": dict(DEFAULT_ENGINE_CONFIG["cli_agent"]), + "concurrency": dict(DEFAULT_ENGINE_CONFIG["concurrency"]), + } + if not isinstance(cfg, dict): + return eng + raw = cfg.get("engine") + if not isinstance(raw, dict): + return eng + + mode = raw.get("provider_mode") + if mode in PROVIDER_MODE_CHOICES: + eng["provider_mode"] = mode + + keyless = raw.get("keyless_default") + # Only an explicit bool is persisted; None means "wizard hasn't asked". + if isinstance(keyless, bool): + eng["keyless_default"] = keyless + + cli = raw.get("cli_agent") + if isinstance(cli, dict): + sj = cli.get("structured_json") + if sj in STRUCTURED_JSON_CHOICES: + eng["cli_agent"]["structured_json"] = sj + ttl = cli.get("probe_cache_ttl_s") + if _is_pos_int(ttl): + eng["cli_agent"]["probe_cache_ttl_s"] = ttl + timeout = cli.get("per_call_timeout_s") + if _is_pos_int(timeout): + eng["cli_agent"]["per_call_timeout_s"] = timeout + inflight = cli.get("max_inflight") + if _is_pos_int(inflight): + eng["cli_agent"]["max_inflight"] = inflight + + conc = raw.get("concurrency") + if isinstance(conc, dict): + sg = conc.get("structured_gen") + if _is_pos_int(sg): + eng["concurrency"]["structured_gen"] = sg + if isinstance(conc.get("ram_aware"), bool): + eng["concurrency"]["ram_aware"] = conc["ram_aware"] + + return eng + + def load_fleet_config(path=None): """Load .atlas-ai/fleet.json merged over defaults. diff --git a/tests/core/test_engine_config.py b/tests/core/test_engine_config.py new file mode 100644 index 0000000..c566332 --- /dev/null +++ b/tests/core/test_engine_config.py @@ -0,0 +1,149 @@ +"""Atlas hybrid provider: engine config block defaults + merge (Chunk 1).""" + +import json + +import pytest + +from prd_taskmaster.fleet import engine_config, load_fleet_config + + +# ─── engine_config() pure defaults ─────────────────────────────────────────── + +def test_engine_config_none_returns_full_defaults(): + eng = engine_config(None) + assert eng["provider_mode"] == "hybrid" + assert eng["keyless_default"] is None + assert eng["cli_agent"]["structured_json"] == "auto" + assert eng["cli_agent"]["probe_cache_ttl_s"] == 900 + assert eng["cli_agent"]["per_call_timeout_s"] == 180 + assert eng["cli_agent"]["max_inflight"] is None + assert eng["concurrency"]["structured_gen"] is None + assert eng["concurrency"]["ram_aware"] is False + + +def test_engine_config_no_arg_returns_full_defaults(): + # Called with no argument at all (cfg defaults to None). + eng = engine_config() + assert eng["provider_mode"] == "hybrid" + assert eng["keyless_default"] is None + assert eng["concurrency"]["ram_aware"] is False + + +def test_engine_config_returns_fresh_copy_not_shared_mutable(): + a = engine_config(None) + a["provider_mode"] = "MUTATED" + a["cli_agent"]["probe_cache_ttl_s"] = -999 + b = engine_config(None) + assert b["provider_mode"] == "hybrid" + assert b["cli_agent"]["probe_cache_ttl_s"] == 900 + + +# ─── engine_config() merges valid overrides ────────────────────────────────── + +def test_engine_config_merges_valid_top_level_values(): + raw = {"engine": {"provider_mode": "cli_only", "keyless_default": True}} + eng = engine_config(raw) + assert eng["provider_mode"] == "cli_only" + assert eng["keyless_default"] is True + # untouched keys keep defaults + assert eng["cli_agent"]["structured_json"] == "auto" + assert eng["concurrency"]["ram_aware"] is False + + +def test_engine_config_keyless_default_false_is_honored(): + eng = engine_config({"engine": {"keyless_default": False}}) + assert eng["keyless_default"] is False + + +def test_engine_config_merges_valid_cli_agent_values(): + raw = {"engine": {"cli_agent": { + "structured_json": "schema", + "probe_cache_ttl_s": 60, + "per_call_timeout_s": 30, + "max_inflight": 4, + }}} + eng = engine_config(raw) + assert eng["cli_agent"]["structured_json"] == "schema" + assert eng["cli_agent"]["probe_cache_ttl_s"] == 60 + assert eng["cli_agent"]["per_call_timeout_s"] == 30 + assert eng["cli_agent"]["max_inflight"] == 4 + + +def test_engine_config_merges_valid_concurrency_values(): + raw = {"engine": {"concurrency": {"structured_gen": 8, "ram_aware": True}}} + eng = engine_config(raw) + assert eng["concurrency"]["structured_gen"] == 8 + assert eng["concurrency"]["ram_aware"] is True + + +# ─── engine_config() ignores malformed values (silent fallback) ────────────── + +def test_engine_config_malformed_provider_mode_falls_back(): + eng = engine_config({"engine": {"provider_mode": "warp_drive"}}) + assert eng["provider_mode"] == "hybrid" + + +def test_engine_config_malformed_keyless_default_falls_back(): + # Only true/false/None are valid; a string is malformed -> default None. + eng = engine_config({"engine": {"keyless_default": "yes"}}) + assert eng["keyless_default"] is None + + +def test_engine_config_malformed_structured_json_falls_back(): + eng = engine_config({"engine": {"cli_agent": {"structured_json": "telepathy"}}}) + assert eng["cli_agent"]["structured_json"] == "auto" + + +def test_engine_config_malformed_ints_fall_back(): + raw = {"engine": {"cli_agent": { + "probe_cache_ttl_s": "soon", # not an int + "per_call_timeout_s": 0, # < 1, invalid + "max_inflight": -3, # < 1, invalid (None stays valid via default) + }}} + eng = engine_config(raw) + assert eng["cli_agent"]["probe_cache_ttl_s"] == 900 + assert eng["cli_agent"]["per_call_timeout_s"] == 180 + assert eng["cli_agent"]["max_inflight"] is None + + +def test_engine_config_bool_is_not_accepted_as_int(): + # bool is a subclass of int in Python; ttl must reject True/False. + eng = engine_config({"engine": {"cli_agent": {"probe_cache_ttl_s": True}}}) + assert eng["cli_agent"]["probe_cache_ttl_s"] == 900 + + +def test_engine_config_malformed_ram_aware_falls_back(): + eng = engine_config({"engine": {"concurrency": {"ram_aware": "true"}}}) + assert eng["concurrency"]["ram_aware"] is False + + +def test_engine_config_malformed_structured_gen_falls_back(): + eng = engine_config({"engine": {"concurrency": {"structured_gen": "lots"}}}) + assert eng["concurrency"]["structured_gen"] is None + + +def test_engine_config_non_dict_engine_block_falls_back(): + assert engine_config({"engine": "broken"})["provider_mode"] == "hybrid" + assert engine_config({"engine": ["not", "a", "dict"]})["provider_mode"] == "hybrid" + assert engine_config({"engine": 42})["provider_mode"] == "hybrid" + + +def test_engine_config_non_dict_cli_agent_falls_back(): + eng = engine_config({"engine": {"cli_agent": "nope"}}) + assert eng["cli_agent"]["probe_cache_ttl_s"] == 900 + + +def test_engine_config_non_dict_concurrency_falls_back(): + eng = engine_config({"engine": {"concurrency": 5}}) + assert eng["concurrency"]["ram_aware"] is False + + +def test_engine_config_missing_engine_key_returns_defaults(): + eng = engine_config({"max_concurrency": 7}) + assert eng["provider_mode"] == "hybrid" + + +def test_engine_config_non_dict_cfg_returns_defaults(): + assert engine_config("garbage")["provider_mode"] == "hybrid" + assert engine_config(42)["provider_mode"] == "hybrid" + assert engine_config([])["provider_mode"] == "hybrid" From 37da48720602090021a76ef32a8f7e5872726ceb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:06:27 +0800 Subject: [PATCH 07/73] feat(engine): merge engine block into load_fleet_config output load_fleet_config now always returns a fully-defaulted cfg["engine"], merged from .atlas-ai/fleet.json via engine_config(). Every return path (no file, malformed, valid) carries a valid engine key so downstream chunks can read cfg["engine"] unconditionally. Existing fleet-config tests unchanged. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/fleet.py | 3 ++ tests/core/test_engine_config.py | 88 ++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/prd_taskmaster/fleet.py b/prd_taskmaster/fleet.py index f6f8ff9..42a76c0 100644 --- a/prd_taskmaster/fleet.py +++ b/prd_taskmaster/fleet.py @@ -164,6 +164,7 @@ def load_fleet_config(path=None): "experimental_backends": DEFAULT_FLEET_CONFIG["experimental_backends"], "token_economy": _atlas_config_economy() or DEFAULT_FLEET_CONFIG["token_economy"], "backend": DEFAULT_FLEET_CONFIG["backend"], + "engine": engine_config(None), } p = Path(path) if path else FLEET_CONFIG_PATH if not p.is_file(): @@ -210,6 +211,8 @@ def load_fleet_config(path=None): resolved.setdefault("enabled", True) cfg["escalation"] = resolved + cfg["engine"] = engine_config(raw) + return cfg diff --git a/tests/core/test_engine_config.py b/tests/core/test_engine_config.py index c566332..f2dd4f0 100644 --- a/tests/core/test_engine_config.py +++ b/tests/core/test_engine_config.py @@ -147,3 +147,91 @@ def test_engine_config_non_dict_cfg_returns_defaults(): assert engine_config("garbage")["provider_mode"] == "hybrid" assert engine_config(42)["provider_mode"] == "hybrid" assert engine_config([])["provider_mode"] == "hybrid" + + +# ─── load_fleet_config merges the engine block ─────────────────────────────── + +def test_load_fleet_config_has_engine_defaults_without_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + cfg = load_fleet_config() + assert cfg["engine"]["provider_mode"] == "hybrid" + assert cfg["engine"]["keyless_default"] is None + assert cfg["engine"]["cli_agent"]["probe_cache_ttl_s"] == 900 + assert cfg["engine"]["concurrency"]["ram_aware"] is False + + +def test_load_fleet_config_merges_engine_from_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text(json.dumps({ + "max_concurrency": 5, + "engine": { + "provider_mode": "cli_only", + "keyless_default": True, + "cli_agent": {"per_call_timeout_s": 45}, + "concurrency": {"ram_aware": True}, + }, + })) + cfg = load_fleet_config() + # legacy keys still work + assert cfg["max_concurrency"] == 5 + # engine merged + assert cfg["engine"]["provider_mode"] == "cli_only" + assert cfg["engine"]["keyless_default"] is True + assert cfg["engine"]["cli_agent"]["per_call_timeout_s"] == 45 + # untouched engine sub-keys keep defaults + assert cfg["engine"]["cli_agent"]["probe_cache_ttl_s"] == 900 + assert cfg["engine"]["concurrency"]["ram_aware"] is True + + +def test_load_fleet_config_engine_malformed_file_falls_back(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text("{not json") + cfg = load_fleet_config() + assert cfg["engine"]["provider_mode"] == "hybrid" + assert cfg["engine"]["keyless_default"] is None + + +def test_load_fleet_config_engine_invalid_values_ignored(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text(json.dumps({ + "engine": { + "provider_mode": "warp_drive", # invalid -> hybrid + "keyless_default": "yes", # invalid -> None + "cli_agent": {"probe_cache_ttl_s": 0}, # invalid -> 900 + "concurrency": {"structured_gen": -1}, # invalid -> None + }, + })) + cfg = load_fleet_config() + assert cfg["engine"]["provider_mode"] == "hybrid" + assert cfg["engine"]["keyless_default"] is None + assert cfg["engine"]["cli_agent"]["probe_cache_ttl_s"] == 900 + assert cfg["engine"]["concurrency"]["structured_gen"] is None + + +def test_load_fleet_config_engine_block_independent_per_call(tmp_path, monkeypatch): + # Mutating one returned engine block must not bleed into the next call. + monkeypatch.chdir(tmp_path) + a = load_fleet_config() + a["engine"]["cli_agent"]["probe_cache_ttl_s"] = -1 + b = load_fleet_config() + assert b["engine"]["cli_agent"]["probe_cache_ttl_s"] == 900 + + +def test_engine_config_accepts_loaded_config(tmp_path, monkeypatch): + # engine_config() called on load_fleet_config() output is idempotent. + monkeypatch.chdir(tmp_path) + d = tmp_path / ".atlas-ai" + d.mkdir() + (d / "fleet.json").write_text(json.dumps({ + "engine": {"provider_mode": "api_only"}, + })) + cfg = load_fleet_config() + eng = engine_config(cfg) + assert eng["provider_mode"] == "api_only" + assert eng["cli_agent"]["structured_json"] == "auto" From 626f5a0aba789db7c39c02111cb0a003c73fdf81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:10:33 +0800 Subject: [PATCH 08/73] fix(review): robust main() entry guard (pathToFileURL) + stricter npmrc match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-review I1/M1/M2 on the npm-auth preflight (#3): - I1: a naive file://${argv[1]} guard silently skipped main() (and the whole auth check) when the install path had spaces or was symlinked — a publish could proceed with NO auth check. Use pathToFileURL(argv[1]).href. - M1: repurpose the previously-unused node:url import. - M2: anchor the npmrc token match with startsWith so a crafted host segment can't false-pass. Adds an I1 regression test (script run from a path with spaces still exits 1). Verified: node --test 10/10; no-token + spaces-path both exit 1. Co-Authored-By: Claude Fable 5 --- scripts/check-npm-auth.mjs | 13 +++++++++---- scripts/check-npm-auth.test.mjs | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/scripts/check-npm-auth.mjs b/scripts/check-npm-auth.mjs index 8861642..95f6d95 100644 --- a/scripts/check-npm-auth.mjs +++ b/scripts/check-npm-auth.mjs @@ -19,7 +19,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; +import { pathToFileURL } from "node:url"; const REGISTRY_AUTH_LINE = "//registry.npmjs.org/:_authToken="; @@ -51,9 +51,11 @@ export function resolveNpmAuth({ env = {}, npmrc = "" } = {}) { (line) => !line.startsWith("#") && !line.startsWith(";") && - line.includes(REGISTRY_AUTH_LINE) && + // anchor on the line start (already trimmed) so a crafted host segment + // like //evil.com//registry.npmjs.org/:_authToken= can't false-pass. + line.startsWith(REGISTRY_AUTH_LINE) && // require a non-empty value after the '=' - line.slice(line.indexOf(REGISTRY_AUTH_LINE) + REGISTRY_AUTH_LINE.length).trim() !== "" + line.slice(REGISTRY_AUTH_LINE.length).trim() !== "" ); if (hasAuthToken) { return { @@ -150,6 +152,9 @@ function main() { // Only run main() when executed directly, so tests can import resolveNpmAuth without // triggering the CLI side effects (reading ~/.npmrc, process.exit, network). -if (import.meta.url === `file://${process.argv[1]}`) { +// Use pathToFileURL so paths with spaces or symlinked invocations still match — +// a naive `file://${argv[1]}` compare silently skips main() (and thus the whole +// auth check) when the install path contains a space or is a symlink. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(); } diff --git a/scripts/check-npm-auth.test.mjs b/scripts/check-npm-auth.test.mjs index 045e4cc..d499e9e 100644 --- a/scripts/check-npm-auth.test.mjs +++ b/scripts/check-npm-auth.test.mjs @@ -92,3 +92,28 @@ test("CLI: no-token path exits non-zero with a FAIL message and does not hang", // Prove it exited BEFORE any whoami network probe. assert.doesNotMatch(out, /authenticated to npm registry/, "must not have reached whoami"); }); + +test("CLI: still runs main() (fail-closed) when the script path contains spaces (I1 regression)", () => { + // A naive `file://${argv[1]}` entry guard silently SKIPS main() when the path + // has spaces (percent-encoding mismatch) — which would let a publish proceed + // with NO auth check. pathToFileURL() fixes it. Prove main() still fires here. + const spacesDir = fs.mkdtempSync(path.join(os.tmpdir(), "npm auth spaces-")); + const copied = path.join(spacesDir, "check-npm-auth.mjs"); + fs.copyFileSync(SCRIPT, copied); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "npmauth-home-")); + fs.writeFileSync(path.join(tmpHome, ".npmrc"), "", "utf8"); + + const env = { ...process.env }; + delete env.NPM_TOKEN; + delete env.NODE_AUTH_TOKEN; + env.HOME = tmpHome; + env.USERPROFILE = tmpHome; + + const res = spawnSync(process.execPath, [copied], { env, encoding: "utf8", timeout: 20000 }); + + fs.rmSync(spacesDir, { recursive: true, force: true }); + fs.rmSync(tmpHome, { recursive: true, force: true }); + + assert.equal(res.status, 1, "main() must run (exit 1) even from a path containing spaces"); + assert.match(`${res.stdout}\n${res.stderr}`, /FAIL/); +}); From 1296afa396888149e42c4d355af8a16cdea9724b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:15:16 +0800 Subject: [PATCH 09/73] feat(cli-agent): CliAgentError + per-provider argv builder New keyless CLI-agent module skeleton: error shape with .kind, argv/stdin builder for claude/codex/gemini, native-cli telemetry helper. Schema path (claude --json-schema) vs prompt path selection. subprocess fully mocked. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/cli_agent.py | 178 ++++++++++++++++++ tests/core/test_cli_agent.py | 339 +++++++++++++++++++++++++++++++++++ 2 files changed, 517 insertions(+) create mode 100644 prd_taskmaster/cli_agent.py create mode 100644 tests/core/test_cli_agent.py diff --git a/prd_taskmaster/cli_agent.py b/prd_taskmaster/cli_agent.py new file mode 100644 index 0000000..c5a63be --- /dev/null +++ b/prd_taskmaster/cli_agent.py @@ -0,0 +1,178 @@ +"""Keyless CLI-agent structured-JSON provider (sub-project #1, Chunk 2). + +The structured-JSON twin of llm_client.generate_json(): instead of an HTTP call +against a raw API key, it shells out to a host model CLI (claude / codex / gemini) +using that CLI's own session auth — free, no API key, runs N-parallel inside the +existing NativeBackend ThreadPoolExecutor exactly like N concurrent HTTP calls. + +Reuses llm_client._extract_json verbatim and mirrors generate_json's ONE +parse-retry. Emits one telemetry row per spawn attempt with backend="native-cli". +""" + +import shutil +import subprocess +import time + +from prd_taskmaster.economy import append_telemetry +from prd_taskmaster.llm_client import _extract_json + +# provider -> CLI binary name (mirrors providers._SPAWN_PROBE_CLI; kept local to +# avoid a hard import cycle and because cli_agent must run even if probe is stubbed). +_CLI_FOR_PROVIDER = {"claude-code": "claude", "codex-cli": "codex", "gemini-cli": "gemini"} + +_RETRY_INSTRUCTION = ( + "\nYour previous output failed json.loads. Return ONLY the JSON, no prose, no fences." +) + + +class CliAgentError(Exception): + """kind in {"no_cli", "spawn_refused", "timeout", "invalid_json", "nonzero_exit"}.""" + + def __init__(self, kind, message): + super().__init__(message) + self.kind = kind + + +def _build_argv(provider, binary, prompt, *, schema_hint, structured_json): + """Return (argv, stdin_text). stdin_text is None unless the CLI takes the + prompt on stdin (codex). Raises CliAgentError('no_cli') for unknown providers.""" + p = str(provider or "").lower() + if p == "claude-code": + argv = [binary, "-p", prompt, "--output-format", "json"] + # Schema path: only when a schema is supplied AND prompt-mode not forced. + if schema_hint and structured_json != "prompt": + argv += ["--json-schema", schema_hint] + return argv, None + if p == "codex-cli": + return [binary, "exec", "--skip-git-repo-check", "-"], prompt + if p == "gemini-cli": + return [binary, "-p", prompt], None + raise CliAgentError("no_cli", f"provider {provider!r} is not a spawning CLI agent") + + +def _telemetry(op_class, task_id, model, exit_code, start, parse_retry): + """One native-cli telemetry row. Mirrors llm_client._telemetry minus usage + tokens (the CLIs do not surface token counts) and http_status (None).""" + from datetime import datetime, timezone + + return append_telemetry({ + "ts": datetime.now(timezone.utc).isoformat(), + "op_class": op_class, + "task_id": task_id, + "model": model, + "backend": "native-cli", + "exit": exit_code, + "wall_ms": int((time.monotonic() - start) * 1000), + "escalated": False, + "parse_retry": parse_retry, + "http_status": None, + }) + + +def _parse_claude_envelope(stdout): + """claude --output-format json prints a JSON envelope; the model answer is in + `.result` (a JSON string when --json-schema was used, else free text). Funnel + `.result` through _extract_json so a stringified-JSON result is normalized. + Return parsed JSON, or None to signal a parse failure (one retry upstream).""" + envelope = _extract_json(stdout) + if isinstance(envelope, dict) and "result" in envelope: + result = envelope["result"] + if isinstance(result, (dict, list)): + return result + if isinstance(result, str): + return _extract_json(result) + return None + # No envelope (or non-dict): treat the whole stdout as the payload. + return envelope + + +def _run_once(provider, binary, prompt, *, schema_hint, structured_json, + model, op_class, task_id, timeout, parse_retry=False): + """Spawn the CLI once, parse stdout into JSON. Returns the parsed dict/list, + or None on a parse failure (caller decides whether to retry). Raises + CliAgentError for timeout / nonzero_exit / spawn_refused. Emits exactly one + native-cli telemetry row for this attempt.""" + argv, stdin_text = _build_argv( + provider, binary, prompt, schema_hint=schema_hint, structured_json=structured_json, + ) + start = time.monotonic() + try: + completed = subprocess.run( + argv, + input=stdin_text, + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + _telemetry(op_class, task_id, model, 1, start, parse_retry) + raise CliAgentError("timeout", f"{binary} exceeded {timeout}s timeout") + except OSError as exc: + _telemetry(op_class, task_id, model, 1, start, parse_retry) + raise CliAgentError("spawn_refused", f"{binary} could not spawn: {exc}") + + if completed.returncode != 0: + _telemetry(op_class, task_id, model, 1, start, parse_retry) + detail = (completed.stderr or completed.stdout or "").strip()[:400] + raise CliAgentError("nonzero_exit", f"{binary} exit {completed.returncode}: {detail}") + + if str(provider).lower() == "claude-code": + result = _parse_claude_envelope(completed.stdout) + else: + result = _extract_json(completed.stdout) + + _telemetry(op_class, task_id, model, 0 if result is not None else 1, start, parse_retry) + return result + + +def _has_schema_flag(provider): + """Only claude exposes a native --json-schema flag today; codex/gemini fold + the schema into the prompt text.""" + return str(provider or "").lower() == "claude-code" + + +def generate_json_via_cli(provider, prompt, *, system="", schema_hint="", model=None, + op_class="structured_gen", task_id=None, timeout=180, + structured_json="auto"): + """Structured-JSON generation by shelling out to a keyless host CLI. + + Mirrors llm_client.generate_json: builds the full prompt (system + schema for + CLIs without a schema flag), spawns once, and on a parse failure respawns ONCE + with the corrective instruction. Raises CliAgentError(kind, message) with + kind in {no_cli, spawn_refused, timeout, invalid_json, nonzero_exit}. One + telemetry row (backend=native-cli) per spawn attempt. + """ + cli = _CLI_FOR_PROVIDER.get(str(provider or "").lower()) + if not cli: + raise CliAgentError("no_cli", f"provider {provider!r} is not a spawning CLI agent") + binary = shutil.which(cli) + if not binary: + raise CliAgentError("no_cli", f"{cli} binary not on PATH") + + # Assemble the prompt the model sees. The CLI takes no separate system slot, + # so prepend system. Fold the schema into the prompt only when the CLI has no + # native schema flag (codex/gemini) or prompt-mode is forced for claude. + base_prompt = prompt + if system: + base_prompt = system + "\n\n" + base_prompt + use_schema_flag = _has_schema_flag(provider) and structured_json != "prompt" + if schema_hint and not use_schema_flag: + base_prompt += "\n\nReturn ONLY valid JSON matching:\n" + schema_hint + + flag_schema = schema_hint if use_schema_flag else "" + + attempt_prompt = base_prompt + parse_retry = False + while True: + result = _run_once( + provider, binary, attempt_prompt, + schema_hint=flag_schema, structured_json=structured_json, + model=model, op_class=op_class, task_id=task_id, timeout=timeout, + parse_retry=parse_retry, + ) + if result is not None: + return result + if parse_retry: + raise CliAgentError("invalid_json", "CLI output failed JSON parsing after one retry") + parse_retry = True + attempt_prompt = base_prompt + _RETRY_INSTRUCTION diff --git a/tests/core/test_cli_agent.py b/tests/core/test_cli_agent.py new file mode 100644 index 0000000..ce36b0c --- /dev/null +++ b/tests/core/test_cli_agent.py @@ -0,0 +1,339 @@ +"""Chunk 2: cli_agent — keyless CLI-agent structured-JSON provider. + +Hermetic: every subprocess.run is monkeypatched (no real claude/codex/gemini), +no network. Telemetry asserted by chdir-ing into tmp_path and reading the +.atlas-ai/telemetry.jsonl the module appends to. +""" + +import json +import subprocess + +import pytest + +from prd_taskmaster import cli_agent as C + + +# ── A reusable fake for subprocess.run ─────────────────────────────────────── +class FakeCompleted: + """Mimics subprocess.CompletedProcess just enough for cli_agent.""" + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def make_runner(scripted): + """Return a fake subprocess.run that records calls and replays `scripted` + (a list of FakeCompleted or Exception instances), one per invocation.""" + calls = [] + seq = list(scripted) + + def fake_run(argv, *args, **kwargs): + calls.append({"argv": argv, "kwargs": kwargs}) + item = seq.pop(0) + if isinstance(item, Exception): + raise item + return item + + fake_run.calls = calls + return fake_run + + +# ── Task 1: error shape + argv builders ────────────────────────────────────── + +def test_cli_agent_error_has_kind(): + err = C.CliAgentError("timeout", "boom") + assert err.kind == "timeout" + assert "boom" in str(err) + assert isinstance(err, Exception) + + +def test_build_argv_claude_schema_path(): + argv, stdin = C._build_argv( + "claude-code", "/bin/claude", "PROMPT", schema_hint='{"type":"object"}', + structured_json="auto", + ) + assert argv == ["/bin/claude", "-p", "PROMPT", "--output-format", "json", + "--json-schema", '{"type":"object"}'] + assert stdin is None + + +def test_build_argv_claude_prompt_path_when_no_schema(): + argv, stdin = C._build_argv( + "claude-code", "/bin/claude", "PROMPT", schema_hint="", + structured_json="auto", + ) + assert argv == ["/bin/claude", "-p", "PROMPT", "--output-format", "json"] + assert stdin is None + + +def test_build_argv_claude_prompt_mode_forces_no_schema(): + argv, _ = C._build_argv( + "claude-code", "/bin/claude", "PROMPT", schema_hint='{"x":1}', + structured_json="prompt", + ) + assert "--json-schema" not in argv + + +def test_build_argv_codex_uses_stdin(): + argv, stdin = C._build_argv( + "codex-cli", "/bin/codex", "PROMPT", schema_hint="", structured_json="auto", + ) + assert argv == ["/bin/codex", "exec", "--skip-git-repo-check", "-"] + assert stdin == "PROMPT" + + +def test_build_argv_gemini(): + argv, stdin = C._build_argv( + "gemini-cli", "/bin/gemini", "PROMPT", schema_hint="", structured_json="auto", + ) + assert argv == ["/bin/gemini", "-p", "PROMPT"] + assert stdin is None + + +def test_build_argv_unknown_provider_raises_no_cli(): + with pytest.raises(C.CliAgentError) as ei: + C._build_argv("openrouter", "/bin/x", "P", schema_hint="", structured_json="auto") + assert ei.value.kind == "no_cli" + + +# ── Task 2: _run_once spawn + parse + failure classification ────────────────── + +def test_run_once_claude_envelope_result_is_json_string(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + envelope = json.dumps({"type": "result", "result": '{"tasks": [1, 2]}'}) + fake = make_runner([FakeCompleted(returncode=0, stdout=envelope)]) + monkeypatch.setattr(C.subprocess, "run", fake) + out = C._run_once( + "claude-code", "/bin/claude", "PROMPT", schema_hint="", structured_json="auto", + model="sonnet", op_class="structured_gen", task_id=3, timeout=180, + ) + assert out == {"tasks": [1, 2]} + # captured argv is the claude prompt path + assert fake.calls[0]["argv"][:2] == ["/bin/claude", "-p"] + + +def test_run_once_claude_envelope_result_is_object(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + envelope = json.dumps({"result": {"tasks": []}}) + fake = make_runner([FakeCompleted(returncode=0, stdout=envelope)]) + monkeypatch.setattr(C.subprocess, "run", fake) + out = C._run_once( + "claude-code", "/bin/claude", "P", schema_hint="", structured_json="auto", + model="sonnet", op_class="structured_gen", task_id=None, timeout=180, + ) + assert out == {"tasks": []} + + +def test_run_once_codex_extract_from_fenced_stdout(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + stdout = "Here you go:\n```json\n{\"ok\": true}\n```\n" + fake = make_runner([FakeCompleted(returncode=0, stdout=stdout)]) + monkeypatch.setattr(C.subprocess, "run", fake) + out = C._run_once( + "codex-cli", "/bin/codex", "P", schema_hint="", structured_json="auto", + model="gpt-5.2-codex", op_class="structured_gen", task_id=None, timeout=180, + ) + assert out == {"ok": True} + # codex carries the prompt on stdin, not argv + assert fake.calls[0]["kwargs"].get("input") == "P" + + +def test_run_once_invalid_json_returns_none(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + fake = make_runner([FakeCompleted(returncode=0, stdout="totally not json")]) + monkeypatch.setattr(C.subprocess, "run", fake) + out = C._run_once( + "gemini-cli", "/bin/gemini", "P", schema_hint="", structured_json="auto", + model=None, op_class="structured_gen", task_id=None, timeout=180, + ) + assert out is None # signals the caller to do its one parse-retry + + +def test_run_once_nonzero_exit_raises(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + fake = make_runner([FakeCompleted(returncode=2, stdout="", stderr="bad flag")]) + monkeypatch.setattr(C.subprocess, "run", fake) + with pytest.raises(C.CliAgentError) as ei: + C._run_once( + "claude-code", "/bin/claude", "P", schema_hint="", structured_json="auto", + model=None, op_class="structured_gen", task_id=None, timeout=180, + ) + assert ei.value.kind == "nonzero_exit" + assert "bad flag" in str(ei.value) + + +def test_run_once_timeout_raises(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + fake = make_runner([subprocess.TimeoutExpired(cmd="claude", timeout=180)]) + monkeypatch.setattr(C.subprocess, "run", fake) + with pytest.raises(C.CliAgentError) as ei: + C._run_once( + "claude-code", "/bin/claude", "P", schema_hint="", structured_json="auto", + model=None, op_class="structured_gen", task_id=None, timeout=180, + ) + assert ei.value.kind == "timeout" + + +def test_run_once_oserror_raises_spawn_refused(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + fake = make_runner([OSError("nested spawn refused")]) + monkeypatch.setattr(C.subprocess, "run", fake) + with pytest.raises(C.CliAgentError) as ei: + C._run_once( + "claude-code", "/bin/claude", "P", schema_hint="", structured_json="auto", + model=None, op_class="structured_gen", task_id=None, timeout=180, + ) + assert ei.value.kind == "spawn_refused" + + +def test_run_once_emits_one_native_cli_telemetry_row(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + envelope = json.dumps({"result": '{"ok": true}'}) + fake = make_runner([FakeCompleted(returncode=0, stdout=envelope)]) + monkeypatch.setattr(C.subprocess, "run", fake) + C._run_once( + "claude-code", "/bin/claude", "P", schema_hint="", structured_json="auto", + model="sonnet", op_class="structured_gen", task_id=9, timeout=180, + ) + rows = [json.loads(l) for l in + (tmp_path / ".atlas-ai" / "telemetry.jsonl").read_text().splitlines()] + assert len(rows) == 1 + assert rows[0]["backend"] == "native-cli" + assert rows[0]["exit"] == 0 + assert rows[0]["task_id"] == 9 + assert rows[0]["http_status"] is None + assert "tokens_in" not in rows[0] + + +def test_run_once_invalid_json_telemetry_exit_1(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + fake = make_runner([FakeCompleted(returncode=0, stdout="nope")]) + monkeypatch.setattr(C.subprocess, "run", fake) + C._run_once( + "gemini-cli", "/bin/gemini", "P", schema_hint="", structured_json="auto", + model=None, op_class="structured_gen", task_id=None, timeout=180, + ) + rows = [json.loads(l) for l in + (tmp_path / ".atlas-ai" / "telemetry.jsonl").read_text().splitlines()] + assert rows[0]["exit"] == 1 + assert rows[0]["parse_retry"] is False + + +# ── Task 3: generate_json_via_cli public entry ─────────────────────────────── + +def test_generate_json_via_cli_happy_path(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + envelope = json.dumps({"result": '{"tasks": [{"id": 1}]}'}) + fake = make_runner([FakeCompleted(returncode=0, stdout=envelope)]) + monkeypatch.setattr(C.subprocess, "run", fake) + out = C.generate_json_via_cli("claude-code", "Make tasks", task_id=1) + assert out == {"tasks": [{"id": 1}]} + assert len(fake.calls) == 1 # one spawn, no retry needed + + +def test_generate_json_via_cli_no_cli_when_binary_missing(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: None) + with pytest.raises(C.CliAgentError) as ei: + C.generate_json_via_cli("claude-code", "P") + assert ei.value.kind == "no_cli" + + +def test_generate_json_via_cli_parse_retry_succeeds(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + # First spawn: garbage; second spawn (the one retry): valid. + fake = make_runner([ + FakeCompleted(returncode=0, stdout="garbage no json"), + FakeCompleted(returncode=0, stdout='{"ok": true}'), + ]) + monkeypatch.setattr(C.subprocess, "run", fake) + out = C.generate_json_via_cli("gemini-cli", "P") + assert out == {"ok": True} + assert len(fake.calls) == 2 + # The retry prompt must carry the corrective instruction. + retry_prompt = fake.calls[1]["argv"][2] # gemini -p + assert "ONLY the JSON" in retry_prompt + + +def test_generate_json_via_cli_invalid_json_after_retry_raises(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + fake = make_runner([ + FakeCompleted(returncode=0, stdout="nope"), + FakeCompleted(returncode=0, stdout="still nope"), + ]) + monkeypatch.setattr(C.subprocess, "run", fake) + with pytest.raises(C.CliAgentError) as ei: + C.generate_json_via_cli("gemini-cli", "P") + assert ei.value.kind == "invalid_json" + assert len(fake.calls) == 2 # exactly one retry, no third spawn + + +def test_generate_json_via_cli_schema_hint_appended_to_prompt_when_no_schema_flag(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + fake = make_runner([FakeCompleted(returncode=0, stdout='{"ok": 1}')]) + monkeypatch.setattr(C.subprocess, "run", fake) + # gemini has no schema flag -> schema_hint must be folded into the prompt text. + C.generate_json_via_cli("gemini-cli", "Base", schema_hint='{"type":"object"}') + sent_prompt = fake.calls[0]["argv"][2] + assert "Base" in sent_prompt + assert '{"type":"object"}' in sent_prompt + + +def test_generate_json_via_cli_claude_schema_uses_flag_not_prompt(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + fake = make_runner([FakeCompleted(returncode=0, stdout=json.dumps({"result": "{}"}))]) + monkeypatch.setattr(C.subprocess, "run", fake) + C.generate_json_via_cli("claude-code", "Base", schema_hint='{"type":"object"}', + structured_json="schema") + argv = fake.calls[0]["argv"] + assert "--json-schema" in argv + # schema goes via the flag, so the prompt slot stays the bare prompt + assert argv[2] == "Base" + + +def test_generate_json_via_cli_two_telemetry_rows_on_retry(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + fake = make_runner([ + FakeCompleted(returncode=0, stdout="bad"), + FakeCompleted(returncode=0, stdout='{"ok": true}'), + ]) + monkeypatch.setattr(C.subprocess, "run", fake) + C.generate_json_via_cli("gemini-cli", "P", task_id=5) + rows = [json.loads(l) for l in + (tmp_path / ".atlas-ai" / "telemetry.jsonl").read_text().splitlines()] + assert len(rows) == 2 + assert rows[0]["exit"] == 1 and rows[0]["parse_retry"] is False + assert rows[1]["exit"] == 0 and rows[1]["parse_retry"] is True + assert all(r["backend"] == "native-cli" for r in rows) + + +# ── Task 4: guardrails ─────────────────────────────────────────────────────── + +def test_generate_json_via_cli_api_provider_is_no_cli(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + # an API/plan provider name must never reach a spawn — it is no_cli. + monkeypatch.setattr(C.subprocess, "run", + lambda *a, **k: pytest.fail("must not spawn for api provider")) + for provider in ("anthropic", "openai", "perplexity", ""): + with pytest.raises(C.CliAgentError) as ei: + C.generate_json_via_cli(provider, "P") + assert ei.value.kind == "no_cli" + + +def test_codex_prompt_carried_on_stdin_not_argv(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + fake = make_runner([FakeCompleted(returncode=0, stdout='{"ok": true}')]) + monkeypatch.setattr(C.subprocess, "run", fake) + C.generate_json_via_cli("codex-cli", "SECRET PROMPT", schema_hint='{"x":1}') + argv = fake.calls[0]["argv"] + assert "SECRET PROMPT" not in " ".join(argv) # never on the command line + assert "SECRET PROMPT" in fake.calls[0]["kwargs"]["input"] # on stdin + assert '{"x":1}' in fake.calls[0]["kwargs"]["input"] # schema folded into stdin From 0177ae2cc643239b93fd9418704e105315fb923f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:26:05 +0800 Subject: [PATCH 10/73] fix(cli-agent): keep JSON directive with --json-schema; surface claude stdout error detail Fix #1: claude --output-format json --json-schema still returns prose in .result unless the prompt also carries a JSON-only directive. Drop the `not use_schema_flag` gate so the directive is always folded in for all providers (belt-and-suspenders). Fix #2: on nonzero exit, claude writes the real error in a JSON envelope on stdout (is_error/result/api_error_status) while stderr may hold only benign warnings. Add _claude_error_detail() that parses the envelope and surfaces the stdout reason; falls back to stderr then stdout. Use _has_schema_flag() instead of inline str check. Add two regression tests locking both fixes. 386 passed, 0 failures. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/cli_agent.py | 44 ++++++++++++++++++++++++---- tests/core/test_cli_agent.py | 56 ++++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/prd_taskmaster/cli_agent.py b/prd_taskmaster/cli_agent.py index c5a63be..1164ba2 100644 --- a/prd_taskmaster/cli_agent.py +++ b/prd_taskmaster/cli_agent.py @@ -86,6 +86,30 @@ def _parse_claude_envelope(stdout): return envelope +def _claude_error_detail(stdout, stderr): + """For a claude-code nonzero exit, prefer the error reason from the JSON + envelope in stdout (which carries the real cause in .result / api_error_status) + over stderr (which may only hold benign warnings like 'no stdin data received'). + Falls back to stderr, then stdout, then a generic message.""" + import json as _json + if stdout: + try: + envelope = _json.loads(stdout.strip()) + if isinstance(envelope, dict) and envelope.get("is_error"): + parts = [] + result_text = envelope.get("result", "") + if result_text: + parts.append(str(result_text)) + status = envelope.get("api_error_status") + if status: + parts.append(f"api_error_status={status}") + if parts: + return "; ".join(parts)[:400] + except (_json.JSONDecodeError, ValueError): + pass + return (stderr or stdout or "no detail").strip()[:400] + + def _run_once(provider, binary, prompt, *, schema_hint, structured_json, model, op_class, task_id, timeout, parse_retry=False): """Spawn the CLI once, parse stdout into JSON. Returns the parsed dict/list, @@ -113,10 +137,13 @@ def _run_once(provider, binary, prompt, *, schema_hint, structured_json, if completed.returncode != 0: _telemetry(op_class, task_id, model, 1, start, parse_retry) - detail = (completed.stderr or completed.stdout or "").strip()[:400] + if _has_schema_flag(provider): + detail = _claude_error_detail(completed.stdout, completed.stderr) + else: + detail = (completed.stderr or completed.stdout or "").strip()[:400] raise CliAgentError("nonzero_exit", f"{binary} exit {completed.returncode}: {detail}") - if str(provider).lower() == "claude-code": + if _has_schema_flag(provider): result = _parse_claude_envelope(completed.stdout) else: result = _extract_json(completed.stdout) @@ -150,13 +177,20 @@ def generate_json_via_cli(provider, prompt, *, system="", schema_hint="", model= raise CliAgentError("no_cli", f"{cli} binary not on PATH") # Assemble the prompt the model sees. The CLI takes no separate system slot, - # so prepend system. Fold the schema into the prompt only when the CLI has no - # native schema flag (codex/gemini) or prompt-mode is forced for claude. + # so prepend system. Fold the schema into the prompt for codex/gemini (no + # native schema flag) or when prompt-mode is forced for claude. For claude, + # ALWAYS include a terse JSON-only directive even when --json-schema is used + # (belt-and-suspenders: claude v2.1.177+ still requires the prompt directive + # to reliably return JSON in .result rather than prose). base_prompt = prompt if system: base_prompt = system + "\n\n" + base_prompt use_schema_flag = _has_schema_flag(provider) and structured_json != "prompt" - if schema_hint and not use_schema_flag: + if schema_hint: + # For codex/gemini, folding the schema into the prompt is the only path. + # For claude, ALWAYS include a terse JSON-only directive even when + # --json-schema is also passed (belt-and-suspenders: claude v2.1.177+ + # still requires the prompt directive to reliably return JSON in .result). base_prompt += "\n\nReturn ONLY valid JSON matching:\n" + schema_hint flag_schema = schema_hint if use_schema_flag else "" diff --git a/tests/core/test_cli_agent.py b/tests/core/test_cli_agent.py index ce36b0c..86399b7 100644 --- a/tests/core/test_cli_agent.py +++ b/tests/core/test_cli_agent.py @@ -293,8 +293,60 @@ def test_generate_json_via_cli_claude_schema_uses_flag_not_prompt(monkeypatch, t structured_json="schema") argv = fake.calls[0]["argv"] assert "--json-schema" in argv - # schema goes via the flag, so the prompt slot stays the bare prompt - assert argv[2] == "Base" + # schema goes via the flag AND a JSON directive is folded into the prompt + # (belt-and-suspenders: claude v2.1.177+ requires both) + assert "Base" in argv[2] + assert "Return ONLY valid JSON" in argv[2] + + +# ── Fix #1: claude schema-mode always includes a JSON directive in the prompt ── + +def test_claude_schema_mode_prompt_always_contains_json_directive(monkeypatch, tmp_path): + """Regression: --json-schema alone is not enough on claude v2.1.177+. + The prompt must ALSO carry a JSON-only directive so .result is JSON, not prose.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(C.shutil, "which", lambda c: "/bin/" + c) + fake = make_runner([FakeCompleted(returncode=0, stdout=json.dumps({"result": '{"x":1}'}))]) + monkeypatch.setattr(C.subprocess, "run", fake) + C.generate_json_via_cli( + "claude-code", "Do something", schema_hint='{"type":"object"}', + ) + argv = fake.calls[0]["argv"] + # --json-schema flag must be present (native schema path) + assert "--json-schema" in argv + # The prompt passed to -p must ALSO contain a JSON-only directive + sent_prompt = argv[2] + assert "Return ONLY valid JSON" in sent_prompt + + +# ── Fix #2: claude nonzero-exit surfaces stdout envelope error, not stderr ───── + +def test_claude_nonzero_exit_surfaces_stdout_envelope_reason(monkeypatch, tmp_path): + """Regression: on nonzero exit, claude writes the real error in the JSON + envelope on stdout; STDERR may only hold benign warnings. + The CliAgentError message must contain the stdout reason, not the stderr noise.""" + monkeypatch.chdir(tmp_path) + stdout_envelope = json.dumps({ + "is_error": True, + "result": "model claude-3-7-not-found returned 404", + "api_error_status": 404, + }) + stderr_noise = "Warning: no stdin data received" + fake = make_runner([ + FakeCompleted(returncode=1, stdout=stdout_envelope, stderr=stderr_noise) + ]) + monkeypatch.setattr(C.subprocess, "run", fake) + with pytest.raises(C.CliAgentError) as ei: + C._run_once( + "claude-code", "/bin/claude", "P", schema_hint="", structured_json="auto", + model=None, op_class="structured_gen", task_id=None, timeout=180, + ) + assert ei.value.kind == "nonzero_exit" + error_msg = str(ei.value) + # Must surface the stdout reason (the real cause) + assert "404" in error_msg or "model claude-3-7-not-found" in error_msg + # Must NOT surface only the benign stderr warning + assert "no stdin data received" not in error_msg def test_generate_json_via_cli_two_telemetry_rows_on_retry(monkeypatch, tmp_path): From 8faec1611bc4c19689a878cf46a2d423ad5dba57 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:30:57 +0800 Subject: [PATCH 11/73] feat(providers): per-process spawn-probe cache with failure invalidation _probe_spawn_cached(provider, ttl_s): module-level dict keyed by provider, time.monotonic TTL, True cached for ttl_s, False never cached so a transient nested-spawn refusal re-probes instead of pinning off the free path. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/providers.py | 28 ++++++++ tests/core/test_providers_probe_cache.py | 82 ++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/core/test_providers_probe_cache.py diff --git a/prd_taskmaster/providers.py b/prd_taskmaster/providers.py index b9d939b..c6748d2 100644 --- a/prd_taskmaster/providers.py +++ b/prd_taskmaster/providers.py @@ -4,6 +4,7 @@ import os import shutil import subprocess +import time from pathlib import Path from prd_taskmaster.economy import TIER_MODEL_IDS, economy_profile @@ -146,6 +147,33 @@ def _probe_spawn(provider: object) -> bool: return False +# Per-process spawn-probe cache. Keyed by provider -> (monotonic_ts, result). +# Dedupes the 60s probe across the in-process ThreadPoolExecutor fan-out (the +# acceptance-criteria case). Cross-process dedup is out of scope (#1). A False +# result is NEVER stored: a transient nested-spawn refusal must not pin the +# provider off the free path for the whole TTL. +_PROBE_CACHE: dict[str, tuple[float, bool]] = {} + + +def _probe_spawn_cached(provider: object, ttl_s: int) -> bool: + """Cached _probe_spawn: at most one real probe per provider per ttl_s. + True results are cached for ttl_s; False results invalidate the entry so + the next call re-probes (empirical demote, not a sticky failure).""" + key = str(provider or "").lower() + now = time.monotonic() + entry = _PROBE_CACHE.get(key) + if entry is not None: + ts, result = entry + if now - ts < ttl_s: + return result + result = _probe_spawn(provider) + if result: + _PROBE_CACHE[key] = (now, result) + else: + _PROBE_CACHE.pop(key, None) + return result + + def _resolve_configure_profile(economy: str | None) -> dict: cfg = load_fleet_config() if economy is None else {"token_economy": economy} return economy_profile(cfg) diff --git a/tests/core/test_providers_probe_cache.py b/tests/core/test_providers_probe_cache.py new file mode 100644 index 0000000..27ac00b --- /dev/null +++ b/tests/core/test_providers_probe_cache.py @@ -0,0 +1,82 @@ +# tests/core/test_providers_probe_cache.py +"""Per-process spawn-probe cache: _probe_spawn at most once per provider per TTL, +invalidated on the first False result. No real subprocess is ever spawned -- +_probe_spawn itself is monkeypatched and time.monotonic is driven by the test.""" + +import prd_taskmaster.providers as providers + + +def _reset_cache(): + providers._PROBE_CACHE.clear() + + +def test_cached_hit_calls_probe_once_within_ttl(monkeypatch): + _reset_cache() + calls = {"n": 0} + + def fake_probe(provider): + calls["n"] += 1 + return True + + clock = {"t": 1000.0} + monkeypatch.setattr(providers, "_probe_spawn", fake_probe) + monkeypatch.setattr(providers.time, "monotonic", lambda: clock["t"]) + + assert providers._probe_spawn_cached("claude-code", 900) is True + clock["t"] = 1500.0 # 500s later, still inside the 900s TTL + assert providers._probe_spawn_cached("claude-code", 900) is True + assert calls["n"] == 1 # second call served from cache + + +def test_reprobes_after_ttl_expires(monkeypatch): + _reset_cache() + calls = {"n": 0} + + def fake_probe(provider): + calls["n"] += 1 + return True + + clock = {"t": 1000.0} + monkeypatch.setattr(providers, "_probe_spawn", fake_probe) + monkeypatch.setattr(providers.time, "monotonic", lambda: clock["t"]) + + assert providers._probe_spawn_cached("claude-code", 900) is True + clock["t"] = 1000.0 + 901.0 # just past TTL + assert providers._probe_spawn_cached("claude-code", 900) is True + assert calls["n"] == 2 # TTL expired -> re-probed + + +def test_false_result_is_not_cached(monkeypatch): + _reset_cache() + calls = {"n": 0} + results = iter([False, True]) # first probe refuses, second succeeds + + def fake_probe(provider): + calls["n"] += 1 + return next(results) + + clock = {"t": 1000.0} + monkeypatch.setattr(providers, "_probe_spawn", fake_probe) + monkeypatch.setattr(providers.time, "monotonic", lambda: clock["t"]) + + assert providers._probe_spawn_cached("claude-code", 900) is False + # Same instant, well inside TTL -- a cached False would skip the probe. + assert providers._probe_spawn_cached("claude-code", 900) is True + assert calls["n"] == 2 # the False was never cached + + +def test_distinct_providers_are_cached_independently(monkeypatch): + _reset_cache() + seen = [] + + def fake_probe(provider): + seen.append(provider) + return True + + monkeypatch.setattr(providers, "_probe_spawn", fake_probe) + monkeypatch.setattr(providers.time, "monotonic", lambda: 1000.0) + + assert providers._probe_spawn_cached("claude-code", 900) is True + assert providers._probe_spawn_cached("codex-cli", 900) is True + assert providers._probe_spawn_cached("claude-code", 900) is True + assert seen == ["claude-code", "codex-cli"] # claude-code second call cached From 5e1ae4dd8b1f75ef0098c4c451b151f70501356f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:32:03 +0800 Subject: [PATCH 12/73] feat(resolver): ProviderHandle + resolve_provider 3-tier precedence New prd_taskmaster/provider_resolver.py. frozen ProviderHandle(kind/provider/ role/model/reason). resolve_provider consults engine_config + _read_taskmaster_ model + _provider_usable + _probe_spawn_cached + discover_key; plan floor always. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/provider_resolver.py | 119 +++++++++++++++++++++++++++ tests/core/test_provider_resolver.py | 60 ++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 prd_taskmaster/provider_resolver.py create mode 100644 tests/core/test_provider_resolver.py diff --git a/prd_taskmaster/provider_resolver.py b/prd_taskmaster/provider_resolver.py new file mode 100644 index 0000000..14ea7de --- /dev/null +++ b/prd_taskmaster/provider_resolver.py @@ -0,0 +1,119 @@ +"""Single decision point: pick the provider TIER for one role at gen time. + +Three kinds, in contract precedence (keyless_default truthy/null): + 1. cli -- provider_mode in {hybrid, cli_only} AND role provider is a spawning + CLI AND usable (_provider_usable) AND _probe_spawn_cached() true. + 2. api -- provider_mode in {hybrid, api_only} AND discover_key() returns creds. + 3. plan -- always the floor; returned when nothing above is usable. +keyless_default=False swaps tiers 1 and 2. provider_mode=plan_only short-circuits +to plan; cli_only never falls through to api; api_only never tries the CLI. + +Nothing here spawns a process or hits the network: it consults + - _read_taskmaster_model(role) -> the role's configured provider + - _provider_usable(...) -> credential/CLI presence + - _probe_spawn_cached(provider,ttl) -> empirical nested-spawn check (cached) + - discover_key() -> raw-key API creds +The cli_agent / llm_client actually run the chosen tier downstream. +""" + +import os +import shutil +from dataclasses import dataclass + +from prd_taskmaster.fleet import engine_config +from prd_taskmaster.lib import _read_taskmaster_model +from prd_taskmaster.llm_client import discover_key +from prd_taskmaster.providers import ( + _SPAWNING_PROVIDERS, + _provider_usable, + _probe_spawn_cached, +) + + +@dataclass(frozen=True) +class ProviderHandle: + kind: str # "cli" | "api" | "plan" + provider: str # e.g. "claude-code", "anthropic", "" + role: str # "main" | "fallback" | "research" + model: str | None + reason: str # human-readable why this tier (telemetry/logs) + + +def _usability_facts() -> dict: + """The keyword args _provider_usable expects. discover_key is consulted + separately for the api tier; here we only need CLI/key presence flags.""" + return { + "has_claude": shutil.which("claude") is not None, + "has_codex": shutil.which("codex") is not None, + "has_anthropic_key": bool(os.environ.get("ANTHROPIC_API_KEY")), + "has_openai_key": bool(os.environ.get("OPENAI_API_KEY")), + "has_perplexity_key": bool(os.environ.get("PERPLEXITY_API_KEY")), + } + + +def _try_cli(role: str, provider: str, model, ttl_s: int) -> ProviderHandle | None: + """CLI tier: spawning provider, usable, and spawn probe passes (cached).""" + if provider not in _SPAWNING_PROVIDERS: + return None + if not _provider_usable(provider, **_usability_facts()): + return None + if not _probe_spawn_cached(provider, ttl_s): + return None + return ProviderHandle( + kind="cli", provider=provider, role=role, model=model, + reason=f"keyless CLI-agent ({provider}) usable + spawn-probe ok", + ) + + +def _try_api(role: str, model) -> ProviderHandle | None: + """Raw-key API tier: discover_key found credentials.""" + creds = discover_key() + if not creds: + return None + return ProviderHandle( + kind="api", provider=creds.get("provider", ""), role=role, model=model, + reason=f"raw-key API ({creds.get('provider', '')}) via discover_key", + ) + + +def _plan_floor(role: str, model, reason: str) -> ProviderHandle: + return ProviderHandle(kind="plan", provider="", role=role, model=model, reason=reason) + + +def resolve_provider(role, op_class="structured_gen", *, fleet_config=None) -> ProviderHandle: + """Resolve the provider tier for one role. See module docstring for precedence.""" + engine = engine_config(fleet_config) + mode = engine.get("provider_mode", "hybrid") + keyless = engine.get("keyless_default") # True | False | None + ttl_s = (engine.get("cli_agent") or {}).get("probe_cache_ttl_s", 900) + + role_cfg = _read_taskmaster_model(role) or {} + provider = str(role_cfg.get("provider", "")).lower() + model = role_cfg.get("modelId") + + if mode == "plan_only": + return _plan_floor(role, model, "provider_mode=plan_only") + + cli_allowed = mode in {"hybrid", "cli_only"} + api_allowed = mode in {"hybrid", "api_only"} + + # keyless_default False -> key-first; True/None -> CLI-first. + cli_first = keyless is not False + + if cli_first: + order = [ + ("cli", lambda: _try_cli(role, provider, model, ttl_s) if cli_allowed else None), + ("api", lambda: _try_api(role, model) if api_allowed else None), + ] + else: + order = [ + ("api", lambda: _try_api(role, model) if api_allowed else None), + ("cli", lambda: _try_cli(role, provider, model, ttl_s) if cli_allowed else None), + ] + + for _name, attempt in order: + handle = attempt() + if handle is not None: + return handle + + return _plan_floor(role, model, f"no usable CLI/API tier (mode={mode})") diff --git a/tests/core/test_provider_resolver.py b/tests/core/test_provider_resolver.py new file mode 100644 index 0000000..f5dd52a --- /dev/null +++ b/tests/core/test_provider_resolver.py @@ -0,0 +1,60 @@ +# tests/core/test_provider_resolver.py +"""resolve_provider precedence. Every external fact is monkeypatched -- no +subprocess, no network, no real config file is read. + +Knobs under test (all default-applied by fleet.engine_config): + provider_mode : hybrid | api_only | cli_only | plan_only + keyless_default : True/null -> CLI-first ; False -> key-first ; floor always +""" + +import prd_taskmaster.provider_resolver as pr +from prd_taskmaster.provider_resolver import ProviderHandle + + +def _engine(provider_mode="hybrid", keyless_default=None, ttl_s=900): + """A fleet_config dict whose engine block is what the resolver reads.""" + return { + "engine": { + "provider_mode": provider_mode, + "keyless_default": keyless_default, + "cli_agent": {"probe_cache_ttl_s": ttl_s}, + } + } + + +def _patch(monkeypatch, *, role_provider="claude-code", role_model="sonnet", + usable=True, probe=True, key=None): + """Wire the four facts resolve_provider consults.""" + monkeypatch.setattr( + pr, "_read_taskmaster_model", + lambda role: {"provider": role_provider, "modelId": role_model}, + ) + monkeypatch.setattr(pr, "_provider_usable", lambda *a, **k: usable) + monkeypatch.setattr(pr, "_probe_spawn_cached", lambda provider, ttl_s: probe) + monkeypatch.setattr(pr, "discover_key", lambda: key) + + +def test_handle_is_frozen_dataclass(): + h = ProviderHandle(kind="plan", provider="", role="main", model=None, reason="x") + assert (h.kind, h.provider, h.role, h.model, h.reason) == ("plan", "", "main", None, "x") + try: + h.kind = "cli" + assert False, "ProviderHandle must be frozen" + except Exception: + pass + + +def test_no_cli_no_key_falls_to_plan_floor(monkeypatch): + # claude-code role, but spawn probe refuses AND no API key -> plan floor. + _patch(monkeypatch, usable=True, probe=False, key=None) + h = pr.resolve_provider("main", fleet_config=_engine()) + assert h.kind == "plan" + assert h.provider == "" + assert h.role == "main" + + +def test_plan_only_mode_always_returns_plan(monkeypatch): + # Even with a perfectly usable CLI and a key, plan_only forces the floor. + _patch(monkeypatch, usable=True, probe=True, key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(provider_mode="plan_only")) + assert h.kind == "plan" From 46b7c1854987b7bbeb7093c304e3e4ac5c08537b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:32:27 +0800 Subject: [PATCH 13/73] test(resolver): keyless_default flips CLI-first <-> key-first tier order Co-Authored-By: Claude Fable 5 --- tests/core/test_provider_resolver.py | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/core/test_provider_resolver.py b/tests/core/test_provider_resolver.py index f5dd52a..0678a7d 100644 --- a/tests/core/test_provider_resolver.py +++ b/tests/core/test_provider_resolver.py @@ -58,3 +58,37 @@ def test_plan_only_mode_always_returns_plan(monkeypatch): _patch(monkeypatch, usable=True, probe=True, key={"provider": "anthropic"}) h = pr.resolve_provider("main", fleet_config=_engine(provider_mode="plan_only")) assert h.kind == "plan" + + +def test_cli_first_when_keyless_default_null(monkeypatch): + # Both a usable CLI and a key exist; keyless_default unset (None) -> CLI wins. + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(keyless_default=None)) + assert h.kind == "cli" + assert h.provider == "claude-code" + assert h.model == "sonnet" + + +def test_cli_first_when_keyless_default_true(monkeypatch): + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(keyless_default=True)) + assert h.kind == "cli" + + +def test_key_first_when_keyless_default_false(monkeypatch): + # Same facts, keyless_default False -> API wins despite the usable CLI. + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(keyless_default=False)) + assert h.kind == "api" + assert h.provider == "anthropic" + + +def test_key_first_demotes_to_cli_when_no_key(monkeypatch): + # keyless_default False but no key present -> still falls to the usable CLI, + # not the plan floor (api tier missing -> next tier in order). + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, key=None) + h = pr.resolve_provider("main", fleet_config=_engine(keyless_default=False)) + assert h.kind == "cli" From f075d9dc61654c2d6ffeadd5e25170ee24167d93 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:32:59 +0800 Subject: [PATCH 14/73] test(resolver): mode gating (api_only/cli_only) + spawn-refused demote Covers: api_only ignores usable CLI; api_only+no-key -> plan; cli_only ignores key; cli_only+refused -> plan; hybrid spawn-refused demotes to api (gh#11); non-spawning role provider skips CLI tier; unusable CLI demotes to api. Co-Authored-By: Claude Fable 5 --- tests/core/test_provider_resolver.py | 62 ++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/core/test_provider_resolver.py b/tests/core/test_provider_resolver.py index 0678a7d..0db47ff 100644 --- a/tests/core/test_provider_resolver.py +++ b/tests/core/test_provider_resolver.py @@ -92,3 +92,65 @@ def test_key_first_demotes_to_cli_when_no_key(monkeypatch): _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, key=None) h = pr.resolve_provider("main", fleet_config=_engine(keyless_default=False)) assert h.kind == "cli" + + +def test_api_only_ignores_usable_cli(monkeypatch): + # CLI is usable, but api_only must use the key and never the CLI. + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(provider_mode="api_only")) + assert h.kind == "api" + + +def test_api_only_with_no_key_falls_to_plan(monkeypatch): + # api_only + no key -> CLI tier is not allowed, so plan floor (not cli). + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, key=None) + h = pr.resolve_provider("main", fleet_config=_engine(provider_mode="api_only")) + assert h.kind == "plan" + + +def test_cli_only_ignores_key(monkeypatch): + # cli_only with a usable CLI uses it even though a key exists. + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=True, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(provider_mode="cli_only")) + assert h.kind == "cli" + + +def test_cli_only_with_refused_spawn_falls_to_plan(monkeypatch): + # cli_only + spawn refused -> api tier not allowed, so plan floor (not api), + # even with a key present. + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=False, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine(provider_mode="cli_only")) + assert h.kind == "plan" + + +def test_spawn_refused_demotes_to_api_in_hybrid(monkeypatch): + # hybrid, CLI usable per config but _probe_spawn_cached refuses -> demote to + # the key API tier (the nested-claude gh#11 case). This is the core demote. + _patch(monkeypatch, role_provider="claude-code", usable=True, probe=False, + key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine()) # hybrid, keyless null + assert h.kind == "api" + assert h.provider == "anthropic" + assert "spawn" not in h.reason or h.kind == "api" # reason reflects api tier + + +def test_non_spawning_role_provider_skips_cli_tier(monkeypatch): + # Role provider is a raw-key provider (anthropic), not a spawning CLI. The + # CLI tier is skipped on provider identity; with a key it resolves to api. + _patch(monkeypatch, role_provider="anthropic", role_model="claude-sonnet-4-20250514", + usable=True, probe=True, key={"provider": "anthropic"}) + h = pr.resolve_provider("main", fleet_config=_engine()) + assert h.kind == "api" + + +def test_unusable_cli_demotes_to_api(monkeypatch): + # Spawning provider in config, probe would pass, but _provider_usable is + # False (e.g. binary absent) -> demote to api. + _patch(monkeypatch, role_provider="codex-cli", usable=False, probe=True, + key={"provider": "anthropic"}) + h = pr.resolve_provider("fallback", fleet_config=_engine()) + assert h.kind == "api" + assert h.role == "fallback" From 50f2e507f6ec68843ef880f5e57a73670cf1a212 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:41:34 +0800 Subject: [PATCH 15/73] test(resolver): pin probe-cache TTL boundary + fix tautological demote assert Add test_ttl_boundary_exact_expires to prove strict < in _probe_spawn_cached expires the entry at elapsed==ttl_s (kills <= mutant). Replace the always-true "spawn not in reason or kind==api" guard with a real API-tier reason assertion. Add op_class reserved-for-Chunk-4 comment in resolve_provider. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/provider_resolver.py | 1 + tests/core/test_provider_resolver.py | 3 ++- tests/core/test_providers_probe_cache.py | 21 +++++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/prd_taskmaster/provider_resolver.py b/prd_taskmaster/provider_resolver.py index 14ea7de..d3eb265 100644 --- a/prd_taskmaster/provider_resolver.py +++ b/prd_taskmaster/provider_resolver.py @@ -81,6 +81,7 @@ def _plan_floor(role: str, model, reason: str) -> ProviderHandle: def resolve_provider(role, op_class="structured_gen", *, fleet_config=None) -> ProviderHandle: + # op_class is reserved for Chunk 4 op-class routing; accepted here but not yet used. """Resolve the provider tier for one role. See module docstring for precedence.""" engine = engine_config(fleet_config) mode = engine.get("provider_mode", "hybrid") diff --git a/tests/core/test_provider_resolver.py b/tests/core/test_provider_resolver.py index 0db47ff..e04b96c 100644 --- a/tests/core/test_provider_resolver.py +++ b/tests/core/test_provider_resolver.py @@ -134,7 +134,8 @@ def test_spawn_refused_demotes_to_api_in_hybrid(monkeypatch): h = pr.resolve_provider("main", fleet_config=_engine()) # hybrid, keyless null assert h.kind == "api" assert h.provider == "anthropic" - assert "spawn" not in h.reason or h.kind == "api" # reason reflects api tier + # reason must name the API tier, not the refused spawn path + assert "api" in h.reason.lower() or "API" in h.reason def test_non_spawning_role_provider_skips_cli_tier(monkeypatch): diff --git a/tests/core/test_providers_probe_cache.py b/tests/core/test_providers_probe_cache.py index 27ac00b..1e71fc3 100644 --- a/tests/core/test_providers_probe_cache.py +++ b/tests/core/test_providers_probe_cache.py @@ -65,6 +65,27 @@ def fake_probe(provider): assert calls["n"] == 2 # the False was never cached +def test_ttl_boundary_exact_expires(monkeypatch): + """At elapsed == ttl_s the entry is expired (strict < check), so a re-probe + occurs. A mutant that changes < to <= would serve the cache and keep calls at + 1 -- this test catches that by asserting calls["n"] == 2 at the exact boundary.""" + _reset_cache() + calls = {"n": 0} + + def fake_probe(provider): + calls["n"] += 1 + return True + + clock = {"t": 1000.0} + monkeypatch.setattr(providers, "_probe_spawn", fake_probe) + monkeypatch.setattr(providers.time, "monotonic", lambda: clock["t"]) + + assert providers._probe_spawn_cached("claude-code", 900) is True # primes cache at t=1000 + clock["t"] = 1900.0 # elapsed == ttl_s exactly (1900 - 1000 == 900) + assert providers._probe_spawn_cached("claude-code", 900) is True + assert calls["n"] == 2 # strict < means boundary is expired -> re-probed + + def test_distinct_providers_are_cached_independently(monkeypatch): _reset_cache() seen = [] From 86082e0e028dc6f889a9c8dbe31b2eb4148754bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:44:55 +0800 Subject: [PATCH 16/73] feat(backend): route parse_prd through resolve_provider (api/cli/plan) Chunk 4 Task 4.1. parse_prd now resolves role "main" and dispatches on the handle: plan -> parse floor, cli -> cli_agent.generate_json_via_cli (demoting CliAgentError to the floor), api -> existing llm_client.generate_json path (byte-identical). Adds _cli_timeout/_cli_structured_mode reading the shared engine.cli_agent config + imports cli_agent and resolve_provider. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/backend.py | 69 ++++++++++++++++------- tests/core/test_native_backend.py | 91 +++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 20 deletions(-) diff --git a/prd_taskmaster/backend.py b/prd_taskmaster/backend.py index 2d113a9..8165caa 100644 --- a/prd_taskmaster/backend.py +++ b/prd_taskmaster/backend.py @@ -12,8 +12,9 @@ from typing import Any, Protocol import prd_taskmaster -from prd_taskmaster import fleet, llm_client, parallel, taskmaster, tm_parallel +from prd_taskmaster import cli_agent, fleet, llm_client, parallel, taskmaster, tm_parallel from prd_taskmaster.economy import append_telemetry, economy_profile, shift_tier +from prd_taskmaster.provider_resolver import resolve_provider from prd_taskmaster.lib import CommandError, _detect_taskmaster_method, now_iso from prd_taskmaster.validation import run_validate_tasks @@ -301,6 +302,14 @@ def _report_candidates(tag: str | None) -> list[Path]: return paths +def _cli_timeout(config: dict | None = None) -> int: + return int(fleet.engine_config(config)["cli_agent"]["per_call_timeout_s"]) + + +def _cli_structured_mode(config: dict | None = None) -> str: + return str(fleet.engine_config(config)["cli_agent"]["structured_json"]) + + class NativeBackend(Backend): name = "native" @@ -342,7 +351,8 @@ def init_project(self) -> dict: } def parse_prd(self, prd_path, num_tasks, tag=None) -> dict: - if not llm_client.discover_key(): + handle = resolve_provider("main") + if handle.kind == "plan": return { "ok": False, "agent_action_required": _agent_parse_action(prd_path, num_tasks, tag), @@ -369,28 +379,47 @@ def parse_prd(self, prd_path, num_tasks, tag=None) -> dict: "the Native Mode tasks.json path." ) - try: - generated = llm_client.generate_json( - prompt, - system=system, - schema_hint=TASKS_SCHEMA_HINT, - tier=tier, - op_class="structured_gen", - return_telemetry_ref=True, - ) - except llm_client.LLMError as exc: - if exc.kind == "no_key": + telemetry_ref = None + if handle.kind == "cli": + try: + candidate = cli_agent.generate_json_via_cli( + handle.provider, + prompt, + system=system, + schema_hint=TASKS_SCHEMA_HINT, + model=handle.model, + op_class="structured_gen", + timeout=_cli_timeout(config), + structured_json=_cli_structured_mode(config), + ) + except cli_agent.CliAgentError: return { "ok": False, "agent_action_required": _agent_parse_action(prd_path, num_tasks, tag), } - return {"ok": False, "error": str(exc), "kind": exc.kind, "backend": "native"} - - telemetry_ref = None - if isinstance(generated, tuple) and len(generated) == 2: - candidate, telemetry_ref = generated + ai_label = "cli" else: - candidate = generated + try: + generated = llm_client.generate_json( + prompt, + system=system, + schema_hint=TASKS_SCHEMA_HINT, + tier=tier, + op_class="structured_gen", + return_telemetry_ref=True, + ) + except llm_client.LLMError as exc: + if exc.kind == "no_key": + return { + "ok": False, + "agent_action_required": _agent_parse_action(prd_path, num_tasks, tag), + } + return {"ok": False, "error": str(exc), "kind": exc.kind, "backend": "native"} + if isinstance(generated, tuple) and len(generated) == 2: + candidate, telemetry_ref = generated + else: + candidate = generated + ai_label = "api" try: tasks, validation = _validate_task_candidate(candidate) @@ -411,7 +440,7 @@ def parse_prd(self, prd_path, num_tasks, tag=None) -> dict: "task_count": len(tasks), "tag": resolved, "backend": "native", - "ai": "api", + "ai": ai_label, "validation": validation, } if telemetry_ref is not None: diff --git a/tests/core/test_native_backend.py b/tests/core/test_native_backend.py index 55b7935..48302a1 100644 --- a/tests/core/test_native_backend.py +++ b/tests/core/test_native_backend.py @@ -321,3 +321,94 @@ def test_no_key_operations_return_agent_action_required(tmp_path, monkeypatch): assert rate["ok"] is False assert rate["agent_action_required"]["op"] == "rate" assert "scoring_rubric" in rate["agent_action_required"] + + +def _stub_handle(kind, provider="", role="main", model=None, reason="test"): + from prd_taskmaster.provider_resolver import ProviderHandle + return ProviderHandle(kind=kind, provider=provider, role=role, model=model, reason=reason) + + +def test_parse_prd_cli_kind_drives_cli_agent(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend, TASKS_SCHEMA_HINT + + monkeypatch.chdir(tmp_path) + prd = tmp_path / "prd.md" + prd.write_text("# PRD\n\nREQ-001: Build native backend.") + # Even if a key existed, cli kind must win — prove the resolver, not discover_key, decides. + monkeypatch.setattr(llm_client, "discover_key", lambda: {"provider": "anthropic", "key": "k"}) + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("cli", "claude-code", role) + ) + + cli_calls = [] + + def fake_cli(provider, prompt, **kwargs): + cli_calls.append({"provider": provider, "prompt": prompt, **kwargs}) + return _valid_tasks(2) + + monkeypatch.setattr(backend_mod.cli_agent, "generate_json_via_cli", fake_cli) + # If the api path were taken this would blow up the test. + monkeypatch.setattr( + llm_client, "generate_json", lambda *a, **k: pytest.fail("api path taken on cli kind") + ) + + result = NativeBackend().parse_prd(prd, 2, tag="native-tag") + + assert result["ok"] is True + assert result["task_count"] == 2 + assert result["backend"] == "native" + assert result["ai"] == "cli" + assert len(cli_calls) == 1 + assert cli_calls[0]["provider"] == "claude-code" + assert cli_calls[0]["model"] is None # plan-kind handle has model=None; contract must thread it through + assert cli_calls[0]["schema_hint"] == TASKS_SCHEMA_HINT + assert cli_calls[0]["op_class"] == "structured_gen" + written = json.loads((tmp_path / ".taskmaster" / "tasks" / "tasks.json").read_text()) + assert [task["id"] for task in written["native-tag"]["tasks"]] == [1, 2] + + +def test_parse_prd_plan_kind_returns_agent_action_required(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend, TASKS_SCHEMA_HINT + + monkeypatch.chdir(tmp_path) + prd = tmp_path / "prd.md" + prd.write_text("# PRD\n") + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("plan", role=role) + ) + monkeypatch.setattr( + backend_mod.cli_agent, + "generate_json_via_cli", + lambda *a, **k: pytest.fail("cli path taken on plan kind"), + ) + + result = NativeBackend().parse_prd(prd, 1, tag="master") + + assert result["ok"] is False + assert result["agent_action_required"]["op"] == "parse_prd" + assert result["agent_action_required"]["schema_hint"] == TASKS_SCHEMA_HINT + + +def test_parse_prd_cli_agent_error_falls_back_to_plan(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend + + monkeypatch.chdir(tmp_path) + prd = tmp_path / "prd.md" + prd.write_text("# PRD\n") + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("cli", "claude-code", role) + ) + + def boom(provider, prompt, **kwargs): + raise backend_mod.cli_agent.CliAgentError("spawn_refused", "nested claude refused") + + monkeypatch.setattr(backend_mod.cli_agent, "generate_json_via_cli", boom) + + result = NativeBackend().parse_prd(prd, 1, tag="master") + + assert result["ok"] is False + # CLI failure must demote to the plan floor, not hard-error. + assert result["agent_action_required"]["op"] == "parse_prd" From 3faae07bef76e1387b534c3f58a388a7561279f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:46:26 +0800 Subject: [PATCH 17/73] feat(backend): cli-kind expansion worker inside the ThreadPoolExecutor fan-out Chunk 4 Task 4.2. expand() resolves role "main" ONCE and threads the handle + config into each _expand_packet worker, so N keyless `claude -p` children run concurrently inside the unchanged ThreadPoolExecutor. _expand_packet gains a cli short-circuit (one call, no api tier-escalation ladder; CliAgentError -> failed packet). plan-kind returns the expand floor; result "ai" now reflects handle.kind. The api path (escalation ladder) is byte-identical. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/backend.py | 34 ++++++++-- tests/core/test_native_backend.py | 109 ++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) diff --git a/prd_taskmaster/backend.py b/prd_taskmaster/backend.py index 8165caa..4c3b851 100644 --- a/prd_taskmaster/backend.py +++ b/prd_taskmaster/backend.py @@ -456,7 +456,8 @@ def expand(self, task_ids=None, research=True, tag=None) -> dict: except Exception as exc: return {"ok": False, "error": str(exc), "backend": "native"} - if not llm_client.discover_key(): + handle = resolve_provider("main") + if handle.kind == "plan": return { "ok": False, "tag": resolved, @@ -472,7 +473,7 @@ def expand(self, task_ids=None, research=True, tag=None) -> dict: with ThreadPoolExecutor(max_workers=workers) as executor: futures = [ - executor.submit(self._expand_packet, packet, profile, research) + executor.submit(self._expand_packet, packet, profile, research, handle, config) for packet in packets ] for future in as_completed(futures): @@ -504,10 +505,12 @@ def expand(self, task_ids=None, research=True, tag=None) -> dict: "failed": failed, "results": outcomes, "backend": "native", - "ai": "api", + "ai": handle.kind, } - def _expand_packet(self, packet: dict, profile: dict, research: bool) -> dict: + def _expand_packet( + self, packet: dict, profile: dict, research: bool, handle: Any, config: dict | None = None + ) -> dict: task_id = packet.get("id") start_tier = profile.get("structured_gen_start", "standard") prompt = packet.get("prompt", "") @@ -518,6 +521,29 @@ def _expand_packet(self, packet: dict, profile: dict, research: bool) -> dict: "one strict JSON result object for parallel.apply_results." ) + if handle.kind == "cli": + try: + result = cli_agent.generate_json_via_cli( + handle.provider, + prompt, + system=system, + schema_hint=PARALLEL_RESULT_SCHEMA_HINT, + model=handle.model, + op_class="structured_gen", + task_id=task_id, + timeout=_cli_timeout(config), + structured_json=_cli_structured_mode(config), + ) + except cli_agent.CliAgentError as exc: + return { + "ok": False, + "task_id": task_id, + "error": str(exc), + "kind": exc.kind, + "escalated": False, + } + return self._packet_success(packet, result, escalated=False) + try: result = llm_client.generate_json( prompt, diff --git a/tests/core/test_native_backend.py b/tests/core/test_native_backend.py index 48302a1..e2b4d07 100644 --- a/tests/core/test_native_backend.py +++ b/tests/core/test_native_backend.py @@ -412,3 +412,112 @@ def boom(provider, prompt, **kwargs): assert result["ok"] is False # CLI failure must demote to the plan floor, not hard-error. assert result["agent_action_required"]["op"] == "parse_prd" + + +def test_expand_cli_kind_drives_cli_agent_and_produces_graph(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend + + monkeypatch.chdir(tmp_path) + tasks_path = _seed_project(tmp_path, [_pending_task()]) + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("cli", "claude-code", role) + ) + monkeypatch.setattr( + llm_client, "generate_json", lambda *a, **k: pytest.fail("api path taken on cli kind") + ) + + cli_calls = [] + + def fake_cli(provider, prompt, **kwargs): + cli_calls.append({"provider": provider, **kwargs}) + return { + "id": 1, + "complexityScore": 8, + "recommendedSubtasks": 2, + "reasoning": "Needs careful backend integration.", + "researchNotes": "Reuse parallel.apply_results for the merge.", + "subtasks": [ + {"title": "Write expansion test", "description": "Cover merge.", + "details": "Assert once.", "dependencies": []}, + {"title": "Implement expansion", "description": "Apply packet.", + "details": "CLI path.", "dependencies": [1]}, + ], + } + + monkeypatch.setattr(backend_mod.cli_agent, "generate_json_via_cli", fake_cli) + + result = NativeBackend().expand(tag="master") + + assert result["ok"] is True + assert result["applied"] == [1] + assert result["failed"] == [] + assert result["ai"] == "cli" + assert len(cli_calls) == 1 + assert cli_calls[0]["provider"] == "claude-code" + merged = json.loads(tasks_path.read_text()) + titles = [s["title"] for s in merged["master"]["tasks"][0]["subtasks"]] + assert titles == ["Write expansion test", "Implement expansion"] + + +def test_expand_plan_kind_returns_agent_action_required(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend + + monkeypatch.chdir(tmp_path) + _seed_project(tmp_path, [_pending_task()]) + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("plan", role=role) + ) + monkeypatch.setattr( + backend_mod.cli_agent, + "generate_json_via_cli", + lambda *a, **k: pytest.fail("cli path taken on plan kind"), + ) + + result = NativeBackend().expand(tag="master") + + assert result["ok"] is False + assert result["agent_action_required"]["op"] == "expand" + assert result["agent_action_required"]["packets"] + + +def test_expand_cli_kind_fans_out_in_parallel(tmp_path, monkeypatch): + """Three packets must be in flight concurrently on the cli path.""" + import threading + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend + + monkeypatch.chdir(tmp_path) + _seed_project(tmp_path, [_pending_task(1), _pending_task(2), _pending_task(3)]) + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("cli", "claude-code", role) + ) + # Force >=3 workers regardless of profile defaults. + monkeypatch.setattr(backend_mod, "_native_concurrency", lambda n, c, p: max(n, 3)) + + barrier = threading.Barrier(3, timeout=5) + + def fake_cli(provider, prompt, **kwargs): + # If fan-out were serial, the 2nd/3rd never arrive and this times out. + barrier.wait() + tid = kwargs["task_id"] + return { + "id": tid, + "complexityScore": 5, + "recommendedSubtasks": 2, + "reasoning": "parallel proof", + "researchNotes": "n/a", + "subtasks": [ + {"title": "a", "description": "x", "details": "y", "dependencies": []}, + {"title": "b", "description": "x", "details": "y", "dependencies": [1]}, + ], + } + + monkeypatch.setattr(backend_mod.cli_agent, "generate_json_via_cli", fake_cli) + + result = NativeBackend().expand(tag="master") + + assert result["ok"] is True + assert sorted(result["applied"]) == [1, 2, 3] + assert result["ai"] == "cli" From 7ce4ce547fa80570b6aca4e05d3a6946f7c9f84f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:47:30 +0800 Subject: [PATCH 18/73] feat(backend): route rate through resolve_provider (api/cli/plan) Chunk 4 Task 4.3. rate() resolves role "main" and dispatches: plan -> rate floor, cli -> cli_agent.generate_json_via_cli (CliAgentError demotes to floor), api -> existing llm_client.generate_json (unchanged). Report write + result "ai" now reflects the resolved kind. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/backend.py | 48 +++++++++++++++------ tests/core/test_native_backend.py | 69 +++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/prd_taskmaster/backend.py b/prd_taskmaster/backend.py index 4c3b851..b871601 100644 --- a/prd_taskmaster/backend.py +++ b/prd_taskmaster/backend.py @@ -641,7 +641,8 @@ def rate(self, tag=None, research=True) -> dict: return {"ok": False, "error": str(exc), "backend": "native"} summaries = _task_summaries(tasks) - if not llm_client.discover_key(): + handle = resolve_provider("main") + if handle.kind == "plan": return { "ok": False, "tag": resolved, @@ -662,22 +663,43 @@ def rate(self, tag=None, research=True) -> dict: "You are the prd-taskmaster native backend complexity engine. Return " "strict JSON in TaskMaster complexity report format." ) - try: - candidate = llm_client.generate_json( - prompt, - system=system, - schema_hint=COMPLEXITY_REPORT_SCHEMA_HINT, - tier=tier, - op_class="structured_gen", - ) - except llm_client.LLMError as exc: - if exc.kind == "no_key": + if handle.kind == "cli": + try: + candidate = cli_agent.generate_json_via_cli( + handle.provider, + prompt, + system=system, + schema_hint=COMPLEXITY_REPORT_SCHEMA_HINT, + model=handle.model, + op_class="structured_gen", + timeout=_cli_timeout(config), + structured_json=_cli_structured_mode(config), + ) + except cli_agent.CliAgentError: return { "ok": False, "tag": resolved, "agent_action_required": _agent_rate_action(resolved, summaries), } - return {"ok": False, "error": str(exc), "kind": exc.kind, "backend": "native"} + ai_label = "cli" + else: + try: + candidate = llm_client.generate_json( + prompt, + system=system, + schema_hint=COMPLEXITY_REPORT_SCHEMA_HINT, + tier=tier, + op_class="structured_gen", + ) + except llm_client.LLMError as exc: + if exc.kind == "no_key": + return { + "ok": False, + "tag": resolved, + "agent_action_required": _agent_rate_action(resolved, summaries), + } + return {"ok": False, "error": str(exc), "kind": exc.kind, "backend": "native"} + ai_label = "api" if isinstance(candidate, dict): analysis = candidate.get("complexityAnalysis") @@ -716,7 +738,7 @@ def rate(self, tag=None, research=True) -> dict: "complexityAnalysis": analysis, "raw": report, "backend": "native", - "ai": "api", + "ai": ai_label, } diff --git a/tests/core/test_native_backend.py b/tests/core/test_native_backend.py index e2b4d07..3c27f0e 100644 --- a/tests/core/test_native_backend.py +++ b/tests/core/test_native_backend.py @@ -521,3 +521,72 @@ def fake_cli(provider, prompt, **kwargs): assert result["ok"] is True assert sorted(result["applied"]) == [1, 2, 3] assert result["ai"] == "cli" + + +def _complexity_payload(): + return { + "complexityAnalysis": [ + { + "taskId": 1, + "taskTitle": "Task 1", + "complexityScore": 5, + "recommendedSubtasks": 3, + "expansionPrompt": "Expand Task 1", + "reasoning": "Moderate implementation work.", + } + ] + } + + +def test_rate_cli_kind_drives_cli_agent(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend + + monkeypatch.chdir(tmp_path) + _seed_project(tmp_path, [_pending_task()]) + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("cli", "claude-code", role) + ) + monkeypatch.setattr( + llm_client, "generate_json", lambda *a, **k: pytest.fail("api path taken on cli kind") + ) + + cli_calls = [] + + def fake_cli(provider, prompt, **kwargs): + cli_calls.append({"provider": provider, **kwargs}) + return _complexity_payload() + + monkeypatch.setattr(backend_mod.cli_agent, "generate_json_via_cli", fake_cli) + + result = NativeBackend().rate(tag="master") + + assert result["ok"] is True + assert result["ai"] == "cli" + assert result["complexityAnalysis"][0]["taskId"] == 1 + assert len(cli_calls) == 1 + assert cli_calls[0]["provider"] == "claude-code" + report = tmp_path / ".taskmaster" / "reports" / "task-complexity-report.json" + assert report.is_file() + + +def test_rate_plan_kind_returns_agent_action_required(tmp_path, monkeypatch): + from prd_taskmaster import backend as backend_mod + from prd_taskmaster.backend import NativeBackend + + monkeypatch.chdir(tmp_path) + _seed_project(tmp_path, [_pending_task()]) + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("plan", role=role) + ) + monkeypatch.setattr( + backend_mod.cli_agent, + "generate_json_via_cli", + lambda *a, **k: pytest.fail("cli path taken on plan kind"), + ) + + result = NativeBackend().rate(tag="master") + + assert result["ok"] is False + assert result["agent_action_required"]["op"] == "rate" + assert "scoring_rubric" in result["agent_action_required"] From 72b1efe1a3e84d26c0b9922954b31e61b5d75dd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:48:49 +0800 Subject: [PATCH 19/73] test(backend): pin resolver to plan in legacy no-key test for host independence Chunk 4 Task 4.4. test_no_key_operations_return_agent_action_required pins resolve_provider -> plan so the no-key->floor intent holds regardless of whether a claude/codex/gemini CLI is installed on the host running the suite. Co-Authored-By: Claude Fable 5 --- tests/core/test_native_backend.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/core/test_native_backend.py b/tests/core/test_native_backend.py index 3c27f0e..85e8b1a 100644 --- a/tests/core/test_native_backend.py +++ b/tests/core/test_native_backend.py @@ -303,6 +303,10 @@ def test_no_key_operations_return_agent_action_required(tmp_path, monkeypatch): prd.write_text("# PRD\n") _seed_project(tmp_path, [_pending_task()]) monkeypatch.setattr(llm_client, "discover_key", lambda: None) + from prd_taskmaster import backend as backend_mod + monkeypatch.setattr( + backend_mod, "resolve_provider", lambda role, *a, **k: _stub_handle("plan", role=role) + ) parse = NativeBackend().parse_prd(prd, 1, tag="master") assert parse["ok"] is False From 02b6098078ea609480a37011868bcd4cf97d6907 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 11:59:35 +0800 Subject: [PATCH 20/73] refactor(validate): task-master binary/version checks advisory off plan_only Contract item 8: the keyless hybrid engine no longer depends on the task-master binary, so its presence/version is advisory (excluded from critical_failures) when provider_mode != plan_only. plan_only keeps the hard gate. validate_setup gains a provider_mode param (default read from fleet.engine_config()). Two legacy capabilities tests updated for the new advisory behavior: - fails_without_binary now pins provider_mode=plan_only to assert the hard gate it was written for (binary fix present, ready False). - critical_failures_count filter now excludes 'advisory' too, mirroring the impl's definition of critical. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/mode_recommend.py | 33 +++++++-- tests/core/test_capabilities.py | 16 +++-- tests/core/test_mode_recommend_validate.py | 79 ++++++++++++++++++++++ 3 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 tests/core/test_mode_recommend_validate.py diff --git a/prd_taskmaster/mode_recommend.py b/prd_taskmaster/mode_recommend.py index e638339..bf15117 100644 --- a/prd_taskmaster/mode_recommend.py +++ b/prd_taskmaster/mode_recommend.py @@ -15,6 +15,7 @@ from pathlib import Path from typing import Any +from prd_taskmaster.fleet import engine_config from prd_taskmaster.lib import emit_json_error from prd_taskmaster.providers import ( _has_perplexity_api_key, @@ -364,7 +365,7 @@ def detect_capabilities() -> dict: } -def validate_setup() -> dict: +def validate_setup(provider_mode: str | None = None) -> dict: """Run all Phase 0 SETUP checks and return per-check pass/fail + fix hints. Returns EXACTLY 6 checks (spec §5): @@ -381,6 +382,13 @@ def validate_setup() -> dict: critical_failures: int checks: list[dict] — exactly 6 entries, each with id, passed, fix """ + if provider_mode is None: + provider_mode = engine_config().get("provider_mode", "hybrid") + # When the engine is NOT plan_only it no longer depends on the task-master + # binary (sub-project #1 removes it), so its presence/version is advisory, + # not a critical gate. plan_only keeps the binary as a hard requirement. + taskmaster_advisory = provider_mode != "plan_only" + checks = [] # Check 1: task-master binary installed @@ -407,9 +415,16 @@ def validate_setup() -> dict: "passed": bool(cli_path), "detail": ( f"Found at {cli_path} (version {cli_version})" if cli_path - else "Not found in PATH" + else ( + "Not found in PATH (advisory: engine no longer requires it)" + if taskmaster_advisory else "Not found in PATH" + ) + ), + "fix": ( + None if cli_path or taskmaster_advisory + else "npm install -g task-master-ai" ), - "fix": "npm install -g task-master-ai" if not cli_path else None, + **({"severity": "advisory"} if taskmaster_advisory else {}), }) # Check 2: version >= minimum @@ -423,8 +438,11 @@ def validate_setup() -> dict: if version_info.get("detected_version") else "version not detectable" ), - "fix": "npm install -g task-master-ai@latest" if not version_info["supported"] else None, - "severity": "warning", + "fix": ( + None if version_info["supported"] or taskmaster_advisory + else "npm install -g task-master-ai@latest" + ), + "severity": "advisory" if taskmaster_advisory else "warning", }) # Check 3: .taskmaster/ directory exists @@ -560,10 +578,11 @@ def validate_setup() -> dict: "severity": "warning", }) - # Aggregate — only non-warning failures are "critical" + # Aggregate — neither "warning" nor "advisory" failures are "critical" + _non_critical = {"warning", "advisory"} critical_failures = [ c for c in checks - if not c["passed"] and c.get("severity") != "warning" + if not c["passed"] and c.get("severity") not in _non_critical ] all_passed = len(critical_failures) == 0 diff --git a/tests/core/test_capabilities.py b/tests/core/test_capabilities.py index 1c5a80f..f2a8173 100644 --- a/tests/core/test_capabilities.py +++ b/tests/core/test_capabilities.py @@ -189,11 +189,15 @@ def test_validate_setup_each_check_has_fix_hint(self, monkeypatch, tmp_path): assert "fix" in check def test_validate_setup_fails_without_binary(self, monkeypatch, tmp_path): - """binary check fails when task-master is not in PATH.""" + """binary check fails (hard) when task-master is not in PATH — but only in + plan_only mode. The hybrid engine no longer requires the binary, so the + binary check + its npm-install fix is only a HARD gate under plan_only + (Chunk 5: task-master checks went advisory off plan_only). Pinned to + plan_only to assert the hard-gate behavior this test was written for.""" monkeypatch.setenv("PATH", str(tmp_path)) # empty dir — no binary monkeypatch.chdir(tmp_path) - result = validate_setup() + result = validate_setup(provider_mode="plan_only") binary_check = next(c for c in result["checks"] if c["id"] == "binary") assert binary_check["passed"] is False @@ -234,14 +238,18 @@ def test_validate_setup_passes_with_full_project(self, monkeypatch, tmp_path): assert result["critical_failures"] == 0 def test_validate_setup_critical_failures_count(self, monkeypatch, tmp_path): - """critical_failures counts non-warning failed checks only.""" + """critical_failures counts failed checks that are neither 'warning' nor + 'advisory'. (Chunk 5 added the 'advisory' severity for the task-master + binary/version checks off plan_only — the in-test filter mirrors the + impl's definition of 'critical'.)""" monkeypatch.setenv("PATH", str(tmp_path)) monkeypatch.chdir(tmp_path) result = validate_setup() + _non_critical = {"warning", "advisory"} critical_ids = [ c["id"] for c in result["checks"] - if not c["passed"] and c.get("severity") != "warning" + if not c["passed"] and c.get("severity") not in _non_critical ] assert result["critical_failures"] == len(critical_ids) diff --git a/tests/core/test_mode_recommend_validate.py b/tests/core/test_mode_recommend_validate.py new file mode 100644 index 0000000..cae1ceb --- /dev/null +++ b/tests/core/test_mode_recommend_validate.py @@ -0,0 +1,79 @@ +"""validate_setup: task-master binary/version checks are advisory in hybrid mode.""" +import json + +import pytest + +from prd_taskmaster import mode_recommend + + +def _no_taskmaster(monkeypatch): + """No task-master binary on PATH, no claude/codex either.""" + monkeypatch.setattr(mode_recommend.shutil, "which", lambda name: None) + + +def _not_nested(monkeypatch): + """Deterministic non-nested context so the main-provider spawn probe does not + fire (provider_main usability then turns purely on the credential/CLI check).""" + monkeypatch.delenv("CLAUDECODE", raising=False) + monkeypatch.delenv("CLAUDE_CODE_CHILD_SESSION", raising=False) + +def _seed_config(tmp_path, main_provider="claude-code"): + tm = tmp_path / ".taskmaster" + tm.mkdir(parents=True, exist_ok=True) + (tm / "config.json").write_text(json.dumps({ + "models": { + "main": {"provider": main_provider, "modelId": "sonnet"}, + "research": {"provider": "perplexity", "modelId": "sonar"}, + "fallback": {"provider": "codex-cli", "modelId": "gpt-5.2-codex"}, + } + })) + + +def test_hybrid_mode_does_not_hard_fail_on_missing_taskmaster(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _seed_config(tmp_path) + _not_nested(monkeypatch) + _no_taskmaster(monkeypatch) + # claude usable so provider_main passes; only the task-master checks would fail. + monkeypatch.setattr(mode_recommend.shutil, "which", + lambda name: "/usr/bin/claude" if name == "claude" else None) + + result = mode_recommend.validate_setup(provider_mode="hybrid") + + binary = next(c for c in result["checks"] if c["id"] == "binary") + version = next(c for c in result["checks"] if c["id"] == "version") + assert binary["severity"] == "advisory" + assert version["severity"] == "advisory" + # binary/version are NOT in critical_failures even though they "failed" + assert not binary["passed"] + assert result["critical_failures"] == 0 + assert result["ready"] is True + + +def test_plan_only_mode_still_hard_fails_on_missing_taskmaster(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _seed_config(tmp_path) + _no_taskmaster(monkeypatch) + + result = mode_recommend.validate_setup(provider_mode="plan_only") + + binary = next(c for c in result["checks"] if c["id"] == "binary") + assert binary.get("severity") != "advisory" + assert not binary["passed"] + assert result["critical_failures"] >= 1 + assert result["ready"] is False + + +def test_default_provider_mode_reads_engine_config_hybrid(tmp_path, monkeypatch): + """No explicit provider_mode → engine_config() default 'hybrid' → advisory.""" + monkeypatch.chdir(tmp_path) + _seed_config(tmp_path) + _not_nested(monkeypatch) + monkeypatch.setattr(mode_recommend.shutil, "which", + lambda name: "/usr/bin/claude" if name == "claude" else None) + + result = mode_recommend.validate_setup() # no arg → engine_config default + + binary = next(c for c in result["checks"] if c["id"] == "binary") + assert binary["severity"] == "advisory" + assert result["ready"] is True From a501c852a8f1e3d096d4bb5638314ab25ed2cf94 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 12:01:06 +0800 Subject: [PATCH 21/73] feat(setup): wizard detect+recommend panel (step 1) run_setup() reuses run_detect_providers + detect_capabilities to render a per-role recommendation panel with reasons. Non-interactive scaffold; accept/ add-key/validate land next. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/setup_wizard.py | 118 ++++++++++++++++++++++++++++++++ tests/core/test_setup_wizard.py | 63 +++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 prd_taskmaster/setup_wizard.py create mode 100644 tests/core/test_setup_wizard.py diff --git a/prd_taskmaster/setup_wizard.py b/prd_taskmaster/setup_wizard.py new file mode 100644 index 0000000..73059c2 --- /dev/null +++ b/prd_taskmaster/setup_wizard.py @@ -0,0 +1,118 @@ +"""Setup wizard — `atlas setup`. + +Beats `task-master models --setup`: zero-config recommendation by default, an +optional guided layer that explains every auto-decision, and a live one-token +probe per chosen provider BEFORE the pipeline runs (the differentiator). + +Steps: Detect&Recommend → Accept → Customise → Add-key (writes +engine.keyless_default after asking) → Validate. + +Pure-core `run_setup()` returns a dict (never exits); `cmd_setup` is the CLI +wrapper. Interactivity is fully guarded behind `accept_default` / `validate_only` +so the function is non-interactive under --yes and in tests. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +from prd_taskmaster import fleet +from prd_taskmaster.lib import _ensure_env_entry +from prd_taskmaster.mode_recommend import detect_capabilities, validate_setup +from prd_taskmaster.providers import run_configure_providers, run_detect_providers + +# one-token live-probe commands per provider kind (Task 5) +_PROBE_CMD = { + "claude-code": ["claude", "-p", "ok"], + "codex-cli": ["codex", "--version"], + "gemini-cli": ["gemini", "--version"], +} +_PROBE_TIMEOUT = 60 + + +def _env_flag(name: str) -> bool: + return bool(os.environ.get(name)) + + +def _detect_line() -> str: + """`Atlas detected: claude ✓ codex ✓ gemini ✗ ANTHROPIC_API_KEY ✗ ...`""" + def mark(ok: bool) -> str: + return "✓" if ok else "✗" + claude = shutil.which("claude") is not None + codex = shutil.which("codex") is not None + gemini = shutil.which("gemini") is not None + akey = _env_flag("ANTHROPIC_API_KEY") + pkey = _env_flag("PERPLEXITY_API_KEY") or _env_flag("PERPLEXITY_API_BASE_URL") + return ( + f"Atlas detected: claude {mark(claude)} codex {mark(codex)} " + f"gemini {mark(gemini)} ANTHROPIC_API_KEY {mark(akey)} " + f"PERPLEXITY {mark(pkey)}" + ) + + +_ROLE_REASON = { + "claude-code": "free via your Claude session, no API key", + "codex-cli": "separate quota pool, runs in parallel", + "gemini-cli": "separate quota pool", + "anthropic": "paid Anthropic API key", + "perplexity-api-free": "local proxy on :8765", + "perplexity": "Perplexity API key", +} + + +def _recommend() -> dict: + """Reuse the zero-config detectors and shape a per-role recommendation.""" + detected = run_detect_providers().get("providers", {}) + caps = detect_capabilities() + recommendation = {} + for role in ("main", "fallback", "research"): + entry = detected.get(role, {}) + provider = entry.get("provider", "") + recommendation[role] = { + "provider": provider, + "modelId": entry.get("modelId"), + "source": entry.get("source", "-"), + "reason": _ROLE_REASON.get(provider, entry.get("source", "")), + } + return {"recommendation": recommendation, "capabilities": caps} + + +def _panel(recommendation: dict, caps: dict) -> list[str]: + lines = [_detect_line(), ""] + lines.append("Recommended (zero-config, keyless):") + for role in ("main", "fallback", "research"): + rec = recommendation[role] + model = rec.get("modelId") or "" + label = f"{rec['provider']}/{model}".rstrip("/") + lines.append(f" {role:<9} {label:<28} ← {rec['reason']}") + lines.append( + f"Tier: {caps.get('tier', 'free')} — {caps.get('recommended_reason', '')}" + ) + lines.append("[Enter] accept [c] customise [k] add an API key [v] validate only") + return lines + + +def _validate(mode: str | None) -> dict: + """Indirection so tests can stub the heavy validate path. Calls the + refactored validate_setup with the resolved provider_mode.""" + return validate_setup(provider_mode=mode) + + +def run_setup(accept_default: bool = False, validate_only: bool = False) -> dict: + """Drive the wizard. Returns a dict; never exits, never raises on the + happy path. Non-interactive when accept_default or validate_only is set.""" + rec = _recommend() + recommendation = rec["recommendation"] + caps = rec["capabilities"] + panel = _panel(recommendation, caps) + + result = { + "ok": True, + "panel": panel, + "recommendation": recommendation, + "tier": caps.get("tier", "free"), + } + # Accept / customise / add-key / validate are layered in Tasks 3–4. + return result diff --git a/tests/core/test_setup_wizard.py b/tests/core/test_setup_wizard.py new file mode 100644 index 0000000..4d76c26 --- /dev/null +++ b/tests/core/test_setup_wizard.py @@ -0,0 +1,63 @@ +"""Setup wizard: detect+recommend panel, accept, add-key, validate. + +Hermetic: every detector is monkeypatched and every subprocess.run is faked — +no real claude/codex/gemini is ever spawned, no network is touched. +""" +import json + +import pytest + +from prd_taskmaster import setup_wizard + + +def _stub_detectors(monkeypatch, *, claude=True, codex=True, gemini=False, + anthropic_key=False, perplexity_proxy=True): + """Stub run_detect_providers + detect_capabilities so the panel is deterministic.""" + providers = { + "main": {"provider": "claude-code" if claude else "anthropic", + "status": "detected", "source": "claude CLI"}, + "fallback": {"provider": "codex-cli" if codex else "claude-code", + "status": "detected", "source": "codex CLI"}, + "research": {"provider": "perplexity-api-free" if perplexity_proxy else "claude-code", + "status": "detected", "source": "proxy"}, + } + monkeypatch.setattr(setup_wizard, "run_detect_providers", + lambda: {"ok": True, "providers": providers}) + caps = { + "ok": True, "tier": "free", + "recommended_mode": "C", "recommended_reason": "Plan + Ralph Loop", + "capabilities": {"codex-cli": codex, "gemini-cli": gemini}, + "has_external_ai_tools": codex or gemini, + } + monkeypatch.setattr(setup_wizard, "detect_capabilities", lambda: caps) + # PATH-based presence flags used by the env-detection line. + def fake_which(name): + return { + "claude": "/usr/bin/claude" if claude else None, + "codex": "/usr/bin/codex" if codex else None, + "gemini": "/usr/bin/gemini" if gemini else None, + }.get(name) + monkeypatch.setattr(setup_wizard.shutil, "which", fake_which) + if anthropic_key: + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + else: + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + +def test_recommend_panel_lists_each_role_with_reason(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch) + monkeypatch.setattr(setup_wizard, "_validate", lambda mode: {"ok": True, "ready": True, "checks": []}) + + result = setup_wizard.run_setup(accept_default=True) + + panel = "\n".join(result["panel"]) + assert "Atlas detected" in panel + assert "claude ✓" in panel + assert "codex ✓" in panel + assert "gemini ✗" in panel + assert "main" in panel and "claude-code" in panel + assert "fallback" in panel and "codex-cli" in panel + assert "research" in panel + assert result["recommendation"]["main"]["provider"] == "claude-code" + assert result["ok"] is True From 2f6e20d980a0262ef862fc47653a21877ae78f97 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 12:02:28 +0800 Subject: [PATCH 22/73] feat(setup): Accept + Validate steps with live one-token probe --yes runs configure-providers + validate non-interactively (no input()). --validate runs validate_setup PLUS a live claude -p / codex --version probe per chosen provider, demoting `ready` on a real 401/ENOENT before the pipeline. The Task-2 panel test now stubs configure/live-probe since the accept path went live (no behavior change to the panel under test). Co-Authored-By: Claude Fable 5 --- prd_taskmaster/setup_wizard.py | 55 +++++++++++++++++++++++++++- tests/core/test_setup_wizard.py | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/prd_taskmaster/setup_wizard.py b/prd_taskmaster/setup_wizard.py index 73059c2..b65c200 100644 --- a/prd_taskmaster/setup_wizard.py +++ b/prd_taskmaster/setup_wizard.py @@ -100,6 +100,44 @@ def _validate(mode: str | None) -> dict: return validate_setup(provider_mode=mode) +def _live_probe(provider: str) -> dict: + """One-token liveness probe for a chosen provider. Surfaces a real + 401/ENOENT BEFORE the pipeline. Spawning CLIs only; API/proxy providers + are validated by validate_setup's credential checks, so they pass here.""" + cmd = _PROBE_CMD.get(provider) + if not cmd: + return {"provider": provider, "ok": True, "skipped": "no live probe for this provider"} + binary = shutil.which(cmd[0]) + if not binary: + return {"provider": provider, "ok": False, "error": f"{cmd[0]} not found in PATH"} + probe = [binary, *cmd[1:]] + try: + proc = subprocess.run(probe, capture_output=True, text=True, timeout=_PROBE_TIMEOUT) + except (subprocess.TimeoutExpired, OSError) as exc: + return {"provider": provider, "ok": False, "error": f"probe failed: {exc}"} + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "").strip().splitlines() + return {"provider": provider, "ok": False, "error": err[-1] if err else f"exit {proc.returncode}"} + return {"provider": provider, "ok": True} + + +def _run_validate_step(recommendation: dict, mode: str | None) -> dict: + """validate_setup (credential-aware checks) PLUS a live one-token probe per + chosen provider. A failed live probe demotes `ready` to False — that is the + 'surfaces a real 401 before the pipeline' differentiator.""" + base = _validate(mode) + probed = set() + live_probes = [] + for role in ("main", "fallback", "research"): + provider = recommendation.get(role, {}).get("provider", "") + if provider in _PROBE_CMD and provider not in probed: + probed.add(provider) + live_probes.append(_live_probe(provider)) + live_ok = all(p["ok"] for p in live_probes) + ready = bool(base.get("ready")) and live_ok + return {**base, "ready": ready, "live_probes": live_probes} + + def run_setup(accept_default: bool = False, validate_only: bool = False) -> dict: """Drive the wizard. Returns a dict; never exits, never raises on the happy path. Non-interactive when accept_default or validate_only is set.""" @@ -114,5 +152,20 @@ def run_setup(accept_default: bool = False, validate_only: bool = False) -> dict "recommendation": recommendation, "tier": caps.get("tier", "free"), } - # Accept / customise / add-key / validate are layered in Tasks 3–4. + + mode = fleet.engine_config().get("provider_mode", "hybrid") + + if validate_only: + result["validation"] = _run_validate_step(recommendation, mode) + return result + + if accept_default: + configured = run_configure_providers() + result["accepted"] = True + result["configured"] = configured + result["validation"] = _run_validate_step(recommendation, mode) + return result + + # Interactive branch is layered in Task 4 (Customise / Add-key prompts). + result["validation"] = _run_validate_step(recommendation, mode) return result diff --git a/tests/core/test_setup_wizard.py b/tests/core/test_setup_wizard.py index 4d76c26..546f828 100644 --- a/tests/core/test_setup_wizard.py +++ b/tests/core/test_setup_wizard.py @@ -48,6 +48,11 @@ def test_recommend_panel_lists_each_role_with_reason(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) _stub_detectors(monkeypatch) monkeypatch.setattr(setup_wizard, "_validate", lambda mode: {"ok": True, "ready": True, "checks": []}) + # Accept path is live now (configure + validate); stub the heavy steps so this + # test stays focused on the detect+recommend panel. + monkeypatch.setattr(setup_wizard, "run_configure_providers", + lambda *a, **k: {"ok": True, "changed": [], "models": {}}) + monkeypatch.setattr(setup_wizard, "_live_probe", lambda provider: {"provider": provider, "ok": True}) result = setup_wizard.run_setup(accept_default=True) @@ -61,3 +66,62 @@ def test_recommend_panel_lists_each_role_with_reason(tmp_path, monkeypatch): assert "research" in panel assert result["recommendation"]["main"]["provider"] == "claude-code" assert result["ok"] is True + + +def test_yes_is_non_interactive_and_configures(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch) + called = {"configure": 0, "input": 0} + monkeypatch.setattr(setup_wizard, "run_configure_providers", + lambda *a, **k: called.__setitem__("configure", called["configure"] + 1) or + {"ok": True, "changed": ["main"], "models": {}}) + monkeypatch.setattr(setup_wizard, "_validate", + lambda mode: {"ok": True, "ready": True, "checks": []}) + monkeypatch.setattr(setup_wizard, "_live_probe", lambda provider: {"provider": provider, "ok": True}) + # any input() call must blow the test up + def boom(*a, **k): + called["input"] += 1 + raise AssertionError("input() called under --yes") + monkeypatch.setattr("builtins.input", boom) + + result = setup_wizard.run_setup(accept_default=True) + + assert called["configure"] == 1 + assert called["input"] == 0 + assert result["accepted"] is True + assert result["validation"]["ready"] is True + + +def test_validate_surfaces_forced_auth_failure(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch) + # validate_setup passes, but the LIVE probe of the chosen provider fails (401/ENOENT). + monkeypatch.setattr(setup_wizard, "_validate", + lambda mode: {"ok": True, "ready": True, "checks": []}) + + def fake_run(cmd, **kw): + class R: + returncode = 1 + stdout = "" + stderr = "Error: 401 invalid x-api-key" + return R() + monkeypatch.setattr(setup_wizard.subprocess, "run", fake_run) + + result = setup_wizard.run_setup(validate_only=True) + + assert result["validation"]["ready"] is False # live probe demotes readiness + probes = result["validation"]["live_probes"] + assert any(p["ok"] is False and "401" in (p.get("error") or "") for p in probes) + + +def test_validate_only_does_not_configure(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch) + monkeypatch.setattr(setup_wizard, "run_configure_providers", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("configure under --validate"))) + monkeypatch.setattr(setup_wizard, "_validate", lambda mode: {"ok": True, "ready": True, "checks": []}) + monkeypatch.setattr(setup_wizard, "_live_probe", lambda provider: {"provider": provider, "ok": True}) + + result = setup_wizard.run_setup(validate_only=True) + assert result.get("accepted") is not True + assert result["validation"]["ready"] is True From 66baf4095f81623648059b2f0c8fd213d09ac62a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 12:04:18 +0800 Subject: [PATCH 23/73] feat(setup): Add-key step asks decision-#2 question, writes keyless_default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a key is added AND a spawning CLI exists, the wizard asks once (keyless-free vs paid-key as primary) and persists engine.keyless_default via fleet.save_engine_config. No CLI → no question, flag stays null (no global default). run_setup gains an injectable `choose` callback for the interactive branch so it stays test-safe. Adds fleet.save_engine_config(updates) — the atomic engine-block persister the Chunk-5 contract depends on. Chunk 1 landed engine_config() but not the persister; this fills that contract gap (engine block deep-merged, siblings and all other top-level fleet.json keys preserved). Tests read the persisted value back via engine_config(load_fleet_config()) since Chunk 1's no-arg engine_config() returns pure defaults (does not read the file). Co-Authored-By: Claude Fable 5 --- prd_taskmaster/fleet.py | 35 +++++++++++++++++ prd_taskmaster/setup_wizard.py | 70 ++++++++++++++++++++++++++++++++- tests/core/test_setup_wizard.py | 61 ++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 2 deletions(-) diff --git a/prd_taskmaster/fleet.py b/prd_taskmaster/fleet.py index 42a76c0..c9d06e5 100644 --- a/prd_taskmaster/fleet.py +++ b/prd_taskmaster/fleet.py @@ -150,6 +150,41 @@ def engine_config(cfg=None): return eng +def save_engine_config(updates): + """Deep-merge `updates` into the `engine` block of .atlas-ai/fleet.json, + write atomically, and return the new merged engine block (Chunk 5 contract). + + Only the `engine` sub-tree is touched; every other top-level key in the file + is preserved. A missing/unreadable/non-dict file is treated as empty. One + level of nested-dict merge (matching the engine block's shape) is applied so + a partial `{"cli_agent": {...}}` update does not clobber sibling cli_agent + keys. The returned block is normalized through `engine_config` (defaults + + validation) so callers see exactly what a fresh load would. + """ + path = FLEET_CONFIG_PATH + path.parent.mkdir(parents=True, exist_ok=True) + try: + doc = json.loads(path.read_text()) if path.is_file() else {} + except (json.JSONDecodeError, OSError): + doc = {} + if not isinstance(doc, dict): + doc = {} + engine = doc.get("engine") + if not isinstance(engine, dict): + engine = {} + if isinstance(updates, dict): + for k, v in updates.items(): + if isinstance(v, dict) and isinstance(engine.get(k), dict): + engine[k].update(v) + else: + engine[k] = v + doc["engine"] = engine + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(doc, indent=2)) + tmp.replace(path) + return engine_config(doc) + + def load_fleet_config(path=None): """Load .atlas-ai/fleet.json merged over defaults. diff --git a/prd_taskmaster/setup_wizard.py b/prd_taskmaster/setup_wizard.py index b65c200..267dde0 100644 --- a/prd_taskmaster/setup_wizard.py +++ b/prd_taskmaster/setup_wizard.py @@ -138,9 +138,53 @@ def _run_validate_step(recommendation: dict, mode: str | None) -> dict: return {**base, "ready": ready, "live_probes": live_probes} -def run_setup(accept_default: bool = False, validate_only: bool = False) -> dict: +def _has_spawning_cli() -> bool: + return any(shutil.which(b) for b in ("claude", "codex", "gemini")) + + +def add_key(var: str, ask_value, ask_keyless) -> dict: + """Add-key step (decision #2). + + `ask_value()` returns the raw key string (e.g. an input() call). + `ask_keyless()` returns True if the user wants the FREE keyless CLI as + primary, False if the PAID key. Both are injected so the step is testable. + + Writes the key to .env (via _ensure_env_entry — non-secret local append), + then — only when a key was added AND a spawning CLI exists — asks the + keyless/paid question and persists engine.keyless_default. With no CLI the + question is meaningless (only one path exists) so the flag stays unset + (null) — no global default imposed (decision #2).""" + value = (ask_value() or "").strip() + if not value: + return {"ok": False, "reason": "no key entered", "asked_keyless": False, + "keyless_default": fleet.engine_config().get("keyless_default")} + + changed = _ensure_env_entry(Path(".env"), var, value) + + asked = False + keyless_default = fleet.engine_config().get("keyless_default") + if _has_spawning_cli(): + asked = True + # True → keyless CLI primary → keyless_default True + # False → paid key primary → keyless_default False + keyless_default = bool(ask_keyless()) + fleet.save_engine_config({"keyless_default": keyless_default}) + + return { + "ok": True, + "env_changed": changed, + "var": var, + "asked_keyless": asked, + "keyless_default": keyless_default, + } + + +def run_setup(accept_default: bool = False, validate_only: bool = False, choose=None) -> dict: """Drive the wizard. Returns a dict; never exits, never raises on the happy path. Non-interactive when accept_default or validate_only is set.""" + if choose is None: + choose = input + rec = _recommend() recommendation = rec["recommendation"] caps = rec["capabilities"] @@ -166,6 +210,28 @@ def run_setup(accept_default: bool = False, validate_only: bool = False) -> dict result["validation"] = _run_validate_step(recommendation, mode) return result - # Interactive branch is layered in Task 4 (Customise / Add-key prompts). + # Interactive: present the panel, read a one-char choice. The default + # (Enter / 'a') accepts. 'k' adds a key + asks the decision-#2 question. + choice = (choose() or "").strip().lower() + if choice in ("", "a", "accept"): + configured = run_configure_providers() + result["accepted"] = True + result["configured"] = configured + elif choice in ("k", "key"): + result["add_key"] = add_key( + var="ANTHROPIC_API_KEY", + ask_value=lambda: choose("Paste API key: "), + ask_keyless=lambda: (choose( + "Primary provider? [k]eyless (free) / [p]aid key: ").strip().lower() + not in ("p", "paid")), + ) + configured = run_configure_providers() + result["accepted"] = True + result["configured"] = configured + elif choice in ("c", "customise", "customize"): + # Customise = repair-on-detect for now (task-master-style picker is a + # later enhancement); run_configure_providers never clobbers user choices. + result["configured"] = run_configure_providers() + result["accepted"] = True result["validation"] = _run_validate_step(recommendation, mode) return result diff --git a/tests/core/test_setup_wizard.py b/tests/core/test_setup_wizard.py index 546f828..81437bb 100644 --- a/tests/core/test_setup_wizard.py +++ b/tests/core/test_setup_wizard.py @@ -125,3 +125,64 @@ def test_validate_only_does_not_configure(tmp_path, monkeypatch): result = setup_wizard.run_setup(validate_only=True) assert result.get("accepted") is not True assert result["validation"]["ready"] is True + + +def test_add_key_writes_env_and_keyless_flag_when_cli_present(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch, claude=True) # a spawning CLI exists + # user supplies a key, then answers "paid" (key as primary) -> keyless_default False + result = setup_wizard.add_key( + var="ANTHROPIC_API_KEY", + ask_value=lambda: "sk-newkey", + ask_keyless=lambda: False, + ) + env_text = (tmp_path / ".env").read_text() + assert 'ANTHROPIC_API_KEY="sk-newkey"' in env_text + # Read the PERSISTED engine block back. Chunk 1's engine_config() with no arg + # returns pure defaults (it does not read the file); the on-disk value is + # surfaced via load_fleet_config()'s merged engine block. + engine = setup_wizard.fleet.engine_config(setup_wizard.fleet.load_fleet_config()) + assert engine["keyless_default"] is False + assert result["keyless_default"] is False + assert result["asked_keyless"] is True + + +def test_add_key_keyless_true_when_user_chooses_keyless(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch, claude=True) + setup_wizard.add_key( + var="ANTHROPIC_API_KEY", + ask_value=lambda: "sk-newkey", + ask_keyless=lambda: True, + ) + # Persisted value read via the file-reading path (see note above). + persisted = setup_wizard.fleet.engine_config(setup_wizard.fleet.load_fleet_config()) + assert persisted["keyless_default"] is True + + +def test_add_key_does_not_ask_keyless_without_cli(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch, claude=False, codex=False, gemini=False) + def must_not_ask(): + raise AssertionError("asked keyless question with no CLI present") + result = setup_wizard.add_key( + var="ANTHROPIC_API_KEY", + ask_value=lambda: "sk-newkey", + ask_keyless=must_not_ask, + ) + assert result["asked_keyless"] is False + # flag stays null (unset) — no global default imposed + assert setup_wizard.fleet.engine_config()["keyless_default"] is None + + +def test_add_key_blank_value_is_noop(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub_detectors(monkeypatch, claude=True) + result = setup_wizard.add_key( + var="ANTHROPIC_API_KEY", + ask_value=lambda: " ", + ask_keyless=lambda: True, + ) + assert result["ok"] is False + assert not (tmp_path / ".env").exists() or 'ANTHROPIC_API_KEY' not in (tmp_path / ".env").read_text() + assert result["asked_keyless"] is False From 345d321bec3a4a996e193f74f90740eea4b67cb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 12:05:24 +0800 Subject: [PATCH 24/73] feat(cli): wire `atlas setup` verb (--yes, --validate) cmd_setup drives run_setup and exits non-zero when validation is not ready so CI/dispatch can gate on `atlas setup --validate`. Subparser + DISPATCH follow cli.py conventions. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/cli.py | 7 +++++ prd_taskmaster/setup_wizard.py | 17 +++++++++++++ tests/core/test_setup_wizard.py | 45 +++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/prd_taskmaster/cli.py b/prd_taskmaster/cli.py index 6e149ab..a11b8e0 100644 --- a/prd_taskmaster/cli.py +++ b/prd_taskmaster/cli.py @@ -9,6 +9,7 @@ from prd_taskmaster.preflight import cmd_preflight, cmd_detect_taskmaster from prd_taskmaster.providers import cmd_configure_providers, cmd_detect_providers from prd_taskmaster.capabilities import cmd_detect_capabilities +from prd_taskmaster.setup_wizard import cmd_setup from prd_taskmaster.templates import cmd_load_template from prd_taskmaster.validation import cmd_validate_prd, cmd_validate_tasks from prd_taskmaster.tasks import cmd_calc_tasks, cmd_backup_prd, cmd_enrich_tasks @@ -183,6 +184,11 @@ def build_parser() -> argparse.ArgumentParser: # detect-capabilities sub.add_parser("detect-capabilities", help="Scan for available skills and tools") + # setup — guided provider/setup wizard (better than task-master models --setup) + p = sub.add_parser("setup", help="Guided provider setup wizard (detect, recommend, validate)") + p.add_argument("--yes", action="store_true", help="Accept the recommendation non-interactively (CI/dispatch)") + p.add_argument("--validate", action="store_true", help="Dry-run gate: validate_setup + a live one-token probe per provider") + # load-template p = sub.add_parser("load-template", help="Load PRD template") p.add_argument("--type", required=True, choices=["comprehensive", "minimal"]) @@ -360,6 +366,7 @@ def cmd_status(args) -> None: "configure-providers": cmd_configure_providers, "detect-providers": cmd_detect_providers, "detect-capabilities": cmd_detect_capabilities, + "setup": cmd_setup, "load-template": cmd_load_template, "validate-prd": cmd_validate_prd, "calc-tasks": cmd_calc_tasks, diff --git a/prd_taskmaster/setup_wizard.py b/prd_taskmaster/setup_wizard.py index 267dde0..a52f229 100644 --- a/prd_taskmaster/setup_wizard.py +++ b/prd_taskmaster/setup_wizard.py @@ -235,3 +235,20 @@ def run_setup(accept_default: bool = False, validate_only: bool = False, choose= result["accepted"] = True result["validation"] = _run_validate_step(recommendation, mode) return result + + +import json +import sys + + +def cmd_setup(args) -> None: + """CLI wrapper for `atlas setup`. Emits the result JSON; exit code mirrors + validation readiness (0 = ready, 1 = not ready) so CI / dispatch can gate.""" + result = run_setup( + accept_default=bool(getattr(args, "yes", False)), + validate_only=bool(getattr(args, "validate", False)), + ) + print(json.dumps(result, indent=2, default=str)) + validation = result.get("validation") or {} + ready = validation.get("ready", True) + sys.exit(0 if result.get("ok") and ready else 1) diff --git a/tests/core/test_setup_wizard.py b/tests/core/test_setup_wizard.py index 81437bb..27797b4 100644 --- a/tests/core/test_setup_wizard.py +++ b/tests/core/test_setup_wizard.py @@ -186,3 +186,48 @@ def test_add_key_blank_value_is_noop(tmp_path, monkeypatch): assert result["ok"] is False assert not (tmp_path / ".env").exists() or 'ANTHROPIC_API_KEY' not in (tmp_path / ".env").read_text() assert result["asked_keyless"] is False + + +import os +import subprocess as _sp +import sys +from pathlib import Path as _Path + +REPO_ROOT = _Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / "script.py" + + +def _clean_env(tmp_path): + env = os.environ.copy() + for k in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "PERPLEXITY_API_KEY"): + env.pop(k, None) + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + env["PATH"] = str(bin_dir) + env["HOME"] = str(tmp_path / "home") + return env + + +def test_cli_setup_validate_runs_and_emits_json(tmp_path): + """`script.py setup --validate` exits cleanly and emits a validation block. + No claude/codex on PATH → live probes are skipped/absent, validate runs.""" + env = _clean_env(tmp_path) + proc = _sp.run( + [sys.executable, str(SCRIPT), "setup", "--validate"], + capture_output=True, text=True, cwd=str(tmp_path), env=env, + ) + # exit code mirrors readiness; with no project it is not ready -> exit 1. + assert proc.returncode in (0, 1), proc.stderr + payload = json.loads(proc.stdout) + assert "validation" in payload + assert "panel" in payload + assert isinstance(payload["panel"], list) + + +def test_cli_setup_subcommand_registered(): + """`setup` is a real subcommand (argparse help lists it).""" + proc = _sp.run( + [sys.executable, str(SCRIPT), "--help"], + capture_output=True, text=True, cwd=str(REPO_ROOT), + ) + assert "setup" in proc.stdout From 22b0f56536410c99b2b8a9ed3d67577cf27cfa55 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 12:15:12 +0800 Subject: [PATCH 25/73] chore(setup): hoist imports + accurate docstrings (review nits) Move mid-file `import json` / `import sys` in setup_wizard.py to the top stdlib import block. Soften fleet.save_engine_config docstring to clarify the persisted value is not pre-validated (engine_config corrects on read). Soften mode_recommend.validate_setup docstring to state that the no-arg default resolves to the engine default ("hybrid") via engine_config(), not from the persisted fleet.json; callers needing the persisted mode must pass it explicitly. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/fleet.py | 9 +++++++-- prd_taskmaster/mode_recommend.py | 7 +++++++ prd_taskmaster/setup_wizard.py | 6 ++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/prd_taskmaster/fleet.py b/prd_taskmaster/fleet.py index c9d06e5..5a9f640 100644 --- a/prd_taskmaster/fleet.py +++ b/prd_taskmaster/fleet.py @@ -158,8 +158,13 @@ def save_engine_config(updates): is preserved. A missing/unreadable/non-dict file is treated as empty. One level of nested-dict merge (matching the engine block's shape) is applied so a partial `{"cli_agent": {...}}` update does not clobber sibling cli_agent - keys. The returned block is normalized through `engine_config` (defaults + - validation) so callers see exactly what a fresh load would. + keys. + + The RETURNED block is normalized via `engine_config` (defaults + validation) + so callers see exactly what a fresh load would return. The value written to + the file is the raw merged dict as supplied by the caller; it is not + pre-validated — callers are expected to pass already-valid values, and + `engine_config` corrects any malformed entries on the next read. """ path = FLEET_CONFIG_PATH path.parent.mkdir(parents=True, exist_ok=True) diff --git a/prd_taskmaster/mode_recommend.py b/prd_taskmaster/mode_recommend.py index bf15117..4120839 100644 --- a/prd_taskmaster/mode_recommend.py +++ b/prd_taskmaster/mode_recommend.py @@ -368,6 +368,13 @@ def detect_capabilities() -> dict: def validate_setup(provider_mode: str | None = None) -> dict: """Run all Phase 0 SETUP checks and return per-check pass/fail + fix hints. + `provider_mode` controls whether the task-master binary is a hard + requirement ("plan_only") or advisory. When None, it defaults to the engine + default ("hybrid") via `engine_config()` — which returns compiled-in + defaults, NOT the persisted value in fleet.json. Callers that need the + persisted mode must read it from `fleet.load_fleet_config()` and pass it + explicitly. + Returns EXACTLY 6 checks (spec §5): binary — task-master CLI installed version — task-master version >= TASKMASTER_MIN_VERSION diff --git a/prd_taskmaster/setup_wizard.py b/prd_taskmaster/setup_wizard.py index a52f229..c02ce86 100644 --- a/prd_taskmaster/setup_wizard.py +++ b/prd_taskmaster/setup_wizard.py @@ -13,9 +13,11 @@ """ from __future__ import annotations +import json import os import shutil import subprocess +import sys from pathlib import Path from prd_taskmaster import fleet @@ -237,10 +239,6 @@ def run_setup(accept_default: bool = False, validate_only: bool = False, choose= return result -import json -import sys - - def cmd_setup(args) -> None: """CLI wrapper for `atlas setup`. Emits the result JSON; exit code mirrors validation readiness (0 = ready, 1 = not ready) so CI / dispatch can gate.""" From 4108f0f3c7ccf70447b537ac1e6f480445a70d03 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 12:18:38 +0800 Subject: [PATCH 26/73] feat(backend): flip get_backend('auto') to NativeBackend; deprecate backend='taskmaster' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auto no longer probes for the task-master binary — native is the sole generator (spec §9.2). backend='taskmaster' still works for one release behind a DeprecationWarning. Updated test_backend_factory_precedence_and_auto_detection to expect auto->native. TaskMasterBackend class deletion is gated on golden parity (Task 4). Co-Authored-By: Claude Fable 5 --- prd_taskmaster/backend.py | 21 +++++-- tests/core/test_backend.py | 3 +- tests/core/test_backend_migration.py | 86 ++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 tests/core/test_backend_migration.py diff --git a/prd_taskmaster/backend.py b/prd_taskmaster/backend.py index b871601..d413a7e 100644 --- a/prd_taskmaster/backend.py +++ b/prd_taskmaster/backend.py @@ -6,6 +6,7 @@ import subprocess import tempfile import time +import warnings from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone from pathlib import Path @@ -934,11 +935,21 @@ def get_backend(cfg=None) -> Backend: backend = config.get("backend", "auto") if isinstance(config, dict) else "auto" if backend == "taskmaster": + # Deprecated path: kept for ONE release so existing fleet.json files with + # an explicit "backend": "taskmaster" do not hard-break on upgrade. The + # TaskMaster binary + this branch are deleted in the gated migration task + # (spec §9.4) once golden parity is green. + warnings.warn( + "backend='taskmaster' is deprecated and will be removed in the next " + "release; the native engine is now the sole generator. Remove the " + "'backend' key from .atlas-ai/fleet.json (or set it to 'native') to " + "silence this warning.", + DeprecationWarning, + stacklevel=2, + ) return TaskMasterBackend(_FACTORY_TOKEN) - if backend == "native": - return NativeBackend() - taskmaster_backend = TaskMasterBackend(_FACTORY_TOKEN) - if taskmaster_backend.detect().get("available"): - return taskmaster_backend + # backend == "native" OR "auto" (the default): the native engine is the sole + # generator. 'auto' no longer probes for the task-master binary — it resolves + # to NativeBackend unconditionally (spec §9.2). return NativeBackend() diff --git a/tests/core/test_backend.py b/tests/core/test_backend.py index 64ba6c7..6943eda 100644 --- a/tests/core/test_backend.py +++ b/tests/core/test_backend.py @@ -120,9 +120,10 @@ def test_backend_factory_precedence_and_auto_detection(tmp_path, monkeypatch): auto_fallback = get_backend({"backend": "auto"}) assert isinstance(auto_fallback, NativeBackend) + # flipped: spec §9.2 — auto is always native, even with the task-master binary present _write_fake_taskmaster(tmp_path / "bin") auto = get_backend({"backend": "auto"}) - assert isinstance(auto, TaskMasterBackend) + assert isinstance(auto, NativeBackend) def test_backend_detect_shape_and_version_gate(tmp_path, monkeypatch): diff --git a/tests/core/test_backend_migration.py b/tests/core/test_backend_migration.py new file mode 100644 index 0000000..8a1260b --- /dev/null +++ b/tests/core/test_backend_migration.py @@ -0,0 +1,86 @@ +"""Migration tests: backend='auto' resolves to NativeBackend unconditionally, +backend='taskmaster' still works for one deprecation release with a warning. + +Spec: docs/design/2026-06-15-atlas-engine-hybrid-provider-setup.md §9.2 +""" + +import warnings + +import pytest + +from prd_taskmaster import backend as backend_mod +from prd_taskmaster.backend import ( + NativeBackend, + TaskMasterBackend, + get_backend, +) + + +def test_auto_resolves_native_even_when_taskmaster_binary_present(monkeypatch): + """The migration's core invariant: 'auto' is NativeBackend even when the + task-master binary is on PATH and detect() reports it available. + + We monkeypatch TaskMasterBackend.detect to claim availability; the old + code would have returned the TaskMasterBackend in that case. Post-flip it + must NOT — 'auto' returns NativeBackend unconditionally. + """ + monkeypatch.setattr( + TaskMasterBackend, + "detect", + lambda self: {"name": "taskmaster", "available": True, "ai_ops": True}, + ) + be = get_backend({"backend": "auto"}) + assert isinstance(be, NativeBackend) + assert be.name == "native" + + +def test_auto_resolves_native_when_taskmaster_binary_absent(monkeypatch): + monkeypatch.setattr( + TaskMasterBackend, + "detect", + lambda self: {"name": "taskmaster", "available": False, "ai_ops": False}, + ) + be = get_backend({"backend": "auto"}) + assert isinstance(be, NativeBackend) + + +def test_missing_backend_key_defaults_to_native(monkeypatch): + """An empty/legacy config (no 'backend' key) defaults to 'auto' -> Native.""" + monkeypatch.setattr( + TaskMasterBackend, + "detect", + lambda self: {"name": "taskmaster", "available": True}, + ) + be = get_backend({}) + assert isinstance(be, NativeBackend) + + +def test_explicit_native_returns_native(): + be = get_backend({"backend": "native"}) + assert isinstance(be, NativeBackend) + + +def test_explicit_taskmaster_still_works_but_warns(): + """backend='taskmaster' is honored for ONE deprecation release, with a + DeprecationWarning so dispatch logs surface the impending removal.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + be = get_backend({"backend": "taskmaster"}) + assert isinstance(be, TaskMasterBackend) + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + assert any("taskmaster" in str(w.message).lower() for w in caught) + + +def test_get_backend_does_not_call_taskmaster_detect_on_auto(monkeypatch): + """Regression guard: the old auto path constructed a TaskMasterBackend and + called .detect(). The flip must NOT touch TaskMasterBackend at all on auto — + no binary probe cost, no import-time spawn.""" + called = {"detect": False} + + def boom(self): + called["detect"] = True + return {"available": True} + + monkeypatch.setattr(TaskMasterBackend, "detect", boom) + get_backend({"backend": "auto"}) + assert called["detect"] is False From c25a2eb35838ff923d7a2bf639b6e488b0dff94b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 12:20:23 +0800 Subject: [PATCH 27/73] test(parity): golden-parity harness + sample PRD fixtures Normalizer reduces a task graph to its structural shape (drops volatile prose); differ gates on a pre-declared intended-diff whitelist (skill: AI-golden-parity-refactor). Capture reads the graph from DISK (.taskmaster/tasks/tasks.json via parallel.load_tagged/get_tasks) because parse_prd returns {ok, task_count} with no 'tasks' key; each backend leg runs in its own temp cwd+tag. A unit test feeds a realistic parse_prd-shaped result dict through the extractor to catch the disk-vs-result bug in CI. capture/gate CLI subcommands feed the deletion gate (Task 3). No production code touched. Co-Authored-By: Claude Fable 5 --- tests/core/test_golden_parity_harness.py | 127 ++++++++++++ tests/parity/__init__.py | 0 tests/parity/fixtures/prd_cli_tool.md | 14 ++ tests/parity/fixtures/prd_data_pipeline.md | 15 ++ tests/parity/fixtures/prd_web_api.md | 15 ++ tests/parity/golden_parity.py | 221 +++++++++++++++++++++ 6 files changed, 392 insertions(+) create mode 100644 tests/core/test_golden_parity_harness.py create mode 100644 tests/parity/__init__.py create mode 100644 tests/parity/fixtures/prd_cli_tool.md create mode 100644 tests/parity/fixtures/prd_data_pipeline.md create mode 100644 tests/parity/fixtures/prd_web_api.md create mode 100644 tests/parity/golden_parity.py diff --git a/tests/core/test_golden_parity_harness.py b/tests/core/test_golden_parity_harness.py new file mode 100644 index 0000000..4a572a1 --- /dev/null +++ b/tests/core/test_golden_parity_harness.py @@ -0,0 +1,127 @@ +"""Unit tests for the golden-parity harness normalizer + differ + extractor. + +These are PURE-function tests — they do NOT call any model, backend, or +subprocess. They lock two contracts: + 1. the harness compares the STRUCTURE of two task graphs (parity-relevant + shape) and not volatile fields; + 2. the harness extracts tasks from DISK (.taskmaster/tasks/tasks.json) after + parse_prd, NOT from the parse_prd result dict — which carries only + {ok, task_count, tag, backend} and has NO "tasks" key (backend.py:409-419, + 735-738). Test #2 is the regression guard for the disk-vs-result bug. + +Spec: docs/design/2026-06-15-atlas-engine-hybrid-provider-setup.md §9.3 +Skill: AI-golden-parity-refactor +""" + +import json +from pathlib import Path + +from tests.parity.golden_parity import ( + diff_graphs, + extract_graph_from_disk, + normalize_graph, +) + + +def _graph(*titles): + return { + "tasks": [ + { + "id": i + 1, + "title": t, + "description": f"desc {t}", + "details": "volatile per-run details that must be ignored", + "testStrategy": "volatile too", + "status": "pending", + "dependencies": [], + "priority": "high", + "subtasks": [], + } + for i, t in enumerate(titles) + ] + } + + +def test_normalize_keeps_structural_fields_drops_volatile(): + norm = normalize_graph(_graph("Set up project", "Write tests")) + assert norm == { + "task_count": 2, + "tasks": [ + {"id": 1, "title": "Set up project", "dependencies": [], "subtask_count": 0, "priority": "high"}, + {"id": 2, "title": "Write tests", "dependencies": [], "subtask_count": 0, "priority": "high"}, + ], + } + # details / testStrategy / description must NOT appear — they are prose that + # legitimately differs run-to-run and is not a parity signal. + assert "details" not in norm["tasks"][0] + assert "description" not in norm["tasks"][0] + + +def test_diff_identical_graphs_is_clean(): + a = normalize_graph(_graph("A", "B")) + b = normalize_graph(_graph("A", "B")) + result = diff_graphs(a, b) + assert result["parity"] is True + assert result["diffs"] == [] + + +def test_diff_reports_task_count_mismatch(): + a = normalize_graph(_graph("A", "B")) + b = normalize_graph(_graph("A")) + result = diff_graphs(a, b) + assert result["parity"] is False + assert any("task_count" in d for d in result["diffs"]) + + +def test_diff_reports_dependency_shape_change(): + g1 = _graph("A", "B") + g2 = _graph("A", "B") + g2["tasks"][1]["dependencies"] = [1] + result = diff_graphs(normalize_graph(g1), normalize_graph(g2)) + assert result["parity"] is False + assert any("dependencies" in d for d in result["diffs"]) + + +def test_diff_honors_intended_whitelist(): + """A pre-declared intended diff (e.g. a deliberate title rephrase on task 2) + is allowed and does NOT fail parity — per the skill, declare the whitelist + BEFORE running.""" + a = normalize_graph(_graph("A", "B")) + g2 = _graph("A", "B-renamed") + b = normalize_graph(g2) + result = diff_graphs(a, b, intended={"tasks[1].title"}) + assert result["parity"] is True + assert result["intended_applied"] == ["tasks[1].title"] + + +def test_extract_reads_tasks_from_disk_not_from_parse_result(tmp_path, monkeypatch): + """REGRESSION GUARD for the disk-vs-result bug: parse_prd returns a dict with + {ok, task_count} and NO "tasks"/"raw" key (backend.py:409-419, 735-738). + extract_graph_from_disk must read the graph from .taskmaster/tasks/tasks.json + via parallel.load_tagged + parallel.get_tasks — NOT from the result dict. + + We simulate a completed parse: write a realistic parse_prd-shaped result dict + (no "tasks" key) AND a tasks.json on disk, then assert the extractor returns + the DISK tasks and would have returned nothing useful from the result dict. + """ + monkeypatch.chdir(tmp_path) + tm = tmp_path / ".taskmaster" / "tasks" + tm.mkdir(parents=True) + (tmp_path / ".taskmaster" / "state.json").write_text(json.dumps({"currentTag": "master"})) + disk_tasks = [ + {"id": 1, "title": "From disk A", "dependencies": [], "priority": "high", "subtasks": []}, + {"id": 2, "title": "From disk B", "dependencies": [1], "priority": "medium", "subtasks": []}, + ] + (tm / "tasks.json").write_text(json.dumps({"master": {"tasks": disk_tasks}}, indent=2)) + + # Exactly the shape both backends return — NO "tasks", NO "raw". + parse_result = {"ok": True, "task_count": 2, "tag": "master", "backend": "native", "ai": "api"} + assert "tasks" not in parse_result and "raw" not in parse_result # the trap + + graph = extract_graph_from_disk(parse_result) + assert [t["title"] for t in graph["tasks"]] == ["From disk A", "From disk B"] + # And the normalized shape reflects the on-disk dependency edge, proving we + # did not silently fall back to an empty list from the result dict. + norm = normalize_graph(graph) + assert norm["task_count"] == 2 + assert norm["tasks"][1]["dependencies"] == [1] diff --git a/tests/parity/__init__.py b/tests/parity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/parity/fixtures/prd_cli_tool.md b/tests/parity/fixtures/prd_cli_tool.md new file mode 100644 index 0000000..33aaaec --- /dev/null +++ b/tests/parity/fixtures/prd_cli_tool.md @@ -0,0 +1,14 @@ +# PRD: line-count CLI + +## Goal +Build `lc`, a CLI that counts lines, words, and bytes in files. + +## Requirements +- `lc ` prints lines, words, bytes for one file. +- `lc ` prints per-file rows plus a total row. +- `--lines-only` flag suppresses word/byte columns. +- Reads stdin when no path is given. + +## Acceptance +- Output matches `wc` byte-for-byte on the test corpus. +- Exit 1 with a stderr message on a missing file. diff --git a/tests/parity/fixtures/prd_data_pipeline.md b/tests/parity/fixtures/prd_data_pipeline.md new file mode 100644 index 0000000..1491b86 --- /dev/null +++ b/tests/parity/fixtures/prd_data_pipeline.md @@ -0,0 +1,15 @@ +# PRD: CSV-to-Parquet ingest pipeline + +## Goal +Build a batch pipeline that ingests daily CSV drops and emits partitioned Parquet. + +## Requirements +- Watch an input directory for new `*.csv` files. +- Validate each row against a declared schema; quarantine bad rows to a reject file. +- Write valid rows to Parquet partitioned by event date. +- Emit a per-run manifest with row counts (ingested, rejected) and checksums. +- Re-running on the same input is idempotent (no duplicate partitions). + +## Acceptance +- A malformed row lands in the reject file, not the Parquet output. +- The manifest row counts equal the input minus rejects. diff --git a/tests/parity/fixtures/prd_web_api.md b/tests/parity/fixtures/prd_web_api.md new file mode 100644 index 0000000..3813ed5 --- /dev/null +++ b/tests/parity/fixtures/prd_web_api.md @@ -0,0 +1,15 @@ +# PRD: URL-shortener API + +## Goal +Build a small HTTP service that shortens URLs and redirects short codes. + +## Requirements +- `POST /shorten` accepts a JSON `{ "url": "..." }` and returns a short code. +- `GET /` issues a 301 redirect to the original URL. +- Duplicate URLs return the existing code instead of minting a new one. +- Invalid or missing URLs return a 400 with a JSON error body. +- Codes persist across restarts in a SQLite store. + +## Acceptance +- A shortened URL round-trips: shorten then follow the code reaches the origin. +- Unknown codes return 404. diff --git a/tests/parity/golden_parity.py b/tests/parity/golden_parity.py new file mode 100644 index 0000000..17a9ae0 --- /dev/null +++ b/tests/parity/golden_parity.py @@ -0,0 +1,221 @@ +"""Golden-parity harness for the TaskMaster -> native+cli_agent migration. + +Captures task-graph outputs from each backend on the sample PRDs in +fixtures/, normalizes them to a structural shape (dropping volatile prose), +and diffs them. Only diffs NOT in the pre-declared `intended` whitelist fail +parity. + +IMPORTANT: parse_prd does NOT return the task graph in its result dict — both +NativeBackend.parse_prd (backend.py:409-419) and TaskMasterBackend.parse_prd +(backend.py:735-738) return {ok, task_count, tag, backend, ...} with no "tasks" +key; the tasks are written to .taskmaster/tasks/tasks.json. So capture reads the +graph from DISK via parallel.load_tagged + parallel.get_tasks AFTER parse_prd. +Each backend leg runs in its OWN temp cwd + tag so the two legs do not overwrite +each other's tasks.json. + +This is the binary acceptance gate referenced by the migration deletion task. +Skill: AI-golden-parity-refactor. Spec: §9.3. + +Usage (capture + gate, run from repo root): + python3 -m tests.parity.golden_parity capture --backend taskmaster --out golden/tm + python3 -m tests.parity.golden_parity capture --backend native --out golden/native + python3 -m tests.parity.golden_parity gate --gold golden/tm --new golden/native +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tempfile +from pathlib import Path + +FIXTURES = Path(__file__).parent / "fixtures" +SAMPLE_PRDS = ["prd_cli_tool.md", "prd_web_api.md", "prd_data_pipeline.md"] + +# Pre-declared intended-diff whitelist (skill: declare BEFORE running). +# Each entry is a "tasks[]." path that is allowed to differ between +# the TaskMaster golden and the native+cli_agent output. Start EMPTY — every +# real diff must be consciously promoted here with a one-line justification. +INTENDED_DIFFS: set[str] = set() + + +def extract_graph_from_disk(parse_result: dict | None = None, tag: str | None = None) -> dict: + """Read the task graph that parse_prd wrote to .taskmaster/tasks/tasks.json. + + parse_result is accepted (and may be inspected for {ok}) but its body is NOT + the source of tasks — parse_prd returns only {ok, task_count, tag, ...} with + no "tasks"/"raw" key. The authoritative graph is the on-disk tasks.json for + the current (or given) tag, read via parallel.load_tagged + parallel.get_tasks. + Imported lazily so the pure differ tests do not drag in backend deps. + """ + from prd_taskmaster import parallel + + resolved = tag if tag is not None else ( + parse_result.get("tag") if isinstance(parse_result, dict) and parse_result.get("tag") else None + ) + resolved = parallel.current_tag(resolved) + raw, tag_key = parallel.load_tagged(resolved) + tasks = parallel.get_tasks(raw, tag_key) + return {"tasks": tasks} + + +def normalize_graph(graph: dict) -> dict: + """Reduce a parse_prd/expand task graph to its parity-relevant structure. + + Keeps: task_count, and per-task {id, title, dependencies, subtask_count, + priority}. Drops: details/testStrategy/description (volatile prose), + status (always 'pending' at gen time), and subtask internals (structural + count is the parity signal, not generated subtask prose). + """ + tasks = graph.get("tasks", []) or [] + norm_tasks = [] + for t in tasks: + norm_tasks.append( + { + "id": t.get("id"), + "title": t.get("title", ""), + "dependencies": sorted(t.get("dependencies", []) or []), + "subtask_count": len(t.get("subtasks", []) or []), + "priority": t.get("priority", ""), + } + ) + return {"task_count": len(norm_tasks), "tasks": norm_tasks} + + +def diff_graphs(gold: dict, new: dict, intended: set[str] | None = None) -> dict: + """Structural diff. Returns {parity: bool, diffs: [str], intended_applied: [str]}. + + A diff path in `intended` is recorded in intended_applied and does NOT + count against parity (skill: only explicitly-intended diffs allowed). + """ + intended = intended or set() + diffs: list[str] = [] + intended_applied: list[str] = [] + + if gold.get("task_count") != new.get("task_count"): + diffs.append( + f"task_count: gold={gold.get('task_count')} new={new.get('task_count')}" + ) + + g_tasks = gold.get("tasks", []) + n_tasks = new.get("tasks", []) + for idx in range(max(len(g_tasks), len(n_tasks))): + g = g_tasks[idx] if idx < len(g_tasks) else None + n = n_tasks[idx] if idx < len(n_tasks) else None + if g is None or n is None: + diffs.append(f"tasks[{idx}]: present in only one graph") + continue + for field in ("title", "dependencies", "subtask_count", "priority"): + if g.get(field) != n.get(field): + path = f"tasks[{idx}].{field}" + if path in intended: + intended_applied.append(path) + else: + diffs.append(f"{path}: gold={g.get(field)!r} new={n.get(field)!r}") + + return { + "parity": not diffs, + "diffs": diffs, + "intended_applied": intended_applied, + } + + +def _capture(backend_name: str, out_dir: Path) -> int: + """Run parse_prd on each sample PRD via the named backend; write normalized + graphs to out_dir/.json. + + Each PRD runs in its OWN isolated temp cwd + per-PRD tag so the two backend + legs (which both write the SAME .taskmaster/tasks/tasks.json path) never + overwrite each other. The graph is read from DISK after parse_prd via + extract_graph_from_disk — parse_prd's result dict has no "tasks" key. + + Imported lazily so the pure differ tests do not drag in backend/model deps.""" + from prd_taskmaster.backend import NativeBackend, TaskMasterBackend, _FACTORY_TOKEN + + out_dir = out_dir.resolve() + out_dir.mkdir(parents=True, exist_ok=True) + if backend_name == "taskmaster": + be = TaskMasterBackend(_FACTORY_TOKEN) + elif backend_name == "native": + be = NativeBackend() + else: + print(f"unknown backend: {backend_name}", file=sys.stderr) + return 2 + + rc = 0 + cwd0 = Path.cwd() + for prd in SAMPLE_PRDS: + prd_path = (FIXTURES / prd).resolve() + stem = Path(prd).stem + tag = f"parity_{backend_name}_{stem}" + # Isolated workdir per leg+PRD: parse_prd writes .taskmaster/tasks/tasks.json + # relative to cwd, so distinct cwds keep the two backend legs from clobbering. + with tempfile.TemporaryDirectory(prefix=f"parity_{backend_name}_") as work: + os.chdir(work) + try: + be.init_project() + # point state at this PRD's tag so load_tagged resolves it + state = Path(".taskmaster") / "state.json" + state.parent.mkdir(parents=True, exist_ok=True) + state.write_text(json.dumps({"currentTag": tag})) + result = be.parse_prd(str(prd_path), num_tasks=8, tag=tag) + if not result.get("ok"): + print(f"CAPTURE FAIL {backend_name}/{prd}: {result}", file=sys.stderr) + rc = 1 + continue + # Read the graph from DISK (result dict has no "tasks" key). + graph = extract_graph_from_disk(result, tag=tag) + finally: + os.chdir(cwd0) + norm = normalize_graph(graph) + (out_dir / f"{stem}.json").write_text( + json.dumps(norm, indent=2, sort_keys=True) + ) + print(f"captured {backend_name}/{prd}: {norm['task_count']} tasks") + return rc + + +def _gate(gold_dir: Path, new_dir: Path) -> int: + """Diff every captured PRD graph; print a report; return 0 iff full parity.""" + overall = True + report = [] + for prd in SAMPLE_PRDS: + stem = Path(prd).stem + gold = json.loads((gold_dir / f"{stem}.json").read_text()) + new = json.loads((new_dir / f"{stem}.json").read_text()) + res = diff_graphs(gold, new, intended=INTENDED_DIFFS) + report.append((stem, res)) + if not res["parity"]: + overall = False + + print("=== GOLDEN PARITY REPORT ===") + for stem, res in report: + status = "PARITY_OK" if res["parity"] else "PARITY_FAIL" + print(f"[{status}] {stem}") + for d in res["diffs"]: + print(f" DIFF: {d}") + for i in res["intended_applied"]: + print(f" intended (allowed): {i}") + print("=== %s ===" % ("ALL_PARITY_OK" if overall else "PARITY_FAILED")) + return 0 if overall else 1 + + +def main(argv=None) -> int: + p = argparse.ArgumentParser() + sub = p.add_subparsers(dest="cmd", required=True) + cap = sub.add_parser("capture") + cap.add_argument("--backend", required=True, choices=["taskmaster", "native"]) + cap.add_argument("--out", required=True, type=Path) + gate = sub.add_parser("gate") + gate.add_argument("--gold", required=True, type=Path) + gate.add_argument("--new", required=True, type=Path) + args = p.parse_args(argv) + if args.cmd == "capture": + return _capture(args.backend, args.out) + return _gate(args.gold, args.new) + + +if __name__ == "__main__": + raise SystemExit(main()) From 2a6fbada43ed52210bff46e51cf4ff8228dd0b99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 12:28:52 +0800 Subject: [PATCH 28/73] test(preflight): update engine-preflight backend report to auto->native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the get_backend('auto')->NativeBackend flip (spec §9.2): the engine preflight derives selected = get_backend(cfg).name, so with the task-master binary present it now reports selected='native' (binary still detected/available, just not selected) and ai_ops='agent'. Renamed the test from ..._reports_auto_taskmaster_backend to ..._reports_auto_native_even_with_taskmaster_binary and updated its assertions. No production code change — the flip drove this. Co-Authored-By: Claude Fable 5 --- tests/core/test_engine_preflight.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/core/test_engine_preflight.py b/tests/core/test_engine_preflight.py index f03fd97..9e85ac1 100644 --- a/tests/core/test_engine_preflight.py +++ b/tests/core/test_engine_preflight.py @@ -53,19 +53,23 @@ def test_engine_preflight_never_mutates_without_project(tmp_path, monkeypatch): assert list(tmp_path.iterdir()) == [] -def test_engine_preflight_reports_auto_taskmaster_backend(tmp_path, monkeypatch): +def test_engine_preflight_reports_auto_native_even_with_taskmaster_binary(tmp_path, monkeypatch): + # flipped: spec §9.2 — auto is always native, even with the task-master binary + # present. The binary is still DETECTED (reported available), but it is no + # longer SELECTED; native is the sole generator. _clean_env(monkeypatch, tmp_path, with_binary=True) result = run_engine_preflight() - assert result["backend"]["selected"] == "taskmaster" + assert result["backend"]["selected"] == "native" assert result["backend"]["source"] == "auto" + # the binary is still on PATH and detected, just not selected assert result["backend"]["taskmaster"]["available"] is True assert result["backend"]["taskmaster"]["version"] == "0.43.1" assert result["backend"]["taskmaster"]["min_ok"] is True assert result["backend"]["native"]["agent_fallback"] is True - assert result["backend"]["ai_ops"] == "taskmaster-api" - assert "Backend: taskmaster v0.43.1 (auto)" in result["summary"] + assert result["backend"]["ai_ops"] == "agent" + assert "Backend: native (agent-driven)" in result["summary"] def test_engine_preflight_reports_auto_native_agent_backend(tmp_path, monkeypatch): From c6d2b8a825ae19d8ab1bc5fbc862c75a3fd3fbd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 12:40:54 +0800 Subject: [PATCH 29/73] fix(migration): silence preflight deprecation probe + floor parity gate task_count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit batch.py: replace get_backend({"backend":"taskmaster"}) with TaskMasterBackend(_FACTORY_TOKEN).detect() directly so the internal binary-availability diagnostic does not trigger the user-facing DeprecationWarning on every preflight run. golden_parity.py: diff_graphs now fails immediately with a clear reason ("empty graph — capture produced no tasks") if either graph has task_count < 1, preventing empty-vs-empty from silently passing the gate. Three new unit tests cover empty-vs-empty, empty-gold, and empty-new. Co-Authored-By: Claude Fable 5 --- prd_taskmaster/batch.py | 9 ++++--- tests/core/test_golden_parity_harness.py | 32 ++++++++++++++++++++++++ tests/parity/golden_parity.py | 12 +++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/prd_taskmaster/batch.py b/prd_taskmaster/batch.py index d497a0f..d44e4ba 100644 --- a/prd_taskmaster/batch.py +++ b/prd_taskmaster/batch.py @@ -9,7 +9,7 @@ import json from prd_taskmaster import fleet -from prd_taskmaster.backend import get_backend +from prd_taskmaster.backend import NativeBackend, TaskMasterBackend, _FACTORY_TOKEN, get_backend from prd_taskmaster.capabilities import run_detect_capabilities from prd_taskmaster.lib import CommandError, emit, fail from prd_taskmaster.preflight import run_detect_taskmaster, run_preflight @@ -40,8 +40,11 @@ def _backend_ai_ops(selected: str, taskmaster_detect: dict, native_detect: dict) def _backend_block() -> dict: cfg = fleet.load_fleet_config() selected = get_backend(cfg).name - taskmaster_detect = get_backend({"backend": "taskmaster"}).detect() - native_detect = get_backend({"backend": "native"}).detect() + # Construct TaskMasterBackend directly to probe binary availability without + # triggering the user-facing DeprecationWarning — this is an internal + # diagnostic, not a user opt-in to the deprecated backend. + taskmaster_detect = TaskMasterBackend(_FACTORY_TOKEN).detect() + native_detect = NativeBackend().detect() return { "selected": selected, "source": _backend_source(), diff --git a/tests/core/test_golden_parity_harness.py b/tests/core/test_golden_parity_harness.py index 4a572a1..0625659 100644 --- a/tests/core/test_golden_parity_harness.py +++ b/tests/core/test_golden_parity_harness.py @@ -94,6 +94,38 @@ def test_diff_honors_intended_whitelist(): assert result["intended_applied"] == ["tasks[1].title"] +def test_diff_empty_vs_empty_fails_gate(): + """Empty-vs-empty must NOT pass the parity gate. + + Two empty graphs compare structurally equal, but passing them means + capture produced no tasks at all — which is a broken capture, not parity. + The gate must fail with a clear reason for both sides. + """ + empty = normalize_graph({"tasks": []}) + result = diff_graphs(empty, empty) + assert result["parity"] is False + assert any("empty graph" in d and "gold" in d for d in result["diffs"]) + assert any("empty graph" in d and "new" in d for d in result["diffs"]) + + +def test_diff_empty_gold_fails_gate(): + """An empty gold graph must fail the gate even when new has tasks.""" + empty = normalize_graph({"tasks": []}) + populated = normalize_graph(_graph("A", "B")) + result = diff_graphs(empty, populated) + assert result["parity"] is False + assert any("empty graph" in d and "gold" in d for d in result["diffs"]) + + +def test_diff_empty_new_fails_gate(): + """An empty new graph must fail the gate even when gold has tasks.""" + populated = normalize_graph(_graph("A", "B")) + empty = normalize_graph({"tasks": []}) + result = diff_graphs(populated, empty) + assert result["parity"] is False + assert any("empty graph" in d and "new" in d for d in result["diffs"]) + + def test_extract_reads_tasks_from_disk_not_from_parse_result(tmp_path, monkeypatch): """REGRESSION GUARD for the disk-vs-result bug: parse_prd returns a dict with {ok, task_count} and NO "tasks"/"raw" key (backend.py:409-419, 735-738). diff --git a/tests/parity/golden_parity.py b/tests/parity/golden_parity.py index 17a9ae0..0a24027 100644 --- a/tests/parity/golden_parity.py +++ b/tests/parity/golden_parity.py @@ -89,11 +89,23 @@ def diff_graphs(gold: dict, new: dict, intended: set[str] | None = None) -> dict A diff path in `intended` is recorded in intended_applied and does NOT count against parity (skill: only explicitly-intended diffs allowed). + + Parity requires BOTH graphs to have task_count >= 1. Empty-vs-empty is + not a valid green gate — it means capture produced no tasks at all. """ intended = intended or set() diffs: list[str] = [] intended_applied: list[str] = [] + gold_count = gold.get("task_count", 0) or 0 + new_count = new.get("task_count", 0) or 0 + if gold_count < 1: + diffs.append("empty graph — capture produced no tasks (gold)") + if new_count < 1: + diffs.append("empty graph — capture produced no tasks (new)") + if diffs: + return {"parity": False, "diffs": diffs, "intended_applied": intended_applied} + if gold.get("task_count") != new.get("task_count"): diffs.append( f"task_count: gold={gold.get('task_count')} new={new.get('task_count')}" From 019f242e2990e9b6ea6a53c035509c8769dd45e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 14:11:34 +0800 Subject: [PATCH 30/73] fix(validation): placeholder regex no longer false-flags lowercase/technical The angle-bracket placeholder sub-pattern `<[A-Z][A-Z_ ]+>` was compiled with `re.IGNORECASE` in `run_validate_tasks`, causing legitimate technical tokens like ``, ``, ``, ``, ``, `` to be flagged as template placeholders. This caused valid generated task graphs to fail validation, producing 0 tasks for users whose PRDs mention URL path params or format specifiers. Fix: extract a shared module-level constant `_ANGLE_BRACKET_PLACEHOLDER` using the tighter pattern `<[A-Z][A-Z0-9_]{2,}>` (uppercase-only, minimum 3 chars inside brackets, no re.IGNORECASE). Applied to both `run_validate_prd` and `run_validate_tasks`. True placeholders like ``, ``, `` are still caught; lowercase tokens are not. 13 new regression tests added in TestAngleBracketPlaceholderRegex (5 prove the bug pre-fix; all 13 pass post-fix; full 454-test suite green). Co-Authored-By: Claude Fable 5 --- prd_taskmaster/validation.py | 18 +++- tests/core/test_validation.py | 178 +++++++++++++++++++++++++++++++++- 2 files changed, 192 insertions(+), 4 deletions(-) diff --git a/prd_taskmaster/validation.py b/prd_taskmaster/validation.py index d9f6765..21138c0 100644 --- a/prd_taskmaster/validation.py +++ b/prd_taskmaster/validation.py @@ -19,6 +19,19 @@ _resolve_tasks_payload, ) +# Angle-bracket placeholder sub-pattern: matches only TRUE placeholders like +# , , — NOT lowercase/technical tokens like +# , , , , . +# +# Rules: +# • First char MUST be uppercase ASCII letter [A-Z] (no re.IGNORECASE) +# • Remaining chars MUST be uppercase letters, digits, or underscores +# • Minimum total length inside brackets: 3 chars (e.g. matches, doesn't) +# +# This intentionally excludes common HTTP/CLI/doc tokens (``, ``, +# etc.) which are legitimate in task descriptions and PRD body text. +_ANGLE_BRACKET_PLACEHOLDER = r'<[A-Z][A-Z0-9_]{2,}>' + def run_validate_prd(input_path: str) -> dict: """Run 13 quality checks on a PRD file.""" @@ -222,7 +235,7 @@ def run_validate_prd(input_path: str) -> dict: (r'\[TBD\]', 'tbd'), # [TBD] (r'\[TODO\]', 'todo'), # [TODO] (r'\[INSERT .+?\]', 'insert'), # [INSERT something] - (r'<[A-Z][A-Z_ ]+>', 'angle_bracket'), # + (_ANGLE_BRACKET_PLACEHOLDER, 'angle_bracket'), # , (r'\[(?:Name|Date|Feature|Product|YYYY)\]', 'bracket'), # [Name], [Date], etc. (r'\bTBD\b', 'bare_tbd'), # bare TBD (case-sensitive) (r'\bTODO\b', 'bare_todo'), # bare TODO (case-sensitive) @@ -363,8 +376,7 @@ def run_validate_tasks(input_path: str | None, allow_empty_subtasks: bool, requi ids = [] placeholder_re = re.compile( - r'(\{\{[^}]+\}\}|\[TBD\]|\[TODO\]|\[INSERT .+?\]|<[A-Z][A-Z_ ]+>|\[(?:Name|Date|Feature|Product|YYYY)\])', - re.IGNORECASE, + r'(\{\{[^}]+\}\}|\[TBD\]|\[TODO\]|\[INSERT .+?\]|' + _ANGLE_BRACKET_PLACEHOLDER + r'|\[(?:Name|Date|Feature|Product|YYYY)\])', ) generic_re = re.compile( r'^\s*(implement|build|create|add|fix)\s+(feature|functionality|task|thing|stuff)\s*$', diff --git a/tests/core/test_validation.py b/tests/core/test_validation.py index 9e99af2..43799c0 100644 --- a/tests/core/test_validation.py +++ b/tests/core/test_validation.py @@ -12,10 +12,12 @@ - missing file raises CommandError instead of returning {"ok": False} """ +import json + import pytest from prd_taskmaster.lib import CommandError -from prd_taskmaster.validation import run_validate_prd +from prd_taskmaster.validation import run_validate_prd, run_validate_tasks # ─── Fixtures ───────────────────────────────────────────────────────────────── @@ -505,3 +507,177 @@ def test_placeholder_findings_carry_line_numbers(self, tmp_project): assert out["placeholder_details"], "expected a placeholder finding" for pd in out["placeholder_details"]: assert "match" in pd and isinstance(pd.get("line"), int) and pd["line"] >= 1 + + +# ─── Helpers for run_validate_tasks tests ───────────────────────────────────── + +def _make_tasks_file(tmp_path, tasks: list) -> str: + """Write a tasks.json and return the path string.""" + p = tmp_path / "tasks.json" + p.write_text(json.dumps({"tasks": tasks})) + return str(p) + + +def _good_task(**overrides) -> dict: + """Return a minimal valid task, optionally overriding fields.""" + base = { + "id": 1, + "title": "Implement user authentication flow", + "description": "Add JWT-based authentication to the REST API endpoints.", + "details": "Use PyJWT library, store tokens in Redis with 24h TTL.", + "testStrategy": "Write unit tests for token creation and expiry.", + "status": "pending", + "priority": "high", + "dependencies": [], + } + base.update(overrides) + return base + + +# ─── Regression tests for angle-bracket false-positive (Bug: IGNORECASE) ───── + +def _validate_tasks_problems(path: str) -> list[str]: + """Run run_validate_tasks and return the list of problem strings. + + run_validate_tasks raises CommandError when problems are found; that error + carries the list in e.extra["problems"]. When the file is valid it returns + {"ok": True, ...} with no problems key. + """ + from prd_taskmaster.lib import CommandError as _CE + try: + run_validate_tasks(path, allow_empty_subtasks=True, require_phase_config=False) + return [] + except _CE as exc: + return exc.extra.get("problems", []) + + +class TestAngleBracketPlaceholderRegex: + """Regression suite for the IGNORECASE false-positive in the angle-bracket + placeholder sub-pattern (<[A-Z][A-Z_ ]+> compiled with re.IGNORECASE). + + Technical tokens like , , , are LEGITIMATE + in task titles / descriptions (URL path params, wc output format) and must + NOT be treated as placeholders. + + True template placeholders like , , + MUST still be caught. + """ + + # ── MUST NOT flag (legitimate technical tokens) ──────────────────────── + + def test_url_path_param_code_in_title_not_flagged(self, tmp_path): + """GET / in a task title must not trigger placeholder detection.""" + task = _good_task(title="Implement GET / Redirect Endpoint") + problems = _validate_tasks_problems(_make_tasks_file(tmp_path, [task])) + placeholder_probs = [p for p in problems if "placeholder" in p.lower()] + assert placeholder_probs == [], ( + f" in title was falsely flagged as placeholder: {placeholder_probs}" + ) + + def test_wc_format_tokens_in_details_not_flagged(self, tmp_path): + """ in details must not be flagged.""" + task = _good_task( + details="The wc command output format is: " + ) + problems = _validate_tasks_problems(_make_tasks_file(tmp_path, [task])) + placeholder_probs = [p for p in problems if "placeholder" in p.lower()] + assert placeholder_probs == [], ( + f"wc format tokens were falsely flagged: {placeholder_probs}" + ) + + def test_lowercase_angle_token_code_not_flagged(self, tmp_path): + """Standalone token must not be flagged.""" + task = _good_task(description="Returns HTTP on success.") + problems = _validate_tasks_problems(_make_tasks_file(tmp_path, [task])) + placeholder_probs = [p for p in problems if "placeholder" in p.lower()] + assert placeholder_probs == [], ( + f" in description was falsely flagged: {placeholder_probs}" + ) + + def test_lowercase_angle_token_id_not_flagged(self, tmp_path): + """ in a task field must not be flagged.""" + task = _good_task(description="Fetch resource by from the store.") + problems = _validate_tasks_problems(_make_tasks_file(tmp_path, [task])) + placeholder_probs = [p for p in problems if "placeholder" in p.lower()] + assert placeholder_probs == [], ( + f" in description was falsely flagged: {placeholder_probs}" + ) + + def test_lowercase_angle_token_file_not_flagged(self, tmp_path): + """ in a task field must not be flagged.""" + task = _good_task(testStrategy="Run: process and assert exit code is 0.") + problems = _validate_tasks_problems(_make_tasks_file(tmp_path, [task])) + placeholder_probs = [p for p in problems if "placeholder" in p.lower()] + assert placeholder_probs == [], ( + f" in testStrategy was falsely flagged: {placeholder_probs}" + ) + + # ── MUST still flag (real template placeholders) ─────────────────────── + + def test_uppercase_placeholder_flagged(self, tmp_path): + """ must be caught as a placeholder.""" + task = _good_task(title="Set up integration") + problems = _validate_tasks_problems(_make_tasks_file(tmp_path, [task])) + placeholder_probs = [p for p in problems if "placeholder" in p.lower()] + assert placeholder_probs, " was not caught as a placeholder" + + def test_uppercase_api_key_flagged(self, tmp_path): + """ must be caught as a placeholder.""" + task = _good_task(details="Set the env var to before deploying.") + problems = _validate_tasks_problems(_make_tasks_file(tmp_path, [task])) + placeholder_probs = [p for p in problems if "placeholder" in p.lower()] + assert placeholder_probs, " was not caught as a placeholder" + + def test_uppercase_your_value_flagged(self, tmp_path): + """ must be caught as a placeholder.""" + task = _good_task(testStrategy="Replace with the real token.") + problems = _validate_tasks_problems(_make_tasks_file(tmp_path, [task])) + placeholder_probs = [p for p in problems if "placeholder" in p.lower()] + assert placeholder_probs, " was not caught as a placeholder" + + def test_mustache_placeholder_flagged(self, tmp_path): + """{{variable}} mustache tokens must still be caught.""" + task = _good_task(description="Connect to {{database_url}} endpoint.") + problems = _validate_tasks_problems(_make_tasks_file(tmp_path, [task])) + placeholder_probs = [p for p in problems if "placeholder" in p.lower()] + assert placeholder_probs, "{{variable}} was not caught as a placeholder" + + def test_bracketed_todo_placeholder_flagged(self, tmp_path): + """[TODO] token in a task field must still be caught.""" + task = _good_task(description="[TODO] fill in the implementation details.") + problems = _validate_tasks_problems(_make_tasks_file(tmp_path, [task])) + placeholder_probs = [p for p in problems if "placeholder" in p.lower()] + assert placeholder_probs, "[TODO] was not caught as a placeholder" + + def test_bracketed_tbd_placeholder_flagged(self, tmp_path): + """[TBD] token in a task field must still be caught.""" + task = _good_task(title="Integrate auth [TBD]") + problems = _validate_tasks_problems(_make_tasks_file(tmp_path, [task])) + placeholder_probs = [p for p in problems if "placeholder" in p.lower()] + assert placeholder_probs, "[TBD] was not caught as a placeholder" + + def test_insert_placeholder_flagged(self, tmp_path): + """[INSERT something] token in a task field must still be caught.""" + task = _good_task(details="Contact [INSERT team name] for review.") + problems = _validate_tasks_problems(_make_tasks_file(tmp_path, [task])) + placeholder_probs = [p for p in problems if "placeholder" in p.lower()] + assert placeholder_probs, "[INSERT ...] was not caught as a placeholder" + + # ── PRD-level: validate_prd must also not false-flag lowercase tokens ── + + def test_prd_lowercase_angle_tokens_not_flagged(self, tmp_project): + """run_validate_prd must not false-flag , etc. in PRD text.""" + prd = tmp_project / ".taskmaster" / "docs" / "prd.md" + prd.write_text( + "# PRD\n\n## Executive Summary\nA summary.\n\n" + "## Functional Requirements\n" + "### API Endpoints\n" + "- `GET /` — redirect by short code\n" + "- wc output: ` `\n" + ) + out = run_validate_prd(str(prd)) + angle_bracket_details = [d for d in out.get("placeholder_details", []) + if d.get("type") == "angle_bracket"] + assert angle_bracket_details == [], ( + f"PRD false-flagged angle-bracket tokens: {angle_bracket_details}" + ) From de188ed8ea590d41db0c841b04e046c19dbd8854 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 14:34:56 +0800 Subject: [PATCH 31/73] feat(backend)!: delete TaskMasterBackend + tm_parallel.py + 3 MCP tools (gated on NATIVE_VALIDATED parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native engine is the sole generator. Removes: - TaskMasterBackend (backend.py, ~185 lines), _binary_or_raise + _FACTORY_TOKEN helpers, tm_parallel.py (652 lines) - backend.py imports of taskmaster, tm_parallel, _detect_taskmaster_method, subprocess (orphaned) - backend_detect / init_taskmaster / tm_parallel_expand MCP tools (server.py 32->29 tools) - tm-parallel/tm-plan/tm-run/tm-harvest CLI commands (argparse subparsers + DISPATCH) - npm install -g task-master-ai from skills/setup - BACKEND_CHOICES -> {native}, DEFAULT_FLEET_CONFIG backend default -> native - TaskMasterBackend/tm_parallel-coupled tests (test_backend.py rewrite, test_tm_parallel.py delete, test_prerelaunch_p0_fixes.py _run_packet tests, test_backend_migration.py rewrite) Also rewired the surviving callers that probed the deleted class to source the optional task-master binary presence from lib._detect_taskmaster_method instead: batch._backend_block (engine-preflight) and cli.run_backend_detect — both now native-only with an informational taskmaster file-format probe, no deprecation warning, no crash. Keeps: taskmaster.py (cmd_init_taskmaster + init_taskmaster + file formats), lib._detect_taskmaster_method (preflight/capabilities/engine-preflight), detect_taskmaster + init-taskmaster CLI/MCP surface, .taskmaster/config.json + tasks.json formats, KNOWN_STOCK_TASKMASTER_DEFAULTS config repair. Legacy backend='taskmaster' configs fall back to native + warn. The golden-parity gate's taskmaster capture leg is no longer re-runnable post-deletion (frozen artifact); golden_parity.py --backend taskmaster now exits with a clear "removed" message and only the native leg is live. BREAKING CHANGE: the task-master binary is no longer required or supported by any generation operation. Co-Authored-By: Claude Fable 5 --- mcp-server/server.py | 38 +- prd_taskmaster/backend.py | 223 +------- prd_taskmaster/batch.py | 34 +- prd_taskmaster/cli.py | 67 ++- prd_taskmaster/fleet.py | 7 +- prd_taskmaster/tm_parallel.py | 652 ------------------------ skills/expand-tasks/SKILL.md | 16 +- skills/generate/SKILL.md | 31 +- skills/setup/SKILL.md | 21 +- tests/core/test_backend.py | 315 +----------- tests/core/test_backend_migration.py | 82 ++- tests/core/test_prerelaunch_p0_fixes.py | 59 +-- tests/core/test_tm_parallel.py | 265 ---------- tests/mcp/test_mcp_tools.py | 16 +- tests/parity/golden_parity.py | 27 +- 15 files changed, 171 insertions(+), 1682 deletions(-) delete mode 100644 prd_taskmaster/tm_parallel.py delete mode 100644 tests/core/test_tm_parallel.py diff --git a/mcp-server/server.py b/mcp-server/server.py index 4330502..fd7a395 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """FastMCP server for prd-taskmaster. -Registers 32 tools wrapping the sibling modules (pipeline, capabilities, +Registers 29 tools wrapping the sibling modules (pipeline, capabilities, taskmaster, backend, validation, templates) plus server-native helpers (calc_tasks, backup_prd, append_workflow, debrief, log_progress, gen_test_tasks, read_state, gen_scripts, compute_fleet_waves, context_pack, @@ -24,13 +24,11 @@ from prd_taskmaster import pipeline as P from prd_taskmaster import validation as V from prd_taskmaster import mode_recommend as C -from prd_taskmaster import taskmaster as TM from prd_taskmaster import templates as TPL from prd_taskmaster import lib as LIB from prd_taskmaster import fleet as F from prd_taskmaster import batch as B from prd_taskmaster import task_state as TS -from prd_taskmaster import tm_parallel as TMP from prd_taskmaster import cli as CLI from prd_taskmaster import feedback as FB from prd_taskmaster.context_pack import build_context_pack @@ -38,7 +36,7 @@ mcp = FastMCP("prd-taskmaster") -# ─── Delegation tools (20) ──────────────────────────────────────────────────── +# ─── Delegation tools (17) ──────────────────────────────────────────────────── @mcp.tool() def preflight(cwd: str | None = None) -> dict: @@ -90,12 +88,6 @@ def validate_setup() -> dict: return C.validate_setup() -@mcp.tool() -def init_taskmaster(method: str = "cli") -> dict: - """Initialise TaskMaster in the current project via the CLI.""" - return TM.init_taskmaster(method) - - @mcp.tool() def validate_prd(input_path: str, ai: bool = False) -> dict: """Run the deterministic PRD quality checks and return a graded report.""" @@ -114,15 +106,6 @@ def compute_fleet_waves(concurrency: int = 3, tag: str = "") -> dict: return F.run_fleet_waves(concurrency, tag) -@mcp.tool() -def tm_parallel_expand(tag: str = "", dry_run: bool = False) -> dict: - """Run native TaskMaster expansion in isolated parallel workdirs.""" - try: - return TMP.run_tm_parallel(tag=tag or None, dry_run=dry_run) - except LIB.CommandError as exc: - return {"ok": False, "error": exc.message, **exc.extra} - - @mcp.tool() def next_task(tag: str = "") -> dict: """Select the next TaskMaster-compatible task or subtask.""" @@ -161,17 +144,6 @@ def _backend_tool_call(fn, *args, **kwargs) -> dict: return {"ok": False, "error": str(exc)} -@mcp.tool() -def backend_detect() -> dict: - """Detect resolved backend, both backend detect() results, and ai_ops. - - If ai_ops is "agent", parse_prd, expand_tasks, and rate_tasks may return - ok=false with agent_action_required; headless orchestrators should pre-check - this tool's ai_ops before starting AI operations. - """ - return _backend_tool_call(CLI.run_backend_detect) - - @mcp.tool() def init_project() -> dict: """Initialise the resolved backend project state.""" @@ -182,7 +154,7 @@ def init_project() -> dict: def parse_prd(prd_path: str, num_tasks: int, tag: str = "") -> dict: """Parse a PRD through the resolved backend. - When backend_detect reports ai_ops="agent", this can return ok=false with + When the resolved native backend's ai_ops is "agent", this can return ok=false with agent_action_required instead of doing headless AI work. """ return _backend_tool_call(CLI.run_parse_prd, prd_path, num_tasks, tag=tag or None) @@ -196,7 +168,7 @@ def expand_tasks( ) -> dict: """Expand selected or all pending tasks through the resolved backend. - When backend_detect reports ai_ops="agent", this can return ok=false with + When the resolved native backend's ai_ops is "agent", this can return ok=false with agent_action_required instead of doing headless AI work. """ return _backend_tool_call( @@ -211,7 +183,7 @@ def expand_tasks( def rate_tasks(tag: str = "", research: bool = True) -> dict: """Rate task complexity through the resolved backend. - When backend_detect reports ai_ops="agent", this can return ok=false with + When the resolved native backend's ai_ops is "agent", this can return ok=false with agent_action_required instead of doing headless AI work. """ return _backend_tool_call(CLI.run_rate, tag=tag or None, research=research) diff --git a/prd_taskmaster/backend.py b/prd_taskmaster/backend.py index d413a7e..daf9772 100644 --- a/prd_taskmaster/backend.py +++ b/prd_taskmaster/backend.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import subprocess import tempfile import time import warnings @@ -13,10 +12,10 @@ from typing import Any, Protocol import prd_taskmaster -from prd_taskmaster import cli_agent, fleet, llm_client, parallel, taskmaster, tm_parallel +from prd_taskmaster import cli_agent, fleet, llm_client, parallel from prd_taskmaster.economy import append_telemetry, economy_profile, shift_tier from prd_taskmaster.provider_resolver import resolve_provider -from prd_taskmaster.lib import CommandError, _detect_taskmaster_method, now_iso +from prd_taskmaster.lib import CommandError, now_iso from prd_taskmaster.validation import run_validate_tasks @@ -30,9 +29,6 @@ def expand(self, task_ids=None, research=True, tag=None) -> dict: ... def rate(self, tag=None, research=True) -> dict: ... -_FACTORY_TOKEN = object() - - TASKS_SCHEMA_HINT = """{ "tasks": [ { @@ -150,13 +146,6 @@ def _pending_tasks(tag: str | None, task_ids: Any = None) -> tuple[str, list[dic return resolved, pending -def _binary_or_raise() -> str: - binary = taskmaster._find_binary() - if not binary: - raise CommandError("task-master binary not found in PATH") - return binary - - def _read_json(path: Path) -> dict | None: try: raw = json.loads(path.read_text()) @@ -743,213 +732,23 @@ def rate(self, tag=None, research=True) -> dict: } -class TaskMasterBackend(Backend): - name = "taskmaster" - - def __init__(self, _factory_token: object | None = None) -> None: - if _factory_token is not _FACTORY_TOKEN: - raise CommandError("TaskMasterBackend must be constructed through get_backend") - - def detect(self) -> dict: - detected = _detect_taskmaster_method() - gate = tm_parallel._version_gate() - missing: list[str] = [] - - def add_missing(message: object) -> None: - if message and str(message) not in missing: - missing.append(str(message)) - - if detected.get("method") == "none": - add_missing("task-master binary not found in PATH") - if not gate.get("ok"): - add_missing(gate.get("error")) - - available = bool(gate.get("ok")) - return { - "name": "taskmaster", - "available": available, - "version": gate.get("detected_version") or detected.get("version"), - "ai_ops": available, - "missing": missing, - } - - def init_project(self) -> dict: - return taskmaster.init_taskmaster() - - def parse_prd(self, prd_path, num_tasks, tag=None) -> dict: - binary = _binary_or_raise() - result = subprocess.run( - [binary, "parse-prd", "--input", str(prd_path), "--num-tasks", str(num_tasks)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - d = { - "ok": False, - "task_count": 0, - "exit": result.returncode, - "stderr": result.stderr, - } - d.setdefault("backend", "taskmaster") - d.setdefault("ai", "taskmaster-cli") - return d - _resolved, tasks = _load_tasks(tag) - if not tasks: - # P1-1: a 0-exit parse that produced no tasks is NOT success — it is the - # silent failure that lets the pipeline treat an empty graph as "done". - d = { - "ok": False, - "task_count": 0, - "exit": result.returncode, - "error": ( - "parse-prd exited 0 but produced 0 tasks — the model returned no " - "tasks. Check provider credentials (run: python3 script.py " - "configure-providers) and the PRD content." - ), - } - d.setdefault("backend", "taskmaster") - d.setdefault("ai", "taskmaster-cli") - return d - d = {"ok": True, "task_count": len(tasks)} - d.setdefault("backend", "taskmaster") - d.setdefault("ai", "taskmaster-cli") - return d - - def expand(self, task_ids=None, research=True, tag=None) -> dict: - resolved, pending = _pending_tasks(tag, task_ids) - if len(pending) > 3: - res = tm_parallel.run_tm_parallel(tag=tag) - d = dict(res) - d.setdefault("backend", "taskmaster") - d.setdefault("ai", "taskmaster-cli") - return d - if not pending: - d = {"ok": True, "tag": resolved, "expanded": [], "failed": [], "results": []} - d.setdefault("backend", "taskmaster") - d.setdefault("ai", "taskmaster-cli") - return d - - binary = _binary_or_raise() - results = [] - expanded = [] - failed = [] - any_degraded = False - for task in pending: - task_id = task.get("id") - cmd = [binary, "expand", "--id", str(task_id)] - if research: - cmd.append("--research") - start = time.monotonic() - result = subprocess.run(cmd, capture_output=True, text=True) - wall_ms = int((time.monotonic() - start) * 1000) - degraded = False - if result.returncode != 0 and research: - # P0-3: research provider down (quota/auth). Degrade to a structural - # expand (no --research) — always available, still verifiable — - # rather than hard-failing this task to 0 subtasks. - s_start = time.monotonic() - result = subprocess.run( - [binary, "expand", "--id", str(task_id)], - capture_output=True, - text=True, - ) - wall_ms += int((time.monotonic() - s_start) * 1000) - degraded = True - any_degraded = True - append_telemetry({ - "ts": now_iso(), - "op_class": "structured_gen", - "task_id": task_id, - "model": "", - "backend": "taskmaster-api", - "exit": result.returncode, - "wall_ms": wall_ms, - "escalated": False, - "degraded": degraded, - }) - item = { - "task_id": task_id, - "exit": result.returncode, - "wall_ms": wall_ms, - "stdout": result.stdout, - "stderr": result.stderr, - "degraded": degraded, - } - results.append(item) - if result.returncode == 0: - expanded.append(task_id) - else: - failed.append(task_id) - d = { - "ok": not failed, - "tag": resolved, - "expanded": expanded, - "failed": failed, - "results": results, - } - if any_degraded: - d["degraded"] = True - d.setdefault("backend", "taskmaster") - d.setdefault("ai", "taskmaster-cli") - return d - - def rate(self, tag=None, research=True) -> dict: - binary = _binary_or_raise() - cmd = [binary, "analyze-complexity"] - if research: - cmd.append("--research") - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - d = {"ok": False, "exit": result.returncode, "stderr": result.stderr} - d.setdefault("backend", "taskmaster") - d.setdefault("ai", "taskmaster-cli") - return d - - resolved = parallel.current_tag(tag) - for path in _report_candidates(resolved): - raw = _read_json(path) - if raw is not None: - d = { - "ok": True, - "tag": resolved, - "report": str(path), - "complexityAnalysis": raw.get("complexityAnalysis", []), - "raw": raw, - } - d.setdefault("backend", "taskmaster") - d.setdefault("ai", "taskmaster-cli") - return d - d = { - "ok": False, - "tag": resolved, - "report": None, - "error": "task complexity report not found", - } - d.setdefault("backend", "taskmaster") - d.setdefault("ai", "taskmaster-cli") - return d - - def get_backend(cfg=None) -> Backend: config = fleet.load_fleet_config() if cfg is None else cfg backend = config.get("backend", "auto") if isinstance(config, dict) else "auto" if backend == "taskmaster": - # Deprecated path: kept for ONE release so existing fleet.json files with - # an explicit "backend": "taskmaster" do not hard-break on upgrade. The - # TaskMaster binary + this branch are deleted in the gated migration task - # (spec §9.4) once golden parity is green. + # The taskmaster backend was removed (spec §9.4): a legacy fleet.json + # still pinned to it resolves to native (the sole generator) rather than + # crashing, with a deprecation warning pointing at the config key. warnings.warn( - "backend='taskmaster' is deprecated and will be removed in the next " - "release; the native engine is now the sole generator. Remove the " - "'backend' key from .atlas-ai/fleet.json (or set it to 'native') to " - "silence this warning.", + "backend='taskmaster' has been removed; using the native engine. " + "Remove the 'backend' key from .atlas-ai/fleet.json (or set it to " + "'native') to silence this warning.", DeprecationWarning, stacklevel=2, ) - return TaskMasterBackend(_FACTORY_TOKEN) - # backend == "native" OR "auto" (the default): the native engine is the sole - # generator. 'auto' no longer probes for the task-master binary — it resolves - # to NativeBackend unconditionally (spec §9.2). + # backend == "native" OR "auto" (the default) OR a removed "taskmaster": + # the native engine is the sole generator. 'auto' no longer probes for the + # task-master binary — it resolves to NativeBackend unconditionally (spec §9.2). return NativeBackend() diff --git a/prd_taskmaster/batch.py b/prd_taskmaster/batch.py index d44e4ba..a6337dd 100644 --- a/prd_taskmaster/batch.py +++ b/prd_taskmaster/batch.py @@ -9,9 +9,9 @@ import json from prd_taskmaster import fleet -from prd_taskmaster.backend import NativeBackend, TaskMasterBackend, _FACTORY_TOKEN, get_backend +from prd_taskmaster.backend import NativeBackend, get_backend from prd_taskmaster.capabilities import run_detect_capabilities -from prd_taskmaster.lib import CommandError, emit, fail +from prd_taskmaster.lib import CommandError, _detect_taskmaster_method, emit, fail from prd_taskmaster.preflight import run_detect_taskmaster, run_preflight from prd_taskmaster.providers import run_configure_providers, run_detect_providers @@ -29,43 +29,39 @@ def _backend_source() -> str: return "auto" -def _backend_ai_ops(selected: str, taskmaster_detect: dict, native_detect: dict) -> str: - if selected == "taskmaster": - return "taskmaster-api" if taskmaster_detect.get("available") else "agent" +def _backend_ai_ops(native_detect: dict) -> str: if native_detect.get("ai_ops") == "api": return "native-api" return "agent" def _backend_block() -> dict: - cfg = fleet.load_fleet_config() - selected = get_backend(cfg).name - # Construct TaskMasterBackend directly to probe binary availability without - # triggering the user-facing DeprecationWarning — this is an internal - # diagnostic, not a user opt-in to the deprecated backend. - taskmaster_detect = TaskMasterBackend(_FACTORY_TOKEN).detect() + selected = get_backend(fleet.load_fleet_config()).name + # The task-master backend was removed (spec §9.4): native is the sole + # generator. The `taskmaster` entry is now an informational file-format/binary + # presence probe (via the surviving _detect_taskmaster_method), never a + # selectable backend — so engine-preflight stays honest about the optional + # binary without depending on the deleted TaskMasterBackend class. + tm_detected = _detect_taskmaster_method() + tm_available = tm_detected.get("method") in ("cli", "mcp") native_detect = NativeBackend().detect() return { "selected": selected, "source": _backend_source(), "taskmaster": { - "available": bool(taskmaster_detect.get("available")), - "version": taskmaster_detect.get("version"), - "min_ok": bool(taskmaster_detect.get("available")), + "available": tm_available, + "version": tm_detected.get("version"), + "min_ok": tm_available, }, "native": { "api_provider": native_detect.get("api_provider"), "agent_fallback": True, }, - "ai_ops": _backend_ai_ops(selected, taskmaster_detect, native_detect), + "ai_ops": _backend_ai_ops(native_detect), } def _backend_summary(block: dict) -> str: - if block.get("selected") == "taskmaster": - version = block.get("taskmaster", {}).get("version") - version_text = f" v{version}" if version else "" - return f"Backend: taskmaster{version_text} ({block.get('source', 'auto')})" provider = block.get("native", {}).get("api_provider") if provider: return f"Backend: native (api: {provider})" diff --git a/prd_taskmaster/cli.py b/prd_taskmaster/cli.py index a11b8e0..948e167 100644 --- a/prd_taskmaster/cli.py +++ b/prd_taskmaster/cli.py @@ -18,7 +18,8 @@ from prd_taskmaster.economy import cmd_economy_report from prd_taskmaster.feedback import HARNESS_CHOICES, cmd_feedback_add, cmd_feedback_report from prd_taskmaster.context_pack import build_context_pack -from prd_taskmaster import fleet, parallel, task_state, tm_parallel +from prd_taskmaster import fleet, parallel, task_state +from prd_taskmaster.lib import _detect_taskmaster_method def _backend_source() -> str: @@ -34,28 +35,44 @@ def _backend_source() -> str: return "auto" -def _ai_ops(selected: str, taskmaster_detect: dict, native_detect: dict) -> str: - if selected == "taskmaster": - return "taskmaster-api" if taskmaster_detect.get("available") else "agent" +def _ai_ops(native_detect: dict) -> str: if native_detect.get("ai_ops") == "api": return "native-api" return "agent" +def _taskmaster_file_format_detect() -> dict: + """Informational only: whether the optional task-master binary is on PATH. + + The task-master backend was removed (spec §9.4) — native is the sole + generator — but `.taskmaster/` file-format detection survives, so we still + surface whether the binary exists for diagnostic transparency. + """ + detected = _detect_taskmaster_method() + available = detected.get("method") in ("cli", "mcp") + return { + "available": available, + "version": detected.get("version"), + "min_ok": available, + } + + def run_backend_detect() -> dict: - """Pure core for backend-detect; safe for CLI and MCP wrappers.""" - cfg = fleet.load_fleet_config() - selected_backend = get_backend(cfg) - taskmaster_detect = get_backend({"backend": "taskmaster"}).detect() - native_detect = get_backend({"backend": "native"}).detect() + """Pure core for backend-detect; safe for CLI and MCP wrappers. + + Native is the sole generator now; the `taskmaster` entry is purely an + informational file-format/binary presence probe, never a selectable backend. + """ + selected_backend = get_backend(fleet.load_fleet_config()) + native_detect = selected_backend.detect() return { "ok": True, "selected": selected_backend.name, "source": _backend_source(), - "ai_ops": _ai_ops(selected_backend.name, taskmaster_detect, native_detect), - "resolved": selected_backend.detect(), + "ai_ops": _ai_ops(native_detect), + "resolved": native_detect, "backends": { - "taskmaster": taskmaster_detect, + "taskmaster": _taskmaster_file_format_detect(), "native": native_detect, }, } @@ -265,28 +282,6 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--tag") p.add_argument("--input", required=True) - # tm-parallel native TaskMaster expansion - p = sub.add_parser("tm-parallel", help="Run native TaskMaster expansion in isolated parallel workdirs") - p.add_argument("--tag") - p.add_argument("--missing-only", action="store_true", default=True) - p.add_argument("--concurrency", type=int, default=None) - p.add_argument("--timeout", type=float, default=180) - p.add_argument("--dry-run", action="store_true") - - p = sub.add_parser("tm-plan", help="Plan isolated native TaskMaster expansion workdirs") - p.add_argument("--tag") - p.add_argument("--missing-only", action="store_true", default=True) - - p = sub.add_parser("tm-run", help="Run a planned native TaskMaster expansion batch") - p.add_argument("--run-id", required=True) - p.add_argument("--concurrency", type=int, default=None) - p.add_argument("--timeout", type=float, default=180) - - p = sub.add_parser("tm-harvest", help="Harvest a native TaskMaster expansion batch") - p.add_argument("--run-id", required=True) - p.add_argument("--tag") - p.add_argument("--threshold", type=int, default=7) - # economy-report p = sub.add_parser("economy-report", help="Summarize .atlas-ai/telemetry.jsonl per (op_class, model)") p.add_argument("--input", default=None) @@ -378,10 +373,6 @@ def cmd_status(args) -> None: "parallel-apply": parallel.cmd_apply, "parallel-extract": parallel.cmd_extract, "parallel-inject": parallel.cmd_inject, - "tm-parallel": tm_parallel.cmd_tm_parallel, - "tm-plan": tm_parallel.cmd_tm_plan, - "tm-run": tm_parallel.cmd_tm_run, - "tm-harvest": tm_parallel.cmd_tm_harvest, "fleet-waves": fleet.cmd_fleet_waves, "next-task": task_state.cmd_next_task, "claim-task": task_state.cmd_claim_task, diff --git a/prd_taskmaster/fleet.py b/prd_taskmaster/fleet.py index 5a9f640..7ce55ce 100644 --- a/prd_taskmaster/fleet.py +++ b/prd_taskmaster/fleet.py @@ -45,10 +45,13 @@ "routing": DEFAULT_ROUTING, "experimental_backends": False, "token_economy": "balanced", - "backend": "auto", + "backend": "native", } -BACKEND_CHOICES = {"auto", "taskmaster", "native"} +# The native engine is the sole generator; "auto" is still ACCEPTED by +# get_backend() (it resolves to native), but it is no longer the emitted default +# and the removed "taskmaster" value is no longer a valid persisted choice. +BACKEND_CHOICES = {"native"} # ─── Atlas hybrid provider: engine config block (Chunk 1) ───────────────────── PROVIDER_MODE_CHOICES = {"hybrid", "api_only", "cli_only", "plan_only"} diff --git a/prd_taskmaster/tm_parallel.py b/prd_taskmaster/tm_parallel.py deleted file mode 100644 index 9716396..0000000 --- a/prd_taskmaster/tm_parallel.py +++ /dev/null @@ -1,652 +0,0 @@ -"""Native TaskMaster parallel expansion through isolated workdirs. - -TaskMaster writes project state during `expand`, so this module parallelizes by -creating one tiny TaskMaster project per task, running native expansion inside -those isolated directories, then harvesting only subtasks back through -parallel.apply_results(). -""" - -from __future__ import annotations - -import argparse -import copy -import json -import os -import re -import shutil -import subprocess -import time -from concurrent.futures import ThreadPoolExecutor, as_completed -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from prd_taskmaster import parallel -from prd_taskmaster.economy import ( - TELEMETRY, - TIER_MODEL_IDS, - append_telemetry, - economy_profile, - shift_tier, -) -from prd_taskmaster.fleet import load_fleet_config, resolve_backend -from prd_taskmaster.lib import CommandError, atomic_write, emit, fail -from prd_taskmaster.taskmaster import _find_binary - - -TASKMASTER_MIN_VERSION = "0.43.0" -TM_WORK = Path(".atlas-ai") / "tmwork" - -_CLI_PROVIDERS = {"claude-code", "codex-cli"} - - -def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - - -def _write_json(path: Path, payload: Any) -> None: - atomic_write(path, json.dumps(payload, indent=2, default=str) + "\n") - - -def _read_json(path: Path) -> Any: - return json.loads(path.read_text()) - - -def _run_id() -> str: - stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") - return f"{stamp}-{os.getpid()}" - - -def _parse_version(text: str | None) -> tuple[int, int, int]: - if not text: - return (0, 0, 0) - match = re.search(r"\d+\.\d+\.\d+", text) - if not match: - return (0, 0, 0) - parts = match.group(0).split(".") - return tuple(int(part) for part in parts[:3]) # type: ignore[return-value] - - -def _detect_binary_version(binary: str) -> str | None: - try: - result = subprocess.run( - [binary, "--version"], - capture_output=True, - text=True, - timeout=10, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return None - if result.returncode != 0: - return None - text = f"{result.stdout}\n{result.stderr}" - match = re.search(r"\d+\.\d+\.\d+", text) - return match.group(0) if match else result.stdout.strip().splitlines()[-1] - - -def _version_gate() -> dict: - binary = _find_binary() - if not binary: - return { - "ok": False, - "error": "task-master binary not found in PATH", - "minimum_version": TASKMASTER_MIN_VERSION, - "fallback": "Use python3 script.py parallel-plan --missing-only, then parallel-apply.", - } - detected = _detect_binary_version(binary) - if _parse_version(detected) < _parse_version(TASKMASTER_MIN_VERSION): - return { - "ok": False, - "error": f"task-master >= {TASKMASTER_MIN_VERSION} required for native parallel expansion", - "detected_version": detected, - "minimum_version": TASKMASTER_MIN_VERSION, - "fallback": "Use python3 script.py parallel-plan --missing-only, then parallel-apply.", - } - return {"ok": True, "binary": binary, "detected_version": detected} - - -def _load_tasks(tag: str | None) -> tuple[str, Any, str | None, list[dict]]: - resolved = parallel.current_tag(tag) - raw, tag_key = parallel.load_tagged(resolved) - return resolved, raw, tag_key, parallel.get_tasks(raw, tag_key) - - -def _task_id(task: dict) -> Any: - return task.get("id") - - -def _purge_old_runs() -> None: - if not TM_WORK.is_dir(): - return - cutoff = time.time() - 24 * 60 * 60 - for child in TM_WORK.iterdir(): - try: - if child.is_dir() and child.stat().st_mtime < cutoff: - shutil.rmtree(child) - except OSError: - continue - - -def _copy_state(project_root: Path, workdir: Path, tag: str) -> None: - src = project_root / ".taskmaster" / "state.json" - state = {"currentTag": tag} - if src.is_file(): - try: - loaded = _read_json(src) - if isinstance(loaded, dict): - state = loaded - except (json.JSONDecodeError, OSError): - state = {"currentTag": tag} - state["currentTag"] = tag - _write_json(workdir / ".taskmaster" / "state.json", state) - - -def _copy_prd(project_root: Path, workdir: Path) -> None: - src = project_root / ".taskmaster" / "docs" / "prd.md" - if src.is_file(): - dst = workdir / ".taskmaster" / "docs" / "prd.md" - dst.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, dst) - - -def _model_config_for_tier( - config: dict, - fleet_config: dict, - tier: str, -) -> tuple[dict, str, str | None, str]: - target = resolve_backend(tier, fleet_config) - models = config.get("models") if isinstance(config.get("models"), dict) else {} - current = models.get("main") if isinstance(models.get("main"), dict) else {} - current = dict(current) - provider = str(current.get("provider", "")).lower() - - if ":" not in target: - return current, str(current.get("modelId", "")), f"unparseable routing target preserved: {target}", target - - backend, model = target.split(":", 1) - if backend == "claude": - if provider in _CLI_PROVIDERS: - current["modelId"] = model - return current, model, None, target - full_id = TIER_MODEL_IDS.get(model, model) - return { - "provider": "anthropic", - "modelId": full_id, - "maxTokens": current.get("maxTokens", 64000), - "temperature": current.get("temperature", 0.2), - }, full_id, None, target - - return ( - current, - str(current.get("modelId", "")), - f"non-claude routing target preserved existing provider: {target}", - target, - ) - - -def _write_workdir_config( - project_root: Path, - workdir: Path, - fleet_config: dict, - tier: str, -) -> tuple[str, str | None, str]: - src = project_root / ".taskmaster" / "config.json" - config: dict = {} - if src.is_file(): - try: - loaded = _read_json(src) - if isinstance(loaded, dict): - config = loaded - except (json.JSONDecodeError, OSError): - config = {} - models = config.setdefault("models", {}) - main, model_id, note, target = _model_config_for_tier(config, fleet_config, tier) - models["main"] = main - _write_json(workdir / ".taskmaster" / "config.json", config) - return model_id, note, target - - -def _rewrite_workdir_model(workdir: Path, fleet_config: dict, tier: str) -> tuple[str, str | None, str]: - config_path = workdir / ".taskmaster" / "config.json" - config = _read_json(config_path) if config_path.is_file() else {"models": {}} - if not isinstance(config, dict): - config = {"models": {}} - config.setdefault("models", {}) - main, model_id, note, target = _model_config_for_tier(config, fleet_config, tier) - config["models"]["main"] = main - _write_json(config_path, config) - return model_id, note, target - - -def _write_workdir_task(workdir: Path, tag: str, task: dict) -> None: - task_copy = copy.deepcopy(task) - task_copy["dependencies"] = [] - payload = {tag: {"tasks": [task_copy]}} - _write_json(workdir / ".taskmaster" / "tasks" / "tasks.json", payload) - - -def _write_manifest(run_root: Path, manifest: dict) -> None: - _write_json(run_root / "manifest.json", manifest) - - -def _read_manifest(run_id: str) -> dict: - path = TM_WORK / run_id / "manifest.json" - if not path.is_file(): - raise CommandError(f"tm-parallel run not found: {run_id}", {"run_id": run_id}) - manifest = _read_json(path) - if not isinstance(manifest, dict): - raise CommandError(f"tm-parallel manifest invalid: {run_id}", {"run_id": run_id}) - return manifest - - -def _read_run_results(run_id: str) -> dict: - path = TM_WORK / run_id / "results.json" - if not path.is_file(): - raise CommandError(f"tm-parallel results not found: {run_id}", {"run_id": run_id}) - results = _read_json(path) - if not isinstance(results, dict): - raise CommandError(f"tm-parallel results invalid: {run_id}", {"run_id": run_id}) - return results - - -def _pending_reason(task: dict, missing_only: bool) -> str | None: - status = str(task.get("status", "pending")) - if status != "pending": - return f"status_{status}" - if missing_only and len(task.get("subtasks") or []) >= 2: - return "already_has_subtasks" - return None - - -def run_tm_plan(tag: str | None = None, missing_only: bool = True) -> dict: - """Create isolated TaskMaster workdirs for native expansion.""" - project_root = Path.cwd() - resolved_tag, _raw, _tag_key, tasks = _load_tasks(tag) - _purge_old_runs() - - run_id = _run_id() - run_root = TM_WORK / run_id - fleet_config = load_fleet_config() - profile = economy_profile(fleet_config) - start_tier = profile.get("structured_gen_start", "standard") - workdirs: list[dict] = [] - skipped: list[dict] = [] - - for task in tasks: - task_id = _task_id(task) - reason = _pending_reason(task, missing_only) - if reason: - skipped.append({"task_id": task_id, "reason": reason}) - continue - - workdir = run_root / f"task-{task_id}" - _write_workdir_task(workdir, resolved_tag, task) - _copy_state(project_root, workdir, resolved_tag) - model_id, note, target = _write_workdir_config(project_root, workdir, fleet_config, start_tier) - _copy_prd(project_root, workdir) - item = { - "task_id": task_id, - "path": str(workdir.resolve()), - "model": model_id, - "tier": start_tier, - "routing_target": target, - } - if note: - item["note"] = note - workdirs.append(item) - - manifest = { - "ok": True, - "run_id": run_id, - "tag": resolved_tag, - "created_at": _now_iso(), - "workdirs": workdirs, - "skipped": skipped, - } - _write_manifest(run_root, manifest) - return {"ok": True, "run_id": run_id, "workdirs": workdirs, "skipped": skipped} - - -def _default_concurrency(concurrency: int | None, workdir_count: int, fleet_config: dict, profile: dict) -> int: - if workdir_count <= 0: - return 0 - if concurrency is not None: - if concurrency < 1: - raise CommandError("concurrency must be >= 1") - return min(concurrency, workdir_count) - setting = profile.get("tm_concurrency", "max") - if setting == "min2": - return min(2, workdir_count) - if setting == "max": - max_config = fleet_config.get("max_concurrency", 3) - return min(max_config if isinstance(max_config, int) and max_config >= 1 else 3, workdir_count) - if isinstance(setting, int) and setting >= 1: - return min(setting, workdir_count) - return min(3, workdir_count) - - -_append_telemetry = append_telemetry - - -def _run_one_attempt( - binary: str, task_id: Any, workdir: Path, timeout: float, research: bool = True -) -> tuple[Any, int, str, str]: - start = time.monotonic() - args = [binary, "expand", "--id", str(task_id)] - if research: - args.append("--research") - args.append("--force") - try: - result = subprocess.run( - args, - cwd=workdir, - capture_output=True, - text=True, - timeout=timeout, - ) - wall_ms = int((time.monotonic() - start) * 1000) - return result.returncode, wall_ms, result.stdout, result.stderr - except subprocess.TimeoutExpired as exc: - wall_ms = int((time.monotonic() - start) * 1000) - stdout = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "") - stderr = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "") - return "timeout", wall_ms, stdout, stderr - - -def _run_packet(binary: str, item: dict, timeout: float, fleet_config: dict, profile: dict) -> dict: - task_id = item["task_id"] - workdir = Path(item["path"]) - tier = item.get("tier", profile.get("structured_gen_start", "standard")) - model = item.get("model", "") - escalation = profile.get("escalation", {}) - can_escalate = escalation.get("enabled", True) and escalation.get("max_steps", 1) >= 1 - attempts = [] - - for attempt in (0, 1): - escalated = attempt > 0 - if escalated: - tier = shift_tier(tier, 1, ceiling=escalation.get("ceiling")) - model, note, target = _rewrite_workdir_model(workdir, fleet_config, tier) - item["tier"] = tier - item["model"] = model - item["routing_target"] = target - if note: - item["note"] = note - exit_code, wall_ms, stdout, stderr = _run_one_attempt(binary, task_id, workdir, timeout) - _append_telemetry({ - "ts": _now_iso(), - "op_class": "structured_gen", - "task_id": task_id, - "model": model, - "backend": "taskmaster-api", - "exit": exit_code, - "wall_ms": wall_ms, - "escalated": escalated, - }) - attempts.append({ - "attempt": attempt, - "exit": exit_code, - "wall_ms": wall_ms, - "model": model, - "stdout": stdout, - "stderr": stderr, - }) - if exit_code == 0: - return { - "task_id": task_id, - "exit": 0, - "wall_ms": sum(a["wall_ms"] for a in attempts), - "attempts": len(attempts), - "path": str(workdir), - "model": model, - "success": True, - } - if not can_escalate or attempt == 1: - break - - # All research-backed attempts failed (e.g. research provider out of quota / - # auth down). Degrade to a structural expand (no --research): it does not - # depend on the research provider and is always available. A structurally - # expanded task is still verifiable — far better than 0 subtasks. - exit_code, wall_ms, stdout, stderr = _run_one_attempt( - binary, task_id, workdir, timeout, research=False - ) - _append_telemetry({ - "ts": _now_iso(), - "op_class": "structured_gen", - "task_id": task_id, - "model": model, - "backend": "taskmaster-api", - "exit": exit_code, - "wall_ms": wall_ms, - "escalated": False, - "degraded": True, - }) - attempts.append({ - "attempt": len(attempts), - "exit": exit_code, - "wall_ms": wall_ms, - "model": model, - "stdout": stdout, - "stderr": stderr, - "research": False, - }) - if exit_code == 0: - return { - "task_id": task_id, - "exit": 0, - "wall_ms": sum(a["wall_ms"] for a in attempts), - "attempts": len(attempts), - "path": str(workdir), - "model": model, - "success": True, - "degraded": True, - } - - return { - "task_id": task_id, - "exit": attempts[-1]["exit"], - "wall_ms": sum(a["wall_ms"] for a in attempts), - "attempts": len(attempts), - "path": str(workdir), - "model": model, - "success": False, - } - - -def run_tm_run(run_id: str, concurrency: int | None = None, timeout: float = 180) -> dict: - """Run native TaskMaster expansion for all workdirs in a run.""" - manifest = _read_manifest(run_id) - workdirs = list(manifest.get("workdirs") or []) - if not workdirs: - result = {"ok": True, "run_id": run_id, "results": [], "failed": []} - _write_json(TM_WORK / run_id / "results.json", result) - return result - - binary = _find_binary() - if not binary: - raise CommandError("task-master binary not found in PATH") - - fleet_config = load_fleet_config() - profile = economy_profile(fleet_config) - workers = _default_concurrency(concurrency, len(workdirs), fleet_config, profile) - results: list[dict] = [] - - with ThreadPoolExecutor(max_workers=workers) as executor: - futures = [ - executor.submit(_run_packet, binary, item, timeout, fleet_config, profile) - for item in workdirs - ] - for future in as_completed(futures): - results.append(future.result()) - - results.sort(key=lambda item: item["task_id"]) - failed = [item["task_id"] for item in results if not item.get("success")] - result = {"ok": not failed, "run_id": run_id, "results": results, "failed": failed} - manifest["workdirs"] = workdirs - _write_manifest(TM_WORK / run_id, manifest) - _write_json(TM_WORK / run_id / "results.json", result) - return result - - -def _complexity_from_report(path: Path) -> dict: - if not path.is_file(): - return {} - try: - raw = _read_json(path) - except (json.JSONDecodeError, OSError): - return {} - items = raw.get("complexityAnalysis") if isinstance(raw, dict) else None - if not isinstance(items, list): - return {} - result = {} - for item in items: - if isinstance(item, dict) and "taskId" in item: - result[item["taskId"]] = item - return result - - -def _load_complexity(tag: str) -> dict: - reports = Path(".taskmaster") / "reports" - if not reports.is_dir(): - return {} - merged: dict = {} - generic = reports / "task-complexity-report.json" - merged.update(_complexity_from_report(generic)) - if tag and tag != "master": - merged.update(_complexity_from_report(reports / f"task-complexity-report_{tag}.json")) - for path in sorted(reports.glob("task-complexity-report*.json")): - if path not in {generic, reports / f"task-complexity-report_{tag}.json"}: - for task_id, item in _complexity_from_report(path).items(): - merged.setdefault(task_id, item) - return merged - - -def _extract_subtasks(workdir: Path, tag: str) -> list[dict]: - path = workdir / ".taskmaster" / "tasks" / "tasks.json" - raw = _read_json(path) - if isinstance(raw, dict) and tag in raw and isinstance(raw[tag], dict): - tasks = raw[tag].get("tasks") or [] - elif isinstance(raw, dict) and isinstance(raw.get("tasks"), list): - tasks = raw.get("tasks") or [] - else: - tasks = [] - if not tasks: - return [] - task = tasks[0] - return task.get("subtasks") or [] - - -def run_tm_harvest(run_id: str, tag: str | None = None, threshold: int = 7) -> dict: - """Harvest successful isolated expansions and merge once through apply_results.""" - manifest = _read_manifest(run_id) - run_results = _read_run_results(run_id) - resolved_tag = tag or manifest.get("tag") or parallel.current_tag(None) - successful = {item["task_id"]: item for item in run_results.get("results", []) if item.get("success")} - failed = list(run_results.get("failed") or []) - complexity = _load_complexity(resolved_tag) - by_task = {item["task_id"]: item for item in manifest.get("workdirs") or []} - harvested: list[dict] = [] - retained: list[str] = [] - - for task_id, item in by_task.items(): - workdir = Path(item["path"]) - if task_id not in successful: - if workdir.exists(): - retained.append(str(workdir)) - continue - subtasks = _extract_subtasks(workdir, resolved_tag) - c = complexity.get(task_id, {}) - harvested.append({ - "id": task_id, - "complexityScore": c.get("complexityScore"), - "recommendedSubtasks": c.get("recommendedSubtasks"), - "reasoning": c.get("reasoning", ""), - "researchNotes": "", - "subtasks": subtasks, - }) - - if harvested: - result = parallel.apply_results(harvested, tag=resolved_tag, threshold=threshold) - else: - result = { - "ok": False, - "tag": resolved_tag, - "applied": [], - "report": None, - "needs_more_subtasks": [], - } - - for task_id in successful: - workdir = Path(by_task[task_id]["path"]) - if workdir.exists(): - shutil.rmtree(workdir) - - run_root = TM_WORK / run_id - if failed: - retained = [str(Path(by_task[task_id]["path"])) for task_id in failed if task_id in by_task and Path(by_task[task_id]["path"]).exists()] - elif run_root.exists(): - shutil.rmtree(run_root) - - result.update({"run_id": run_id, "failed": failed, "retained_workdirs": retained}) - return result - - -def run_tm_parallel( - tag: str | None = None, - missing_only: bool = True, - concurrency: int | None = None, - timeout: float = 180, - dry_run: bool = False, -) -> dict: - """Plan, run, and harvest native TaskMaster expansion.""" - gate = _version_gate() - if not gate.get("ok"): - return gate - plan = run_tm_plan(tag=tag, missing_only=missing_only) - if dry_run: - return {**plan, "dry_run": True} - run = run_tm_run(plan["run_id"], concurrency=concurrency, timeout=timeout) - harvest = run_tm_harvest(plan["run_id"], tag=tag) - harvest["run"] = run - return harvest - - -def _emit_result(result: dict) -> None: - emit(result) - - -def cmd_tm_plan(args: argparse.Namespace) -> None: - try: - _emit_result(run_tm_plan(tag=args.tag, missing_only=args.missing_only)) - except CommandError as exc: - fail(exc.message, **exc.extra) - - -def cmd_tm_run(args: argparse.Namespace) -> None: - try: - _emit_result(run_tm_run(args.run_id, concurrency=args.concurrency, timeout=args.timeout)) - except CommandError as exc: - fail(exc.message, **exc.extra) - - -def cmd_tm_harvest(args: argparse.Namespace) -> None: - try: - _emit_result(run_tm_harvest(args.run_id, tag=args.tag, threshold=args.threshold)) - except CommandError as exc: - fail(exc.message, **exc.extra) - - -def cmd_tm_parallel(args: argparse.Namespace) -> None: - try: - _emit_result( - run_tm_parallel( - tag=args.tag, - missing_only=args.missing_only, - concurrency=args.concurrency, - timeout=args.timeout, - dry_run=args.dry_run, - ) - ) - except CommandError as exc: - fail(exc.message, **exc.extra) diff --git a/skills/expand-tasks/SKILL.md b/skills/expand-tasks/SKILL.md index 9a0bc93..603dcac 100644 --- a/skills/expand-tasks/SKILL.md +++ b/skills/expand-tasks/SKILL.md @@ -42,14 +42,14 @@ Do NOT activate for: single task research (use /research-before-coding), PRD gen ## Native-parallel first (token economy) -Before launching agent waves, check the cheaper path: when task-master ≥ 0.43 AND the -configured research role is a REAL structured API (not the free local proxy), prefer -`python3 script.py tm-parallel` (or the `tm_parallel_expand` MCP tool) — it runs NATIVE -`expand --research` in N isolated workdirs on economy-tier models and merges atomically. -For native backend operation, use `python3 script.py expand`, or backend op expand (native api). -Use THIS skill's agent waves when: the research role is the free local proxy, no API key -exists, tm-parallel reports failures for specific tasks (rerun just those here), or the -research must be repo-grounded (agents can read the codebase; native expand cannot). +Before launching agent waves, check the cheaper path: the native engine expands tasks +in parallel for free. Prefer `python3 script.py expand` — backend op expand (native api) — +or the `expand_tasks` MCP tool: it runs structured `expand` across pending tasks +concurrently (inheriting the engine's ThreadPoolExecutor) on economy-tier models / +keyless host CLIs and merges atomically. +Use THIS skill's agent waves when: no provider/CLI is available, native expand reports +failures for specific tasks (rerun just those here), or the research must be repo-grounded +(agents can read the codebase; native expand cannot). ## Prerequisites diff --git a/skills/generate/SKILL.md b/skills/generate/SKILL.md index 6bde14a..3e59c79 100644 --- a/skills/generate/SKILL.md +++ b/skills/generate/SKILL.md @@ -225,9 +225,8 @@ Use the normative backend operation instead of home-rolled classification: - **MCP fallback**: `mcp__plugin_prd_go__tm_analyze_complexity` (wraps the CLI) - **CLI**: `task-master analyze-complexity` -**Important — output location**: the TaskMasterBackend internal -`analyze-complexity` step does NOT emit JSON to stdout. It writes structured -analysis to +**Important — output location**: the `analyze-complexity` step does NOT emit +JSON to stdout. It writes structured analysis to `.taskmaster/reports/task-complexity-report.json` and prints a human-readable table to stdout. To read the structured result, read the report file: @@ -261,27 +260,11 @@ Detected in the v4 Shade dogfood 2026-04-13. python3 script.py expand ``` -The backend operation chooses the correct implementation. Its -TaskMasterBackend.expand internals may use serial native expansion, -`tm-parallel`, or the TaskMaster backend direct methods below. The native/agent -path uses agent-parallel planning and atomic apply when direct native API -expansion is unavailable. - -**TaskMaster backend direct methods** (only when explicitly operating that backend): - -```bash -# Preferred: research-enriched expansion (when a research provider is configured) -task-master expand --all --research - -# Fallback: structural-only (still valuable; always available) -task-master expand --all -``` - -**MCP note on `expand_task`**: task-master's MCP currently exposes -`expand_task(id=...)` per-task only. Do NOT call `expand_task` in parallel -across IDs — same race, same data loss. If you must use the MCP, call -`expand_task` serially for every task, or shell out to -`task-master expand --all` via Bash. +The native engine is the sole generator. `script.py expand` (backend op expand) +expands pending tasks via the native structured path — a keyless host CLI +(`claude`/`codex`/`gemini`) or a provider API key — running in parallel and +applying atomically. When no provider/CLI is available it falls back to +agent-parallel planning + atomic apply (the native/agent floor). ### Patience under slow providers diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 232ffdc..50b6bcf 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -48,20 +48,16 @@ Run backend detection: python3 script.py backend-detect ``` -If TaskMaster is unavailable, report one compact info line: - -``` -TaskMaster is optional. Installing task-master-ai unlocks the TaskMaster backend: - npm install -g task-master-ai -Proceeding with the resolved backend. -``` - -Do NOT auto-install and do NOT stop for installation. Continue with the resolved -backend. +The native engine is the sole generator and needs no external binary — a +keyless host CLI (`claude` / `codex` / `gemini`) on PATH, or a provider API key, +is sufficient (see Chunk 7's `atlas setup` wizard). The `task-master` binary is +no longer required or supported; `backend-detect` reports its presence purely as +informational. Continue with the resolved (native) backend. ### Step 2: Project init -Check whether the current project has a `.taskmaster/` directory. +Check whether the current project has a `.taskmaster/` directory (the engine +still reads/writes the `.taskmaster/` file format for tasks and config). If missing, run backend op `init`: @@ -69,8 +65,7 @@ If missing, run backend op `init`: python3 script.py init-project ``` -If explicitly operating the TaskMaster backend, this may wrap -`task-master init --yes` with the engine's `.mcp.json` protection. If +This initialises the native project state and the `.taskmaster/` file format. If `.taskmaster/` is present, continue. ### Step 2.5: Customisation bootstrap (REQUIRED — closes execute-task deadlock) diff --git a/tests/core/test_backend.py b/tests/core/test_backend.py index 6943eda..7827060 100644 --- a/tests/core/test_backend.py +++ b/tests/core/test_backend.py @@ -1,320 +1,49 @@ -"""Backend abstraction tests for the v4.1 TaskMaster seam.""" +"""Backend abstraction tests. The task-master backend was removed (spec §9.4); +NativeBackend is the sole generator. These tests cover the factory + the +fleet.json backend-key validation that survive the deletion.""" import json -import sys -import textwrap -from pathlib import Path - -import pytest - -from prd_taskmaster.lib import CommandError - - -def _write_fake_taskmaster(bin_dir: Path, version: str = "0.43.1") -> Path: - bin_dir.mkdir(parents=True, exist_ok=True) - script = bin_dir / "task-master" - script.write_text( - textwrap.dedent( - f"""\ - #!{sys.executable} - import json - import os - import sys - from pathlib import Path - - def current_tag(): - state = Path(".taskmaster/state.json") - if state.is_file(): - return json.loads(state.read_text()).get("currentTag", "master") - return "master" - - def write_tasks(count): - tag = current_tag() - path = Path(".taskmaster/tasks/tasks.json") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({{tag: {{"tasks": [ - {{"id": i, "title": f"Task {{i}}", "status": "pending"}} - for i in range(1, count + 1) - ]}}}}, indent=2)) - - def log_command(args): - log = os.environ.get("FAKE_TM_LOG") - if log: - with open(log, "a") as f: - f.write(json.dumps(args) + "\\n") - - args = sys.argv[1:] - if args == ["--version"]: - print("{version}") - raise SystemExit(0) - - if args and args[0] == "parse-prd": - log_command(args) - count = int(args[args.index("--num-tasks") + 1]) - write_tasks(count) - print("parse stdout ignored") - raise SystemExit(0) - - if args and args[0] == "expand": - log_command(args) - print("expanded") - raise SystemExit(0) - - if args and args[0] == "analyze-complexity": - log_command(args) - print('{{"complexityAnalysis":[{{"taskId":999}}]}}') - raise SystemExit(0) - - print("unexpected command: " + " ".join(args), file=sys.stderr) - raise SystemExit(2) - """ - ).lstrip() - ) - script.chmod(0o755) - return script - - -def _isolate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, with_binary: bool = True) -> Path: - monkeypatch.chdir(tmp_path) - monkeypatch.setenv("HOME", str(tmp_path / "home")) - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - if with_binary: - _write_fake_taskmaster(bin_dir) - monkeypatch.setenv("PATH", str(bin_dir)) - return bin_dir - - -def _seed_tasks(tmp_path: Path, count: int, tag: str = "master") -> None: - tm = tmp_path / ".taskmaster" - (tm / "tasks").mkdir(parents=True, exist_ok=True) - (tm / "reports").mkdir(parents=True, exist_ok=True) - (tm / "state.json").write_text(json.dumps({"currentTag": tag})) - tasks = [ - { - "id": idx, - "title": f"Task {idx}", - "status": "pending", - "dependencies": [], - "subtasks": [], - } - for idx in range(1, count + 1) - ] - (tm / "tasks" / "tasks.json").write_text(json.dumps({tag: {"tasks": tasks}}, indent=2)) +import warnings def test_backend_factory_precedence_and_auto_detection(tmp_path, monkeypatch): - from prd_taskmaster.backend import NativeBackend, TaskMasterBackend, get_backend + """get_backend always returns NativeBackend. 'native' and 'auto' resolve to + it directly; a legacy 'taskmaster' config falls back to native with a + DeprecationWarning (the class itself is gone).""" + from prd_taskmaster.backend import NativeBackend, get_backend - _isolate(tmp_path, monkeypatch, with_binary=False) + monkeypatch.chdir(tmp_path) - explicit = get_backend({"backend": "taskmaster"}) - assert isinstance(explicit, TaskMasterBackend) - assert explicit.name == "taskmaster" - assert explicit.detect()["available"] is False + # legacy backend="taskmaster" no longer constructs a removed class — it + # resolves to native with a deprecation warning. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + legacy = get_backend({"backend": "taskmaster"}) + assert isinstance(legacy, NativeBackend) + assert legacy.name == "native" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) native = get_backend({"backend": "native"}) assert isinstance(native, NativeBackend) assert native.name == "native" - auto_fallback = get_backend({"backend": "auto"}) - assert isinstance(auto_fallback, NativeBackend) - - # flipped: spec §9.2 — auto is always native, even with the task-master binary present - _write_fake_taskmaster(tmp_path / "bin") auto = get_backend({"backend": "auto"}) assert isinstance(auto, NativeBackend) -def test_backend_detect_shape_and_version_gate(tmp_path, monkeypatch): - from prd_taskmaster.backend import get_backend - - bin_dir = _isolate(tmp_path, monkeypatch, with_binary=False) - _write_fake_taskmaster(bin_dir, version="0.42.0") - - result = get_backend({"backend": "taskmaster"}).detect() - - assert set(result) == {"name", "available", "version", "ai_ops", "missing"} - assert result["name"] == "taskmaster" - assert result["available"] is False - assert result["ai_ops"] is False - assert result["version"] == "0.42.0" - assert any("0.43.0" in item for item in result["missing"]) - - -def test_parse_prd_runs_taskmaster_and_counts_tasks_json(tmp_path, monkeypatch): - from prd_taskmaster.backend import get_backend - - _isolate(tmp_path, monkeypatch) - tm = tmp_path / ".taskmaster" - tm.mkdir() - (tm / "state.json").write_text(json.dumps({"currentTag": "master"})) - prd = tmp_path / "prd.md" - prd.write_text("# PRD\n") - - result = get_backend({"backend": "taskmaster"}).parse_prd(prd, 5) - - assert result["ok"] is True - assert result["task_count"] == 5 - - -def test_expand_delegates_to_tm_parallel_for_more_than_three_pending(tmp_path, monkeypatch): - from prd_taskmaster import tm_parallel - from prd_taskmaster.backend import get_backend - - _isolate(tmp_path, monkeypatch) - _seed_tasks(tmp_path, 4) - called = {} - - def fake_run_tm_parallel(**kwargs): - called.update(kwargs) - return {"ok": True, "delegated": True} - - monkeypatch.setattr(tm_parallel, "run_tm_parallel", fake_run_tm_parallel) - - result = get_backend({"backend": "taskmaster"}).expand(tag="master") - - expected = {"ok": True, "delegated": True, "backend": "taskmaster", "ai": "taskmaster-cli"} - assert result == expected - assert called["tag"] == "master" - -def test_expand_serial_branch_runs_binary_and_appends_telemetry(tmp_path, monkeypatch): - from prd_taskmaster.backend import get_backend - - _isolate(tmp_path, monkeypatch) - _seed_tasks(tmp_path, 3) - log_path = tmp_path / "fake-tm.jsonl" - monkeypatch.setenv("FAKE_TM_LOG", str(log_path)) - - result = get_backend({"backend": "taskmaster"}).expand(tag="master") - - assert result["ok"] is True - assert result["expanded"] == [1, 2, 3] - commands = [json.loads(line) for line in log_path.read_text().splitlines()] - assert commands == [ - ["expand", "--id", "1", "--research"], - ["expand", "--id", "2", "--research"], - ["expand", "--id", "3", "--research"], - ] - telemetry = [ - json.loads(line) - for line in (tmp_path / ".atlas-ai" / "telemetry.jsonl").read_text().splitlines() - ] - assert [row["task_id"] for row in telemetry] == [1, 2, 3] - assert {row["op_class"] for row in telemetry} == {"structured_gen"} - assert {row["backend"] for row in telemetry} == {"taskmaster-api"} - - -def test_rate_reads_report_file_not_stdout(tmp_path, monkeypatch): - from prd_taskmaster.backend import get_backend - - _isolate(tmp_path, monkeypatch) - _seed_tasks(tmp_path, 1) - report = tmp_path / ".taskmaster" / "reports" / "task-complexity-report.json" - report.write_text( - json.dumps( - { - "complexityAnalysis": [ - {"taskId": 7, "complexityScore": 8, "recommendedSubtasks": 4} - ] - } - ) - ) - - result = get_backend({"backend": "taskmaster"}).rate() - - assert result["ok"] is True - assert result["report"] == str(Path(".taskmaster/reports/task-complexity-report.json")) - assert result["complexityAnalysis"][0]["taskId"] == 7 - - def test_load_fleet_config_backend_key_validates_silently(tmp_path, monkeypatch): from prd_taskmaster.fleet import load_fleet_config monkeypatch.chdir(tmp_path) - assert load_fleet_config()["backend"] == "auto" + # the native engine is the sole generator: the default backend is "native". + assert load_fleet_config()["backend"] == "native" cfg_dir = tmp_path / ".atlas-ai" cfg_dir.mkdir() (cfg_dir / "fleet.json").write_text(json.dumps({"backend": "native"})) assert load_fleet_config()["backend"] == "native" + # an invalid/removed value (e.g. the removed "taskmaster", or garbage) is + # silently repaired back to the "native" default. (cfg_dir / "fleet.json").write_text(json.dumps({"backend": "broken"})) - assert load_fleet_config()["backend"] == "auto" - - -def test_taskmaster_responses_carry_backend_identity(tmp_path, monkeypatch): - from prd_taskmaster.backend import get_backend - import types - - _isolate(tmp_path, monkeypatch, with_binary=False) - _seed_tasks(tmp_path, 1) - - def fake_run(cmd, capture_output=True, text=True): - return types.SimpleNamespace(returncode=1, stdout='', stderr='boom') - - monkeypatch.setattr('prd_taskmaster.backend.subprocess.run', fake_run) - monkeypatch.setattr('prd_taskmaster.backend._binary_or_raise', lambda: 'task-master') - - backend = get_backend({'backend': 'taskmaster'}) - - parse_result = backend.parse_prd(str(tmp_path / 'prd.md'), 3) - rate_result = backend.rate() - - for res in (parse_result, rate_result): - assert res.get('ok') is False - assert res.get('backend') == 'taskmaster' - assert res.get('ai') == 'taskmaster-cli' - - -# ── pre-relaunch P0-3 (serial path) + P1-1 (parse zero-task) ──────────────── - -def test_expand_serial_degrades_to_structural_on_research_failure(tmp_path, monkeypatch): - """Serial (<=3 tasks) path: when expand --research fails (research provider down), - retry without --research rather than hard-failing to 0 subtasks. (P0-3)""" - from prd_taskmaster.backend import get_backend - - bin_dir = _isolate(tmp_path, monkeypatch, with_binary=False) - script = bin_dir / "task-master" - script.write_text( - "#!/bin/sh\n" - 'if [ "$1" = "--version" ]; then echo 0.43.1; exit 0; fi\n' - 'for a in "$@"; do if [ "$a" = "--research" ]; then echo "quota" >&2; exit 1; fi; done\n' - "exit 0\n" - ) - script.chmod(0o755) - _seed_tasks(tmp_path, 2) - - result = get_backend({"backend": "taskmaster"}).expand(tag="master") - - assert result["ok"] is True - assert result["expanded"] == [1, 2] - assert result.get("degraded") is True - - -def test_parse_prd_zero_tasks_is_failure(tmp_path, monkeypatch): - """parse-prd that exits 0 but produces no tasks must be reported ok=False, not a - silent success that the pipeline treats as done. (P1-1)""" - from prd_taskmaster.backend import get_backend - - bin_dir = _isolate(tmp_path, monkeypatch, with_binary=False) - script = bin_dir / "task-master" - script.write_text( - "#!/bin/sh\n" - 'if [ "$1" = "--version" ]; then echo 0.43.1; exit 0; fi\n' - 'echo "parsed nothing"\n' - "exit 0\n" - ) - script.chmod(0o755) - tm = tmp_path / ".taskmaster" - (tm / "tasks").mkdir(parents=True, exist_ok=True) - (tm / "state.json").write_text(json.dumps({"currentTag": "master"})) - # parse-prd exits 0 but the model produced no tasks - (tm / "tasks" / "tasks.json").write_text(json.dumps({"master": {"tasks": []}})) - prd = tmp_path / "prd.md" - prd.write_text("# PRD\n") - - result = get_backend({"backend": "taskmaster"}).parse_prd(prd, 5) - - assert result["ok"] is False - assert result["task_count"] == 0 + assert load_fleet_config()["backend"] == "native" diff --git a/tests/core/test_backend_migration.py b/tests/core/test_backend_migration.py index 8a1260b..cd334e8 100644 --- a/tests/core/test_backend_migration.py +++ b/tests/core/test_backend_migration.py @@ -1,9 +1,11 @@ -"""Migration tests: backend='auto' resolves to NativeBackend unconditionally, -backend='taskmaster' still works for one deprecation release with a warning. +"""Migration tests: NativeBackend is the sole generator. The task-master backend +was removed (spec §9.4); backend='auto' resolves to NativeBackend unconditionally +and a legacy backend='taskmaster' config falls back to native with a warning. -Spec: docs/design/2026-06-15-atlas-engine-hybrid-provider-setup.md §9.2 +Spec: docs/design/2026-06-15-atlas-engine-hybrid-provider-setup.md §9.2, §9.4 """ +import importlib import warnings import pytest @@ -11,46 +13,20 @@ from prd_taskmaster import backend as backend_mod from prd_taskmaster.backend import ( NativeBackend, - TaskMasterBackend, get_backend, ) -def test_auto_resolves_native_even_when_taskmaster_binary_present(monkeypatch): - """The migration's core invariant: 'auto' is NativeBackend even when the - task-master binary is on PATH and detect() reports it available. - - We monkeypatch TaskMasterBackend.detect to claim availability; the old - code would have returned the TaskMasterBackend in that case. Post-flip it - must NOT — 'auto' returns NativeBackend unconditionally. - """ - monkeypatch.setattr( - TaskMasterBackend, - "detect", - lambda self: {"name": "taskmaster", "available": True, "ai_ops": True}, - ) +def test_auto_resolves_native(monkeypatch): + """The migration's core invariant: 'auto' is NativeBackend. Post-deletion + there is no task-master binary probe at all — native is unconditional.""" be = get_backend({"backend": "auto"}) assert isinstance(be, NativeBackend) assert be.name == "native" -def test_auto_resolves_native_when_taskmaster_binary_absent(monkeypatch): - monkeypatch.setattr( - TaskMasterBackend, - "detect", - lambda self: {"name": "taskmaster", "available": False, "ai_ops": False}, - ) - be = get_backend({"backend": "auto"}) - assert isinstance(be, NativeBackend) - - -def test_missing_backend_key_defaults_to_native(monkeypatch): +def test_missing_backend_key_defaults_to_native(): """An empty/legacy config (no 'backend' key) defaults to 'auto' -> Native.""" - monkeypatch.setattr( - TaskMasterBackend, - "detect", - lambda self: {"name": "taskmaster", "available": True}, - ) be = get_backend({}) assert isinstance(be, NativeBackend) @@ -60,27 +36,27 @@ def test_explicit_native_returns_native(): assert isinstance(be, NativeBackend) -def test_explicit_taskmaster_still_works_but_warns(): - """backend='taskmaster' is honored for ONE deprecation release, with a - DeprecationWarning so dispatch logs surface the impending removal.""" - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - be = get_backend({"backend": "taskmaster"}) - assert isinstance(be, TaskMasterBackend) - assert any(issubclass(w.category, DeprecationWarning) for w in caught) - assert any("taskmaster" in str(w.message).lower() for w in caught) +# ─── Post-deletion: the task-master surface is gone (Chunk 6 Task 4) ────────── +def test_taskmaster_backend_is_removed(): + """Post-deletion: TaskMasterBackend no longer exists in the backend module.""" + assert not hasattr(backend_mod, "TaskMasterBackend") -def test_get_backend_does_not_call_taskmaster_detect_on_auto(monkeypatch): - """Regression guard: the old auto path constructed a TaskMasterBackend and - called .detect(). The flip must NOT touch TaskMasterBackend at all on auto — - no binary probe cost, no import-time spawn.""" - called = {"detect": False} - def boom(self): - called["detect"] = True - return {"available": True} +def test_tm_parallel_module_is_removed(): + with pytest.raises(ModuleNotFoundError): + importlib.import_module("prd_taskmaster.tm_parallel") - monkeypatch.setattr(TaskMasterBackend, "detect", boom) - get_backend({"backend": "auto"}) - assert called["detect"] is False + +def test_backend_choices_is_native_only(): + from prd_taskmaster import fleet + + assert fleet.BACKEND_CHOICES == {"native"} + + +def test_taskmaster_backend_request_falls_back_to_native(recwarn): + """An old fleet.json still pinned to backend='taskmaster' must NOT crash now + that the class is gone — it resolves to native with a deprecation warning.""" + be = get_backend({"backend": "taskmaster"}) + assert isinstance(be, NativeBackend) + assert any(issubclass(w.category, DeprecationWarning) for w in recwarn.list) diff --git a/tests/core/test_prerelaunch_p0_fixes.py b/tests/core/test_prerelaunch_p0_fixes.py index 109b810..7001aed 100644 --- a/tests/core/test_prerelaunch_p0_fixes.py +++ b/tests/core/test_prerelaunch_p0_fixes.py @@ -13,12 +13,10 @@ """ import json -import stat from pathlib import Path from prd_taskmaster.mode_recommend import validate_setup from prd_taskmaster.providers import run_configure_providers -from prd_taskmaster.tm_parallel import _run_packet # ── shared fixtures (self-contained; mirror test_dogfood_fixes helpers) ────── @@ -159,58 +157,11 @@ def test_validate_setup_passes_when_main_provider_is_usable(tmp_path, monkeypatc assert result["ready"] is True -# ── P0-3: expand must degrade to structural when research provider is down ─── - -def _fake_research_sensitive_taskmaster(bin_dir: Path) -> str: - """A fake `task-master` that FAILS when --research is present (quota/auth down) - and SUCCEEDS for a structural expand. Returns the binary path.""" - script = bin_dir / "task-master" - script.write_text( - "#!/bin/sh\n" - 'for a in "$@"; do\n' - ' if [ "$a" = "--research" ]; then echo "Perplexity API error: quota" >&2; exit 1; fi\n' - "done\n" - "exit 0\n" - ) - script.chmod(script.stat().st_mode | stat.S_IEXEC) - return str(script) - - -def test_expand_packet_degrades_to_structural_on_research_failure(tmp_path): - """The dogfood outage: research provider out of quota. expand --research fails; - the engine must retry WITHOUT --research (structural, 'always available') rather - than hard-fail to 0 subtasks. Success is marked degraded.""" - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - binary = _fake_research_sensitive_taskmaster(bin_dir) - workdir = tmp_path / "wd" - workdir.mkdir() - - item = {"task_id": 1, "path": str(workdir), "tier": "standard", "model": "sonnet"} - profile = {"structured_gen_start": "standard", "escalation": {"enabled": False}} - - result = _run_packet(binary, item, timeout=30, fleet_config={}, profile=profile) - - assert result["success"] is True - assert result.get("degraded") is True - - -def test_expand_packet_fails_when_structural_also_fails(tmp_path): - """If even structural expand fails, the packet is a genuine failure (not masked).""" - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - script = bin_dir / "task-master" - script.write_text("#!/bin/sh\necho 'hard failure' >&2\nexit 1\n") - script.chmod(script.stat().st_mode | stat.S_IEXEC) - workdir = tmp_path / "wd" - workdir.mkdir() - - item = {"task_id": 1, "path": str(workdir), "tier": "standard", "model": "sonnet"} - profile = {"structured_gen_start": "standard", "escalation": {"enabled": False}} - - result = _run_packet(str(script), item, timeout=30, fleet_config={}, profile=profile) - - assert result["success"] is False +# NOTE: P0-3 (expand must degrade to structural when research provider is down) +# previously tested tm_parallel._run_packet against a fake `task-master` binary. +# The task-master backend was removed (spec §9.4) — native is the sole generator — +# so the binary-path degrade tests were deleted with the module. The native +# engine's structural-decomposition fallback lives in NativeBackend.expand. # ── #11/#12: nested-session spawn PROBE (verify, don't assume) ─────────────── diff --git a/tests/core/test_tm_parallel.py b/tests/core/test_tm_parallel.py deleted file mode 100644 index 1efb23e..0000000 --- a/tests/core/test_tm_parallel.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Tests for native TaskMaster expansion through isolated workdirs.""" - -import json -import os -import subprocess -import sys -import textwrap -from pathlib import Path - - -REPO = Path(__file__).resolve().parents[2] -SCRIPT = REPO / "script.py" - - -def _write_fake_taskmaster(bin_dir: Path, mode: str = "ok", version: str = "0.43.1") -> None: - bin_dir.mkdir(parents=True, exist_ok=True) - script = bin_dir / "task-master" - script.write_text( - textwrap.dedent( - f"""\ - #!/bin/sh - if [ "$1" = "--version" ]; then - echo "{version}" - exit 0 - fi - if [ "$1" = "expand" ]; then - if [ "{mode}" = "slow" ]; then - /bin/sleep 2 - fi - if [ "{mode}" = "fail" ]; then - echo "forced failure" >&2 - exit 1 - fi - "{sys.executable}" - "$@" <<'PY' -import json -import sys -from pathlib import Path - -args = sys.argv[1:] -task_id = None -for idx, arg in enumerate(args): - if arg == "--id" and idx + 1 < len(args): - task_id = int(args[idx + 1]) - elif arg.startswith("--id="): - task_id = int(arg.split("=", 1)[1]) -if task_id is None: - raise SystemExit("missing --id") - -path = Path(".taskmaster/tasks/tasks.json") -raw = json.loads(path.read_text()) -tag, wrapper = next(iter(raw.items())) -task = next(t for t in wrapper["tasks"] if t["id"] == task_id) -task["subtasks"] = [ - {{"id": 1, "title": f"Task {{task_id}} first", "description": "one", "details": "alpha", "dependencies": []}}, - {{"id": 2, "title": f"Task {{task_id}} second", "description": "two", "details": "beta", "dependencies": [1]}}, -] -path.write_text(json.dumps(raw, indent=2)) -PY - exit $? - fi - echo "unexpected command: $*" >&2 - exit 2 - """ - ).lstrip() - ) - script.chmod(0o755) - - -def _seed_project(tmp_path: Path, provider: str = "claude-code") -> Path: - tm = tmp_path / ".taskmaster" - tasks_dir = tm / "tasks" - docs_dir = tm / "docs" - reports_dir = tm / "reports" - tasks_dir.mkdir(parents=True) - docs_dir.mkdir(parents=True) - reports_dir.mkdir(parents=True) - (tm / "state.json").write_text(json.dumps({"currentTag": "master"})) - (tm / "config.json").write_text( - json.dumps( - { - "models": { - "main": { - "provider": provider, - "modelId": "sonnet", - "maxTokens": 64000, - "temperature": 0.2, - }, - "research": {"provider": "anthropic", "modelId": "sonnet"}, - }, - "global": {"defaultTag": "master"}, - }, - indent=2, - ) - ) - (docs_dir / "prd.md").write_text("# PRD\n") - (reports_dir / "task-complexity-report.json").write_text( - json.dumps( - { - "complexityAnalysis": [ - {"taskId": 1, "complexityScore": 8, "recommendedSubtasks": 4, "reasoning": "complex"} - ] - } - ) - ) - payload = { - "master": { - "tasks": [ - { - "id": 1, - "title": "Build native expansion", - "description": "Use isolated workdirs", - "details": "Keep main dependencies", - "testStrategy": "unit", - "status": "pending", - "dependencies": [99], - "subtasks": [], - }, - { - "id": 2, - "title": "Already expanded", - "description": "Skip by default", - "details": "", - "testStrategy": "", - "status": "pending", - "dependencies": [1], - "subtasks": [ - {"id": 1, "title": "a", "description": "", "details": "", "dependencies": []}, - {"id": 2, "title": "b", "description": "", "details": "", "dependencies": []}, - ], - }, - ] - } - } - tasks_path = tasks_dir / "tasks.json" - tasks_path.write_text(json.dumps(payload, indent=2)) - return tasks_path - - -def _read_json(path: Path): - return json.loads(path.read_text()) - - -def test_tm_parallel_dry_run_manifest_and_dependency_stripping(tmp_path, monkeypatch): - from prd_taskmaster import tm_parallel - - tasks_path = _seed_project(tmp_path) - _write_fake_taskmaster(tmp_path / "bin") - monkeypatch.setenv("PATH", str(tmp_path / "bin")) - monkeypatch.chdir(tmp_path) - - result = tm_parallel.run_tm_parallel(dry_run=True) - - assert result["ok"] is True - assert result["run_id"] - assert len(result["workdirs"]) == 1 - assert result["workdirs"][0]["task_id"] == 1 - assert result["skipped"] == [{"task_id": 2, "reason": "already_has_subtasks"}] - workdir = Path(result["workdirs"][0]["path"]) - isolated = _read_json(workdir / ".taskmaster" / "tasks" / "tasks.json") - assert isolated["master"]["tasks"][0]["dependencies"] == [] - assert _read_json(tasks_path)["master"]["tasks"][0]["dependencies"] == [99] - - -def test_tm_parallel_happy_path_merges_subtasks_and_cleans_workdirs(tmp_path, monkeypatch): - from prd_taskmaster import tm_parallel - - tasks_path = _seed_project(tmp_path) - _write_fake_taskmaster(tmp_path / "bin") - monkeypatch.setenv("PATH", str(tmp_path / "bin")) - monkeypatch.chdir(tmp_path) - - result = tm_parallel.run_tm_parallel() - - assert result["ok"] is True - assert result["applied"] == [1] - assert result["failed"] == [] - merged = _read_json(tasks_path) - task1 = merged["master"]["tasks"][0] - assert task1["dependencies"] == [99] - assert [s["title"] for s in task1["subtasks"]] == ["Task 1 first", "Task 1 second"] - assert not Path(".atlas-ai/tmwork").joinpath(result["run_id"]).exists() - - -def test_tm_run_failure_retries_with_escalated_config_and_telemetry(tmp_path, monkeypatch): - from prd_taskmaster import tm_parallel - - _seed_project(tmp_path, provider="anthropic") - _write_fake_taskmaster(tmp_path / "bin", mode="fail") - monkeypatch.setenv("PATH", str(tmp_path / "bin")) - monkeypatch.chdir(tmp_path) - - plan = tm_parallel.run_tm_plan() - run = tm_parallel.run_tm_run(plan["run_id"], concurrency=1, timeout=5) - - assert run["ok"] is False - assert run["failed"] == [1] - # 2 research-backed attempts (one escalated) + 1 structural degrade attempt. - # The structural fallback (P0-3) means a research outage no longer ends at 2 - # research failures — it always tries a no---research pass before giving up. - assert run["results"][0]["attempts"] == 3 - workdir = Path(plan["workdirs"][0]["path"]) - config = _read_json(workdir / ".taskmaster" / "config.json") - assert config["models"]["main"]["provider"] == "anthropic" - assert config["models"]["main"]["modelId"] == tm_parallel.TIER_MODEL_IDS["opus"] - rows = [json.loads(line) for line in Path(".atlas-ai/telemetry.jsonl").read_text().splitlines()] - assert [row["task_id"] for row in rows] == [1, 1, 1] - assert rows[0]["exit"] == 1 - assert rows[1]["escalated"] is True - # final row is the structural (no-research) degrade attempt - assert rows[2].get("degraded") is True - - -def test_tm_run_timeout_records_failure_and_retains_workdir(tmp_path, monkeypatch): - from prd_taskmaster import tm_parallel - - _seed_project(tmp_path) - _write_fake_taskmaster(tmp_path / "bin", mode="slow") - monkeypatch.setenv("PATH", str(tmp_path / "bin")) - monkeypatch.chdir(tmp_path) - - plan = tm_parallel.run_tm_plan() - run = tm_parallel.run_tm_run(plan["run_id"], concurrency=1, timeout=0.1) - harvest = tm_parallel.run_tm_harvest(plan["run_id"]) - - assert run["ok"] is False - assert run["failed"] == [1] - assert harvest["failed"] == [1] - assert Path(plan["workdirs"][0]["path"]).is_dir() - rows = [json.loads(line) for line in Path(".atlas-ai/telemetry.jsonl").read_text().splitlines()] - assert rows[-1]["exit"] == "timeout" - - -def test_tm_parallel_version_gate_refuses_old_taskmaster(tmp_path, monkeypatch): - from prd_taskmaster import tm_parallel - - _seed_project(tmp_path) - _write_fake_taskmaster(tmp_path / "bin", version="0.42.0") - monkeypatch.setenv("PATH", str(tmp_path / "bin")) - monkeypatch.chdir(tmp_path) - - result = tm_parallel.run_tm_parallel() - - assert result["ok"] is False - assert result["minimum_version"] == "0.43.0" - assert "parallel-plan" in result["fallback"] - - -def test_tm_cli_dry_run_outputs_manifest_from_tmp_project(tmp_path, monkeypatch): - _seed_project(tmp_path) - _write_fake_taskmaster(tmp_path / "bin") - monkeypatch.setenv("PATH", str(tmp_path / "bin")) - - proc = subprocess.run( - [sys.executable, str(SCRIPT), "tm-parallel", "--dry-run"], - cwd=tmp_path, - capture_output=True, - text=True, - env={**os.environ, "PATH": str(tmp_path / "bin")}, - ) - - assert proc.returncode == 0, proc.stderr - data = json.loads(proc.stdout) - assert data["ok"] is True - assert data["dry_run"] is True - assert data["workdirs"][0]["task_id"] == 1 diff --git a/tests/mcp/test_mcp_tools.py b/tests/mcp/test_mcp_tools.py index cc49463..eb25944 100644 --- a/tests/mcp/test_mcp_tools.py +++ b/tests/mcp/test_mcp_tools.py @@ -214,22 +214,24 @@ def test_context_pack_tool_returns_core_pack(tmp_path): } -def test_server_registers_32_tools(): - """Verify server.py declares all 32 expected tool functions at module scope.""" +def test_server_registers_29_tools(): + """Verify server.py declares all 29 expected tool functions at module scope. + + The task-master backend was removed (spec §9.4): the init_taskmaster, + tm_parallel_expand, and backend_detect MCP tools were deleted (32 -> 29). + """ import server as S expected = { "preflight", "current_phase", "advance_phase", "check_gate", - "detect_taskmaster", "init_taskmaster", "validate_setup", + "detect_taskmaster", "validate_setup", "detect_capabilities", "load_template", "validate_prd", "calc_tasks", "gen_test_tasks", "backup_prd", "append_workflow", "debrief", "log_progress", "read_state", "gen_scripts", "compute_fleet_waves", "engine_preflight", - "tm_parallel_expand", "next_task", "claim_task", "set_task_status", - "backend_detect", "init_project", "parse_prd", "expand_tasks", @@ -238,7 +240,7 @@ def test_server_registers_32_tools(): "feedback_submit", "feedback_report", } - assert len(expected) == 32 + assert len(expected) == 29 public_attrs = {name for name in dir(S) if not name.startswith("_")} missing = expected - public_attrs assert not missing, f"missing tools: {sorted(missing)}" @@ -247,7 +249,7 @@ def test_server_registers_32_tools(): def test_backend_ai_tools_document_agent_action_required(): import server as S - for name in ("backend_detect", "parse_prd", "expand_tasks", "rate_tasks"): + for name in ("parse_prd", "expand_tasks", "rate_tasks"): doc = getattr(S, name).__doc__ or "" assert "agent_action_required" in doc assert "ai_ops" in doc diff --git a/tests/parity/golden_parity.py b/tests/parity/golden_parity.py index 0a24027..4186808 100644 --- a/tests/parity/golden_parity.py +++ b/tests/parity/golden_parity.py @@ -5,13 +5,14 @@ and diffs them. Only diffs NOT in the pre-declared `intended` whitelist fail parity. -IMPORTANT: parse_prd does NOT return the task graph in its result dict — both -NativeBackend.parse_prd (backend.py:409-419) and TaskMasterBackend.parse_prd -(backend.py:735-738) return {ok, task_count, tag, backend, ...} with no "tasks" -key; the tasks are written to .taskmaster/tasks/tasks.json. So capture reads the -graph from DISK via parallel.load_tagged + parallel.get_tasks AFTER parse_prd. -Each backend leg runs in its OWN temp cwd + tag so the two legs do not overwrite -each other's tasks.json. +IMPORTANT: parse_prd does NOT return the task graph in its result dict — +NativeBackend.parse_prd returns {ok, task_count, tag, backend, ...} with no +"tasks" key; the tasks are written to .taskmaster/tasks/tasks.json. So capture +reads the graph from DISK via parallel.load_tagged + parallel.get_tasks AFTER +parse_prd. Each leg runs in its OWN temp cwd + tag so legs do not overwrite each +other's tasks.json. The task-master backend was removed (spec §9.4): its golden +capture is a frozen committed artifact and the 'taskmaster' leg is no longer +re-runnable — only the native leg is live. This is the binary acceptance gate referenced by the migration deletion task. Skill: AI-golden-parity-refactor. Spec: §9.3. @@ -144,12 +145,20 @@ def _capture(backend_name: str, out_dir: Path) -> int: extract_graph_from_disk — parse_prd's result dict has no "tasks" key. Imported lazily so the pure differ tests do not drag in backend/model deps.""" - from prd_taskmaster.backend import NativeBackend, TaskMasterBackend, _FACTORY_TOKEN + from prd_taskmaster.backend import NativeBackend out_dir = out_dir.resolve() out_dir.mkdir(parents=True, exist_ok=True) if backend_name == "taskmaster": - be = TaskMasterBackend(_FACTORY_TOKEN) + # The task-master backend was removed (spec §9.4): the golden TaskMaster + # capture is a frozen, committed artifact and is no longer re-capturable. + # Gate against the committed gold; only the native leg is live. + print( + "the 'taskmaster' capture leg was removed with the backend (spec §9.4); " + "gate against the frozen committed gold instead", + file=sys.stderr, + ) + return 2 elif backend_name == "native": be = NativeBackend() else: From 534c781c4be5d257d5119be1cd02f9e3810cddab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 18:04:58 +0800 Subject: [PATCH 32/73] fix(tasks): validate/enrich honor active tag instead of stale flat key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-tasks and enrich-tasks resolved tasks via _resolve_tasks_payload, which returned the legacy top-level flat "tasks" list BEFORE checking the active tag. With state.json currentTag set (e.g. "productization") and a coexisting flat key, both ops silently operated on the wrong tasks — making --tag/currentTag a no-op in manual/native mode and forcing a flat-key clobber as a workaround. - _resolve_tasks_payload(raw, tag=None): explicit tag wins, else currentTag; a requested tag that exists now takes precedence over a coexisting flat key. An explicit --tag that does not exist raises (with available_tags) instead of silently falling back to flat. Flat-only files remain backward-compatible. - Thread --tag through run_validate_tasks / run_enrich_tasks and add the --tag CLI option to both subparsers (was missing). Results now report the resolved tag. Regression tests: currentTag wins over flat, explicit --tag override, missing tag errors (no silent fallback), flat-only backward-compat; enrich e2e via the CLI shim leaves the flat key untouched. Co-Authored-By: Claude Opus 4.8 --- prd_taskmaster/cli.py | 10 +++++ prd_taskmaster/lib.py | 40 ++++++++++++++--- prd_taskmaster/tasks.py | 12 +++-- prd_taskmaster/validation.py | 25 +++++++++-- tests/core/test_cli.py | 51 +++++++++++++++++++++ tests/core/test_validation.py | 84 +++++++++++++++++++++++++++++++++++ 6 files changed, 209 insertions(+), 13 deletions(-) diff --git a/prd_taskmaster/cli.py b/prd_taskmaster/cli.py index 948e167..aac2052 100644 --- a/prd_taskmaster/cli.py +++ b/prd_taskmaster/cli.py @@ -251,6 +251,11 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Require every task to include phaseConfig metadata after enrich-tasks", ) + p.add_argument( + "--tag", + default=None, + help="TaskMaster tag to validate (default: state.json currentTag, else flat/master)", + ) # enrich-tasks p = sub.add_parser("enrich-tasks", help="Add phaseConfig metadata to tasks.json") @@ -259,6 +264,11 @@ def build_parser() -> argparse.ArgumentParser: default=None, help="Path to tasks.json (default: .taskmaster/tasks/tasks.json)", ) + p.add_argument( + "--tag", + default=None, + help="TaskMaster tag to enrich (default: state.json currentTag, else flat/master)", + ) # ─── parallel research bridge (agent-parallel research fan-out) ─────────── # parallel-plan diff --git a/prd_taskmaster/lib.py b/prd_taskmaster/lib.py index da0a118..7bf1816 100644 --- a/prd_taskmaster/lib.py +++ b/prd_taskmaster/lib.py @@ -349,20 +349,48 @@ def _current_taskmaster_tag() -> str: return "master" -def _resolve_tasks_payload(raw: object) -> tuple[list | None, object]: - """Return the active task list and write-back wrapper for flat or tagged TaskMaster files.""" +def _resolve_tasks_payload(raw: object, tag: str | None = None) -> tuple[list | None, object]: + """Return the active task list and write-back wrapper for flat or tagged TaskMaster files. + + Resolution order (tagged structures win over a coexisting legacy flat key): + 1. An explicit ``tag`` argument, if that tag exists as ``raw[tag]["tasks"]``. + 2. ``state.json``'s ``currentTag``, if that tag exists as ``raw[tag]["tasks"]``. + 3. The legacy flat top-level ``raw["tasks"]`` list (backward-compat). + 4. The first tagged ``{... : {"tasks": [...]}}`` block found. + + The critical invariant: when a file holds BOTH a legacy flat ``tasks`` key + AND a tagged structure, an explicit/currentTag selection must operate on the + tagged block — not silently fall through to the stale flat tasks. The flat + key is only honored when no requested tag resolves (true legacy files). + """ if isinstance(raw, list): return raw, {"tasks": raw} if not isinstance(raw, dict): return None, raw - if isinstance(raw.get("tasks"), list): - return raw["tasks"], raw - tag = _current_taskmaster_tag() - tagged = raw.get(tag) + # 1 + 2: a requested tag (explicit arg, else currentTag) that actually exists + # takes precedence over a coexisting legacy flat ``tasks`` key. + requested = tag if (isinstance(tag, str) and tag) else _current_taskmaster_tag() + tagged = raw.get(requested) if isinstance(tagged, dict) and isinstance(tagged.get("tasks"), list): return tagged["tasks"], raw + # An EXPLICIT tag that does not exist is an error, not a silent flat fallback — + # otherwise --tag would be a misleading no-op (the BUG2 failure mode). + if isinstance(tag, str) and tag: + raise CommandError( + f"requested tag {tag!r} not found in tasks.json", + {"available_tags": sorted( + k for k, v in raw.items() + if isinstance(v, dict) and isinstance(v.get("tasks"), list) + )}, + ) + + # 3: legacy flat format (no tagged block matched the active tag). + if isinstance(raw.get("tasks"), list): + return raw["tasks"], raw + + # 4: first tagged block as a last resort. for value in raw.values(): if isinstance(value, dict) and isinstance(value.get("tasks"), list): return value["tasks"], raw diff --git a/prd_taskmaster/tasks.py b/prd_taskmaster/tasks.py index a8466c2..0a44bc4 100644 --- a/prd_taskmaster/tasks.py +++ b/prd_taskmaster/tasks.py @@ -14,6 +14,7 @@ emit, fail, _resolve_tasks_payload, + _current_taskmaster_tag, ) @@ -225,7 +226,7 @@ def _generate_acceptance_criteria(task: dict, complexity: str) -> list: return criteria -def run_enrich_tasks(input_path: str | None) -> dict: +def run_enrich_tasks(input_path: str | None, tag: str | None = None) -> dict: """Enrich tasks.json with phaseConfig metadata. Classifies each task's complexity (SIMPLE/MEDIUM/COMPLEX/RESEARCH/VALIDATION), @@ -234,6 +235,10 @@ def run_enrich_tasks(input_path: str | None) -> dict: This is intentionally a direct write — TaskMaster CLI cannot inject structured JSON metadata, so we own the enrichment as a post-parse step. + + Honors the active TaskMaster tag (explicit ``tag`` arg, else ``state.json`` + ``currentTag``) so the enrichment lands on the active tag's tasks rather than + a stale legacy flat ``tasks`` key (BUG2). """ tasks_path = Path(input_path) if input_path else TASKMASTER_TASKS / "tasks.json" if not tasks_path.is_file(): @@ -246,7 +251,7 @@ def run_enrich_tasks(input_path: str | None) -> dict: raise CommandError(f"Failed to parse {tasks_path}: {e}") # Support {tasks: [...]}, tagged {"master": {"tasks": [...]}}, and bare list formats. - tasks, wrapper = _resolve_tasks_payload(raw) + tasks, wrapper = _resolve_tasks_payload(raw, tag=tag) if not isinstance(tasks, list): raise CommandError( "tasks.json must be a list, a flat object with a 'tasks' list, or a tagged TaskMaster object", @@ -279,6 +284,7 @@ def run_enrich_tasks(input_path: str | None) -> dict: return { "ok": True, "tasks_path": str(tasks_path), + "tag": tag or _current_taskmaster_tag(), "total_tasks": len(tasks), "enriched": enriched_count, "already_enriched": len(tasks) - enriched_count, @@ -287,6 +293,6 @@ def run_enrich_tasks(input_path: str | None) -> dict: def cmd_enrich_tasks(args: argparse.Namespace) -> None: try: - emit(run_enrich_tasks(args.input)) + emit(run_enrich_tasks(args.input, tag=getattr(args, "tag", None))) except CommandError as e: fail(e.message, **e.extra) diff --git a/prd_taskmaster/validation.py b/prd_taskmaster/validation.py index 21138c0..adbc95d 100644 --- a/prd_taskmaster/validation.py +++ b/prd_taskmaster/validation.py @@ -17,6 +17,7 @@ has_section, word_count, _resolve_tasks_payload, + _current_taskmaster_tag, ) # Angle-bracket placeholder sub-pattern: matches only TRUE placeholders like @@ -351,8 +352,18 @@ def cmd_validate_prd(args: argparse.Namespace) -> None: fail(e.message, **e.extra) -def run_validate_tasks(input_path: str | None, allow_empty_subtasks: bool, require_phase_config: bool) -> dict: - """Validate a manually-authored TaskMaster-compatible tasks.json file.""" +def run_validate_tasks( + input_path: str | None, + allow_empty_subtasks: bool, + require_phase_config: bool, + tag: str | None = None, +) -> dict: + """Validate a manually-authored TaskMaster-compatible tasks.json file. + + Honors the active TaskMaster tag: an explicit ``tag`` argument wins, else + ``state.json``'s ``currentTag``; the validated task list is resolved from + the tagged block so manual/native mode does not silently validate a stale + legacy flat ``tasks`` key (BUG2).""" tasks_path = Path(input_path) if input_path else TASKMASTER_TASKS / "tasks.json" if not tasks_path.is_file(): raise CommandError(f"tasks.json not found: {tasks_path}") @@ -363,7 +374,7 @@ def run_validate_tasks(input_path: str | None, allow_empty_subtasks: bool, requi except json.JSONDecodeError as e: raise CommandError(f"Failed to parse {tasks_path}: {e}") - tasks, _ = _resolve_tasks_payload(raw) + tasks, _ = _resolve_tasks_payload(raw, tag=tag) if not isinstance(tasks, list): raise CommandError( "tasks.json must be a list, a flat object with a 'tasks' list, or a tagged TaskMaster object", @@ -497,6 +508,7 @@ def check_text(label: str, field: str, value: object, *, required: bool = True) return { "ok": True, "tasks_path": str(tasks_path), + "tag": tag or _current_taskmaster_tag(), "task_count": len(tasks), "subtask_count": sum(len(t.get("subtasks", []) or []) for t in tasks if isinstance(t, dict)), "message": "Task file is valid for manual prd-taskmaster mode", @@ -505,6 +517,11 @@ def check_text(label: str, field: str, value: object, *, required: bool = True) def cmd_validate_tasks(args: argparse.Namespace) -> None: try: - emit(run_validate_tasks(args.input, args.allow_empty_subtasks, args.require_phase_config)) + emit(run_validate_tasks( + args.input, + args.allow_empty_subtasks, + args.require_phase_config, + tag=getattr(args, "tag", None), + )) except CommandError as e: fail(e.message, **e.extra) diff --git a/tests/core/test_cli.py b/tests/core/test_cli.py index 4a64e5d..4c80082 100644 --- a/tests/core/test_cli.py +++ b/tests/core/test_cli.py @@ -184,6 +184,57 @@ def test_enrich_tasks_adds_phase_config(tmp_path): assert "acceptanceCriteria" in task["phaseConfig"] +def test_enrich_tasks_honors_current_tag_over_flat_key(tmp_path): + """BUG2 (end-to-end via the CLI shim): with state.json currentTag set and a + coexisting legacy flat 'tasks' key, enrich-tasks must enrich the active + tag's tasks and leave the flat tasks untouched.""" + tm = tmp_path / ".taskmaster" + (tm / "tasks").mkdir(parents=True) + (tm / "state.json").write_text(json.dumps({"currentTag": "productization"})) + flat = [{"id": 9, "title": "legacy", "description": "d", "priority": "high", + "status": "pending", "subtasks": []}] + prod = [{"id": i, "title": f"prod {i}", "description": "d", "priority": "high", + "status": "pending", "subtasks": []} for i in range(1, 4)] + (tm / "tasks" / "tasks.json").write_text( + json.dumps({"tasks": flat, "productization": {"tasks": prod}}) + ) + + data = run_cli("enrich-tasks", cwd=tmp_path) + assert data["ok"] is True + assert data["tag"] == "productization" + assert data["total_tasks"] == 3 + + written = json.loads((tm / "tasks" / "tasks.json").read_text()) + assert all("phaseConfig" in t for t in written["productization"]["tasks"]) + assert all("phaseConfig" not in t for t in written["tasks"]) # flat untouched + + +def test_validate_tasks_explicit_tag_flag(tmp_path): + """BUG2: the --tag flag is wired on validate-tasks and selects that tag.""" + tm = tmp_path / ".taskmaster" + (tm / "tasks").mkdir(parents=True) + (tm / "state.json").write_text(json.dumps({"currentTag": "master"})) + + def vtask(tid): + return {"id": tid, "title": f"t{tid}", "description": "d", "details": "dd", + "testStrategy": "verify", "status": "pending", "priority": "high", + "dependencies": [], + "subtasks": [{"id": 1, "title": "s", "description": "d", + "status": "pending", "dependencies": []}, + {"id": 2, "title": "s2", "description": "d", + "status": "pending", "dependencies": [1]}]} + + (tm / "tasks" / "tasks.json").write_text(json.dumps({ + "master": {"tasks": [vtask(1), vtask(2)]}, + "productization": {"tasks": [vtask(i) for i in range(1, 6)]}, + })) + + data = run_cli("validate-tasks", "--tag", "productization", cwd=tmp_path) + assert data["ok"] is True + assert data["tag"] == "productization" + assert data["task_count"] == 5 + + def test_backend_command_parser_entries(): from prd_taskmaster.cli import build_parser diff --git a/tests/core/test_validation.py b/tests/core/test_validation.py index 43799c0..69d2578 100644 --- a/tests/core/test_validation.py +++ b/tests/core/test_validation.py @@ -681,3 +681,87 @@ def test_prd_lowercase_angle_tokens_not_flagged(self, tmp_project): assert angle_bracket_details == [], ( f"PRD false-flagged angle-bracket tokens: {angle_bracket_details}" ) + + +def _task(tid, *, subtasks=2): + """Build a minimally-valid manual-mode task.""" + subs = [ + {"id": i, "title": f"sub {i}", "description": "d", "status": "pending", + "dependencies": ([i - 1] if i > 1 else [])} + for i in range(1, subtasks + 1) + ] + return { + "id": tid, "title": f"task {tid}", "description": "d", "details": "dd", + "testStrategy": "verify X", "status": "pending", "priority": "high", + "dependencies": [], "subtasks": subs, + } + + +class TestValidateTasksTagResolution: + """BUG2: validate-tasks must honor the active tag (explicit --tag, else + state.json currentTag) and NOT silently validate a coexisting stale flat + 'tasks' key.""" + + def _project(self, tmp_path, current_tag, *, flat=None, tagged=None): + tm = tmp_path / ".taskmaster" + (tm / "tasks").mkdir(parents=True) + if current_tag is not None: + (tm / "state.json").write_text(json.dumps({"currentTag": current_tag})) + data = {} + if flat is not None: + data["tasks"] = flat + for tag, tasks in (tagged or {}).items(): + data[tag] = {"tasks": tasks} + (tm / "tasks" / "tasks.json").write_text(json.dumps(data, indent=2)) + return tm / "tasks" / "tasks.json" + + def test_currenttag_wins_over_coexisting_flat_key(self, tmp_path, monkeypatch): + # flat key has 1 INVALID task (no subtasks); productization has 3 valid. + flat = [{"id": 9, "title": "legacy", "description": "d", "details": "dd", + "testStrategy": "t", "status": "pending", "priority": "high", + "dependencies": [], "subtasks": []}] + tagged = {"productization": [_task(i) for i in range(1, 4)]} + path = self._project(tmp_path, "productization", flat=flat, tagged=tagged) + monkeypatch.chdir(tmp_path) + + out = run_validate_tasks(str(path), allow_empty_subtasks=False, + require_phase_config=False) + assert out["ok"] is True + assert out["tag"] == "productization" + assert out["task_count"] == 3 # the tagged block, NOT the 1 flat task + + def test_explicit_tag_overrides_currenttag(self, tmp_path, monkeypatch): + tagged = { + "master": [_task(i) for i in range(1, 3)], + "productization": [_task(i) for i in range(1, 6)], + } + path = self._project(tmp_path, "master", tagged=tagged) + monkeypatch.chdir(tmp_path) + + out = run_validate_tasks(str(path), allow_empty_subtasks=False, + require_phase_config=False, tag="productization") + assert out["ok"] is True + assert out["tag"] == "productization" + assert out["task_count"] == 5 + + def test_explicit_missing_tag_errors_not_silent_flat_fallback(self, tmp_path, monkeypatch): + flat = [_task(1)] + tagged = {"productization": [_task(i) for i in range(1, 3)]} + path = self._project(tmp_path, "productization", flat=flat, tagged=tagged) + monkeypatch.chdir(tmp_path) + + with pytest.raises(CommandError) as exc: + run_validate_tasks(str(path), allow_empty_subtasks=False, + require_phase_config=False, tag="nope") + assert "not found" in str(exc.value).lower() + assert "productization" in exc.value.extra.get("available_tags", []) + + def test_flat_only_backward_compat(self, tmp_path, monkeypatch): + flat = [_task(i) for i in range(1, 4)] + path = self._project(tmp_path, None, flat=flat) + monkeypatch.chdir(tmp_path) + + out = run_validate_tasks(str(path), allow_empty_subtasks=False, + require_phase_config=False) + assert out["ok"] is True + assert out["task_count"] == 3 From bf94bad6e2350074d4e37f7d1242e9fe1312b5ab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 18:05:09 +0800 Subject: [PATCH 33/73] fix(mcp): harden server so one failing tool can't close the transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backup_prd (and the other server-native tools) did raw filesystem I/O with no exception guard. A directory / unreadable / non-UTF-8 PRD path made src.read_text() raise IsADirectoryError/UnicodeDecodeError out of the tool body; FastMCP let it propagate and tore down the whole stdio transport — the operator saw "MCP error -32000: Connection closed" and ALL 32 atlas-engine tools dropped from the session at once. - _HardenedMCP wraps mcp.tool() so EVERY tool fails closed: CommandError -> structured {ok:false,error}; SystemExit -> structured exit; any other exception -> {ok:false,error,tool} with the traceback logged to STDERR (never stdout, which carries JSON-RPC). One tool can no longer crash the host. - backup_prd: reject non-file sources, copy bytes (tolerates non-UTF-8 PRDs), and catch OSError (full disk / unwritable dest). Tests: load_template unknown-type now fails closed (was: propagated raise), backup_prd on a directory returns a structured error, and an arbitrary raising tool is caught by the global wrapper. Co-Authored-By: Claude Opus 4.8 --- mcp-server/server.py | 76 +++++++++++++++++++++++++++++++++++-- tests/mcp/test_mcp_tools.py | 46 ++++++++++++++++++---- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/mcp-server/server.py b/mcp-server/server.py index fd7a395..2a9ab25 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -12,7 +12,9 @@ """ from __future__ import annotations +import functools import sys +import traceback from datetime import datetime from pathlib import Path @@ -33,7 +35,63 @@ from prd_taskmaster import feedback as FB from prd_taskmaster.context_pack import build_context_pack -mcp = FastMCP("prd-taskmaster") +_mcp = FastMCP("prd-taskmaster") + + +class _HardenedMCP: + """Wraps FastMCP so a single tool can never crash the stdio transport. + + BUG3: an unhandled exception inside a tool body (e.g. ``backup_prd`` doing + raw ``Path.read_text()`` on a directory / unreadable file / full disk) + propagated out of the tool function and tore down the whole MCP process — + the operator saw "MCP error -32000: Connection closed" and ALL tools + vanished from the session at once. Tools must fail *closed* with a + structured ``{"ok": False, "error": ...}`` payload, never by terminating + the host. This wrapper installs that contract on every ``@mcp.tool()`` + uniformly, regardless of whether the individual body remembered to catch. + """ + + def __init__(self, inner: FastMCP) -> None: + self._inner = inner + + def tool(self, *t_args, **t_kwargs): + inner_decorator = self._inner.tool(*t_args, **t_kwargs) + + def decorator(fn): + @functools.wraps(fn) + def guarded(*args, **kwargs): + try: + return fn(*args, **kwargs) + except LIB.CommandError as exc: + return {"ok": False, "error": exc.message, **getattr(exc, "extra", {})} + except SystemExit as exc: + # A backend op called sys.exit(); surface it, don't let it + # bubble up and stop mcp.run(). + return {"ok": False, "error": "tool exited", "exit": exc.code} + except BaseException as exc: # noqa: BLE001 — fail closed, never crash the host + # Diagnostics go to stderr (captured by the MCP host log), + # never to stdout (which carries the JSON-RPC stream). + print( + f"[prd-taskmaster] tool {fn.__name__!r} raised " + f"{type(exc).__name__}: {exc}\n{traceback.format_exc()}", + file=sys.stderr, + flush=True, + ) + return { + "ok": False, + "error": f"{type(exc).__name__}: {exc}", + "tool": fn.__name__, + } + + return inner_decorator(guarded) + + return decorator + + def __getattr__(self, name): + return getattr(self._inner, name) + + +mcp = _HardenedMCP(_mcp) # ─── Delegation tools (17) ──────────────────────────────────────────────────── @@ -227,13 +285,25 @@ def gen_test_tasks(total: int) -> dict: @mcp.tool() def backup_prd(input_path: str) -> dict: - """Copy a PRD to a timestamped prd-backup-YYYYMMDD-HHMMSS.md sibling.""" + """Copy a PRD to a timestamped prd-backup-YYYYMMDD-HHMMSS.md sibling. + + Hardened (BUG3): the source may be a directory, unreadable, non-UTF-8, or + the destination may be unwritable / on a full disk. Every such case returns + a structured error instead of raising — a failed backup must not close the + MCP transport. + """ src = Path(input_path) if not src.exists(): return {"ok": False, "error": f"source missing: {input_path}"} + if not src.is_file(): + return {"ok": False, "error": f"source is not a file: {input_path}"} ts = datetime.now().strftime("%Y%m%d-%H%M%S") dst = src.parent / f"prd-backup-{ts}.md" - dst.write_text(src.read_text()) + try: + # Binary copy: tolerates non-UTF-8 PRDs that read_text() would choke on. + dst.write_bytes(src.read_bytes()) + except OSError as exc: + return {"ok": False, "error": f"backup failed: {exc}", "source": str(src)} return {"ok": True, "backup_path": str(dst)} diff --git a/tests/mcp/test_mcp_tools.py b/tests/mcp/test_mcp_tools.py index eb25944..edc93c3 100644 --- a/tests/mcp/test_mcp_tools.py +++ b/tests/mcp/test_mcp_tools.py @@ -32,15 +32,45 @@ def test_load_template_minimal(): assert len(r["content"]) > 10 -def test_load_template_unknown_type_raises(): - """LIVE contract: the package's run_load_template raises CommandError on an - unknown type (the plugin returned an ok=False dict). The server wrapper - does not catch it, so the call propagates the raise.""" +def test_load_template_unknown_type_fails_closed(): + """LIVE contract (BUG3 hardening): the package's run_load_template raises + CommandError on an unknown type, but the MCP server's _HardenedMCP wrapper + catches EVERY tool exception and returns a structured {"ok": False, ...} + payload instead of letting it propagate and tear down the stdio transport. + A single failing tool must never close the connection / drop all tools.""" import server as S - from prd_taskmaster.lib import CommandError - with pytest.raises(CommandError) as exc: - S.load_template("bogus") - assert "not found" in str(exc.value).lower() + r = S.load_template("bogus") + assert isinstance(r, dict) + assert r["ok"] is False + assert "not found" in str(r.get("error", "")).lower() + + +def test_backup_prd_directory_input_fails_closed(tmp_path): + """BUG3 regression: backup_prd on a directory used to raise IsADirectoryError + out of the tool body and close the MCP transport. It must now return a + structured error instead.""" + import server as S + r = S.backup_prd(str(tmp_path)) + assert isinstance(r, dict) + assert r["ok"] is False + assert "not a file" in str(r.get("error", "")).lower() + + +def test_hardened_wrapper_catches_arbitrary_tool_exception(): + """BUG3: any tool whose body raises an unexpected exception is caught by the + global _HardenedMCP wrapper and surfaced as a structured error — proving the + host process cannot be crashed by a single tool.""" + import server as S + + @S.mcp.tool() + def _boom_for_test(): + raise RuntimeError("kaboom") + + r = _boom_for_test() + assert isinstance(r, dict) + assert r["ok"] is False + assert "kaboom" in str(r.get("error", "")) + assert r.get("tool") == "_boom_for_test" def test_compute_fleet_waves_tool(tmp_path, monkeypatch): From e456014afa41cf97983f8d1146ea453483a2caef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 20:28:42 +0800 Subject: [PATCH 34/73] feat(mcp): add suggestion + suggestion_report tools to atlas-engine Brings the free-text improvement-suggestion capability into atlas-engine (it previously lived only in atlas-launcher). New prd_taskmaster/suggestions.py mirrors feedback.py: validates a free-text row (text required; optional context/source_repo/session/agent), flock-guarded append to .atlas-ai/suggestions.jsonl. Path is env-overridable via ATLAS_SUGGESTIONS_PATH so the engine + launcher can be pointed at ONE aggregated, reviewable log. - suggestion(text, context, source_repo, session, agent) -> append - suggestion_report() -> counts, by-repo, last 5 - tool count 29 -> 31; tests added (store + env-override + summary) Co-Authored-By: Claude Opus 4.8 --- mcp-server/server.py | 36 +++++++++- prd_taskmaster/suggestions.py | 126 +++++++++++++++++++++++++++++++++ tests/core/test_suggestions.py | 77 ++++++++++++++++++++ tests/mcp/test_mcp_tools.py | 11 +-- 4 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 prd_taskmaster/suggestions.py create mode 100644 tests/core/test_suggestions.py diff --git a/mcp-server/server.py b/mcp-server/server.py index 2a9ab25..55a9a5b 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """FastMCP server for prd-taskmaster. -Registers 29 tools wrapping the sibling modules (pipeline, capabilities, +Registers 31 tools wrapping the sibling modules (pipeline, capabilities, taskmaster, backend, validation, templates) plus server-native helpers (calc_tasks, backup_prd, append_workflow, debrief, log_progress, gen_test_tasks, read_state, gen_scripts, compute_fleet_waves, context_pack, -feedback). +feedback, suggestion). No explicit process termination — mcp.run() is the event loop and returns naturally when the transport closes. @@ -33,6 +33,7 @@ from prd_taskmaster import task_state as TS from prd_taskmaster import cli as CLI from prd_taskmaster import feedback as FB +from prd_taskmaster import suggestions as SG from prd_taskmaster.context_pack import build_context_pack _mcp = FastMCP("prd-taskmaster") @@ -407,6 +408,37 @@ def feedback_report() -> dict: return FB.summarize_feedback() +@mcp.tool() +def suggestion( + text: str, + context: str = "", + source_repo: str = "", + session: str = "", + agent: str = "", +) -> dict: + """Capture a free-text improvement suggestion about using the Atlas engine. + + Appends a row to the suggestions log (``.atlas-ai/suggestions.jsonl`` by + default, or ``ATLAS_SUGGESTIONS_PATH`` if set — point it at a shared file to + unify with the launcher's suggestion log). For dogfooding pain points, + missing tools, and rough edges so they are durably recorded, not lost in a + transcript. Use ``feedback_submit`` instead for structured 1-5 ratings. + """ + return SG.append_suggestion({ + "text": text, + "context": context, + "source_repo": source_repo, + "session": session, + "agent": agent, + }) + + +@mcp.tool() +def suggestion_report() -> dict: + """Summarize the suggestions log (counts, by-repo, last 5).""" + return SG.summarize_suggestions() + + @mcp.tool() def gen_scripts(output_dir: str = ".atlas-ai/scripts") -> dict: """Write stub scripts (ship-check.py, progress.sh, summary.py) if absent.""" diff --git a/prd_taskmaster/suggestions.py b/prd_taskmaster/suggestions.py new file mode 100644 index 0000000..341f509 --- /dev/null +++ b/prd_taskmaster/suggestions.py @@ -0,0 +1,126 @@ +"""Atlas agent suggestion JSONL store and summary helpers. + +Free-text improvement suggestions about using the Atlas engine — dogfooding +pain points, missing tools, rough edges — captured durably so they can be +reviewed and triaged later, rather than lost in a transcript. + +Sibling to ``feedback.py`` (which is structured rating feedback). The store +path is env-overridable (``ATLAS_SUGGESTIONS_PATH``) so the engine and the +launcher can be pointed at ONE aggregated, human-reviewable log. +""" + +import json +import os +import time +from pathlib import Path +from typing import Any + +from prd_taskmaster.lib import locked_update + +DEFAULT_SUGGESTIONS = Path(".atlas-ai") / "suggestions.jsonl" +# Optional free-text/string metadata fields, all carried through verbatim. +STR_FIELDS = ("context", "source_repo", "session", "agent", "task_ref") + + +def _store_path(path: str | Path | None = None) -> Path: + """Resolve the suggestions log path. + + Precedence: explicit ``path`` arg → ``ATLAS_SUGGESTIONS_PATH`` env → + repo-local ``.atlas-ai/suggestions.jsonl``. Point the env at a shared file + to unify engine + launcher suggestions into one reviewable log. + """ + if path: + return Path(path) + env = os.environ.get("ATLAS_SUGGESTIONS_PATH", "").strip() + return Path(env) if env else DEFAULT_SUGGESTIONS + + +def _error(message: str, **extra: Any) -> dict: + return {"ok": False, "error": message, **extra} + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _normalize_suggestion_row(row: Any) -> dict: + if not isinstance(row, dict): + return _error("suggestion row must be a dict") + + text = row.get("text") + if not isinstance(text, str) or not text.strip(): + return _error("text is required and must be a non-empty string", field="text") + + ts = row.get("ts", time.time()) + if not _is_number(ts): + return _error("ts must be an epoch number", field="ts") + + normalized: dict[str, Any] = {"ts": float(ts), "text": text.strip()} + for field in STR_FIELDS: + value = row.get(field, "") + if not isinstance(value, str): + return _error(f"{field} must be a string", field=field) + if value: + normalized[field] = value + + if "context_obj" in row: + context_obj = row["context_obj"] + if not isinstance(context_obj, dict): + return _error("context_obj must be a dict", field="context_obj") + normalized["context_obj"] = context_obj + + return {"ok": True, "row": normalized} + + +def append_suggestion(row, path=None): + """Append one validated suggestion row to JSONL under a flock-guarded update.""" + try: + normalized = _normalize_suggestion_row(row) + if not normalized.get("ok"): + return normalized + + p = _store_path(path) + payload = normalized["row"] + line = json.dumps(payload, default=str) + "\n" + locked_update(p, lambda current: current + line) + return {"ok": True, "suggestions_path": str(p), "row": payload} + except Exception as exc: + return _error("failed to append suggestion", detail=str(exc), error_type=type(exc).__name__) + + +def summarize_suggestions(path=None): + """Summarize the suggestions log, skipping malformed JSONL lines.""" + p = _store_path(path) + rows = [] + skipped = 0 + try: + if p.is_file(): + for line in p.read_text().splitlines(): + line = line.strip() + if not line: + continue + try: + parsed = json.loads(line) + except json.JSONDecodeError: + skipped += 1 + continue + if not isinstance(parsed, dict): + skipped += 1 + continue + rows.append(parsed) + except Exception as exc: + return _error("failed to summarize suggestions", detail=str(exc), error_type=type(exc).__name__) + + by_repo: dict[str, int] = {} + for row in rows: + repo = str(row.get("source_repo", "") or "unspecified") + by_repo[repo] = by_repo.get(repo, 0) + 1 + + return { + "ok": True, + "total": len(rows), + "by_source_repo": dict(sorted(by_repo.items())), + "last_5": rows[-5:], + "skipped_lines": skipped, + "suggestions_path": str(p), + } diff --git a/tests/core/test_suggestions.py b/tests/core/test_suggestions.py new file mode 100644 index 0000000..94b4050 --- /dev/null +++ b/tests/core/test_suggestions.py @@ -0,0 +1,77 @@ +"""Suggestion JSONL store + summary contracts (sibling to test_feedback).""" + +import json +from pathlib import Path + +from prd_taskmaster.suggestions import append_suggestion, summarize_suggestions + + +def _rows(path: Path) -> list[dict]: + return [json.loads(line) for line in path.read_text().splitlines()] + + +def test_append_suggestion_writes_valid_jsonl_row(tmp_path): + path = tmp_path / "suggestions.jsonl" + + result = append_suggestion( + { + "ts": 123.5, + "text": "Unify the engine + launcher suggestion logs.", + "context": "phase: integrate", + "source_repo": "prd-taskmaster-plugin", + "session": "claude-prd-taskmaster-plugin", + }, + path=path, + ) + + assert result["ok"] is True + assert result["suggestions_path"] == str(path) + assert _rows(path) == [ + { + "ts": 123.5, + "text": "Unify the engine + launcher suggestion logs.", + "context": "phase: integrate", + "source_repo": "prd-taskmaster-plugin", + "session": "claude-prd-taskmaster-plugin", + } + ] + + +def test_append_suggestion_rejects_empty_text_without_writing(tmp_path): + path = tmp_path / "suggestions.jsonl" + + for text in ("", " ", None, 5): + result = append_suggestion({"text": text}, path=path) + assert result["ok"] is False + assert "text" in result["error"] + + assert not path.exists() + + +def test_env_override_resolves_store_path(tmp_path, monkeypatch): + target = tmp_path / "shared" / "suggestions.jsonl" + monkeypatch.setenv("ATLAS_SUGGESTIONS_PATH", str(target)) + + result = append_suggestion({"text": "lands at the shared path"}) + + assert result["ok"] is True + assert result["suggestions_path"] == str(target) + assert target.is_file() + + +def test_summarize_suggestions_counts_and_skips_corrupt(tmp_path): + path = tmp_path / "suggestions.jsonl" + path.write_text( + json.dumps({"ts": 1.0, "text": "a", "source_repo": "repo-x"}) + + "\nnot json\n" + + json.dumps({"ts": 2.0, "text": "b", "source_repo": "repo-x"}) + + "\n" + ) + + report = summarize_suggestions(path) + + assert report["ok"] is True + assert report["total"] == 2 + assert report["skipped_lines"] == 1 + assert report["by_source_repo"] == {"repo-x": 2} + assert len(report["last_5"]) == 2 diff --git a/tests/mcp/test_mcp_tools.py b/tests/mcp/test_mcp_tools.py index edc93c3..d849404 100644 --- a/tests/mcp/test_mcp_tools.py +++ b/tests/mcp/test_mcp_tools.py @@ -1,4 +1,4 @@ -"""MCP tool contract tests — the merged server.py registers 30 tools. +"""MCP tool contract tests — the merged server.py registers 31 tools. Retargeted from the plugin: server.py now imports from prd_taskmaster.* and lives at mcp-server/server.py. We add the repo root (so `prd_taskmaster` is @@ -244,11 +244,12 @@ def test_context_pack_tool_returns_core_pack(tmp_path): } -def test_server_registers_29_tools(): - """Verify server.py declares all 29 expected tool functions at module scope. +def test_server_registers_31_tools(): + """Verify server.py declares all 31 expected tool functions at module scope. The task-master backend was removed (spec §9.4): the init_taskmaster, tm_parallel_expand, and backend_detect MCP tools were deleted (32 -> 29). + The suggestion + suggestion_report tools were then added (29 -> 31). """ import server as S expected = { @@ -269,8 +270,10 @@ def test_server_registers_29_tools(): "context_pack", "feedback_submit", "feedback_report", + "suggestion", + "suggestion_report", } - assert len(expected) == 29 + assert len(expected) == 31 public_attrs = {name for name in dir(S) if not name.startswith("_")} missing = expected - public_attrs assert not missing, f"missing tools: {sorted(missing)}" From f9940184e75705dcb412c235080ee4455a0afb5d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 22:53:01 +0800 Subject: [PATCH 35/73] feat(llm_client): add google/gemini raw-API provider (planning parity) Adds a third structured-gen provider to llm_client: google (Gemini, generativelanguage v1beta generateContent), discovered via GOOGLE_API_KEY or GEMINI_API_KEY at lowest precedence. Threads through discover_key, _resolve_model (default gemini-2.5-flash), _http_call, and _usage_fields (usageMetadata tokens). Closes the planning provider-parity gap vs task-master's AI-SDK provider set. Implemented by a Gemini-2.5-Flash goose worker and verified by an external, unfakable acceptance gate (tests/core/test_llm_client_google.py). Co-Authored-By: Claude Opus 4.8 --- prd_taskmaster/llm_client.py | 34 +++++++- tests/core/test_llm_client.py | 3 +- tests/core/test_llm_client_google.py | 124 +++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 tests/core/test_llm_client_google.py diff --git a/prd_taskmaster/llm_client.py b/prd_taskmaster/llm_client.py index f99d392..1b4f7bf 100644 --- a/prd_taskmaster/llm_client.py +++ b/prd_taskmaster/llm_client.py @@ -64,6 +64,10 @@ def discover_key(): return None # free proxy: agent path only return {"provider": "openai-compatible", "key": key, "base_url": base} + key = _env_or_dotenv("GOOGLE_API_KEY") or _env_or_dotenv("GEMINI_API_KEY") + if key: + return {"provider": "google", "key": key, "base_url": "https://generativelanguage.googleapis.com/v1beta"} + return None @@ -73,6 +77,8 @@ def _resolve_model(model, tier, provider): if provider == "anthropic": short = {"fast": "haiku", "standard": "sonnet", "capable": "opus", "frontier": "fable"}.get(tier or "standard", "sonnet") return TIER_MODEL_IDS.get(short, TIER_MODEL_IDS["sonnet"]) + if provider == "google": + return "gemini-2.5-flash" return DEFAULT_OPENAI_MODEL @@ -131,12 +137,18 @@ def _usage_int(value): def _usage_fields(provider, data): - usage = data.get("usage") if isinstance(data, dict) else None + if provider == "google": + usage = data + else: + usage = data.get("usage") if isinstance(data, dict) else None if not isinstance(usage, dict): return {} if provider == "anthropic": tokens_in = _usage_int(usage.get("input_tokens")) tokens_out = _usage_int(usage.get("output_tokens")) + elif provider == "google": + tokens_in = _usage_int(usage.get("promptTokenCount")) + tokens_out = _usage_int(usage.get("candidatesTokenCount")) else: tokens_in = _usage_int(usage.get("prompt_tokens")) tokens_out = _usage_int(usage.get("completion_tokens")) @@ -160,6 +172,17 @@ def _http_call(creds, model, system, prompt, max_tokens, timeout): } if system: body["system"] = system + elif creds["provider"] == "google": + base = creds["base_url"].rstrip("/") + url = f"{base}/models/{model}:generateContent" + headers = { + "x-goog-api-key": creds["key"], + "Content-Type": "application/json", + } + contents = [{"role": "user", "parts": [{"text": prompt}]}] + body = {"contents": contents, "generationConfig": {"maxOutputTokens": max_tokens}} + if system: + body["system_instruction"] = {"parts": [{"text": system}]} else: base = creds["base_url"].rstrip("/") url = base + "/chat/completions" @@ -178,10 +201,13 @@ def _http_call(creds, model, system, prompt, max_tokens, timeout): req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers, method="POST") with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) - usage = _usage_fields(creds["provider"], data) + usage_for_telemetry = _usage_fields(creds["provider"], data.get("usageMetadata", {})) if creds["provider"] == "google" else _usage_fields(creds["provider"], data) + if creds["provider"] == "anthropic": - return data["content"][0]["text"], usage - return data["choices"][0]["message"]["content"], usage + return data["content"][0]["text"], usage_for_telemetry + elif creds["provider"] == "google": + return data["candidates"][0]["content"]["parts"][0]["text"], usage_for_telemetry + return data["choices"][0]["message"]["content"], usage_for_telemetry def generate_json(prompt, *, system="", schema_hint="", model=None, tier=None, diff --git a/tests/core/test_llm_client.py b/tests/core/test_llm_client.py index 43d32ea..f2324b6 100644 --- a/tests/core/test_llm_client.py +++ b/tests/core/test_llm_client.py @@ -59,7 +59,8 @@ def test_discover_key_excludes_local_free_proxy(monkeypatch, tmp_path): def test_discover_key_none_when_nothing(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) - for v in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENAI_COMPATIBLE_API_KEY"): + for v in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENAI_COMPATIBLE_API_KEY", + "GOOGLE_API_KEY", "GEMINI_API_KEY"): monkeypatch.delenv(v, raising=False) assert L.discover_key() is None diff --git a/tests/core/test_llm_client_google.py b/tests/core/test_llm_client_google.py new file mode 100644 index 0000000..48ca985 --- /dev/null +++ b/tests/core/test_llm_client_google.py @@ -0,0 +1,124 @@ +"""Acceptance gate: Google/Gemini provider in llm_client (hermetic; urlopen monkeypatched). + +This file is the UNFAKABLE contract for adding the google provider. It is written +BEFORE the implementation and must not be modified by the worker. The worker's job +is to edit prd_taskmaster/llm_client.py until every test here passes. + +Gemini REST contract (generativelanguage v1beta, generateContent): + URL : {base}/models/{model}:generateContent base = https://generativelanguage.googleapis.com/v1beta + Auth : header x-goog-api-key: + Body : {"contents":[{"role":"user","parts":[{"text": }]}], + "generationConfig":{"maxOutputTokens": }} + + optional "system_instruction":{"parts":[{"text": }]} when system given + Result : data["candidates"][0]["content"]["parts"][0]["text"] + Usage : data["usageMetadata"]["promptTokenCount"] / ["candidatesTokenCount"] +Provider key discovery: GOOGLE_API_KEY or GEMINI_API_KEY, LOWEST precedence +(checked after anthropic, openai, and openai-compatible). +""" + +import io +import json + +import pytest + +from prd_taskmaster import llm_client as L + + +class FakeResponse(io.BytesIO): + def __enter__(self): return self + def __exit__(self, *a): return False + + +def _google_ok(payload_text, usage=None): + payload = {"candidates": [{"content": {"parts": [{"text": payload_text}]}}]} + if usage is not None: + payload["usageMetadata"] = usage + return FakeResponse(json.dumps(payload).encode()) + + +# ── discovery + precedence ─────────────────────────────────────────────────── + +def test_discover_key_google_from_google_api_key(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + for v in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENAI_COMPATIBLE_API_KEY", "GEMINI_API_KEY"): + monkeypatch.delenv(v, raising=False) + monkeypatch.setenv("GOOGLE_API_KEY", "g-key-1") + k = L.discover_key() + assert k["provider"] == "google" and k["key"] == "g-key-1" + + +def test_discover_key_google_from_gemini_api_key(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + for v in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENAI_COMPATIBLE_API_KEY", "GOOGLE_API_KEY"): + monkeypatch.delenv(v, raising=False) + monkeypatch.setenv("GEMINI_API_KEY", "g-key-2") + k = L.discover_key() + assert k["provider"] == "google" and k["key"] == "g-key-2" + + +def test_google_is_lowest_precedence(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_COMPATIBLE_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "sk-oai") + monkeypatch.setenv("GOOGLE_API_KEY", "g-key") + assert L.discover_key()["provider"] == "openai" # openai still wins over google + + +# ── request shape + parse ──────────────────────────────────────────────────── + +def test_google_request_shape_and_parse(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + for v in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENAI_COMPATIBLE_API_KEY", "GEMINI_API_KEY"): + monkeypatch.delenv(v, raising=False) + monkeypatch.setenv("GOOGLE_API_KEY", "g-key-1") + captured = {} + + def fake_urlopen(req, timeout=None): + captured["url"] = req.full_url + captured["headers"] = {k.lower(): v for k, v in dict(req.headers).items()} + captured["body"] = json.loads(req.data) + return _google_ok('{"answer": 42}') + + monkeypatch.setattr(L.urllib.request, "urlopen", fake_urlopen) + out = L.generate_json("give me json", system="be terse", model="gemini-2.5-flash") + assert out == {"answer": 42} + assert "generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" in captured["url"] + assert captured["headers"].get("x-goog-api-key") == "g-key-1" + # contents carry the prompt + assert captured["body"]["contents"][0]["parts"][0]["text"] == "give me json" + # system goes to system_instruction, not contents + assert captured["body"]["system_instruction"]["parts"][0]["text"] == "be terse" + + +def test_google_default_model_is_gemini_flash(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + for v in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENAI_COMPATIBLE_API_KEY", "GEMINI_API_KEY"): + monkeypatch.delenv(v, raising=False) + monkeypatch.setenv("GOOGLE_API_KEY", "g-key-1") + captured = {} + + def fake_urlopen(req, timeout=None): + captured["url"] = req.full_url + return _google_ok('{"ok": true}') + + monkeypatch.setattr(L.urllib.request, "urlopen", fake_urlopen) + assert L.generate_json("x") == {"ok": True} # no model/tier given + assert "models/gemini-2.5-flash:generateContent" in captured["url"] + + +def test_google_telemetry_includes_usage_tokens(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + for v in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENAI_COMPATIBLE_API_KEY", "GEMINI_API_KEY"): + monkeypatch.delenv(v, raising=False) + monkeypatch.setenv("GOOGLE_API_KEY", "g-key-1") + monkeypatch.setattr( + L.urllib.request, "urlopen", + lambda req, timeout=None: _google_ok( + '{"x": 1}', usage={"promptTokenCount": 50, "candidatesTokenCount": 12}), + ) + L.generate_json("x", task_id=21, model="gemini-2.5-flash") + rows = [json.loads(l) for l in (tmp_path / ".atlas-ai" / "telemetry.jsonl").read_text().splitlines()] + assert rows[0]["backend"] == "native-api" + assert rows[0]["tokens_in"] == 50 + assert rows[0]["tokens_out"] == 12 From fbac5f1918c582af708016ee070c2ef02ed4df2b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 23:03:11 +0800 Subject: [PATCH 36/73] test(hermetic): make native-vs-agent tests env-key independent The native_backend parse_prd/expand/rate tests mocked llm_client.discover_key but backend now routes via resolve_provider (bound discover_key + os.environ reads), so results flipped on the shell's real API keys: native_backend needed a key present (5 failed without), while test_cli/test_engine_preflight 'no-key' tests needed keys absent (failed with one). No environment was fully green. Fix (test-side only, no production change): native_backend tests patch backend.resolve_provider to a deterministic api ProviderHandle; the no-key tests in test_cli/test_engine_preflight clear all provider key env vars. Now 36/36 green both with and without ambient keys. Root cause was diagnosed independently, then fixed by a Gemini-2.5-Flash goose worker given the precise recipe (dogfood: decomposition/precision lets a cheap model match the planned fix exactly). Co-Authored-By: Claude Opus 4.8 --- tests/core/test_cli.py | 8 ++++++-- tests/core/test_engine_preflight.py | 2 +- tests/core/test_native_backend.py | 32 ++++++++++++++++++++++++++--- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/tests/core/test_cli.py b/tests/core/test_cli.py index 4c80082..8bdbf74 100644 --- a/tests/core/test_cli.py +++ b/tests/core/test_cli.py @@ -335,7 +335,9 @@ def test_context_pack_cli_prints_signature_json(tmp_path): } -def test_native_parse_prd_no_key_returns_agent_action_json(tmp_path): +def test_native_parse_prd_no_key_returns_agent_action_json(tmp_path, monkeypatch): + for _k in ("ANTHROPIC_API_KEY","OPENAI_API_KEY","GEMINI_API_KEY","GOOGLE_API_KEY","OPENAI_COMPATIBLE_API_KEY"): + monkeypatch.delenv(_k, raising=False) env = clean_cli_env(tmp_path) force_native_backend(tmp_path) prd = tmp_path / "prd.md" @@ -359,7 +361,9 @@ def test_native_parse_prd_no_key_returns_agent_action_json(tmp_path): assert data["agent_action_required"]["num_tasks"] == 3 -def test_native_expand_and_rate_no_key_return_agent_action_json(tmp_path): +def test_native_expand_and_rate_no_key_return_agent_action_json(tmp_path, monkeypatch): + for _k in ("ANTHROPIC_API_KEY","OPENAI_API_KEY","GEMINI_API_KEY","GOOGLE_API_KEY","OPENAI_COMPATIBLE_API_KEY"): + monkeypatch.delenv(_k, raising=False) env = clean_cli_env(tmp_path) force_native_backend(tmp_path) seed_tasks(tmp_path) diff --git a/tests/core/test_engine_preflight.py b/tests/core/test_engine_preflight.py index 9e85ac1..874afab 100644 --- a/tests/core/test_engine_preflight.py +++ b/tests/core/test_engine_preflight.py @@ -12,7 +12,7 @@ def _clean_env(monkeypatch, tmp_path: Path, *, with_binary: bool = False) -> None: monkeypatch.chdir(tmp_path) monkeypatch.setenv("HOME", str(tmp_path / "home")) - for key in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENAI_COMPATIBLE_API_KEY"): + for key in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENAI_COMPATIBLE_API_KEY"): monkeypatch.delenv(key, raising=False) bin_dir = tmp_path / "bin" if with_binary: diff --git a/tests/core/test_native_backend.py b/tests/core/test_native_backend.py index 85e8b1a..f9e0f4b 100644 --- a/tests/core/test_native_backend.py +++ b/tests/core/test_native_backend.py @@ -122,6 +122,11 @@ def test_parse_prd_validates_and_writes_tagged_tasks(tmp_path, monkeypatch): prd = tmp_path / "prd.md" prd.write_text("# PRD\n\nREQ-001: Build native backend.") calls = [] + from prd_taskmaster import backend + from prd_taskmaster.provider_resolver import ProviderHandle + monkeypatch.setattr(backend, "resolve_provider", + lambda role, *a, **k: ProviderHandle( + kind="api", provider="openai", role=role, model=None, reason="test")) monkeypatch.setattr(llm_client, "discover_key", lambda: {"provider": "openai", "key": "k"}) def fake_generate_json(prompt, **kwargs): @@ -150,6 +155,12 @@ def test_parse_prd_success_echoes_telemetry_reference(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) prd = tmp_path / "prd.md" prd.write_text("# PRD\n\nREQ-001: Build native backend.") + calls = [] + from prd_taskmaster import backend + from prd_taskmaster.provider_resolver import ProviderHandle + monkeypatch.setattr(backend, "resolve_provider", + lambda role, *a, **k: ProviderHandle( + kind="api", provider="anthropic", role=role, model=None, reason="test")) monkeypatch.setattr(llm_client, "discover_key", lambda: {"provider": "anthropic", "key": "k"}) telemetry_ref = { "path": str(tmp_path / ".atlas-ai" / "telemetry.jsonl"), @@ -160,7 +171,6 @@ def test_parse_prd_success_echoes_telemetry_reference(tmp_path, monkeypatch): "backend": "native-api", "exit": 0, } - calls = [] def fake_generate_json(prompt, **kwargs): calls.append(kwargs) @@ -188,9 +198,15 @@ def test_parse_prd_invalid_candidate_returns_error_without_overwrite(tmp_path, m before = tasks_path.read_text() prd = tmp_path / "prd.md" prd.write_text("# PRD\n") + from prd_taskmaster import backend + from prd_taskmaster.provider_resolver import ProviderHandle + monkeypatch.setattr(backend, "resolve_provider", + lambda role, *a, **k: ProviderHandle( + kind="api", provider="anthropic", role=role, model=None, reason="test")) monkeypatch.setattr(llm_client, "discover_key", lambda: {"provider": "anthropic", "key": "k"}) invalid = _valid_tasks(1) invalid["tasks"][0]["subtasks"] = invalid["tasks"][0]["subtasks"][:1] + monkeypatch.setattr(llm_client, "generate_json", lambda *a, **k: invalid) result = NativeBackend().parse_prd(prd, 1) @@ -206,8 +222,13 @@ def test_expand_builds_packets_escalates_invalid_json_and_merges_once(tmp_path, monkeypatch.chdir(tmp_path) tasks_path = _seed_project(tmp_path, [_pending_task()]) - monkeypatch.setattr(llm_client, "discover_key", lambda: {"provider": "anthropic", "key": "k"}) calls = [] + from prd_taskmaster import backend + from prd_taskmaster.provider_resolver import ProviderHandle + monkeypatch.setattr(backend, "resolve_provider", + lambda role, *a, **k: ProviderHandle( + kind="api", provider="anthropic", role=role, model=None, reason="test")) + monkeypatch.setattr(llm_client, "discover_key", lambda: {"provider": "anthropic", "key": "k"}) def fake_generate_json(prompt, **kwargs): calls.append(kwargs) @@ -263,8 +284,13 @@ def test_rate_writes_taskmaster_report_from_batched_generation(tmp_path, monkeyp monkeypatch.chdir(tmp_path) _seed_project(tmp_path, [_pending_task()]) - monkeypatch.setattr(llm_client, "discover_key", lambda: {"provider": "openai", "key": "k"}) calls = [] + from prd_taskmaster import backend + from prd_taskmaster.provider_resolver import ProviderHandle + monkeypatch.setattr(backend, "resolve_provider", + lambda role, *a, **k: ProviderHandle( + kind="api", provider="openai", role=role, model=None, reason="test")) + monkeypatch.setattr(llm_client, "discover_key", lambda: {"provider": "openai", "key": "k"}) def fake_generate_json(prompt, **kwargs): calls.append({"prompt": prompt, **kwargs}) From a95734906fab70f8ca9fe43c48f80b974f662f88 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 23:58:02 +0800 Subject: [PATCH 37/73] test+chore(dogfood): land T2/T3 fixes + decomposition/execution model-spectrum bench Lands the two hardening fixes proven via the goose-gemini dogfood, plus the benchmark harness and findings from the cheap/local-model investigation. Source fixes (regression-gated): - mode_recommend._parse_version: return a fixed 3-tuple (pad/truncate) so equal versions compare equal ('1.2' == '1.2.0'); fixes min-version mis-compare. - providers._provider_usable + validate_setup: gate google/gemini raw-API providers on a Google key (has_google_key), matching anthropic/openai; was silently 'usable'. Dogfood bench (.atlas-dogfood/): A/B harness, decomposition + structured-edit benchmarks, and RESULTS.md. Findings: a cheap hosted model (gemini-flash) matches codex on BOTH decomposition and execution at ~1/9 cost; a local 8B reasoning model matches codex on decomposition once thinking-mode is disabled; local execution needs a structured-edit path (model emits a patch, harness applies) to clear the agentic tool-calling wall. Full suite green with AND without API keys (485 passed). Co-Authored-By: Claude Opus 4.8 --- .atlas-dogfood/.gitignore | 4 + .atlas-dogfood/RESULTS.md | 202 ++++++++++++++++++ .atlas-dogfood/bench/decomp_bench.py | 160 ++++++++++++++ .atlas-dogfood/bench/decomp_results.json | 72 +++++++ .atlas-dogfood/bench/gate_t2.sh | 9 + .atlas-dogfood/bench/gate_t3.sh | 10 + .atlas-dogfood/bench/instr_t2.md | 29 +++ .atlas-dogfood/bench/instr_t3.md | 39 ++++ .atlas-dogfood/bench/prd_sample.md | 27 +++ .atlas-dogfood/bench/run_trial.sh | 37 ++++ .atlas-dogfood/bench/structured_edit_poc.py | 114 ++++++++++ .../bench/structured_edit_results.json | 26 +++ prd_taskmaster/mode_recommend.py | 15 +- prd_taskmaster/providers.py | 10 +- tests/core/test_parse_version_tuple.py | 42 ++++ tests/core/test_provider_usable_google.py | 100 +++++++++ 16 files changed, 887 insertions(+), 9 deletions(-) create mode 100644 .atlas-dogfood/.gitignore create mode 100644 .atlas-dogfood/RESULTS.md create mode 100644 .atlas-dogfood/bench/decomp_bench.py create mode 100644 .atlas-dogfood/bench/decomp_results.json create mode 100755 .atlas-dogfood/bench/gate_t2.sh create mode 100755 .atlas-dogfood/bench/gate_t3.sh create mode 100644 .atlas-dogfood/bench/instr_t2.md create mode 100644 .atlas-dogfood/bench/instr_t3.md create mode 100644 .atlas-dogfood/bench/prd_sample.md create mode 100755 .atlas-dogfood/bench/run_trial.sh create mode 100644 .atlas-dogfood/bench/structured_edit_poc.py create mode 100644 .atlas-dogfood/bench/structured_edit_results.json create mode 100644 tests/core/test_parse_version_tuple.py create mode 100644 tests/core/test_provider_usable_google.py diff --git a/.atlas-dogfood/.gitignore b/.atlas-dogfood/.gitignore new file mode 100644 index 0000000..f3e64d4 --- /dev/null +++ b/.atlas-dogfood/.gitignore @@ -0,0 +1,4 @@ +# transient worker/gate logs from bench runs +*.log +worker_*.log +gate_*.log diff --git a/.atlas-dogfood/RESULTS.md b/.atlas-dogfood/RESULTS.md new file mode 100644 index 0000000..b945777 --- /dev/null +++ b/.atlas-dogfood/RESULTS.md @@ -0,0 +1,202 @@ +# Dogfood: goose-gemini vs codex on hardening /atlas + +**Goal:** loop until a cheap goose+Gemini-2.5-Flash worker reaches results +comparable to codex on real /atlas hardening tasks — decomposing into smaller +subtasks as needed to compensate for the cheaper model. + +**Method (A/B, unfakable):** for each real task I (the orchestrator) write a +pytest acceptance gate the worker may NOT edit. `.atlas-dogfood/bench/run_trial.sh` +runs a worker (codex | gemini) autonomously, runs the external gate (pytest + +ruff), records verdict/wall/diff, then resets the source file to baseline so the +next worker gets a clean tree. Worker self-report is never trusted — the gate is +truth. Codex (`codex exec`, gpt via OPENAI_API_KEY) is the bar; gemini is goose +(`GOOSE_PROVIDER=google GOOSE_MODEL=gemini-2.5-flash`). + +## Results + +| Iter | Task | Granularity | codex | gemini | Note | +|------|------|-------------|-------|--------|------| +| 1 | T2 `_parse_version` fixed 3-tuple (real cmp bug) | coarse (1 instr) | PASS 358s | PASS 38s | gemini matches codex; ~9x faster, far cheaper. Small task → no discrimination. | +| 2 | T3 google/gemini credential-gating in `_provider_usable` + `validate_setup` wiring | coarse, **cross-file (2 files)** | PASS 362s | PASS 41s | gemini matches codex on a cross-file task with **no decomposition**; tighter diff (4 vs 6 ins). ~9x faster, far cheaper. | + +## Emerging insight (after 2 iterations) +The discriminator is NOT task size or cross-file-ness — goose-gemini-2.5-flash matches +codex on both, at ~1/9th the wall time and far lower cost, when the task is +**precisely specified** (exact contract in the instruction). The decomposition +hypothesis most likely bites on **under-specified / autonomous** tasks (e.g. "these +5 tests fail, find and fix the root cause") where the model must infer the spec +itself. Reframe for the orchestrator: the lever for cheap models is **specification +precision** — atomizing a task into small, precisely-specified subtasks IS the +decomposition that lets a cheap model match a strong one. Iter 3 will test an +UNDER-specified task to find gemini's failure point, then show decomposition +(adding spec/structure) closing the gap. + +## Iteration 3 — the decomposition test (under-specified vs precise) ★ +Real task: a chunk of the suite was **non-hermetic** — `resolve_provider` reads real +API keys (os.environ + bound `discover_key`), so results flipped on the shell: +native_backend needed a key present (5 fail without); test_cli/test_engine_preflight +"no-key" tests needed keys absent (fail with one). NO env was fully green. + +Two-phase experiment (operator planned the fix in a SEALED file first): +- **Under-specified** ("5 tests fail, find root cause & fix"): goose-gemini ran with + ambient keys → failures were masked → it made **ZERO changes**, got confused, and + asked for clarification. It did NOT find the root cause autonomously. **gemini < codex/operator.** +- **Decomposed/precise** (operator handed gemini the exact recipe: patch + `backend.resolve_provider` in native tests; clear key env in no-key tests): gemini + produced a diff **matching the operator's sealed plan exactly** — test-side only, no + production change, no weakened assertions. Verified 36/36 green **with AND without** + ambient keys. Committed (fbac5f1). **gemini == operator/codex.** + +## VERDICT +goose-gemini-2.5-flash is **comparable to codex** on /atlas hardening — and ~9x +faster / far cheaper — **whenever the task is precisely specified**, including +cross-file changes. It falls short only on **autonomous diagnosis of under-specified** +problems. The user's hypothesis is confirmed and operationalized: **decomposition = +specification precision is the lever** that lets the cheap model match the strong one. +Orchestrator implication: spend the strong/expensive model on *decomposition & +diagnosis*; fan the *precisely-specified subtasks* out to cheap goose-gemini workers. + +Real hardening landed this run: google provider (f994018) + hermetic test suite (fbac5f1). + +## Iteration 4 — full model spectrum: DECOMPOSITION vs EXECUTION ★★ +Question: how far are cheap/local models from codex at BOTH (a) decomposing a PRD into +tasks and (b) actually completing the generated tasks? Harnesses: +`.atlas-dogfood/bench/decomp_bench.py` (decomposition, scored by the engine's own +`run_validate_tasks`) and goose+ollama on the T2 coding gate (execution). + +### (a) Decomposition — PRD → tasks (one-shot JSON, gate = run_validate_tasks) +| Model | Gate | Note | +|---|---|---| +| openai gpt-4.1-mini (strong API) | PASS | clean 5 tasks | +| **gemini-2.5-flash** | **PASS** | **matches the strong API** | +| qwen3 ~8B (local) | FAIL | no JSON extracted — reasoning-model `` wrapper defeats _extract_json | +| llama3.2:3B (local) | FAIL | 14 problems: status "in progress" (not "in-progress"), missing fields | +| qwen2.5:1.5B (local) | FAIL | only 3 problems — produced 6 tasks, 3 lacked a 2nd subtask | +Local failures are FIXABLE format/discipline issues, not reasoning failures. The gap +to codex on decomposition is SHORT. + +### (b) Execution — complete the T2 coding task (agentic, gate = pytest+ruff) +| Worker | Gate | Note | +|---|---|---| +| codex | PASS | (iter 1) | +| gemini-2.5-flash via goose | PASS | (iter 1) == codex, ~9x faster | +| llama3.2:3B via goose | FAIL | 4s, emitted a malformed tool call (`edit` missing `path`), then rambled; ZERO edits | +| qwen3 ~8B via goose | FAIL | 219s wandering files, confused, ZERO edits | +Local ≤8B models CANNOT drive an agentic tool-using harness. The gap to codex on +execution is LONG — a model-capability wall (tool-calling), not a prompt issue. + +## What prd-taskmaster should ADJUST (to bring goose + any/local model in line) +DECOMPOSITION (cheap wins, likely gets local models passing the gate): +1. Validation-aware REPAIR loop: when run_validate_tasks fails, feed the specific + `problems` back for ONE repair pass. Today generate_json only retries on JSON-parse + failure, not on validation problems — so weak models never get to self-correct + "missing 2nd subtask" / "invalid status". +2. Reasoning-model JSON extraction: strip `...` (and similar) before + _extract_json. qwen3/deepseek-r1 failed ONLY on this. +3. Status/field normalization: map "in progress"->"in-progress", coerce obvious + variants before validating (kills avoidable llama failures). +4. Few-shot: embed one COMPLETE valid example task in the prompt for weak models. + +EXECUTION (the hard wall — needs an architectural path, not prompt tweaks): +5. A constrained STRUCTURED-EDIT execution path for models that can't drive freeform + tool-calling: the model returns a structured patch/full-file JSON; the HARNESS + applies it + runs the gate. This is the original "raw API + harness applies" + design — it lets cheap/local models contribute to execution without agentic tool-use. +6. Capability-tiered routing: goose agentic path only for models proven to drive tools + (gemini+); structured-edit path for weak/local; codex/claude for the hardest tasks. + The orchestrator decomposes with a strong model, then routes each subtask to the + cheapest worker whose capability clears the bar. + +### Bottom line for the user's question +- Decomposition: gemini is ALREADY at codex level; local models are CLOSE and reachable + with adjustments #1-#4. +- Execution: gemini is at codex level; local models are FAR — only adjustments #5-#6 + (a non-agentic structured-edit path) can bring them in, and even then only for simple + tasks. The strong model stays essential for decomposition/diagnosis and hard execution. + +## Engine debt found (not yet fixed) +5 tests in `tests/core/test_native_backend.py` (parse_prd / expand / rate) fail on +`main`: they mock the OLD seam `llm_client.discover_key`, but backend.py now routes +via `resolve_provider("main")` (backend.py:344) which returns `kind="plan"` in a +keyless env → handoff path → `ok:False`. Stale tests from the resolver refactor. +Fix = update those tests to mock `resolve_provider` (or add a discover_key API +fallback in backend). Deferred: fixing requires editing tests, which conflicts with +the worker-gate methodology; will handle as a direct maintenance task. + +### Iteration 1 detail (T2) +Real bug: variable-length version tuples mis-compare ((1,2) < (1,2,0) is True → +"1.2" wrongly older than "1.2.0"). Gate: `tests/core/test_parse_version_tuple.py` +(11 cases incl. the equality regression). Both workers normalized to a 3-tuple +and passed the external gate; both diffs clean; source reset verified; gate test +untampered. **Verdict: comparable on small, well-specified tasks — gemini wins on +cost/latency.** + +## Next +T2 did not discriminate (too small). Escalate to a harder, multi-branch real task +— hero candidate: **credential-aware `validate_setup`** (audit P0-2, the v5.2.0 +relaunch blocker: the readiness gate green-lights a keyless paid config). Expect +codex to one-shot it and gemini-coarse to struggle → then demonstrate the +decomposition hypothesis (split into smaller subtasks until gemini matches codex). + +## Iteration 5 — quantifying the decomposition gap-closing ★★★ +Enhanced decomp_bench.py to measure raw → +normalize → +repair (max_tokens 8192). +- **Variance is the headline for raw local runs:** qwen2.5:1.5b went 3-problems → 0-tasks + across runs; qwen3 went clean-JSON → no-json. Small local models are UNRELIABLE + run-to-run, and a validation-aware repair pass did NOT rescue them (llama3.2:3b could + not self-correct even when handed the exact problems). Normalization/repair help only + capable models that slip; they do not lift sub-floor (<=3B) models. +- **DECISIVE FIX for local reasoning models — disable thinking:** qwen3:8b with `/no_think` + passed the gate **3/3, clean (5 tasks, 2 subtasks each)**. Thinking-mode was the entire + source of its strict-JSON instability. With it off, a LOCAL 8B model == codex/gemini on + decomposition. + +### QUANTIFIED answer — "how far to bring local in line with codex" +DECOMPOSITION: hosted-cheap gemini-flash already == codex (distance 0); local 8B reasoning +(qwen3) + thinking-disabled == codex, 3/3 (distance ≈ ONE config flag); local <=3B not +reliable (distance = a bigger model). EXECUTION: unchanged — local can't drive the agentic +harness; needs the structured-edit path (distance = an architectural change, not a model swap). + +### Refined prd-taskmaster adjustments (priority order) +1. ★ Reasoning-model handling: detect reasoning models and DISABLE thinking for + structured-gen (ollama think:false / `/no_think`; strip `` before JSON extract). + Decisive: qwen3 0%→100% reliable on decomposition. +2. Raise structured-gen max_tokens (4096→8192+) to avoid truncating multi-task JSON. +3. Validation-aware repair loop (feed run_validate_tasks problems back, 1 pass) — helps + capable models on hard PRDs; will NOT rescue sub-floor models. +4. Status/priority normalization before validation — kills avoidable format fails. +EXECUTION: structured-edit path + capability-tiered routing (from iter4) remain the levers. + +## Iteration 6 — structured-edit EXECUTION POC (does adjustment #5 work?) ★★★ +POC: model never touches the FS; it returns JSON {find, replace}; a deterministic +harness applies it (str.replace) + runs gate_t2.sh. (.atlas-dogfood/bench/structured_edit_poc.py) +| Model | Result | Meaning | +|---|---|---| +| gemini-2.5-flash (control) | edit applied, near-pass | viable; failed only an over-specified edge ("1.two.3"→(0,0,0) vs its (1,0,3) per-part leniency) | +| qwen3:8b (/no_think) | applicable edit, wrong fix | **path lowers the bar: 0 edits in agentic mode → an APPLICABLE edit here** | +| llama3.2:3b | NO-EDIT | below protocol floor — can't emit valid {find,replace} JSON | +| qwen2.5:1.5b | NO-EDIT | below protocol floor | + +CONCLUSION: the structured-edit path REMOVES the tool-calling wall (an 8B local model +that made zero edits via agentic goose can now produce an applicable edit), but it does +NOT remove the CAPABILITY requirement — the edit must still be correct, and that still +tracks model strength. Tiny (<=3B) models can't even follow the JSON edit protocol. + +## ===== FINAL SYNTHESIS: how far to bring goose+local in line with codex ===== +DECOMPOSITION (PRD→tasks): +- gemini-flash (hosted cheap): == codex. distance 0. +- local 8B reasoning + thinking-disabled: == codex (3/3). distance ≈ one config flag. +- local <=3B: unreliable; below floor. distance = a bigger model. +EXECUTION (complete tasks): +- gemini via goose: == codex (~9x cheaper/faster). +- local via AGENTIC goose: fails entirely (tool-calling wall). +- local via STRUCTURED-EDIT: 8B can participate (applicable edits) but not yet reliably + correct; <=3B can't follow the protocol. distance = architectural path (built here) + + a stronger local model for correctness. +NET: a cheap HOSTED model (gemini-flash) already matches codex for BOTH decomposition and +execution at a fraction of the cost — that is the immediate win. LOCAL models reach codex +on decomposition (8B + no-think) but remain short on execution correctness; the +structured-edit path is the right architecture to let them contribute as they improve. +Highest-value prd-taskmaster changes: (1) reasoning-model thinking-disable for structured +gen, (2) max_tokens bump, (3) validation-aware repair loop, (4) a structured-edit execution +backend + capability-tiered routing (strong model decomposes/diagnoses; cheapest capable +worker executes each precisely-specified subtask, gated externally). diff --git a/.atlas-dogfood/bench/decomp_bench.py b/.atlas-dogfood/bench/decomp_bench.py new file mode 100644 index 0000000..853c381 --- /dev/null +++ b/.atlas-dogfood/bench/decomp_bench.py @@ -0,0 +1,160 @@ +"""Decomposition-quality benchmark: how well do cheap/local models turn a PRD into +TaskMaster tasks, scored by /atlas's OWN gate (run_validate_tasks via +backend._validate_task_candidate)? Drives every model through the engine's own +llm_client._http_call + _extract_json so the only variable is the model. + +Measures THREE points per model so we can quantify the gap-closing of proposed +prd-taskmaster adjustments: + raw = one call, validate as-is (today's behaviour) + +norm = + status/priority/think-tag normalization (adjustments #2/#3) + +repair = + one validation-aware repair pass (adjustment #1) + +Usage: python .atlas-dogfood/bench/decomp_bench.py [num_tasks] +""" +import json +import os +import re +import sys +import time +from pathlib import Path + +from prd_taskmaster import backend, llm_client +from prd_taskmaster.lib import CommandError + +NUM_TASKS = int(sys.argv[1]) if len(sys.argv) > 1 else 5 +MAX_TOKENS = 8192 +PRD = (Path(__file__).parent / "prd_sample.md").read_text() + +PROMPT = ( + f"Parse this PRD into exactly {NUM_TASKS} TaskMaster-compatible tasks.\n" + "Target tag: master.\n" + "Return only the tasks JSON object.\n\n" + f"PRD:\n{PRD}" + "\n\nReturn ONLY valid JSON matching:\n" + backend.TASKS_SCHEMA_HINT +) +SYSTEM = ( + "You are the prd-taskmaster native backend. Generate strict JSON for the " + "Native Mode tasks.json path." +) + +_ALLOWED_STATUS = {"pending", "in-progress", "review", "done", "deferred", "cancelled"} +_ALLOWED_PRIORITY = {"high", "medium", "low"} + + +def _norm_status(v): + s = str(v or "pending").strip().lower().replace(" ", "-").replace("_", "-") + aliases = {"inprogress": "in-progress", "in-progress": "in-progress", + "todo": "pending", "open": "pending", "wip": "in-progress", + "complete": "done", "completed": "done", "closed": "done"} + s = aliases.get(s, s) + return s if s in _ALLOWED_STATUS else "pending" + + +def normalize_candidate(candidate): + """Adjustments #2/#3: strip reasoning wrappers were handled at extract; here + coerce status/priority variants so trivial format drift does not fail the gate.""" + if not isinstance(candidate, dict): + return candidate + for task in candidate.get("tasks") or []: + if not isinstance(task, dict): + continue + task["status"] = _norm_status(task.get("status")) + pr = str(task.get("priority", "") or "").strip().lower() + task["priority"] = pr if pr in _ALLOWED_PRIORITY else "medium" + for sub in task.get("subtasks") or []: + if isinstance(sub, dict): + sub["status"] = _norm_status(sub.get("status")) + return candidate + + +def extract(text): + # adjustment #2: drop reasoning ... blocks before parsing + text = re.sub(r".*?", "", text, flags=re.DOTALL | re.IGNORECASE) + return llm_client._extract_json(text) + + +def score(candidate): + try: + tasks, _ = backend._validate_task_candidate(candidate) + return True, len(tasks), [len(t.get("subtasks") or []) for t in tasks], [] + except CommandError as e: + extra = getattr(e, "extra", {}) or {} + probs = extra.get("problems") or [getattr(e, "message", str(e))] + tasks = candidate.get("tasks") if isinstance(candidate, dict) else None + nt = len(tasks) if isinstance(tasks, list) else 0 + sc = [len(t.get("subtasks") or []) for t in tasks] if isinstance(tasks, list) else [] + return False, nt, sc, probs + + +def _creds(provider): + if provider == "google": + return {"provider": "google", "key": os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"), + "base_url": "https://generativelanguage.googleapis.com/v1beta"} + if provider == "openai": + return {"provider": "openai", "key": os.environ.get("OPENAI_API_KEY"), + "base_url": "https://api.openai.com/v1"} + if provider == "ollama": + return {"provider": "openai-compatible", "key": "ollama", + "base_url": "http://127.0.0.1:11434/v1"} + raise ValueError(provider) + + +MODELS = [ + ("openai:gpt-4.1-mini (strong API bar)", "openai", "gpt-4.1-mini", 120), + ("google:gemini-2.5-flash", "google", "gemini-2.5-flash", 120), + ("ollama:qwen3:latest (~8B local)", "ollama", "qwen3:latest", 300), + ("ollama:llama3.2:3b (3B local)", "ollama", "llama3.2:3b", 240), + ("ollama:qwen2.5:1.5b (1.5B local)", "ollama", "qwen2.5:1.5b", 240), +] + +results = [] +for label, provider, model, timeout in MODELS: + row = {"model": label} + creds = _creds(provider) + if not creds["key"]: + row.update(stage="none", note="no key/endpoint"); results.append(row); continue + t0 = time.monotonic() + try: + text, _ = llm_client._http_call(creds, model, SYSTEM, PROMPT, MAX_TOKENS, timeout) + cand = extract(text) + if cand is None: + row.update(raw="no-json", note="no JSON extracted", wall_s=round(time.monotonic() - t0, 1)) + results.append(row); continue + raw_ok, nt, sc, probs = score(cand) + row["raw"] = "PASS" if raw_ok else "FAIL" + # +norm + cand = normalize_candidate(cand) + norm_ok, nt, sc, probs = score(cand) + row["norm"] = "PASS" if norm_ok else "FAIL" + # +repair (one validation-aware pass) only if still failing + if not norm_ok: + repair_prompt = ( + PROMPT + + "\n\nYour previous JSON had these validation problems:\n- " + + "\n- ".join(probs[:12]) + + "\nReturn the COMPLETE corrected tasks JSON (every task needs >=2 subtasks, " + "non-empty title/description/details/testStrategy, priority high|medium|low)." + ) + text2, _ = llm_client._http_call(creds, model, SYSTEM, repair_prompt, MAX_TOKENS, timeout) + cand2 = normalize_candidate(extract(text2) or {}) + rep_ok, nt, sc, probs = score(cand2) + row["repair"] = "PASS" if rep_ok else "FAIL" + else: + row["repair"] = "PASS" + row.update(wall_s=round(time.monotonic() - t0, 1), n_tasks=nt, subtasks=sc, + problems=probs[:5]) + except Exception as e: # noqa: BLE001 + row.update(note=f"{type(e).__name__}: {str(e)[:120]}", wall_s=round(time.monotonic() - t0, 1)) + results.append(row) + +print("\n========= DECOMPOSITION BENCHMARK — gap-closing (gate = run_validate_tasks) =========") +print(f"PRD: per-API-key rate limiter | tasks: {NUM_TASKS} | max_tokens: {MAX_TOKENS}\n") +print(f"{'model':<40} {'raw':>5} {'+norm':>6} {'+repair':>8} note") +for r in results: + print(f"{r['model']:<40} {r.get('raw','-'):>5} {r.get('norm','-'):>6} {r.get('repair','-'):>8} " + f"{r.get('note','') or ('tasks='+str(r.get('n_tasks'))+' sub='+str(r.get('subtasks')))}") + for p in r.get("problems", [])[:3]: + print(f" · {p}") +print() +(Path(__file__).parent / "decomp_results.json").write_text(json.dumps(results, indent=2, default=str)) +print("saved decomp_results.json") diff --git a/.atlas-dogfood/bench/decomp_results.json b/.atlas-dogfood/bench/decomp_results.json new file mode 100644 index 0000000..74dec4d --- /dev/null +++ b/.atlas-dogfood/bench/decomp_results.json @@ -0,0 +1,72 @@ +[ + { + "model": "openai:gpt-4.1-mini (strong API bar)", + "raw": "PASS", + "norm": "PASS", + "repair": "PASS", + "wall_s": 23.7, + "n_tasks": 5, + "subtasks": [ + 2, + 2, + 2, + 2, + 2 + ], + "problems": [] + }, + { + "model": "google:gemini-2.5-flash", + "raw": "PASS", + "norm": "PASS", + "repair": "PASS", + "wall_s": 19.9, + "n_tasks": 5, + "subtasks": [ + 3, + 2, + 2, + 3, + 3 + ], + "problems": [] + }, + { + "model": "ollama:qwen3:latest (~8B local)", + "raw": "no-json", + "note": "no JSON extracted", + "wall_s": 59.0 + }, + { + "model": "ollama:llama3.2:3b (3B local)", + "raw": "FAIL", + "norm": "FAIL", + "repair": "FAIL", + "wall_s": 19.2, + "n_tasks": 5, + "subtasks": [ + 2, + 2, + 2, + 1, + 2 + ], + "problems": [ + "task 4: must have at least 2 subtasks", + "task 5 subtask 1: missing description", + "task 5 subtask 2: missing description" + ] + }, + { + "model": "ollama:qwen2.5:1.5b (1.5B local)", + "raw": "FAIL", + "norm": "FAIL", + "repair": "FAIL", + "wall_s": 12.1, + "n_tasks": 0, + "subtasks": [], + "problems": [ + "generated tasks payload must contain a tasks list" + ] + } +] \ No newline at end of file diff --git a/.atlas-dogfood/bench/gate_t2.sh b/.atlas-dogfood/bench/gate_t2.sh new file mode 100755 index 0000000..d6f42d1 --- /dev/null +++ b/.atlas-dogfood/bench/gate_t2.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# External gate for T2 (_parse_version 3-tuple). pytest + ruff, both must pass. +set -u +REPO=/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +cd "$REPO" || exit 9 +env -u GEMINI_API_KEY -u GOOGLE_API_KEY -u ANTHROPIC_API_KEY -u OPENAI_API_KEY \ + python -m pytest tests/core/test_parse_version_tuple.py -q || exit 1 +ruff check prd_taskmaster/mode_recommend.py || exit 1 +echo "GATE_OK" diff --git a/.atlas-dogfood/bench/gate_t3.sh b/.atlas-dogfood/bench/gate_t3.sh new file mode 100755 index 0000000..1f25373 --- /dev/null +++ b/.atlas-dogfood/bench/gate_t3.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# External gate for T3 (google-aware _provider_usable + validate_setup wiring). +# pytest (new contract + existing validate regression) + ruff, all must pass. +set -u +REPO=/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +cd "$REPO" || exit 9 +env -u GEMINI_API_KEY -u GOOGLE_API_KEY -u ANTHROPIC_API_KEY -u OPENAI_API_KEY -u OPENAI_COMPATIBLE_API_KEY \ + python -m pytest tests/core/test_provider_usable_google.py tests/core/test_mode_recommend_validate.py -q || exit 1 +ruff check prd_taskmaster/providers.py prd_taskmaster/mode_recommend.py || exit 1 +echo "GATE_OK" diff --git a/.atlas-dogfood/bench/instr_t2.md b/.atlas-dogfood/bench/instr_t2.md new file mode 100644 index 0000000..925a7e4 --- /dev/null +++ b/.atlas-dogfood/bench/instr_t2.md @@ -0,0 +1,29 @@ +# Subtask T2: make _parse_version return a fixed-length 3-tuple + +Work in the current repo (prd-taskmaster-public). Edit ONLY +`prd_taskmaster/mode_recommend.py` — specifically the `_parse_version` function. + +## The bug +`_parse_version` currently returns a tuple whose length depends on the input +(e.g. "1.2" -> (1, 2)). Variable-length tuples mis-compare: (1, 2) < (1, 2, 0) +is True in Python, so version "1.2" wrongly sorts as OLDER than "1.2.0" even +though they are equal. This can trip minimum-version gates. + +## Required behaviour (normalize to exactly 3 integer components) +- "1.2.3" -> (1, 2, 3) +- "1.2" -> (1, 2, 0) # pad missing components with 0 +- "1" -> (1, 0, 0) +- "v2.0" -> (2, 0, 0) # leading 'v' stripped (existing behaviour) +- "1.2.3-rc1" -> (1, 2, 3) # pre-release suffix ignored (existing behaviour) +- "1.2.3.4" -> (1, 2, 3) # truncate extra components to 3 +- "garbage" / "" / "1.two.3" -> (0, 0, 0) # parse failure fallback +The returned tuple must ALWAYS have length 3. + +## Hard constraints +- Edit ONLY `prd_taskmaster/mode_recommend.py`. Do NOT touch any file under tests/. +- Pure stdlib; keep the function's docstring accurate. + +## Acceptance (must pass before you declare done) + python -m pytest tests/core/test_parse_version_tuple.py -q + ruff check prd_taskmaster/mode_recommend.py +Iterate until pytest is all-green AND ruff says "All checks passed". diff --git a/.atlas-dogfood/bench/instr_t3.md b/.atlas-dogfood/bench/instr_t3.md new file mode 100644 index 0000000..0c97031 --- /dev/null +++ b/.atlas-dogfood/bench/instr_t3.md @@ -0,0 +1,39 @@ +# Subtask T3: gate google/gemini providers on a Google API key + +Work in the current repo (prd-taskmaster-public). This is a cross-file change. +Edit ONLY these two source files (do NOT touch anything under tests/): + - prd_taskmaster/providers.py + - prd_taskmaster/mode_recommend.py + +## Why +`llm_client` now supports a raw-API `google` provider (GOOGLE_API_KEY / GEMINI_API_KEY). +But `_provider_usable()` still treats `google`/`gemini` as unknown providers and +returns True (assumed usable). So `validate_setup` green-lights a `google` main +model even with NO Google key — the same silent "0 tasks" defect already prevented +for anthropic/openai. Fix it. + +## Required changes +1. In `prd_taskmaster/providers.py`, function `_provider_usable`: + - Add a new keyword-only parameter `has_google_key: bool = False` (MUST default + to False so existing callers keep working). + - Treat provider names `"google"` and `"gemini"` (case-insensitive) as usable + ONLY when `has_google_key` is True. + - Leave all other behaviour unchanged: anthropic/openai/perplexity/claude-code/ + codex-cli as-is, and genuinely unknown providers (openrouter, ollama, …) still + return True. + +2. In `prd_taskmaster/mode_recommend.py`, function `validate_setup`: + - Where it builds `usable_kwargs` for `_provider_usable`, also compute and pass + `has_google_key` = bool(os.environ.get("GOOGLE_API_KEY") or + os.environ.get("GEMINI_API_KEY")). + - This makes the `provider_main` check fail for a google main with no key, and + pass when a Google key is present. + +## Hard constraints +- Edit ONLY the two source files named above. Do NOT modify any test file. +- Pure stdlib. Keep existing tests green. + +## Acceptance (must pass before declaring done) + python -m pytest tests/core/test_provider_usable_google.py tests/core/test_mode_recommend_validate.py -q + ruff check prd_taskmaster/providers.py prd_taskmaster/mode_recommend.py +Iterate until pytest is all-green AND ruff says "All checks passed". diff --git a/.atlas-dogfood/bench/prd_sample.md b/.atlas-dogfood/bench/prd_sample.md new file mode 100644 index 0000000..a7b3376 --- /dev/null +++ b/.atlas-dogfood/bench/prd_sample.md @@ -0,0 +1,27 @@ +# PRD: Per-API-key rate limiter for the public REST API + +## Goal +Add a configurable rate limiter to our public REST API so a single API key cannot +exceed a fixed request budget per rolling window, protecting the service from abuse +and noisy-neighbor overload. + +## Requirements +- REQ-001 (P0): Enforce a per-API-key limit of N requests per rolling 60-second + window (N configurable per key, default 100). Excess requests get HTTP 429 with a + `Retry-After` header. +- REQ-002 (P0): The limiter state must be shared across all API server instances + (use the existing Redis cluster), so the limit holds regardless of which instance + serves the request. +- REQ-003 (P1): Emit a metric (`ratelimit.rejected`) and a structured log line each + time a request is rejected, including the API key id (hashed) and the current count. +- REQ-004 (P1): Provide an admin endpoint to read and override a key's limit at + runtime without a deploy. Depends on REQ-001. +- REQ-005 (P2): Fail open — if Redis is unreachable, allow the request and emit a + `ratelimit.degraded` metric rather than 500ing the caller. Depends on REQ-002. + +## Out of scope +- Per-IP limiting, global limits, and billing/quota enforcement. + +## Acceptance +- Load test shows the 101st request within a window for a default key returns 429. +- Killing Redis mid-test degrades to fail-open with the degraded metric emitted. diff --git a/.atlas-dogfood/bench/run_trial.sh b/.atlas-dogfood/bench/run_trial.sh new file mode 100755 index 0000000..6bf6b77 --- /dev/null +++ b/.atlas-dogfood/bench/run_trial.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# A/B worker trial harness for the goose-gemini-vs-codex dogfood. +# usage: run_trial.sh +# Runs the worker autonomously, runs the external gate, prints a one-line JSON +# result, then resets the named source paths to baseline (gate test files are kept). +set -u +WORKER="$1"; INSTR="$2"; GATE="$3"; shift 3; RESETS=("$@") +REPO=/home/anombyte/Shade_Gen/Projects/prd-taskmaster-public +BENCH="$REPO/.atlas-dogfood/bench" +cd "$REPO" || exit 9 +export PATH="$HOME/.local/bin:$PATH" + +start=$(date +%s) +if [ "$WORKER" = "gemini" ]; then + GOOSE_PROVIDER=google GOOSE_MODEL=gemini-2.5-flash GOOSE_MODE=auto \ + GOOSE_DISABLE_KEYRING=1 GOOGLE_API_KEY="${GEMINI_API_KEY}" \ + timeout 480 goose run -i "$INSTR" > "$BENCH/worker_${WORKER}.log" 2>&1 + wrc=$? +elif [ "$WORKER" = "codex" ]; then + timeout 480 codex exec --skip-git-repo-check \ + -c approval_policy='"never"' -c sandbox_mode='"workspace-write"' \ + - < "$INSTR" > "$BENCH/worker_${WORKER}.log" 2>&1 + wrc=$? +else + echo "unknown worker: $WORKER"; exit 9 +fi +end=$(date +%s) + +bash "$GATE" > "$BENCH/gate_${WORKER}.log" 2>&1 +grc=$? + +diffstat=$(git diff --stat -- "${RESETS[@]}" 2>/dev/null | tail -1 | tr -s ' ') +verdict="FAIL"; [ "$grc" -eq 0 ] && verdict="PASS" +printf '{"worker":"%s","verdict":"%s","worker_rc":%s,"gate_rc":%s,"wall_s":%s,"diff":"%s"}\n' \ + "$WORKER" "$verdict" "$wrc" "$grc" "$((end-start))" "${diffstat}" + +git checkout -- "${RESETS[@]}" 2>/dev/null diff --git a/.atlas-dogfood/bench/structured_edit_poc.py b/.atlas-dogfood/bench/structured_edit_poc.py new file mode 100644 index 0000000..19f02ea --- /dev/null +++ b/.atlas-dogfood/bench/structured_edit_poc.py @@ -0,0 +1,114 @@ +"""Structured-edit EXECUTION POC (adjustment #5): can a model that cannot drive an +agentic tool-using harness still COMPLETE a coding task by emitting a structured +SEARCH/REPLACE edit that a deterministic harness applies + gates? + +Task = T2 (_parse_version -> fixed 3-tuple). The model never touches the filesystem; +it only returns JSON {"find": , "replace": }. +The harness applies it (str.replace, once), runs gate_t2.sh, then resets. + +Usage: python .atlas-dogfood/bench/structured_edit_poc.py +""" +import json +import os +import re +import subprocess +import time +from pathlib import Path + +from prd_taskmaster import llm_client + +REPO = Path(__file__).resolve().parents[2] +TARGET = REPO / "prd_taskmaster" / "mode_recommend.py" +GATE = REPO / ".atlas-dogfood" / "bench" / "gate_t2.sh" + + +def current_func(src: str) -> str: + lines = src.splitlines(keepends=True) + out, capturing = [], False + for ln in lines: + if ln.startswith("def _parse_version"): + capturing = True + elif capturing and ln.startswith("def "): + break + if capturing: + out.append(ln) + return "".join(out).rstrip("\n") + + +BASELINE = TARGET.read_text() +FUNC = current_func(BASELINE) + +TASK = ( + "Fix this Python function so it ALWAYS returns a fixed-length 3-tuple of ints:\n" + ' "1.2.3"->(1,2,3) "1.2"->(1,2,0) "1"->(1,0,0) "v2.0"->(2,0,0)\n' + ' "1.2.3-rc1"->(1,2,3) "1.2.3.4"->(1,2,3) bad/empty->(0,0,0)\n' + "Pad missing components with 0, truncate extras to 3, keep the (0,0,0) fallback.\n\n" + "CURRENT FUNCTION (copy it VERBATIM into \"find\"):\n```python\n" + FUNC + "\n```\n\n" + 'Return ONLY a JSON object: {"find": "", ' + '"replace": ""}. "find" MUST match the current text ' + "character-for-character so a literal string replace succeeds." +) +SYSTEM = "You emit a single JSON search/replace edit. No prose, no tool calls." + + +def _creds(provider): + if provider == "google": + return {"provider": "google", "key": os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"), + "base_url": "https://generativelanguage.googleapis.com/v1beta"} + return {"provider": "openai-compatible", "key": "ollama", "base_url": "http://127.0.0.1:11434/v1"} + + +def run_gate(): + r = subprocess.run(["bash", str(GATE)], capture_output=True, text=True, cwd=str(REPO)) + return r.returncode + + +def reset(): + subprocess.run(["git", "checkout", "--", str(TARGET)], cwd=str(REPO), + capture_output=True, text=True) + + +MODELS = [ + ("google:gemini-2.5-flash (control)", "google", "gemini-2.5-flash", ""), + ("ollama:qwen3:8b (/no_think)", "ollama", "qwen3:latest", "\n/no_think"), + ("ollama:llama3.2:3b", "ollama", "llama3.2:3b", ""), + ("ollama:qwen2.5:1.5b", "ollama", "qwen2.5:1.5b", ""), +] + +results = [] +for label, provider, model, suffix in MODELS: + reset() + row = {"model": label} + creds = _creds(provider) + if not creds["key"]: + row["verdict"] = "skip (no key)"; results.append(row); continue + t0 = time.monotonic() + try: + text, _ = llm_client._http_call(creds, model, SYSTEM, TASK + suffix, 8192, 300) + text = re.sub(r".*?", "", text, flags=re.DOTALL | re.IGNORECASE) + edit = llm_client._extract_json(text) + if not isinstance(edit, dict) or "find" not in edit or "replace" not in edit: + row.update(verdict="NO-EDIT", note="no valid {find,replace} JSON") + else: + find, replace = edit["find"], edit["replace"] + if find not in BASELINE: + # tolerant retry: match on stripped whitespace + row.update(verdict="FIND-MISS", note="`find` did not match source verbatim") + else: + TARGET.write_text(BASELINE.replace(find, replace, 1)) + rc = run_gate() + row.update(verdict="PASS" if rc == 0 else "FAIL-GATE", gate_rc=rc) + row["wall_s"] = round(time.monotonic() - t0, 1) + except Exception as e: # noqa: BLE001 + row.update(verdict="ERR", note=f"{type(e).__name__}: {str(e)[:100]}", + wall_s=round(time.monotonic() - t0, 1)) + reset() + results.append(row) + +print("\n===== STRUCTURED-EDIT EXECUTION POC (T2, model emits find/replace, harness applies) =====\n") +print(f"{'model':<36} {'verdict':<12} {'wall':>6} note") +for r in results: + print(f"{r['model']:<36} {r.get('verdict','-'):<12} {str(r.get('wall_s','?'))+'s':>6} {r.get('note','')}") +print() +(Path(__file__).parent / "structured_edit_results.json").write_text(json.dumps(results, indent=2, default=str)) +print("saved structured_edit_results.json") diff --git a/.atlas-dogfood/bench/structured_edit_results.json b/.atlas-dogfood/bench/structured_edit_results.json new file mode 100644 index 0000000..6204397 --- /dev/null +++ b/.atlas-dogfood/bench/structured_edit_results.json @@ -0,0 +1,26 @@ +[ + { + "model": "google:gemini-2.5-flash (control)", + "verdict": "FAIL-GATE", + "gate_rc": 1, + "wall_s": 16.6 + }, + { + "model": "ollama:qwen3:8b (/no_think)", + "verdict": "FAIL-GATE", + "gate_rc": 1, + "wall_s": 99.9 + }, + { + "model": "ollama:llama3.2:3b", + "verdict": "NO-EDIT", + "note": "no valid {find,replace} JSON", + "wall_s": 6.9 + }, + { + "model": "ollama:qwen2.5:1.5b", + "verdict": "NO-EDIT", + "note": "no valid {find,replace} JSON", + "wall_s": 2.0 + } +] \ No newline at end of file diff --git a/prd_taskmaster/mode_recommend.py b/prd_taskmaster/mode_recommend.py index 4120839..8b2fc11 100644 --- a/prd_taskmaster/mode_recommend.py +++ b/prd_taskmaster/mode_recommend.py @@ -16,7 +16,6 @@ from typing import Any from prd_taskmaster.fleet import engine_config -from prd_taskmaster.lib import emit_json_error from prd_taskmaster.providers import ( _has_perplexity_api_key, _is_nested_claude, @@ -38,15 +37,18 @@ # Private helpers # --------------------------------------------------------------------------- -def _parse_version(v: str) -> tuple[int, ...]: - """Parse a semver-ish string into a comparable tuple. +def _parse_version(v: str) -> tuple[int, int, int]: + """Parse a semver-ish string into a fixed-length 3-tuple of ints. - Strips leading 'v' and ignores any pre-release suffix after '-'. - Returns (0, 0, 0) on parse failure so comparison is always safe. + Strips leading 'v' and ignores any pre-release suffix after '-'. Pads missing + components with 0 and truncates extras to 3, so equal versions compare equal + ("1.2" == "1.2.0"). Returns (0, 0, 0) on parse failure so comparison is safe. """ try: v = v.strip().lstrip("v").split("-")[0] - return tuple(int(x) for x in v.split(".")) + parts = [int(x) for x in v.split(".")] + parts = (parts + [0, 0, 0])[:3] + return (parts[0], parts[1], parts[2]) except Exception: return (0, 0, 0) @@ -486,6 +488,7 @@ def validate_setup(provider_mode: str | None = None) -> dict: "has_anthropic_key": bool(os.environ.get("ANTHROPIC_API_KEY")), "has_openai_key": bool(os.environ.get("OPENAI_API_KEY")), "has_perplexity_key": _has_perplexity_api_key(), + "has_google_key": bool(os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")), } provider_ok = False provider_detail = "Cannot read config" diff --git a/prd_taskmaster/providers.py b/prd_taskmaster/providers.py index c6748d2..d272f8e 100644 --- a/prd_taskmaster/providers.py +++ b/prd_taskmaster/providers.py @@ -79,12 +79,14 @@ def _provider_usable( has_anthropic_key: bool, has_openai_key: bool, has_perplexity_key: bool, + has_google_key: bool = False, ) -> bool: """Can this provider actually run a request in the current environment? - Presence of a *credential* or *CLI* — not merely a config string. Unknown - providers (openrouter, ollama, google, …) are assumed usable: they are - deliberate user choices the engine must not clobber. + Presence of a *credential* or *CLI* — not merely a config string. google/gemini + are raw-API providers gated on a Google key. Genuinely unknown providers + (openrouter, ollama, …) are assumed usable: deliberate user choices the engine + must not clobber. """ p = str(provider or "").lower() if p == "anthropic": @@ -97,6 +99,8 @@ def _provider_usable( return has_claude if p == "codex-cli": return has_codex + if p in ("google", "gemini"): + return has_google_key return True diff --git a/tests/core/test_parse_version_tuple.py b/tests/core/test_parse_version_tuple.py new file mode 100644 index 0000000..4881bf2 --- /dev/null +++ b/tests/core/test_parse_version_tuple.py @@ -0,0 +1,42 @@ +"""Acceptance gate (dogfood bench T2): _parse_version must return a fixed-length +3-tuple so version comparisons are correct. + +Real bug: variable-length tuples mis-compare — (1,2) < (1,2,0) is True in Python, +so version "1.2" wrongly reads as OLDER than "1.2.0" (they are equal), which can +trip min-version gates. Worker must normalize to exactly 3 components. + +This file is the fixed contract; the worker may NOT edit it. +""" + +import pytest + +from prd_taskmaster.mode_recommend import _parse_version as pv + + +@pytest.mark.parametrize("s,expected", [ + ("1.2.3", (1, 2, 3)), + ("1.2", (1, 2, 0)), + ("1", (1, 0, 0)), + ("v2.0", (2, 0, 0)), + ("1.2.3-rc1", (1, 2, 3)), + ("1.2.3.4", (1, 2, 3)), # extra components truncated to 3 + ("garbage", (0, 0, 0)), + ("", (0, 0, 0)), + ("1.two.3", (0, 0, 0)), +]) +def test_parse_version_normalizes_to_3_tuple(s, expected): + out = pv(s) + assert out == expected + assert len(out) == 3 + + +def test_equal_versions_compare_equal_regression(): + # the actual bug this task fixes + assert pv("1.2") == pv("1.2.0") + assert not (pv("1.2") < pv("1.2.0")) + + +def test_ordering_still_correct(): + assert pv("1.2.0") < pv("1.3.0") + assert pv("1.9.0") < pv("1.10.0") + assert pv("2.0.0") > pv("1.99.99") diff --git a/tests/core/test_provider_usable_google.py b/tests/core/test_provider_usable_google.py new file mode 100644 index 0000000..4ae5421 --- /dev/null +++ b/tests/core/test_provider_usable_google.py @@ -0,0 +1,100 @@ +"""Acceptance gate (dogfood bench T3): google/gemini are raw-API providers, so +their usability must be gated on a Google API key — in _provider_usable AND wired +through validate_setup. Cross-file (providers.py + mode_recommend.py). + +Context: llm_client now supports a 'google' provider (GOOGLE_API_KEY / GEMINI_API_KEY). +But _provider_usable still treats 'google'/'gemini' as unknown → always usable, +so validate_setup green-lights a google main with no key (the same class of +silent-0-task defect the anthropic/openai checks already prevent). + +Contract: + _provider_usable("google", has_google_key=True, ...) -> True + _provider_usable("google", has_google_key=False, ...) -> False + _provider_usable("gemini", has_google_key=False, ...) -> False + _provider_usable("openrouter", ...) -> True (unknown still assumed usable) + has_google_key must default to False so existing callers don't break. + validate_setup: a google main with NO GOOGLE_API_KEY/GEMINI_API_KEY -> provider_main fails; + with a key set -> passes. + +This file is the fixed contract; the worker may NOT edit it. +""" + +import json + +import pytest + +from prd_taskmaster import mode_recommend +from prd_taskmaster.providers import _provider_usable + +_BASE = dict( + has_claude=False, has_codex=False, + has_anthropic_key=False, has_openai_key=False, has_perplexity_key=False, +) + + +def test_google_usable_only_with_key(): + assert _provider_usable("google", has_google_key=True, **_BASE) is True + assert _provider_usable("google", has_google_key=False, **_BASE) is False + + +def test_gemini_alias_usable_only_with_key(): + assert _provider_usable("gemini", has_google_key=True, **_BASE) is True + assert _provider_usable("gemini", has_google_key=False, **_BASE) is False + + +def test_has_google_key_defaults_false_backcompat(): + # existing callers that don't pass has_google_key must still work, + # and a google provider without the kwarg is treated as not-usable + assert _provider_usable("google", **_BASE) is False + + +def test_unknown_provider_still_assumed_usable(): + assert _provider_usable("openrouter", **_BASE) is True + assert _provider_usable("ollama", has_google_key=False, **_BASE) is True + + +def test_existing_providers_unchanged(): + assert _provider_usable("anthropic", **{**_BASE, "has_anthropic_key": True}) is True + assert _provider_usable("anthropic", **_BASE) is False + + +def _seed_google_config(tmp_path): + tm = tmp_path / ".taskmaster" + tm.mkdir(parents=True, exist_ok=True) + (tm / "config.json").write_text(json.dumps({ + "models": { + "main": {"provider": "google", "modelId": "gemini-2.5-flash"}, + "research": {"provider": "perplexity", "modelId": "sonar"}, + } + })) + + +def _not_nested(monkeypatch): + monkeypatch.delenv("CLAUDECODE", raising=False) + monkeypatch.delenv("CLAUDE_CODE_CHILD_SESSION", raising=False) + + +def test_validate_setup_gates_google_main_on_key(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _seed_google_config(tmp_path) + _not_nested(monkeypatch) + monkeypatch.setattr(mode_recommend.shutil, "which", lambda name: None) + for v in ("GOOGLE_API_KEY", "GEMINI_API_KEY"): + monkeypatch.delenv(v, raising=False) + + result = mode_recommend.validate_setup(provider_mode="hybrid") + provider_main = next(c for c in result["checks"] if c["id"] == "provider_main") + assert provider_main["passed"] is False # google main, no key -> not usable + + +def test_validate_setup_passes_google_main_with_key(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _seed_google_config(tmp_path) + _not_nested(monkeypatch) + monkeypatch.setattr(mode_recommend.shutil, "which", lambda name: None) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.setenv("GOOGLE_API_KEY", "g-key") + + result = mode_recommend.validate_setup(provider_mode="hybrid") + provider_main = next(c for c in result["checks"] if c["id"] == "provider_main") + assert provider_main["passed"] is True From 17f6e4a71b465daa97bd437c19f99f76ec1fbbb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 12:20:45 +0800 Subject: [PATCH 38/73] docs(architecture): add internal engine architecture diagram suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-level, mostly auto-derived map of the prd-taskmaster engine for internal understanding (not linked from the public README): - 00 system overview + 10/11/12 component breakouts (D2) - 20 function/class map, 30 import graph (no cycles), 31 call graph (auto-derived: pyreverse / code2flow / pydeps + AST) - 40 end-to-end runflow, 50-54 per-phase script-vs-LLM swimlanes, 60 parse-PRD sequence (Mermaid / D2) Okabe-Ito legend marks deterministic Python vs LLM/agent steps. Sources in src/, auto-derived text in generated/, rendered SVGs in rendered/ (PNGs gitignored — rebuild with gen.sh). gen.sh reproduces the whole suite end-to-end. Co-Authored-By: Claude Opus 4.8 --- docs/architecture/.gitignore | 2 + docs/architecture/README.md | 72 + docs/architecture/gen-fnmap.py | 51 + docs/architecture/gen.sh | 54 + .../generated/20-module-function-map.md | 378 +++ .../generated/30-import-cycles.txt | 1 + docs/architecture/generated/callflow-core.dot | 560 ++++ docs/architecture/generated/callflow-core.svg | 2325 +++++++++++++++++ .../generated/classes_AtlasEngine.mmd | 42 + .../generated/classes_AtlasEngine.svg | 113 + .../generated/packages_AtlasEngine.mmd | 135 + .../generated/packages_AtlasEngine.svg | 649 +++++ .../rendered/00-system-overview.svg | 856 ++++++ .../10-component-deterministic-core.svg | 144 + .../11-component-backend-provider-engine.svg | 123 + .../12-component-detect-setup-mode.svg | 134 + .../rendered/40-runflow-script-vs-llm.svg | 136 + .../rendered/50-swimlane-setup.svg | 1 + .../rendered/51-swimlane-discover.svg | 1 + .../rendered/52-swimlane-generate.svg | 1 + .../rendered/53-swimlane-handoff.svg | 1 + .../rendered/54-swimlane-execute.svg | 1 + .../60-sequence-generate-parse-prd.svg | 1 + docs/architecture/src/00-system-overview.d2 | 54 + .../src/10-component-deterministic-core.d2 | 128 + .../11-component-backend-provider-engine.d2 | 65 + .../src/12-component-detect-setup-mode.d2 | 104 + .../src/40-runflow-script-vs-llm.d2 | 117 + docs/architecture/src/50-swimlane-setup.mmd | 48 + .../architecture/src/51-swimlane-discover.mmd | 39 + .../architecture/src/52-swimlane-generate.mmd | 49 + docs/architecture/src/53-swimlane-handoff.mmd | 34 + docs/architecture/src/54-swimlane-execute.mmd | 31 + .../src/60-sequence-generate-parse-prd.mmd | 66 + 34 files changed, 6516 insertions(+) create mode 100644 docs/architecture/.gitignore create mode 100644 docs/architecture/README.md create mode 100755 docs/architecture/gen-fnmap.py create mode 100755 docs/architecture/gen.sh create mode 100644 docs/architecture/generated/20-module-function-map.md create mode 100644 docs/architecture/generated/30-import-cycles.txt create mode 100644 docs/architecture/generated/callflow-core.dot create mode 100644 docs/architecture/generated/callflow-core.svg create mode 100644 docs/architecture/generated/classes_AtlasEngine.mmd create mode 100644 docs/architecture/generated/classes_AtlasEngine.svg create mode 100644 docs/architecture/generated/packages_AtlasEngine.mmd create mode 100644 docs/architecture/generated/packages_AtlasEngine.svg create mode 100644 docs/architecture/rendered/00-system-overview.svg create mode 100644 docs/architecture/rendered/10-component-deterministic-core.svg create mode 100644 docs/architecture/rendered/11-component-backend-provider-engine.svg create mode 100644 docs/architecture/rendered/12-component-detect-setup-mode.svg create mode 100644 docs/architecture/rendered/40-runflow-script-vs-llm.svg create mode 100644 docs/architecture/rendered/50-swimlane-setup.svg create mode 100644 docs/architecture/rendered/51-swimlane-discover.svg create mode 100644 docs/architecture/rendered/52-swimlane-generate.svg create mode 100644 docs/architecture/rendered/53-swimlane-handoff.svg create mode 100644 docs/architecture/rendered/54-swimlane-execute.svg create mode 100644 docs/architecture/rendered/60-sequence-generate-parse-prd.svg create mode 100644 docs/architecture/src/00-system-overview.d2 create mode 100644 docs/architecture/src/10-component-deterministic-core.d2 create mode 100644 docs/architecture/src/11-component-backend-provider-engine.d2 create mode 100644 docs/architecture/src/12-component-detect-setup-mode.d2 create mode 100644 docs/architecture/src/40-runflow-script-vs-llm.d2 create mode 100644 docs/architecture/src/50-swimlane-setup.mmd create mode 100644 docs/architecture/src/51-swimlane-discover.mmd create mode 100644 docs/architecture/src/52-swimlane-generate.mmd create mode 100644 docs/architecture/src/53-swimlane-handoff.mmd create mode 100644 docs/architecture/src/54-swimlane-execute.mmd create mode 100644 docs/architecture/src/60-sequence-generate-parse-prd.mmd diff --git a/docs/architecture/.gitignore b/docs/architecture/.gitignore new file mode 100644 index 0000000..69d29e4 --- /dev/null +++ b/docs/architecture/.gitignore @@ -0,0 +1,2 @@ +# Regenerable raster exports — rebuild with ./gen.sh +rendered/*.png diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..c410da5 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,72 @@ +# Atlas engine — architecture diagram suite (INTERNAL) + +A multi-level map of the `prd-taskmaster` engine: the whole system, each component, the code +flow, and — the point of the suite — **what is deterministic Python script vs what is an LLM / +agent step**, per phase. Not linked from the public README; this is for understanding/maintaining +the engine. + +> The engine is **function-heavy**: 28 modules · 78 imports · **309 functions vs only 8 classes**, +> and **no import cycles**. So UML class diagrams are thin; the load-bearing views are the +> **import graph, the call graph, and the per-phase script-vs-LLM swimlanes**. + +## Legend (used in every hand-authored diagram) + +Okabe-Ito colorblind-safe palette + shape redundancy (survives grayscale): + +| kind | color | shape | +|---|---|---| +| **LLM / agent** (output varies run-to-run) | orange `#E69F00` | hexagon | +| **deterministic engine code** (Python) | sky blue `#56B4E9` | rectangle | +| **interface boundary** (MCP / CLI, fail-closed) | blue `#0072B2` | rectangle | +| **external model execution** | green `#009E73` | oval / stadium | +| **gate** (deterministic checkpoint) | yellow `#F0E442` | diamond | +| **human** | purple `#CC79A7` | person | + +Edges: **solid** = deterministic control flow · **dashed** = LLM-routed choice / bounce-back. + +## Toolchain + +- **D2** (`*.d2`) — system overview + component breakouts (drill-down architecture). +- **Mermaid** (`*.mmd`) — per-phase script-vs-LLM swimlanes + the parse-PRD sequence (GitHub-native, diffable). +- **pyreverse / code2flow / pydeps** — auto-derived import / class / call graphs + cycle check (regenerated from source → never drift). +- AST `gen-fnmap.py` — the per-module function-signature map (the real "down to methods" artifact). + +## The suite + +| # | file | level | how | shows | +|---|---|---|---|---| +| 00 | `src/00-system-overview.d2` | system | D2 (hand) | the whole engine in 4 layers: LLM prompts → fail-closed interface (MCP/CLI) → deterministic core → external models | +| 10 | `src/10-component-deterministic-core.d2` | component | D2 (hand) | phase state-machine + gates: pipeline, shipcheck, validation, tasks, task_state, fleet, parallel, lib | +| 11 | `src/11-component-backend-provider-engine.d2` | component | D2 (hand) | the GENERATE engine: backend → resolve_provider (cli/api/plan) → cli_agent / llm_client / plan-floor, wrapped by economy | +| 12 | `src/12-component-detect-setup-mode.d2` | component | D2 (hand) | detection/SETUP: batch → preflight, validate_setup, detect_capabilities, providers, setup_wizard | +| 20 | `generated/20-module-function-map.md` + `generated/classes_AtlasEngine.mmd` | class/fn | auto | every module's functions (signatures) + the 8 real classes | +| 30 | `generated/packages_AtlasEngine.mmd/.svg` + `generated/30-import-cycles.txt` | code-flow | auto | the 28-module / 78-import dependency graph (cycle check: none) | +| 31 | `generated/callflow-core.dot/.svg` | code-flow | auto | function-level call graph across the deterministic spine (13 modules) | +| 40 | `src/40-runflow-script-vs-llm.d2` | code-flow | D2 (hand) | end-to-end `/atlas` run, every step colored script vs LLM, to `SHIP_CHECK_OK` | +| 50 | `src/50-swimlane-setup.mmd` | swimlane | Mermaid (hand) | SETUP — deterministic-dominant | +| 51 | `src/51-swimlane-discover.mmd` | swimlane | Mermaid (hand) | DISCOVER — LLM-dominant | +| 52 | `src/52-swimlane-generate.mmd` | swimlane | Mermaid (hand) | GENERATE — the hybrid crux | +| 53 | `src/53-swimlane-handoff.mmd` | swimlane | Mermaid (hand) | HANDOFF — split (mode pick + routing) | +| 54 | `src/54-swimlane-execute.mmd` | swimlane | Mermaid (hand) | EXECUTE — deterministic-gated, LLM-executed (anti-fake ship gate) | +| 60 | `src/60-sequence-generate-parse-prd.mmd` | sequence | Mermaid (hand) | one parse-PRD call across the three provider tiers | + +Rendered images are in `rendered/`. The `.d2`/`.mmd`/`.md` sources are the source of truth; +`generated/` is regenerate-on-demand (do not hand-edit). + +## Regenerate + +```bash +./docs/architecture/gen.sh +``` + +Needs on PATH: `python3`, `d2`, `mmdc` (`@mermaid-js/mermaid-cli`), `dot` (graphviz), +`rsvg-convert` (librsvg), `chromium`. Analyzers (pylint/pydeps/code2flow) install into a +throwaway `/tmp` venv. + +## Per-phase script-vs-LLM split (summary) + +- **SETUP** — deterministic-dominant: `batch.run_engine_preflight` → `preflight`, `validate_setup` (6 checks), `providers.*`, `setup_wizard` (+ `_live_probe`), `check_gate('SETUP')`. LLM only reads panels + decides. +- **DISCOVER** — LLM-dominant: `superpowers:brainstorming`, adaptive Q&A, constraint extraction, AskUserQuestion. Code = only `check_gate('DISCOVER')`. +- **GENERATE** — hybrid: LLM writes PRD + task JSON; code = `validate_prd` (placeholder HARD FAIL), `resolve_provider`, `backend.parse_prd/expand`, `economy.shift_tier`, `parallel.apply_results`, `validate_tasks`, `check_gate('GENERATE')`. +- **HANDOFF** — split: code = `detect_capabilities`, `fleet.compute_waves/route_task`, `check_gate('HANDOFF')`; LLM recommends one mode + writes the workflow block. +- **EXECUTE** — deterministic-gated: code = `task_state.*` (flock), `shipcheck.py` 5 gates → `SHIP_CHECK_OK` (Gate 5 `EXIT_STATUS_RE` = the anti-fake gate); LLM does the coding + CDD evidence. The model cannot self-declare shipped. diff --git a/docs/architecture/gen-fnmap.py b/docs/architecture/gen-fnmap.py new file mode 100755 index 0000000..f5a3abc --- /dev/null +++ b/docs/architecture/gen-fnmap.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""AUTO-GENERATED helper: emit a per-module function/class signature map (Markdown). +Usage: python3 gen-fnmap.py > generated/20-module-function-map.md +Static AST only — does not import the target package.""" +import ast, sys, pathlib + +root = pathlib.Path(sys.argv[1]) +out = ["# Module -> function / class map", "", + "_AUTO-GENERATED by gen.sh (gen-fnmap.py) — do not edit; regenerate._", ""] + + +def sig(fn: ast.AST) -> str: + a = fn.args + parts = [ar.arg for ar in a.posonlyargs] + [ar.arg for ar in a.args] + if a.vararg: + parts.append("*" + a.vararg.arg) + if a.kwonlyargs and not a.vararg: + parts.append("*") + parts += [ar.arg for ar in a.kwonlyargs] + if a.kwarg: + parts.append("**" + a.kwarg.arg) + ret = "" + try: + if fn.returns is not None: + ret = " -> " + ast.unparse(fn.returns) + except Exception: + ret = "" + return f"{fn.name}({', '.join(parts)}){ret}" + + +for p in sorted(root.glob("*.py")): + if p.name == "__init__.py": + continue + try: + tree = ast.parse(p.read_text()) + except Exception as e: # noqa: BLE001 + out.append(f"## `{p.name}`\n_parse error: {e}_\n") + continue + funcs = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))] + classes = [n for n in tree.body if isinstance(n, ast.ClassDef)] + out.append(f"## `{p.name}` ({len(funcs)} functions, {len(classes)} classes)") + for c in classes: + meths = [m for m in c.body if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef))] + out.append(f"- **class {c.name}** — methods: " + (", ".join(m.name for m in meths) or "(none)")) + if funcs: + out.append("") + for f in funcs: + out.append(f"- `{sig(f)}`") + out.append("") + +print("\n".join(out)) diff --git a/docs/architecture/gen.sh b/docs/architecture/gen.sh new file mode 100755 index 0000000..a6b94aa --- /dev/null +++ b/docs/architecture/gen.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Regenerate the Atlas internal architecture diagram suite. +# - auto-derived graphs (pyreverse / code2flow / pydeps + AST fn-map) -> generated/ +# - hand-authored sources (src/*.d2, src/*.mmd) rendered -> rendered/ +# +# Requires on PATH: python3, d2, mmdc (@mermaid-js/mermaid-cli), dot (graphviz), +# rsvg-convert (librsvg), chromium (for mmdc). +# Analyzers (pylint/pydeps/code2flow) are installed into a throwaway venv at /tmp. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +PKG="$REPO/prd_taskmaster" +SRC="$HERE/src"; GEN="$HERE/generated"; REND="$HERE/rendered" +mkdir -p "$GEN" "$REND" + +VENV=/tmp/atlas-diagrams-venv +python3 -m venv "$VENV" 2>/dev/null || true +"$VENV/bin/pip" install -q --disable-pip-version-check pylint pydeps code2flow + +echo "==> auto-derive (pyreverse / code2flow / fn-map / pydeps)" +"$VENV/bin/pyreverse" -o mmd -p AtlasEngine -d "$GEN" "$PKG/" # 30 import + class (.mmd) +"$VENV/bin/pyreverse" -o svg -p AtlasEngine -d "$GEN" "$PKG/" 2>/dev/null || true +CORE=(pipeline shipcheck validation tasks task_state fleet parallel backend provider_resolver economy preflight mode_recommend lib) +"$VENV/bin/code2flow" --language py -o "$GEN/callflow-core.dot" \ + "${CORE[@]/#/$PKG/}" >/dev/null 2>&1 || \ + "$VENV/bin/code2flow" --language py -o "$GEN/callflow-core.dot" $(printf "%s.py " "${CORE[@]/#/$PKG/}") +# (the line above tolerates code2flow needing explicit .py paths) +dot -Tsvg "$GEN/callflow-core.dot" -o "$GEN/callflow-core.svg" +python3 "$HERE/gen-fnmap.py" "$PKG" > "$GEN/20-module-function-map.md" +"$VENV/bin/pydeps" "$PKG" --no-output --show-cycles > "$GEN/30-import-cycles.txt" 2>&1 || true + +echo "==> puppeteer config for mmdc (system chromium)" +CHROME="$(command -v chromium || command -v chromium-browser || true)" +PUP=/tmp/puppeteer-mmd.json +printf '{ "executablePath": "%s", "args": ["--no-sandbox","--disable-gpu"] }\n' "$CHROME" > "$PUP" + +echo "==> render mermaid (src/*.mmd)" +for m in "$SRC"/*.mmd; do + b="$(basename "$m" .mmd)" + mmdc -p "$PUP" -i "$m" -o "$REND/$b.svg" + rsvg-convert -z 1.4 -o "$REND/$b.png" "$REND/$b.svg" + echo " $b" +done + +echo "==> render d2 (src/*.d2)" +for d in "$SRC"/*.d2; do + b="$(basename "$d" .d2)" + d2 --layout elk "$d" "$REND/$b.svg" + rsvg-convert -z 1.5 -o "$REND/$b.png" "$REND/$b.svg" + echo " $b" +done + +echo "==> done. sources in src/, auto-derived in generated/, images in rendered/." diff --git a/docs/architecture/generated/20-module-function-map.md b/docs/architecture/generated/20-module-function-map.md new file mode 100644 index 0000000..230dbb2 --- /dev/null +++ b/docs/architecture/generated/20-module-function-map.md @@ -0,0 +1,378 @@ +# Module -> function / class map + +_AUTO-GENERATED by gen.sh (gen-fnmap.py) — do not edit; regenerate._ + +## `backend.py` (18 functions, 2 classes) +- **class Backend** — methods: detect, init_project, parse_prd, expand, rate +- **class NativeBackend** — methods: detect, init_project, parse_prd, expand, _expand_packet, _packet_success, rate + +- `_task_id_set(task_ids) -> set[str] | None` +- `_load_tasks(tag) -> tuple[str, list[dict]]` +- `_pending_tasks(tag, task_ids) -> tuple[str, list[dict]]` +- `_read_json(path) -> dict | None` +- `_candidate_tasks(candidate) -> list[dict]` +- `_validate_task_candidate(candidate) -> tuple[list[dict], dict]` +- `_load_existing_tagged() -> dict` +- `_write_tasks_into_tag(tasks, tag) -> str` +- `_native_concurrency(work_count, fleet_config, profile) -> int` +- `_task_summaries(tasks) -> list[dict]` +- `_complexity_report_path(tag) -> Path` +- `_agent_parse_action(prd_path, num_tasks, tag) -> dict` +- `_agent_expand_action(tag, task_ids, packets) -> dict` +- `_agent_rate_action(tag, summaries) -> dict` +- `_report_candidates(tag) -> list[Path]` +- `_cli_timeout(config) -> int` +- `_cli_structured_mode(config) -> str` +- `get_backend(cfg) -> Backend` + +## `batch.py` (6 functions, 0 classes) + +- `_backend_source() -> str` +- `_backend_ai_ops(native_detect) -> str` +- `_backend_block() -> dict` +- `_backend_summary(block) -> str` +- `run_engine_preflight(configure) -> dict` +- `cmd_engine_preflight(args) -> None` + +## `capabilities.py` (2 functions, 0 classes) + +- `run_detect_capabilities() -> dict` +- `cmd_detect_capabilities(args) -> None` + +## `cli.py` (20 functions, 0 classes) + +- `_backend_source() -> str` +- `_ai_ops(native_detect) -> str` +- `_taskmaster_file_format_detect() -> dict` +- `run_backend_detect() -> dict` +- `_selected_backend()` +- `run_init_project() -> dict` +- `run_parse_prd(input_path, num_tasks, tag) -> dict` +- `run_expand(task_ids, research, tag) -> dict` +- `run_rate(tag, research) -> dict` +- `_emit_with_status(result) -> None` +- `_cmd_backend_call(fn, *args, **kwargs) -> None` +- `cmd_backend_detect(args) -> None` +- `cmd_init_project(args) -> None` +- `cmd_parse_prd(args) -> None` +- `cmd_expand(args) -> None` +- `cmd_rate(args) -> None` +- `cmd_context_pack(args) -> None` +- `build_parser() -> argparse.ArgumentParser` +- `cmd_status(args) -> None` +- `main()` + +## `cli_agent.py` (7 functions, 1 classes) +- **class CliAgentError** — methods: __init__ + +- `_build_argv(provider, binary, prompt, *, schema_hint, structured_json)` +- `_telemetry(op_class, task_id, model, exit_code, start, parse_retry)` +- `_parse_claude_envelope(stdout)` +- `_claude_error_detail(stdout, stderr)` +- `_run_once(provider, binary, prompt, *, schema_hint, structured_json, model, op_class, task_id, timeout, parse_retry)` +- `_has_schema_flag(provider)` +- `generate_json_via_cli(provider, prompt, *, system, schema_hint, model, op_class, task_id, timeout, structured_json)` + +## `context_pack.py` (9 functions, 0 classes) + +- `build_context_pack(paths, include_private) -> dict` +- `_class_entry(node, source, include_private) -> dict` +- `_callable_entry(node, source) -> dict` +- `_filtered(name, include_private) -> bool` +- `_is_private(name) -> bool` +- `_signature_text(node, source) -> str` +- `_signature_from_segment(segment) -> str` +- `_line_starts(text) -> list[int]` +- `_index(line_starts, position) -> int` + +## `economy.py` (9 functions, 0 classes) + +- `shift_tier(tier, steps, floor, ceiling)` +- `economy_profile(cfg)` +- `append_telemetry(row, path)` +- `_token_int(value)` +- `_price_key_for_model(model)` +- `_estimate_cost_usd(tokens_in, tokens_out, rates)` +- `_summarize_costs(rows)` +- `summarize_telemetry(path)` +- `cmd_economy_report(args)` + +## `feedback.py` (10 functions, 0 classes) + +- `_error(message, **extra) -> dict` +- `_is_number(value) -> bool` +- `_normalize_text(row, field) -> str | dict` +- `_normalize_feedback_row(row) -> dict` +- `append_feedback(row, path)` +- `_valid_rating(value) -> bool` +- `summarize_feedback(path)` +- `_emit_result(result) -> None` +- `cmd_feedback_add(args) -> None` +- `cmd_feedback_report(args) -> None` + +## `fleet.py` (21 functions, 0 classes) + +- `_atlas_config_economy() -> str | None` +- `_is_pos_int(value)` +- `engine_config(cfg)` +- `save_engine_config(updates)` +- `load_fleet_config(path)` +- `available_backends()` +- `task_tier(task)` +- `_code_impl_shift_bounds(profile)` +- `route_task(task, config, backends, attempt)` +- `resolve_backend(tier, config)` +- `_task_id(task)` +- `_status(task)` +- `_is_done(task)` +- `_is_pending(task)` +- `_dependencies(task)` +- `_chunk(items, size)` +- `_load_tagged_or_raise(tag)` +- `ready_set(tasks)` +- `compute_waves(tasks, max_concurrency)` +- `run_fleet_waves(concurrency, tag)` +- `cmd_fleet_waves(args)` + +## `lib.py` (25 functions, 1 classes) +- **class CommandError** — methods: __init__ + +- `emit(data) -> None` +- `fail(message, **extra) -> None` +- `now_iso() -> str` +- `atomic_write(path, content) -> None` +- `locked_update(path, transform) -> str` +- `emit_json_error(message, **extra) -> dict` +- `read_json(path) -> dict` +- `write_json(path, data) -> None` +- `word_count(text) -> int` +- `count_requirements(text) -> int` +- `has_section(text, heading) -> bool` +- `get_section_content(text, heading) -> str` +- `_detect_taskmaster_method() -> dict` +- `_read_taskmaster_model(role) -> dict` +- `_read_taskmaster_config() -> dict` +- `_write_taskmaster_config(config) -> None` +- `_local_port_open(host, port, timeout) -> bool` +- `_env_file_has_key(env_path, key) -> bool` +- `_ensure_env_entry(env_path, key, value, comment) -> bool` +- `_read_env_file_value(env_path, key) -> str | None` +- `_detect_perplexity_mcp() -> str | None` +- `_is_local_perplexity_free(model) -> bool` +- `_current_taskmaster_tag() -> str` +- `_resolve_tasks_payload(raw, tag) -> tuple[list | None, object]` +- `_read_execution_state() -> dict` + +## `llm_client.py` (10 functions, 1 classes) +- **class LLMError** — methods: __init__ + +- `_sleep(seconds)` +- `_env_or_dotenv(name)` +- `discover_key()` +- `_resolve_model(model, tier, provider)` +- `_extract_json(text)` +- `_usage_int(value)` +- `_usage_fields(provider, data)` +- `_http_call(creds, model, system, prompt, max_tokens, timeout)` +- `generate_json(prompt, *, system, schema_hint, model, tier, max_tokens, timeout, op_class, task_id, return_telemetry_ref)` +- `_telemetry(op_class, task_id, model, exit_code, start, parse_retry, http_status, usage)` + +## `mode_recommend.py` (8 functions, 0 classes) + +- `_parse_version(v) -> tuple[int, int, int]` +- `_check_taskmaster_version(cli_path) -> dict` +- `_safe_call(fn) -> bool` +- `_mcp_config_has_server(config_path, server_name, *, allow_top_level) -> bool` +- `detect_atlas_launcher() -> dict` +- `detect_taskmaster() -> dict` +- `detect_capabilities() -> dict` +- `validate_setup(provider_mode) -> dict` + +## `parallel.py` (14 functions, 0 classes) + +- `out(payload)` +- `fail(msg)` +- `_resolve_tag(tag_or_args)` +- `current_tag(args)` +- `load_tagged(tag)` +- `get_tasks(raw, tag)` +- `write_atomic(path, payload)` +- `build_packets(tasks, missing_only) -> list` +- `cmd_plan(args)` +- `apply_results(results, tag, threshold) -> dict` +- `cmd_apply(args)` +- `cmd_extract(args)` +- `cmd_inject(args)` +- `main()` + +## `pipeline.py` (12 functions, 2 classes) +- **class _CASMiss** — methods: __init__ +- **class _IllegalTransition** — methods: __init__ + +- `_load_state() -> dict` +- `current_phase() -> dict` +- `advance_phase(expected_current, target, evidence) -> dict` +- `check_gate(phase, evidence) -> dict` +- `_read_taskmaster_state() -> dict` +- `_read_taskmaster_tasks() -> dict` +- `_tag_task_lists(tasks) -> dict[str, list[dict]]` +- `_count_tasks(items) -> dict[str, int]` +- `_current_tag(state, tag_lists) -> str` +- `_fresh_tag(existing) -> str` +- `_recommended_pending_tag(tag_counts) -> str | None` +- `preflight(cwd) -> dict` + +## `preflight.py` (4 functions, 0 classes) + +- `run_preflight() -> dict` +- `cmd_preflight(args) -> None` +- `run_detect_taskmaster() -> dict` +- `cmd_detect_taskmaster(args) -> None` + +## `provider_resolver.py` (5 functions, 1 classes) +- **class ProviderHandle** — methods: (none) + +- `_usability_facts() -> dict` +- `_try_cli(role, provider, model, ttl_s) -> ProviderHandle | None` +- `_try_api(role, model) -> ProviderHandle | None` +- `_plan_floor(role, model, reason) -> ProviderHandle` +- `resolve_provider(role, op_class, *, fleet_config) -> ProviderHandle` + +## `providers.py` (18 functions, 0 classes) + +- `_role_empty(value) -> bool` +- `_provider_usable(provider, *, has_claude, has_codex, has_anthropic_key, has_openai_key, has_perplexity_key, has_google_key) -> bool` +- `_is_stock_taskmaster_default(role) -> bool` +- `_is_nested_claude() -> bool` +- `_is_spawning_provider(provider) -> bool` +- `_probe_spawn(provider) -> bool` +- `_probe_spawn_cached(provider, ttl_s) -> bool` +- `_resolve_configure_profile(economy) -> dict` +- `_desired_main_model(has_claude, has_codex, has_anthropic_key) -> dict | None` +- `_desired_fallback_model(has_claude, has_codex, has_anthropic_key) -> dict | None` +- `_main_model_for_start_tier(model, tier) -> dict` +- `_has_perplexity_api_key() -> bool` +- `_perplexity_research_model(model_id) -> dict` +- `_local_proxy_research_model(local_proxy_url) -> dict` +- `run_configure_providers(economy) -> dict` +- `cmd_configure_providers(args) -> None` +- `run_detect_providers() -> dict` +- `cmd_detect_providers(args) -> None` + +## `render.py` (17 functions, 0 classes) + +- `_display_width(s) -> int` +- `_clip(s, width) -> str` +- `_ascii_mode() -> bool` +- `_g(name, ascii_mode) -> str` +- `_b(name, ascii_mode) -> str` +- `grade_word(percentage) -> str` +- `bar(score, maximum, segments, *, ascii_mode) -> str` +- `box(title, lines, *, ascii_mode, width) -> str` +- `oneline_summary(validation, task_counts, *, ascii_mode) -> str` +- `phase_header_title(phase) -> str` +- `phase_tracker(state, *, ascii_mode) -> str` +- `validation_scorecard(validation, task_counts, *, ascii_mode) -> str` +- `preflight_panel(preflight, *, ascii_mode) -> str` +- `shipcheck_panel(shipcheck, *, ascii_mode) -> str` +- `_gate_tokens(i) -> list[str]` +- `execute_panel(task_counts, *, ascii_mode) -> str` +- `handoff_panel(validation, task_counts, *, ascii_mode) -> str` + +## `setup_wizard.py` (11 functions, 0 classes) + +- `_env_flag(name) -> bool` +- `_detect_line() -> str` +- `_recommend() -> dict` +- `_panel(recommendation, caps) -> list[str]` +- `_validate(mode) -> dict` +- `_live_probe(provider) -> dict` +- `_run_validate_step(recommendation, mode) -> dict` +- `_has_spawning_cli() -> bool` +- `add_key(var, ask_value, ask_keyless) -> dict` +- `run_setup(accept_default, validate_only, choose) -> dict` +- `cmd_setup(args) -> None` + +## `shipcheck.py` (10 functions, 0 classes) + +- `gate_pipeline(atlas) -> Tuple[bool, List[str]]` +- `gate_tasks(repo_root) -> Tuple[bool, List[str], list]` +- `_has_card_for(cdd_dir, tid) -> bool` +- `gate_cdd(atlas, tasks) -> Tuple[bool, List[str]]` +- `gate_plan(repo_root) -> Tuple[bool, List[str]]` +- `gate_exit_codes(atlas) -> Tuple[bool, List[str]]` +- `log_override(atlas, message) -> None` +- `run_all_gates(repo_root, override_active) -> Tuple[bool, List[str]]` +- `run_ship_check(cwd, dry_run, override) -> dict` +- `main(argv) -> int` + +## `status.py` (4 functions, 0 classes) + +- `_subtask_count(items) -> int` +- `_task_counts() -> dict | None` +- `_validation() -> dict | None` +- `run_render_status(phase, fmt, show_all) -> dict` + +## `suggestions.py` (6 functions, 0 classes) + +- `_store_path(path) -> Path` +- `_error(message, **extra) -> dict` +- `_is_number(value) -> bool` +- `_normalize_suggestion_row(row) -> dict` +- `append_suggestion(row, path)` +- `summarize_suggestions(path)` + +## `task_state.py` (19 functions, 0 classes) + +- `_priority_rank(task) -> int` +- `_sortable_id(value) -> tuple[int, int | str]` +- `_status(item) -> str` +- `_dependencies(item) -> list` +- `_resolve_tasks(tag) -> tuple[str, dict, str | None, list[dict]]` +- `_tag_key_for_raw(raw, tag) -> str | None` +- `_ready_subtask(parent) -> dict | None` +- `_subtask_envelope(parent, subtask) -> dict` +- `_in_progress_candidates(tasks) -> list[dict]` +- `_ready_candidates(tasks, ready_ids) -> list[dict]` +- `_select_next_task(resolved_tag, tasks) -> dict` +- `run_next_task(tag) -> dict` +- `_claim_selected_task(tasks, selected) -> dict` +- `run_claim_task(tag) -> dict` +- `_split_id(id_str) -> tuple[str, str | None]` +- `run_set_status(id_str, status, tag) -> dict` +- `cmd_next_task(args) -> None` +- `cmd_claim_task(args) -> None` +- `cmd_set_status(args) -> None` + +## `taskmaster.py` (5 functions, 0 classes) + +- `_build_env() -> dict` +- `_find_binary() -> str | None` +- `init_taskmaster(method) -> dict` +- `cmd_init_taskmaster(args) -> None` +- `detect_taskmaster_method() -> str` + +## `tasks.py` (8 functions, 0 classes) + +- `run_calc_tasks(requirements, scale) -> dict` +- `cmd_calc_tasks(args) -> None` +- `run_backup_prd(input_path) -> dict` +- `cmd_backup_prd(args) -> None` +- `_classify_task(task) -> dict` +- `_generate_acceptance_criteria(task, complexity) -> list` +- `run_enrich_tasks(input_path, tag) -> dict` +- `cmd_enrich_tasks(args) -> None` + +## `templates.py` (2 functions, 0 classes) + +- `run_load_template(template_type) -> dict` +- `cmd_load_template(args) -> None` + +## `validation.py` (5 functions, 0 classes) + +- `run_validate_prd(input_path) -> dict` +- `_persist_validation(result) -> None` +- `cmd_validate_prd(args) -> None` +- `run_validate_tasks(input_path, allow_empty_subtasks, require_phase_config, tag) -> dict` +- `cmd_validate_tasks(args) -> None` + diff --git a/docs/architecture/generated/30-import-cycles.txt b/docs/architecture/generated/30-import-cycles.txt new file mode 100644 index 0000000..21887d0 --- /dev/null +++ b/docs/architecture/generated/30-import-cycles.txt @@ -0,0 +1 @@ +# pydeps --show-cycles over prd_taskmaster: no import cycles detected. diff --git a/docs/architecture/generated/callflow-core.dot b/docs/architecture/generated/callflow-core.dot new file mode 100644 index 0000000..b477cf5 --- /dev/null +++ b/docs/architecture/generated/callflow-core.dot @@ -0,0 +1,560 @@ +digraph G { +concentrate=true; +splines="ortho"; +rankdir="LR"; +subgraph legend{ + rank = min; + label = "legend"; + Legend [shape=none, margin=0, label = < +
Code2flow Legend
+ + + + + +
Regular function
Trunk function (nothing calls this)
Leaf function (this calls nothing else)
Function call
+ >]; +}node_015bbe25 [label="501: _expand_packet()" name="backend::NativeBackend._expand_packet" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_f1140762 [label="607: _packet_success()" name="backend::NativeBackend._packet_success" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_96821269 [label="440: expand()" name="backend::NativeBackend.expand" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_6c351af6 [label="343: parse_prd()" name="backend::NativeBackend.parse_prd" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_c464c120 [label="625: rate()" name="backend::NativeBackend.rate" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_cced52be [label="251: _agent_expand_action()" name="backend::_agent_expand_action" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_337797aa [label="240: _agent_parse_action()" name="backend::_agent_parse_action" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_46c63267 [label="267: _agent_rate_action()" name="backend::_agent_rate_action" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_0b0dc172 [label="157: _candidate_tasks()" name="backend::_candidate_tasks" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_8dcb74ff [label="299: _cli_structured_mode()" name="backend::_cli_structured_mode" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_1c764779 [label="295: _cli_timeout()" name="backend::_cli_timeout" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_03502d3a [label="235: _complexity_report_path()" name="backend::_complexity_report_path" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_b3542df0 [label="183: _load_existing_tagged()" name="backend::_load_existing_tagged" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_53230e82 [label="130: _load_tasks()" name="backend::_load_tasks" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_e72cbbc2 [label="204: _native_concurrency()" name="backend::_native_concurrency" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_cab4cce3 [label="136: _pending_tasks()" name="backend::_pending_tasks" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_b506da62 [label="122: _task_id_set()" name="backend::_task_id_set" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_1eb7e5b5 [label="218: _task_summaries()" name="backend::_task_summaries" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_59dd988f [label="170: _validate_task_candidate()" name="backend::_validate_task_candidate" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_7c8b1579 [label="195: _write_tasks_into_tag()" name="backend::_write_tasks_into_tag" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_4a59f6f7 [label="136: _estimate_cost_usd()" name="economy::_estimate_cost_usd" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_dc97aa28 [label="125: _price_key_for_model()" name="economy::_price_key_for_model" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_8ca8203f [label="141: _summarize_costs()" name="economy::_summarize_costs" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_7a008da0 [label="119: _token_int()" name="economy::_token_int" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_beb043e2 [label="95: append_telemetry()" name="economy::append_telemetry" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_bcfd79f0 [label="242: cmd_economy_report()" name="economy::cmd_economy_report" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_852a6e4c [label="73: economy_profile()" name="economy::economy_profile" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_8050206b [label="62: shift_tier()" name="economy::shift_tier" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_b53bcf8a [label="180: summarize_telemetry()" name="economy::summarize_telemetry" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_afee3412 [label="79: _atlas_config_economy()" name="fleet::_atlas_config_economy" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_9c67934f [label="364: _chunk()" name="fleet::_chunk" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_ae53abce [label="297: _code_impl_shift_bounds()" name="fleet::_code_impl_shift_bounds" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_3169723f [label="360: _dependencies()" name="fleet::_dependencies" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_bdd602cf [label="352: _is_done()" name="fleet::_is_done" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_3d6a13b1 [label="356: _is_pending()" name="fleet::_is_pending" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_095b5e91 [label="97: _is_pos_int()" name="fleet::_is_pos_int" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_1b951c53 [label="368: _load_tagged_or_raise()" name="fleet::_load_tagged_or_raise" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_50a1ddf5 [label="348: _status()" name="fleet::_status" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_33f3a237 [label="344: _task_id()" name="fleet::_task_id" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_b549f0a3 [label="274: available_backends()" name="fleet::available_backends" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_7a2c53d3 [label="473: cmd_fleet_waves()" name="fleet::cmd_fleet_waves" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_ce645b30 [label="394: compute_waves()" name="fleet::compute_waves" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_c61d14f6 [label="102: engine_config()" name="fleet::engine_config" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_dd756297 [label="196: load_fleet_config()" name="fleet::load_fleet_config" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_6bd79f77 [label="383: ready_set()" name="fleet::ready_set" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_e0f7ae2e [label="328: resolve_backend()" name="fleet::resolve_backend" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_bcb52703 [label="306: route_task()" name="fleet::route_task" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_f0a0926b [label="433: run_fleet_waves()" name="fleet::run_fleet_waves" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_4e3b783a [label="156: save_engine_config()" name="fleet::save_engine_config" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_af906479 [label="285: task_tier()" name="fleet::task_tier" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_946f29f6 [label="53: __init__()" name="lib::CommandError.__init__" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_3a2c6e67 [label="337: _current_taskmaster_tag()" name="lib::_current_taskmaster_tag" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_f6e2fb66 [label="165: _detect_taskmaster_method()" name="lib::_detect_taskmaster_method" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_9d4f8e24 [label="275: _ensure_env_entry()" name="lib::_ensure_env_entry" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_162f6de3 [label="268: _env_file_has_key()" name="lib::_env_file_has_key" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_8478cefe [label="400: _read_execution_state()" name="lib::_read_execution_state" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_5d9a2b00 [label="223: _read_taskmaster_model()" name="lib::_read_taskmaster_model" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_ec6290a9 [label="352: _resolve_tasks_payload()" name="lib::_resolve_tasks_payload" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_03be3e25 [label="69: atomic_write()" name="lib::atomic_write" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_0d5b9b97 [label="117: count_requirements()" name="lib::count_requirements" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_de4ff705 [label="33: emit()" name="lib::emit" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_b59dfc10 [label="95: emit_json_error()" name="lib::emit_json_error" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_40250b05 [label="128: get_section_content()" name="lib::get_section_content" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_d7f3f280 [label="122: has_section()" name="lib::has_section" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_46f934ce [label="78: locked_update()" name="lib::locked_update" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_3dd96d9f [label="61: now_iso()" name="lib::now_iso" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_996cd29d [label="100: read_json()" name="lib::read_json" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_ec9848d3 [label="113: word_count()" name="lib::word_count" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_18602a47 [label="108: write_json()" name="lib::write_json" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_dcd94f4c [label="56: _check_taskmaster_version()" name="mode_recommend::_check_taskmaster_version" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_08b8e045 [label="109: _mcp_config_has_server()" name="mode_recommend::_mcp_config_has_server" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_55755815 [label="40: _parse_version()" name="mode_recommend::_parse_version" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_7f270ce4 [label="101: _safe_call()" name="mode_recommend::_safe_call" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_ed45feea [label="139: detect_atlas_launcher()" name="mode_recommend::detect_atlas_launcher" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_288b23cc [label="220: detect_capabilities()" name="mode_recommend::detect_capabilities" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_4a95057d [label="168: detect_taskmaster()" name="mode_recommend::detect_taskmaster" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_119f79ba [label="370: validate_setup()" name="mode_recommend::validate_setup" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_22bc972f [label="0: (global)()" name="parallel::(global)" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_361d8e74 [label="54: _resolve_tag()" name="parallel::_resolve_tag" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_ba0e4460 [label="125: apply_results()" name="parallel::apply_results" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_9f271c70 [label="89: build_packets()" name="parallel::build_packets" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_41b5565a [label="185: cmd_apply()" name="parallel::cmd_apply" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_73895360 [label="192: cmd_extract()" name="parallel::cmd_extract" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_9239b15a [label="200: cmd_inject()" name="parallel::cmd_inject" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_27e8bdc0 [label="117: cmd_plan()" name="parallel::cmd_plan" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_70ae6e31 [label="63: current_tag()" name="parallel::current_tag" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_ed9d25e2 [label="49: fail()" name="parallel::fail" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_f32885c0 [label="79: get_tasks()" name="parallel::get_tasks" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_ed51d56f [label="67: load_tagged()" name="parallel::load_tagged" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_72d63ed3 [label="214: main()" name="parallel::main" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_d03c6573 [label="45: out()" name="parallel::out" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_6534447f [label="83: write_atomic()" name="parallel::write_atomic" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_dee7e970 [label="256: __init__()" name="pipeline::_CASMiss.__init__" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_806e4187 [label="259: __init__()" name="pipeline::_IllegalTransition.__init__" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_ab2d0df0 [label="148: _count_tasks()" name="pipeline::_count_tasks" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_24142b43 [label="158: _current_tag()" name="pipeline::_current_tag" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_0e230765 [label="167: _fresh_tag()" name="pipeline::_fresh_tag" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_082a778e [label="33: _load_state()" name="pipeline::_load_state" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_0889fc74 [label="118: _read_taskmaster_state()" name="pipeline::_read_taskmaster_state" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_91aeba13 [label="127: _read_taskmaster_tasks()" name="pipeline::_read_taskmaster_tasks" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_0dec453f [label="175: _recommended_pending_tag()" name="pipeline::_recommended_pending_tag" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_b829a1a5 [label="137: _tag_task_lists()" name="pipeline::_tag_task_lists" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_704323d0 [label="49: advance_phase()" name="pipeline::advance_phase" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_8f7e4dd4 [label="39: current_phase()" name="pipeline::current_phase" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_7955eacd [label="187: preflight()" name="pipeline::preflight" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_321eccd1 [label="90: cmd_detect_taskmaster()" name="preflight::cmd_detect_taskmaster" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_badd6759 [label="77: cmd_preflight()" name="preflight::cmd_preflight" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_bdafb9b8 [label="84: run_detect_taskmaster()" name="preflight::run_detect_taskmaster" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_739b9bad [label="20: run_preflight()" name="preflight::run_preflight" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_3332348c [label="79: _plan_floor()" name="provider_resolver::_plan_floor" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_5609db09 [label="68: _try_api()" name="provider_resolver::_try_api" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_5802af96 [label="54: _try_cli()" name="provider_resolver::_try_cli" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_b28d07f1 [label="42: _usability_facts()" name="provider_resolver::_usability_facts" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_5ccd8a5f [label="83: resolve_provider()" name="provider_resolver::resolve_provider" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_aef9a9e0 [label="0: (global)()" name="shipcheck::(global)" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_9daf3d17 [label="101: _has_card_for()" name="shipcheck::_has_card_for" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_dcddb8a7 [label="118: gate_cdd()" name="shipcheck::gate_cdd" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_6bdc25a1 [label="141: gate_exit_codes()" name="shipcheck::gate_exit_codes" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_860c7ac6 [label="61: gate_pipeline()" name="shipcheck::gate_pipeline" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_22c7fbb8 [label="132: gate_plan()" name="shipcheck::gate_plan" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_166a5a71 [label="74: gate_tasks()" name="shipcheck::gate_tasks" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_99615e9b [label="166: log_override()" name="shipcheck::log_override" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_959ccd53 [label="280: main()" name="shipcheck::main" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_14f57ab6 [label="180: run_all_gates()" name="shipcheck::run_all_gates" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_1ace7292 [label="208: run_ship_check()" name="shipcheck::run_ship_check" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_fa2537f0 [label="161: _claim_selected_task()" name="task_state::_claim_selected_task" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_85154c93 [label="40: _dependencies()" name="task_state::_dependencies" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_cd506363 [label="96: _in_progress_candidates()" name="task_state::_in_progress_candidates" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_4f44f5be [label="25: _priority_rank()" name="task_state::_priority_rank" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_a783286a [label="109: _ready_candidates()" name="task_state::_ready_candidates" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_0f7d8536 [label="65: _ready_subtask()" name="task_state::_ready_subtask" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_c89f856b [label="45: _resolve_tasks()" name="task_state::_resolve_tasks" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_eb6c47cd [label="122: _select_next_task()" name="task_state::_select_next_task" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_873601f4 [label="29: _sortable_id()" name="task_state::_sortable_id" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_7c6a623c [label="221: _split_id()" name="task_state::_split_id" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_cce708e5 [label="36: _status()" name="task_state::_status" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_faa34b75 [label="83: _subtask_envelope()" name="task_state::_subtask_envelope" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_cc660e06 [label="57: _tag_key_for_raw()" name="task_state::_tag_key_for_raw" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_12259c7b [label="297: cmd_claim_task()" name="task_state::cmd_claim_task" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_c41dc369 [label="290: cmd_next_task()" name="task_state::cmd_next_task" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_fd98ecb0 [label="304: cmd_set_status()" name="task_state::cmd_set_status" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_6d9054a6 [label="181: run_claim_task()" name="task_state::run_claim_task" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_cb095ffd [label="155: run_next_task()" name="task_state::run_next_task" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_354f2337 [label="230: run_set_status()" name="task_state::run_set_status" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_aa98bfc2 [label="135: _classify_task()" name="tasks::_classify_task" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_c61f9f91 [label="180: _generate_acceptance_criteria()" name="tasks::_generate_acceptance_criteria" shape="rect" style="rounded,filled" fillcolor="#6db33f" ]; +node_4e896d64 [label="86: cmd_backup_prd()" name="tasks::cmd_backup_prd" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_86415f94 [label="59: cmd_calc_tasks()" name="tasks::cmd_calc_tasks" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_f97b5e96 [label="294: cmd_enrich_tasks()" name="tasks::cmd_enrich_tasks" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_27779b2b [label="66: run_backup_prd()" name="tasks::run_backup_prd" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_78043230 [label="30: run_calc_tasks()" name="tasks::run_calc_tasks" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_76be2ca1 [label="229: run_enrich_tasks()" name="tasks::run_enrich_tasks" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_b1831293 [label="338: _persist_validation()" name="validation::_persist_validation" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_b58afb0f [label="350: cmd_validate_prd()" name="validation::cmd_validate_prd" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_92879697 [label="525: cmd_validate_tasks()" name="validation::cmd_validate_tasks" shape="rect" style="rounded,filled" fillcolor="#966F33" ]; +node_d590851f [label="37: run_validate_prd()" name="validation::run_validate_prd" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_9431b506 [label="362: run_validate_tasks()" name="validation::run_validate_tasks" shape="rect" style="rounded,filled" fillcolor="#cccccc" ]; +node_015bbe25 -> node_f1140762 [color="#0072B2" penwidth="2"]; +node_015bbe25 -> node_f1140762 [color="#0072B2" penwidth="2"]; +node_015bbe25 -> node_f1140762 [color="#0072B2" penwidth="2"]; +node_015bbe25 -> node_8dcb74ff [color="#0072B2" penwidth="2"]; +node_015bbe25 -> node_1c764779 [color="#0072B2" penwidth="2"]; +node_015bbe25 -> node_beb043e2 [color="#0072B2" penwidth="2"]; +node_015bbe25 -> node_8050206b [color="#0072B2" penwidth="2"]; +node_015bbe25 -> node_3dd96d9f [color="#0072B2" penwidth="2"]; +node_96821269 -> node_cced52be [color="#E69F00" penwidth="2"]; +node_96821269 -> node_e72cbbc2 [color="#E69F00" penwidth="2"]; +node_96821269 -> node_cab4cce3 [color="#E69F00" penwidth="2"]; +node_96821269 -> node_852a6e4c [color="#E69F00" penwidth="2"]; +node_96821269 -> node_5ccd8a5f [color="#E69F00" penwidth="2"]; +node_6c351af6 -> node_337797aa [color="#D55E00" penwidth="2"]; +node_6c351af6 -> node_337797aa [color="#D55E00" penwidth="2"]; +node_6c351af6 -> node_337797aa [color="#D55E00" penwidth="2"]; +node_6c351af6 -> node_8dcb74ff [color="#D55E00" penwidth="2"]; +node_6c351af6 -> node_1c764779 [color="#D55E00" penwidth="2"]; +node_6c351af6 -> node_59dd988f [color="#D55E00" penwidth="2"]; +node_6c351af6 -> node_7c8b1579 [color="#D55E00" penwidth="2"]; +node_6c351af6 -> node_852a6e4c [color="#D55E00" penwidth="2"]; +node_6c351af6 -> node_5ccd8a5f [color="#D55E00" penwidth="2"]; +node_c464c120 -> node_46c63267 [color="#000000" penwidth="2"]; +node_c464c120 -> node_46c63267 [color="#000000" penwidth="2"]; +node_c464c120 -> node_46c63267 [color="#000000" penwidth="2"]; +node_c464c120 -> node_8dcb74ff [color="#000000" penwidth="2"]; +node_c464c120 -> node_1c764779 [color="#000000" penwidth="2"]; +node_c464c120 -> node_03502d3a [color="#000000" penwidth="2"]; +node_c464c120 -> node_53230e82 [color="#000000" penwidth="2"]; +node_c464c120 -> node_1eb7e5b5 [color="#000000" penwidth="2"]; +node_c464c120 -> node_852a6e4c [color="#000000" penwidth="2"]; +node_c464c120 -> node_5ccd8a5f [color="#000000" penwidth="2"]; +node_0b0dc172 -> node_946f29f6 [color="#56B4E9" penwidth="2"]; +node_cab4cce3 -> node_53230e82 [color="#009E73" penwidth="2"]; +node_cab4cce3 -> node_b506da62 [color="#009E73" penwidth="2"]; +node_59dd988f -> node_0b0dc172 [color="#CC79A7" penwidth="2"]; +node_59dd988f -> node_9431b506 [color="#CC79A7" penwidth="2"]; +node_7c8b1579 -> node_b3542df0 [color="#E69F00" penwidth="2"]; +node_8ca8203f -> node_4a59f6f7 [color="#CC79A7" penwidth="2"]; +node_8ca8203f -> node_4a59f6f7 [color="#CC79A7" penwidth="2"]; +node_8ca8203f -> node_dc97aa28 [color="#CC79A7" penwidth="2"]; +node_8ca8203f -> node_7a008da0 [color="#CC79A7" penwidth="2"]; +node_8ca8203f -> node_7a008da0 [color="#CC79A7" penwidth="2"]; +node_beb043e2 -> node_46f934ce [color="#56B4E9" penwidth="2"]; +node_bcfd79f0 -> node_b53bcf8a [color="#000000" penwidth="2"]; +node_bcfd79f0 -> node_de4ff705 [color="#000000" penwidth="2"]; +node_b53bcf8a -> node_8ca8203f [color="#56B4E9" penwidth="2"]; +node_bdd602cf -> node_50a1ddf5 [color="#CC79A7" penwidth="2"]; +node_3d6a13b1 -> node_50a1ddf5 [color="#E69F00" penwidth="2"]; +node_1b951c53 -> node_946f29f6 [color="#009E73" penwidth="2"]; +node_1b951c53 -> node_946f29f6 [color="#009E73" penwidth="2"]; +node_1b951c53 -> node_946f29f6 [color="#009E73" penwidth="2"]; +node_7a2c53d3 -> node_f0a0926b [color="#009E73" penwidth="2"]; +node_7a2c53d3 -> node_de4ff705 [color="#009E73" penwidth="2"]; +node_ce645b30 -> node_9c67934f [color="#000000" penwidth="2"]; +node_ce645b30 -> node_3169723f [color="#000000" penwidth="2"]; +node_ce645b30 -> node_bdd602cf [color="#000000" penwidth="2"]; +node_ce645b30 -> node_3d6a13b1 [color="#000000" penwidth="2"]; +node_ce645b30 -> node_33f3a237 [color="#000000" penwidth="2"]; +node_ce645b30 -> node_33f3a237 [color="#000000" penwidth="2"]; +node_ce645b30 -> node_33f3a237 [color="#000000" penwidth="2"]; +node_c61d14f6 -> node_095b5e91 [color="#D55E00" penwidth="2"]; +node_c61d14f6 -> node_095b5e91 [color="#D55E00" penwidth="2"]; +node_c61d14f6 -> node_095b5e91 [color="#D55E00" penwidth="2"]; +node_c61d14f6 -> node_095b5e91 [color="#D55E00" penwidth="2"]; +node_dd756297 -> node_afee3412 [color="#CC79A7" penwidth="2"]; +node_dd756297 -> node_c61d14f6 [color="#CC79A7" penwidth="2"]; +node_dd756297 -> node_c61d14f6 [color="#CC79A7" penwidth="2"]; +node_6bd79f77 -> node_3169723f [color="#CC79A7" penwidth="2"]; +node_6bd79f77 -> node_bdd602cf [color="#CC79A7" penwidth="2"]; +node_6bd79f77 -> node_3d6a13b1 [color="#CC79A7" penwidth="2"]; +node_6bd79f77 -> node_33f3a237 [color="#CC79A7" penwidth="2"]; +node_6bd79f77 -> node_33f3a237 [color="#CC79A7" penwidth="2"]; +node_bcb52703 -> node_852a6e4c [color="#009E73" penwidth="2"]; +node_bcb52703 -> node_8050206b [color="#009E73" penwidth="2"]; +node_bcb52703 -> node_8050206b [color="#009E73" penwidth="2"]; +node_bcb52703 -> node_ae53abce [color="#009E73" penwidth="2"]; +node_bcb52703 -> node_b549f0a3 [color="#009E73" penwidth="2"]; +node_bcb52703 -> node_e0f7ae2e [color="#009E73" penwidth="2"]; +node_bcb52703 -> node_af906479 [color="#009E73" penwidth="2"]; +node_f0a0926b -> node_852a6e4c [color="#009E73" penwidth="2"]; +node_f0a0926b -> node_3d6a13b1 [color="#009E73" penwidth="2"]; +node_f0a0926b -> node_1b951c53 [color="#009E73" penwidth="2"]; +node_f0a0926b -> node_33f3a237 [color="#009E73" penwidth="2"]; +node_f0a0926b -> node_b549f0a3 [color="#009E73" penwidth="2"]; +node_f0a0926b -> node_ce645b30 [color="#009E73" penwidth="2"]; +node_f0a0926b -> node_dd756297 [color="#009E73" penwidth="2"]; +node_f0a0926b -> node_6bd79f77 [color="#009E73" penwidth="2"]; +node_f0a0926b -> node_bcb52703 [color="#009E73" penwidth="2"]; +node_f0a0926b -> node_946f29f6 [color="#009E73" penwidth="2"]; +node_f0a0926b -> node_946f29f6 [color="#009E73" penwidth="2"]; +node_4e3b783a -> node_c61d14f6 [color="#56B4E9" penwidth="2"]; +node_9d4f8e24 -> node_162f6de3 [color="#F0E442" penwidth="2"]; +node_ec6290a9 -> node_946f29f6 [color="#E69F00" penwidth="2"]; +node_ec6290a9 -> node_3a2c6e67 [color="#E69F00" penwidth="2"]; +node_46f934ce -> node_03be3e25 [color="#D55E00" penwidth="2"]; +node_18602a47 -> node_03be3e25 [color="#CC79A7" penwidth="2"]; +node_dcd94f4c -> node_55755815 [color="#F0E442" penwidth="2"]; +node_dcd94f4c -> node_55755815 [color="#F0E442" penwidth="2"]; +node_ed45feea -> node_08b8e045 [color="#56B4E9" penwidth="2"]; +node_288b23cc -> node_7f270ce4 [color="#F0E442" penwidth="2"]; +node_288b23cc -> node_ed45feea [color="#F0E442" penwidth="2"]; +node_288b23cc -> node_4a95057d [color="#F0E442" penwidth="2"]; +node_119f79ba -> node_c61d14f6 [color="#56B4E9" penwidth="2"]; +node_119f79ba -> node_dcd94f4c [color="#56B4E9" penwidth="2"]; +node_22bc972f -> node_72d63ed3 [color="#CC79A7" penwidth="2"]; +node_ba0e4460 -> node_361d8e74 [color="#000000" penwidth="2"]; +node_ba0e4460 -> node_f32885c0 [color="#000000" penwidth="2"]; +node_ba0e4460 -> node_ed51d56f [color="#000000" penwidth="2"]; +node_ba0e4460 -> node_6534447f [color="#000000" penwidth="2"]; +node_ba0e4460 -> node_6534447f [color="#000000" penwidth="2"]; +node_41b5565a -> node_ba0e4460 [color="#56B4E9" penwidth="2"]; +node_41b5565a -> node_d03c6573 [color="#56B4E9" penwidth="2"]; +node_73895360 -> node_70ae6e31 [color="#000000" penwidth="2"]; +node_73895360 -> node_f32885c0 [color="#000000" penwidth="2"]; +node_73895360 -> node_ed51d56f [color="#000000" penwidth="2"]; +node_73895360 -> node_d03c6573 [color="#000000" penwidth="2"]; +node_9239b15a -> node_70ae6e31 [color="#56B4E9" penwidth="2"]; +node_9239b15a -> node_ed51d56f [color="#56B4E9" penwidth="2"]; +node_9239b15a -> node_d03c6573 [color="#56B4E9" penwidth="2"]; +node_9239b15a -> node_6534447f [color="#56B4E9" penwidth="2"]; +node_27e8bdc0 -> node_9f271c70 [color="#000000" penwidth="2"]; +node_27e8bdc0 -> node_70ae6e31 [color="#000000" penwidth="2"]; +node_27e8bdc0 -> node_f32885c0 [color="#000000" penwidth="2"]; +node_27e8bdc0 -> node_ed51d56f [color="#000000" penwidth="2"]; +node_27e8bdc0 -> node_d03c6573 [color="#000000" penwidth="2"]; +node_70ae6e31 -> node_361d8e74 [color="#E69F00" penwidth="2"]; +node_ed9d25e2 -> node_d03c6573 [color="#56B4E9" penwidth="2"]; +node_ed51d56f -> node_ed9d25e2 [color="#CC79A7" penwidth="2"]; +node_ed51d56f -> node_ed9d25e2 [color="#CC79A7" penwidth="2"]; +node_082a778e -> node_996cd29d [color="#D55E00" penwidth="2"]; +node_0889fc74 -> node_996cd29d [color="#F0E442" penwidth="2"]; +node_91aeba13 -> node_996cd29d [color="#009E73" penwidth="2"]; +node_704323d0 -> node_b59dfc10 [color="#000000" penwidth="2"]; +node_704323d0 -> node_b59dfc10 [color="#000000" penwidth="2"]; +node_704323d0 -> node_b59dfc10 [color="#000000" penwidth="2"]; +node_704323d0 -> node_46f934ce [color="#000000" penwidth="2"]; +node_704323d0 -> node_3dd96d9f [color="#000000" penwidth="2"]; +node_704323d0 -> node_dee7e970 [color="#000000" penwidth="2"]; +node_704323d0 -> node_806e4187 [color="#000000" penwidth="2"]; +node_8f7e4dd4 -> node_082a778e [color="#F0E442" penwidth="2"]; +node_7955eacd -> node_ab2d0df0 [color="#0072B2" penwidth="2"]; +node_7955eacd -> node_24142b43 [color="#0072B2" penwidth="2"]; +node_7955eacd -> node_0e230765 [color="#0072B2" penwidth="2"]; +node_7955eacd -> node_082a778e [color="#0072B2" penwidth="2"]; +node_7955eacd -> node_0889fc74 [color="#0072B2" penwidth="2"]; +node_7955eacd -> node_91aeba13 [color="#0072B2" penwidth="2"]; +node_7955eacd -> node_0dec453f [color="#0072B2" penwidth="2"]; +node_7955eacd -> node_b829a1a5 [color="#0072B2" penwidth="2"]; +node_321eccd1 -> node_de4ff705 [color="#E69F00" penwidth="2"]; +node_321eccd1 -> node_bdafb9b8 [color="#E69F00" penwidth="2"]; +node_badd6759 -> node_de4ff705 [color="#E69F00" penwidth="2"]; +node_badd6759 -> node_739b9bad [color="#E69F00" penwidth="2"]; +node_bdafb9b8 -> node_f6e2fb66 [color="#000000" penwidth="2"]; +node_739b9bad -> node_f6e2fb66 [color="#0072B2" penwidth="2"]; +node_739b9bad -> node_8478cefe [color="#0072B2" penwidth="2"]; +node_739b9bad -> node_ec6290a9 [color="#0072B2" penwidth="2"]; +node_5802af96 -> node_b28d07f1 [color="#D55E00" penwidth="2"]; +node_5ccd8a5f -> node_c61d14f6 [color="#CC79A7" penwidth="2"]; +node_5ccd8a5f -> node_5d9a2b00 [color="#CC79A7" penwidth="2"]; +node_5ccd8a5f -> node_3332348c [color="#CC79A7" penwidth="2"]; +node_5ccd8a5f -> node_3332348c [color="#CC79A7" penwidth="2"]; +node_5ccd8a5f -> node_5609db09 [color="#CC79A7" penwidth="2"]; +node_5ccd8a5f -> node_5609db09 [color="#CC79A7" penwidth="2"]; +node_5ccd8a5f -> node_5802af96 [color="#CC79A7" penwidth="2"]; +node_5ccd8a5f -> node_5802af96 [color="#CC79A7" penwidth="2"]; +node_aef9a9e0 -> node_959ccd53 [color="#000000" penwidth="2"]; +node_dcddb8a7 -> node_9daf3d17 [color="#CC79A7" penwidth="2"]; +node_959ccd53 -> node_1ace7292 [color="#009E73" penwidth="2"]; +node_14f57ab6 -> node_dcddb8a7 [color="#D55E00" penwidth="2"]; +node_14f57ab6 -> node_6bdc25a1 [color="#D55E00" penwidth="2"]; +node_14f57ab6 -> node_860c7ac6 [color="#D55E00" penwidth="2"]; +node_14f57ab6 -> node_22c7fbb8 [color="#D55E00" penwidth="2"]; +node_14f57ab6 -> node_166a5a71 [color="#D55E00" penwidth="2"]; +node_14f57ab6 -> node_99615e9b [color="#D55E00" penwidth="2"]; +node_1ace7292 -> node_14f57ab6 [color="#56B4E9" penwidth="2"]; +node_fa2537f0 -> node_946f29f6 [color="#000000" penwidth="2"]; +node_fa2537f0 -> node_faa34b75 [color="#000000" penwidth="2"]; +node_cd506363 -> node_4f44f5be [color="#009E73" penwidth="2"]; +node_cd506363 -> node_873601f4 [color="#009E73" penwidth="2"]; +node_cd506363 -> node_cce708e5 [color="#009E73" penwidth="2"]; +node_cd506363 -> node_cce708e5 [color="#009E73" penwidth="2"]; +node_a783286a -> node_85154c93 [color="#56B4E9" penwidth="2"]; +node_a783286a -> node_4f44f5be [color="#56B4E9" penwidth="2"]; +node_a783286a -> node_873601f4 [color="#56B4E9" penwidth="2"]; +node_0f7d8536 -> node_85154c93 [color="#D55E00" penwidth="2"]; +node_0f7d8536 -> node_873601f4 [color="#D55E00" penwidth="2"]; +node_0f7d8536 -> node_cce708e5 [color="#D55E00" penwidth="2"]; +node_0f7d8536 -> node_cce708e5 [color="#D55E00" penwidth="2"]; +node_c89f856b -> node_946f29f6 [color="#009E73" penwidth="2"]; +node_eb6c47cd -> node_cd506363 [color="#0072B2" penwidth="2"]; +node_eb6c47cd -> node_a783286a [color="#0072B2" penwidth="2"]; +node_eb6c47cd -> node_0f7d8536 [color="#0072B2" penwidth="2"]; +node_eb6c47cd -> node_faa34b75 [color="#0072B2" penwidth="2"]; +node_7c6a623c -> node_946f29f6 [color="#F0E442" penwidth="2"]; +node_cc660e06 -> node_946f29f6 [color="#D55E00" penwidth="2"]; +node_12259c7b -> node_de4ff705 [color="#009E73" penwidth="2"]; +node_12259c7b -> node_6d9054a6 [color="#009E73" penwidth="2"]; +node_c41dc369 -> node_de4ff705 [color="#E69F00" penwidth="2"]; +node_c41dc369 -> node_cb095ffd [color="#E69F00" penwidth="2"]; +node_fd98ecb0 -> node_de4ff705 [color="#000000" penwidth="2"]; +node_fd98ecb0 -> node_354f2337 [color="#000000" penwidth="2"]; +node_6d9054a6 -> node_946f29f6 [color="#D55E00" penwidth="2"]; +node_6d9054a6 -> node_946f29f6 [color="#D55E00" penwidth="2"]; +node_6d9054a6 -> node_946f29f6 [color="#D55E00" penwidth="2"]; +node_6d9054a6 -> node_946f29f6 [color="#D55E00" penwidth="2"]; +node_6d9054a6 -> node_46f934ce [color="#D55E00" penwidth="2"]; +node_6d9054a6 -> node_fa2537f0 [color="#D55E00" penwidth="2"]; +node_6d9054a6 -> node_eb6c47cd [color="#D55E00" penwidth="2"]; +node_6d9054a6 -> node_cc660e06 [color="#D55E00" penwidth="2"]; +node_cb095ffd -> node_c89f856b [color="#0072B2" penwidth="2"]; +node_cb095ffd -> node_eb6c47cd [color="#0072B2" penwidth="2"]; +node_354f2337 -> node_946f29f6 [color="#CC79A7" penwidth="2"]; +node_354f2337 -> node_946f29f6 [color="#CC79A7" penwidth="2"]; +node_354f2337 -> node_946f29f6 [color="#CC79A7" penwidth="2"]; +node_354f2337 -> node_946f29f6 [color="#CC79A7" penwidth="2"]; +node_354f2337 -> node_946f29f6 [color="#CC79A7" penwidth="2"]; +node_354f2337 -> node_946f29f6 [color="#CC79A7" penwidth="2"]; +node_354f2337 -> node_946f29f6 [color="#CC79A7" penwidth="2"]; +node_354f2337 -> node_46f934ce [color="#CC79A7" penwidth="2"]; +node_354f2337 -> node_7c6a623c [color="#CC79A7" penwidth="2"]; +node_354f2337 -> node_cc660e06 [color="#CC79A7" penwidth="2"]; +node_aa98bfc2 -> node_c61f9f91 [color="#56B4E9" penwidth="2"]; +node_4e896d64 -> node_de4ff705 [color="#F0E442" penwidth="2"]; +node_4e896d64 -> node_27779b2b [color="#F0E442" penwidth="2"]; +node_86415f94 -> node_de4ff705 [color="#F0E442" penwidth="2"]; +node_86415f94 -> node_78043230 [color="#F0E442" penwidth="2"]; +node_f97b5e96 -> node_de4ff705 [color="#D55E00" penwidth="2"]; +node_f97b5e96 -> node_76be2ca1 [color="#D55E00" penwidth="2"]; +node_27779b2b -> node_946f29f6 [color="#009E73" penwidth="2"]; +node_78043230 -> node_946f29f6 [color="#000000" penwidth="2"]; +node_76be2ca1 -> node_946f29f6 [color="#E69F00" penwidth="2"]; +node_76be2ca1 -> node_946f29f6 [color="#E69F00" penwidth="2"]; +node_76be2ca1 -> node_946f29f6 [color="#E69F00" penwidth="2"]; +node_76be2ca1 -> node_946f29f6 [color="#E69F00" penwidth="2"]; +node_76be2ca1 -> node_3a2c6e67 [color="#E69F00" penwidth="2"]; +node_76be2ca1 -> node_ec6290a9 [color="#E69F00" penwidth="2"]; +node_76be2ca1 -> node_aa98bfc2 [color="#E69F00" penwidth="2"]; +node_b1831293 -> node_18602a47 [color="#009E73" penwidth="2"]; +node_b58afb0f -> node_b1831293 [color="#CC79A7" penwidth="2"]; +node_b58afb0f -> node_d590851f [color="#CC79A7" penwidth="2"]; +node_92879697 -> node_de4ff705 [color="#CC79A7" penwidth="2"]; +node_92879697 -> node_9431b506 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_946f29f6 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_0d5b9b97 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_40250b05 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_40250b05 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_40250b05 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_40250b05 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_40250b05 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_40250b05 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_40250b05 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_40250b05 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_40250b05 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_40250b05 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_d7f3f280 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_d7f3f280 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_d7f3f280 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_d7f3f280 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_d7f3f280 [color="#CC79A7" penwidth="2"]; +node_d590851f -> node_ec9848d3 [color="#CC79A7" penwidth="2"]; +node_9431b506 -> node_946f29f6 [color="#D55E00" penwidth="2"]; +node_9431b506 -> node_946f29f6 [color="#D55E00" penwidth="2"]; +node_9431b506 -> node_946f29f6 [color="#D55E00" penwidth="2"]; +node_9431b506 -> node_946f29f6 [color="#D55E00" penwidth="2"]; +node_9431b506 -> node_3a2c6e67 [color="#D55E00" penwidth="2"]; +node_9431b506 -> node_ec6290a9 [color="#D55E00" penwidth="2"]; +subgraph cluster_aa95b367 { + node_b506da62 node_53230e82 node_cab4cce3 node_0b0dc172 node_59dd988f node_b3542df0 node_7c8b1579 node_e72cbbc2 node_1eb7e5b5 node_03502d3a node_337797aa node_cced52be node_46c63267 node_1c764779 node_8dcb74ff; + label="File: backend"; + name="backend"; + style="filled"; + graph[style=dotted]; + subgraph cluster_576c9132 { + node_6c351af6 node_96821269 node_015bbe25 node_f1140762 node_c464c120; + label="Class: NativeBackend"; + name="NativeBackend"; + style="filled"; + graph[style=dotted]; + }; +}; +subgraph cluster_dce65b75 { + node_8050206b node_852a6e4c node_beb043e2 node_7a008da0 node_dc97aa28 node_4a59f6f7 node_8ca8203f node_b53bcf8a node_bcfd79f0; + label="File: economy"; + name="economy"; + style="filled"; + graph[style=dotted]; +}; +subgraph cluster_067f25a5 { + node_afee3412 node_095b5e91 node_c61d14f6 node_4e3b783a node_dd756297 node_b549f0a3 node_af906479 node_ae53abce node_bcb52703 node_e0f7ae2e node_33f3a237 node_50a1ddf5 node_bdd602cf node_3d6a13b1 node_3169723f node_9c67934f node_1b951c53 node_6bd79f77 node_ce645b30 node_f0a0926b node_7a2c53d3; + label="File: fleet"; + name="fleet"; + style="filled"; + graph[style=dotted]; +}; +subgraph cluster_0fef7f5b { + node_de4ff705 node_3dd96d9f node_03be3e25 node_46f934ce node_b59dfc10 node_996cd29d node_18602a47 node_ec9848d3 node_0d5b9b97 node_d7f3f280 node_40250b05 node_f6e2fb66 node_5d9a2b00 node_162f6de3 node_9d4f8e24 node_3a2c6e67 node_ec6290a9 node_8478cefe; + label="File: lib"; + name="lib"; + style="filled"; + graph[style=dotted]; + subgraph cluster_364e5e23 { + node_946f29f6; + label="Class: CommandError"; + name="CommandError"; + style="filled"; + graph[style=dotted]; + }; +}; +subgraph cluster_f6071f93 { + node_55755815 node_dcd94f4c node_7f270ce4 node_08b8e045 node_ed45feea node_4a95057d node_288b23cc node_119f79ba; + label="File: mode_recommend"; + name="mode_recommend"; + style="filled"; + graph[style=dotted]; +}; +subgraph cluster_af5744ce { + node_d03c6573 node_ed9d25e2 node_361d8e74 node_70ae6e31 node_ed51d56f node_f32885c0 node_6534447f node_9f271c70 node_27e8bdc0 node_ba0e4460 node_41b5565a node_73895360 node_9239b15a node_72d63ed3 node_22bc972f; + label="File: parallel"; + name="parallel"; + style="filled"; + graph[style=dotted]; +}; +subgraph cluster_e216814c { + node_082a778e node_8f7e4dd4 node_704323d0 node_0889fc74 node_91aeba13 node_b829a1a5 node_ab2d0df0 node_24142b43 node_0e230765 node_0dec453f node_7955eacd; + label="File: pipeline"; + name="pipeline"; + style="filled"; + graph[style=dotted]; + subgraph cluster_57596efa { + node_dee7e970; + label="Class: _CASMiss"; + name="_CASMiss"; + style="filled"; + graph[style=dotted]; + }; + subgraph cluster_a0dd9c82 { + node_806e4187; + label="Class: _IllegalTransition"; + name="_IllegalTransition"; + style="filled"; + graph[style=dotted]; + }; +}; +subgraph cluster_82828e6d { + node_739b9bad node_badd6759 node_bdafb9b8 node_321eccd1; + label="File: preflight"; + name="preflight"; + style="filled"; + graph[style=dotted]; +}; +subgraph cluster_785db3db { + node_b28d07f1 node_5802af96 node_5609db09 node_3332348c node_5ccd8a5f; + label="File: provider_resolver"; + name="provider_resolver"; + style="filled"; + graph[style=dotted]; +}; +subgraph cluster_f234e8e7 { + node_860c7ac6 node_166a5a71 node_9daf3d17 node_dcddb8a7 node_22c7fbb8 node_6bdc25a1 node_99615e9b node_14f57ab6 node_1ace7292 node_959ccd53 node_aef9a9e0; + label="File: shipcheck"; + name="shipcheck"; + style="filled"; + graph[style=dotted]; +}; +subgraph cluster_e9707880 { + node_4f44f5be node_873601f4 node_cce708e5 node_85154c93 node_c89f856b node_cc660e06 node_0f7d8536 node_faa34b75 node_cd506363 node_a783286a node_eb6c47cd node_cb095ffd node_fa2537f0 node_6d9054a6 node_7c6a623c node_354f2337 node_c41dc369 node_12259c7b node_fd98ecb0; + label="File: task_state"; + name="task_state"; + style="filled"; + graph[style=dotted]; +}; +subgraph cluster_b4535220 { + node_78043230 node_86415f94 node_27779b2b node_4e896d64 node_aa98bfc2 node_c61f9f91 node_76be2ca1 node_f97b5e96; + label="File: tasks"; + name="tasks"; + style="filled"; + graph[style=dotted]; +}; +subgraph cluster_cc01ea75 { + node_d590851f node_b1831293 node_b58afb0f node_9431b506 node_92879697; + label="File: validation"; + name="validation"; + style="filled"; + graph[style=dotted]; +}; +} diff --git a/docs/architecture/generated/callflow-core.svg b/docs/architecture/generated/callflow-core.svg new file mode 100644 index 0000000..3b3d099 --- /dev/null +++ b/docs/architecture/generated/callflow-core.svg @@ -0,0 +1,2325 @@ + + + + + + +G + + +cluster_aa95b367 + +File: backend + + +cluster_576c9132 + +Class: NativeBackend + + +cluster_dce65b75 + +File: economy + + +cluster_067f25a5 + +File: fleet + + +cluster_0fef7f5b + +File: lib + + +cluster_364e5e23 + +Class: CommandError + + +cluster_f6071f93 + +File: mode_recommend + + +cluster_af5744ce + +File: parallel + + +cluster_e216814c + +File: pipeline + + +cluster_57596efa + +Class: _CASMiss + + +cluster_a0dd9c82 + +Class: _IllegalTransition + + +cluster_82828e6d + +File: preflight + + +cluster_785db3db + +File: provider_resolver + + +cluster_f234e8e7 + +File: shipcheck + + +cluster_e9707880 + +File: task_state + + +cluster_b4535220 + +File: tasks + + +cluster_cc01ea75 + +File: validation + + + +Legend + +Code2flow Legend + + +Regular function + + + +Trunk function (nothing calls this) + + + +Leaf function (this calls nothing else) + + + +Function call + + + + + + + +node_015bbe25 + +501: _expand_packet() + + + +node_f1140762 + +607: _packet_success() + + + +node_015bbe25->node_f1140762 + + + + + +node_8dcb74ff + +299: _cli_structured_mode() + + + +node_015bbe25->node_8dcb74ff + + + + + +node_1c764779 + +295: _cli_timeout() + + + +node_015bbe25->node_1c764779 + + + + + +node_beb043e2 + +95: append_telemetry() + + + +node_015bbe25->node_beb043e2 + + + + + +node_8050206b + +62: shift_tier() + + + +node_015bbe25->node_8050206b + + + + + +node_3dd96d9f + +61: now_iso() + + + +node_015bbe25->node_3dd96d9f + + + + + +node_96821269 + +440: expand() + + + +node_cced52be + +251: _agent_expand_action() + + + +node_96821269->node_cced52be + + + + + +node_e72cbbc2 + +204: _native_concurrency() + + + +node_96821269->node_e72cbbc2 + + + + + +node_cab4cce3 + +136: _pending_tasks() + + + +node_96821269->node_cab4cce3 + + + + + +node_852a6e4c + +73: economy_profile() + + + +node_96821269->node_852a6e4c + + + + + +node_5ccd8a5f + +83: resolve_provider() + + + +node_96821269->node_5ccd8a5f + + + + + +node_6c351af6 + +343: parse_prd() + + + +node_337797aa + +240: _agent_parse_action() + + + +node_6c351af6->node_337797aa + + + + + +node_6c351af6->node_8dcb74ff + + + + + +node_6c351af6->node_1c764779 + + + + + +node_59dd988f + +170: _validate_task_candidate() + + + +node_6c351af6->node_59dd988f + + + + + +node_7c8b1579 + +195: _write_tasks_into_tag() + + + +node_6c351af6->node_7c8b1579 + + + + + +node_6c351af6->node_852a6e4c + + + + + +node_6c351af6->node_5ccd8a5f + + + + + +node_c464c120 + +625: rate() + + + +node_46c63267 + +267: _agent_rate_action() + + + +node_c464c120->node_46c63267 + + + + + +node_c464c120->node_8dcb74ff + + + + + +node_c464c120->node_1c764779 + + + + + +node_03502d3a + +235: _complexity_report_path() + + + +node_c464c120->node_03502d3a + + + + + +node_53230e82 + +130: _load_tasks() + + + +node_c464c120->node_53230e82 + + + + + +node_1eb7e5b5 + +218: _task_summaries() + + + +node_c464c120->node_1eb7e5b5 + + + + + +node_c464c120->node_852a6e4c + + + + + +node_c464c120->node_5ccd8a5f + + + + + +node_0b0dc172 + +157: _candidate_tasks() + + + +node_946f29f6 + +53: __init__() + + + +node_0b0dc172->node_946f29f6 + + + + + +node_b3542df0 + +183: _load_existing_tagged() + + + +node_cab4cce3->node_53230e82 + + + + + +node_b506da62 + +122: _task_id_set() + + + +node_cab4cce3->node_b506da62 + + + + + +node_59dd988f->node_0b0dc172 + + + + + +node_9431b506 + +362: run_validate_tasks() + + + +node_59dd988f->node_9431b506 + + + + + +node_7c8b1579->node_b3542df0 + + + + + +node_4a59f6f7 + +136: _estimate_cost_usd() + + + +node_dc97aa28 + +125: _price_key_for_model() + + + +node_8ca8203f + +141: _summarize_costs() + + + +node_8ca8203f->node_4a59f6f7 + + + + + +node_8ca8203f->node_dc97aa28 + + + + + +node_7a008da0 + +119: _token_int() + + + +node_8ca8203f->node_7a008da0 + + + + + +node_46f934ce + +78: locked_update() + + + +node_beb043e2->node_46f934ce + + + + + +node_bcfd79f0 + +242: cmd_economy_report() + + + +node_b53bcf8a + +180: summarize_telemetry() + + + +node_bcfd79f0->node_b53bcf8a + + + + + +node_de4ff705 + +33: emit() + + + +node_bcfd79f0->node_de4ff705 + + + + + +node_b53bcf8a->node_8ca8203f + + + + + +node_afee3412 + +79: _atlas_config_economy() + + + +node_9c67934f + +364: _chunk() + + + +node_ae53abce + +297: _code_impl_shift_bounds() + + + +node_3169723f + +360: _dependencies() + + + +node_bdd602cf + +352: _is_done() + + + +node_50a1ddf5 + +348: _status() + + + +node_bdd602cf->node_50a1ddf5 + + + + + +node_3d6a13b1 + +356: _is_pending() + + + +node_3d6a13b1->node_50a1ddf5 + + + + + +node_095b5e91 + +97: _is_pos_int() + + + +node_1b951c53 + +368: _load_tagged_or_raise() + + + +node_1b951c53->node_946f29f6 + + + + + +node_33f3a237 + +344: _task_id() + + + +node_b549f0a3 + +274: available_backends() + + + +node_7a2c53d3 + +473: cmd_fleet_waves() + + + +node_f0a0926b + +433: run_fleet_waves() + + + +node_7a2c53d3->node_f0a0926b + + + + + +node_7a2c53d3->node_de4ff705 + + + + + +node_ce645b30 + +394: compute_waves() + + + +node_ce645b30->node_9c67934f + + + + + +node_ce645b30->node_3169723f + + + + + +node_ce645b30->node_bdd602cf + + + + + +node_ce645b30->node_3d6a13b1 + + + + + +node_ce645b30->node_33f3a237 + + + + + +node_c61d14f6 + +102: engine_config() + + + +node_c61d14f6->node_095b5e91 + + + + + +node_dd756297 + +196: load_fleet_config() + + + +node_dd756297->node_afee3412 + + + + + +node_dd756297->node_c61d14f6 + + + + + +node_6bd79f77 + +383: ready_set() + + + +node_6bd79f77->node_3169723f + + + + + +node_6bd79f77->node_bdd602cf + + + + + +node_6bd79f77->node_3d6a13b1 + + + + + +node_6bd79f77->node_33f3a237 + + + + + +node_e0f7ae2e + +328: resolve_backend() + + + +node_bcb52703 + +306: route_task() + + + +node_bcb52703->node_852a6e4c + + + + + +node_bcb52703->node_8050206b + + + + + +node_bcb52703->node_ae53abce + + + + + +node_bcb52703->node_b549f0a3 + + + + + +node_bcb52703->node_e0f7ae2e + + + + + +node_af906479 + +285: task_tier() + + + +node_bcb52703->node_af906479 + + + + + +node_f0a0926b->node_852a6e4c + + + + + +node_f0a0926b->node_3d6a13b1 + + + + + +node_f0a0926b->node_1b951c53 + + + + + +node_f0a0926b->node_33f3a237 + + + + + +node_f0a0926b->node_b549f0a3 + + + + + +node_f0a0926b->node_ce645b30 + + + + + +node_f0a0926b->node_dd756297 + + + + + +node_f0a0926b->node_6bd79f77 + + + + + +node_f0a0926b->node_bcb52703 + + + + + +node_f0a0926b->node_946f29f6 + + + + + +node_4e3b783a + +156: save_engine_config() + + + +node_4e3b783a->node_c61d14f6 + + + + + +node_3a2c6e67 + +337: _current_taskmaster_tag() + + + +node_f6e2fb66 + +165: _detect_taskmaster_method() + + + +node_9d4f8e24 + +275: _ensure_env_entry() + + + +node_162f6de3 + +268: _env_file_has_key() + + + +node_9d4f8e24->node_162f6de3 + + + + + +node_8478cefe + +400: _read_execution_state() + + + +node_5d9a2b00 + +223: _read_taskmaster_model() + + + +node_ec6290a9 + +352: _resolve_tasks_payload() + + + +node_ec6290a9->node_946f29f6 + + + + + +node_ec6290a9->node_3a2c6e67 + + + + + +node_03be3e25 + +69: atomic_write() + + + +node_0d5b9b97 + +117: count_requirements() + + + +node_b59dfc10 + +95: emit_json_error() + + + +node_40250b05 + +128: get_section_content() + + + +node_d7f3f280 + +122: has_section() + + + +node_46f934ce->node_03be3e25 + + + + + +node_996cd29d + +100: read_json() + + + +node_ec9848d3 + +113: word_count() + + + +node_18602a47 + +108: write_json() + + + +node_18602a47->node_03be3e25 + + + + + +node_dcd94f4c + +56: _check_taskmaster_version() + + + +node_55755815 + +40: _parse_version() + + + +node_dcd94f4c->node_55755815 + + + + + +node_08b8e045 + +109: _mcp_config_has_server() + + + +node_7f270ce4 + +101: _safe_call() + + + +node_ed45feea + +139: detect_atlas_launcher() + + + +node_ed45feea->node_08b8e045 + + + + + +node_288b23cc + +220: detect_capabilities() + + + +node_288b23cc->node_7f270ce4 + + + + + +node_288b23cc->node_ed45feea + + + + + +node_4a95057d + +168: detect_taskmaster() + + + +node_288b23cc->node_4a95057d + + + + + +node_119f79ba + +370: validate_setup() + + + +node_119f79ba->node_c61d14f6 + + + + + +node_119f79ba->node_dcd94f4c + + + + + +node_22bc972f + +0: (global)() + + + +node_72d63ed3 + +214: main() + + + +node_22bc972f->node_72d63ed3 + + + + + +node_361d8e74 + +54: _resolve_tag() + + + +node_ba0e4460 + +125: apply_results() + + + +node_ba0e4460->node_361d8e74 + + + + + +node_f32885c0 + +79: get_tasks() + + + +node_ba0e4460->node_f32885c0 + + + + + +node_ed51d56f + +67: load_tagged() + + + +node_ba0e4460->node_ed51d56f + + + + + +node_6534447f + +83: write_atomic() + + + +node_ba0e4460->node_6534447f + + + + + +node_9f271c70 + +89: build_packets() + + + +node_41b5565a + +185: cmd_apply() + + + +node_41b5565a->node_ba0e4460 + + + + + +node_d03c6573 + +45: out() + + + +node_41b5565a->node_d03c6573 + + + + + +node_73895360 + +192: cmd_extract() + + + +node_70ae6e31 + +63: current_tag() + + + +node_73895360->node_70ae6e31 + + + + + +node_73895360->node_f32885c0 + + + + + +node_73895360->node_ed51d56f + + + + + +node_73895360->node_d03c6573 + + + + + +node_9239b15a + +200: cmd_inject() + + + +node_9239b15a->node_70ae6e31 + + + + + +node_9239b15a->node_ed51d56f + + + + + +node_9239b15a->node_d03c6573 + + + + + +node_9239b15a->node_6534447f + + + + + +node_27e8bdc0 + +117: cmd_plan() + + + +node_27e8bdc0->node_9f271c70 + + + + + +node_27e8bdc0->node_70ae6e31 + + + + + +node_27e8bdc0->node_f32885c0 + + + + + +node_27e8bdc0->node_ed51d56f + + + + + +node_27e8bdc0->node_d03c6573 + + + + + +node_70ae6e31->node_361d8e74 + + + + + +node_ed9d25e2 + +49: fail() + + + +node_ed9d25e2->node_d03c6573 + + + + + +node_ed51d56f->node_ed9d25e2 + + + + + +node_dee7e970 + +256: __init__() + + + +node_806e4187 + +259: __init__() + + + +node_ab2d0df0 + +148: _count_tasks() + + + +node_24142b43 + +158: _current_tag() + + + +node_0e230765 + +167: _fresh_tag() + + + +node_082a778e + +33: _load_state() + + + +node_082a778e->node_996cd29d + + + + + +node_0889fc74 + +118: _read_taskmaster_state() + + + +node_0889fc74->node_996cd29d + + + + + +node_91aeba13 + +127: _read_taskmaster_tasks() + + + +node_91aeba13->node_996cd29d + + + + + +node_0dec453f + +175: _recommended_pending_tag() + + + +node_b829a1a5 + +137: _tag_task_lists() + + + +node_704323d0 + +49: advance_phase() + + + +node_704323d0->node_b59dfc10 + + + + + +node_704323d0->node_46f934ce + + + + + +node_704323d0->node_3dd96d9f + + + + + +node_704323d0->node_dee7e970 + + + + + +node_704323d0->node_806e4187 + + + + + +node_8f7e4dd4 + +39: current_phase() + + + +node_8f7e4dd4->node_082a778e + + + + + +node_7955eacd + +187: preflight() + + + +node_7955eacd->node_ab2d0df0 + + + + + +node_7955eacd->node_24142b43 + + + + + +node_7955eacd->node_0e230765 + + + + + +node_7955eacd->node_082a778e + + + + + +node_7955eacd->node_0889fc74 + + + + + +node_7955eacd->node_91aeba13 + + + + + +node_7955eacd->node_0dec453f + + + + + +node_7955eacd->node_b829a1a5 + + + + + +node_321eccd1 + +90: cmd_detect_taskmaster() + + + +node_321eccd1->node_de4ff705 + + + + + +node_bdafb9b8 + +84: run_detect_taskmaster() + + + +node_321eccd1->node_bdafb9b8 + + + + + +node_badd6759 + +77: cmd_preflight() + + + +node_badd6759->node_de4ff705 + + + + + +node_739b9bad + +20: run_preflight() + + + +node_badd6759->node_739b9bad + + + + + +node_bdafb9b8->node_f6e2fb66 + + + + + +node_739b9bad->node_f6e2fb66 + + + + + +node_739b9bad->node_8478cefe + + + + + +node_739b9bad->node_ec6290a9 + + + + + +node_3332348c + +79: _plan_floor() + + + +node_5609db09 + +68: _try_api() + + + +node_5802af96 + +54: _try_cli() + + + +node_b28d07f1 + +42: _usability_facts() + + + +node_5802af96->node_b28d07f1 + + + + + +node_5ccd8a5f->node_c61d14f6 + + + + + +node_5ccd8a5f->node_5d9a2b00 + + + + + +node_5ccd8a5f->node_3332348c + + + + + +node_5ccd8a5f->node_5609db09 + + + + + +node_5ccd8a5f->node_5802af96 + + + + + +node_aef9a9e0 + +0: (global)() + + + +node_959ccd53 + +280: main() + + + +node_aef9a9e0->node_959ccd53 + + + + + +node_9daf3d17 + +101: _has_card_for() + + + +node_dcddb8a7 + +118: gate_cdd() + + + +node_dcddb8a7->node_9daf3d17 + + + + + +node_6bdc25a1 + +141: gate_exit_codes() + + + +node_860c7ac6 + +61: gate_pipeline() + + + +node_22c7fbb8 + +132: gate_plan() + + + +node_166a5a71 + +74: gate_tasks() + + + +node_99615e9b + +166: log_override() + + + +node_1ace7292 + +208: run_ship_check() + + + +node_959ccd53->node_1ace7292 + + + + + +node_14f57ab6 + +180: run_all_gates() + + + +node_14f57ab6->node_dcddb8a7 + + + + + +node_14f57ab6->node_6bdc25a1 + + + + + +node_14f57ab6->node_860c7ac6 + + + + + +node_14f57ab6->node_22c7fbb8 + + + + + +node_14f57ab6->node_166a5a71 + + + + + +node_14f57ab6->node_99615e9b + + + + + +node_1ace7292->node_14f57ab6 + + + + + +node_fa2537f0 + +161: _claim_selected_task() + + + +node_fa2537f0->node_946f29f6 + + + + + +node_faa34b75 + +83: _subtask_envelope() + + + +node_fa2537f0->node_faa34b75 + + + + + +node_85154c93 + +40: _dependencies() + + + +node_cd506363 + +96: _in_progress_candidates() + + + +node_4f44f5be + +25: _priority_rank() + + + +node_cd506363->node_4f44f5be + + + + + +node_873601f4 + +29: _sortable_id() + + + +node_cd506363->node_873601f4 + + + + + +node_cce708e5 + +36: _status() + + + +node_cd506363->node_cce708e5 + + + + + +node_a783286a + +109: _ready_candidates() + + + +node_a783286a->node_85154c93 + + + + + +node_a783286a->node_4f44f5be + + + + + +node_a783286a->node_873601f4 + + + + + +node_0f7d8536 + +65: _ready_subtask() + + + +node_0f7d8536->node_85154c93 + + + + + +node_0f7d8536->node_873601f4 + + + + + +node_0f7d8536->node_cce708e5 + + + + + +node_c89f856b + +45: _resolve_tasks() + + + +node_c89f856b->node_946f29f6 + + + + + +node_eb6c47cd + +122: _select_next_task() + + + +node_eb6c47cd->node_cd506363 + + + + + +node_eb6c47cd->node_a783286a + + + + + +node_eb6c47cd->node_0f7d8536 + + + + + +node_eb6c47cd->node_faa34b75 + + + + + +node_7c6a623c + +221: _split_id() + + + +node_7c6a623c->node_946f29f6 + + + + + +node_cc660e06 + +57: _tag_key_for_raw() + + + +node_cc660e06->node_946f29f6 + + + + + +node_12259c7b + +297: cmd_claim_task() + + + +node_12259c7b->node_de4ff705 + + + + + +node_6d9054a6 + +181: run_claim_task() + + + +node_12259c7b->node_6d9054a6 + + + + + +node_c41dc369 + +290: cmd_next_task() + + + +node_c41dc369->node_de4ff705 + + + + + +node_cb095ffd + +155: run_next_task() + + + +node_c41dc369->node_cb095ffd + + + + + +node_fd98ecb0 + +304: cmd_set_status() + + + +node_fd98ecb0->node_de4ff705 + + + + + +node_354f2337 + +230: run_set_status() + + + +node_fd98ecb0->node_354f2337 + + + + + +node_6d9054a6->node_946f29f6 + + + + + +node_6d9054a6->node_46f934ce + + + + + +node_6d9054a6->node_fa2537f0 + + + + + +node_6d9054a6->node_eb6c47cd + + + + + +node_6d9054a6->node_cc660e06 + + + + + +node_cb095ffd->node_c89f856b + + + + + +node_cb095ffd->node_eb6c47cd + + + + + +node_354f2337->node_946f29f6 + + + + + +node_354f2337->node_46f934ce + + + + + +node_354f2337->node_7c6a623c + + + + + +node_354f2337->node_cc660e06 + + + + + +node_aa98bfc2 + +135: _classify_task() + + + +node_c61f9f91 + +180: _generate_acceptance_criteria() + + + +node_aa98bfc2->node_c61f9f91 + + + + + +node_4e896d64 + +86: cmd_backup_prd() + + + +node_4e896d64->node_de4ff705 + + + + + +node_27779b2b + +66: run_backup_prd() + + + +node_4e896d64->node_27779b2b + + + + + +node_86415f94 + +59: cmd_calc_tasks() + + + +node_86415f94->node_de4ff705 + + + + + +node_78043230 + +30: run_calc_tasks() + + + +node_86415f94->node_78043230 + + + + + +node_f97b5e96 + +294: cmd_enrich_tasks() + + + +node_f97b5e96->node_de4ff705 + + + + + +node_76be2ca1 + +229: run_enrich_tasks() + + + +node_f97b5e96->node_76be2ca1 + + + + + +node_27779b2b->node_946f29f6 + + + + + +node_78043230->node_946f29f6 + + + + + +node_76be2ca1->node_946f29f6 + + + + + +node_76be2ca1->node_3a2c6e67 + + + + + +node_76be2ca1->node_ec6290a9 + + + + + +node_76be2ca1->node_aa98bfc2 + + + + + +node_b1831293 + +338: _persist_validation() + + + +node_b1831293->node_18602a47 + + + + + +node_b58afb0f + +350: cmd_validate_prd() + + + +node_b58afb0f->node_b1831293 + + + + + +node_d590851f + +37: run_validate_prd() + + + +node_b58afb0f->node_d590851f + + + + + +node_92879697 + +525: cmd_validate_tasks() + + + +node_92879697->node_de4ff705 + + + + + +node_92879697->node_9431b506 + + + + + +node_d590851f->node_946f29f6 + + + + + +node_d590851f->node_0d5b9b97 + + + + + +node_d590851f->node_40250b05 + + + + + +node_d590851f->node_d7f3f280 + + + + + +node_d590851f->node_ec9848d3 + + + + + +node_9431b506->node_946f29f6 + + + + + +node_9431b506->node_3a2c6e67 + + + + + +node_9431b506->node_ec6290a9 + + + + + diff --git a/docs/architecture/generated/classes_AtlasEngine.mmd b/docs/architecture/generated/classes_AtlasEngine.mmd new file mode 100644 index 0000000..7c9862f --- /dev/null +++ b/docs/architecture/generated/classes_AtlasEngine.mmd @@ -0,0 +1,42 @@ +classDiagram + class Backend { + name : str + detect() dict + expand(task_ids, research, tag) dict + init_project() dict + parse_prd(prd_path, num_tasks, tag) dict + rate(tag, research) dict + } + class CliAgentError { + kind + } + class CommandError { + extra : dict + message : str + } + class LLMError { + kind + } + class NativeBackend { + name : str + detect() dict + expand(task_ids, research, tag) dict + init_project() dict + parse_prd(prd_path, num_tasks, tag) dict + rate(tag, research) dict + } + class ProviderHandle { + kind : str + model : str | None + provider : str + reason : str + role : str + } + class _CASMiss { + actual + } + class _IllegalTransition { + source + target + } + NativeBackend --|> Backend diff --git a/docs/architecture/generated/classes_AtlasEngine.svg b/docs/architecture/generated/classes_AtlasEngine.svg new file mode 100644 index 0000000..bfec29a --- /dev/null +++ b/docs/architecture/generated/classes_AtlasEngine.svg @@ -0,0 +1,113 @@ + + + + + + +classes_AtlasEngine + + + +prd_taskmaster.backend.Backend + +Backend + +name : str + +detect(): dict +expand(task_ids, research, tag): dict +init_project(): dict +parse_prd(prd_path, num_tasks, tag): dict +rate(tag, research): dict + + + +prd_taskmaster.cli_agent.CliAgentError + +CliAgentError + +kind + + + + + +prd_taskmaster.lib.CommandError + +CommandError + +extra : dict +message : str + + + + + +prd_taskmaster.llm_client.LLMError + +LLMError + +kind + + + + + +prd_taskmaster.backend.NativeBackend + +NativeBackend + +name : str + +detect(): dict +expand(task_ids, research, tag): dict +init_project(): dict +parse_prd(prd_path, num_tasks, tag): dict +rate(tag, research): dict + + + +prd_taskmaster.backend.NativeBackend->prd_taskmaster.backend.Backend + + + + + +prd_taskmaster.provider_resolver.ProviderHandle + +ProviderHandle + +kind : str +model : str | None +provider : str +reason : str +role : str + + + + + +prd_taskmaster.pipeline._CASMiss + +_CASMiss + +actual + + + + + +prd_taskmaster.pipeline._IllegalTransition + +_IllegalTransition + +source +target + + + + + diff --git a/docs/architecture/generated/packages_AtlasEngine.mmd b/docs/architecture/generated/packages_AtlasEngine.mmd new file mode 100644 index 0000000..c87f372 --- /dev/null +++ b/docs/architecture/generated/packages_AtlasEngine.mmd @@ -0,0 +1,135 @@ +classDiagram + class prd_taskmaster { + } + class backend { + } + class batch { + } + class capabilities { + } + class cli { + } + class cli_agent { + } + class context_pack { + } + class economy { + } + class feedback { + } + class fleet { + } + class lib { + } + class llm_client { + } + class mode_recommend { + } + class parallel { + } + class pipeline { + } + class preflight { + } + class provider_resolver { + } + class providers { + } + class render { + } + class setup_wizard { + } + class shipcheck { + } + class status { + } + class suggestions { + } + class task_state { + } + class taskmaster { + } + class tasks { + } + class templates { + } + class validation { + } + backend --> prd_taskmaster + backend --> cli_agent + backend --> economy + backend --> fleet + backend --> lib + backend --> llm_client + backend --> parallel + backend --> provider_resolver + backend --> validation + batch --> prd_taskmaster + batch --> backend + batch --> capabilities + batch --> fleet + batch --> lib + batch --> preflight + batch --> providers + capabilities --> lib + capabilities --> mode_recommend + cli --> prd_taskmaster + cli --> backend + cli --> batch + cli --> capabilities + cli --> context_pack + cli --> economy + cli --> feedback + cli --> fleet + cli --> lib + cli --> parallel + cli --> preflight + cli --> providers + cli --> setup_wizard + cli --> status + cli --> task_state + cli --> taskmaster + cli --> tasks + cli --> templates + cli --> validation + cli_agent --> economy + cli_agent --> llm_client + economy --> lib + feedback --> lib + fleet --> prd_taskmaster + fleet --> economy + fleet --> lib + fleet --> parallel + llm_client --> economy + llm_client --> lib + mode_recommend --> fleet + mode_recommend --> providers + pipeline --> lib + preflight --> lib + provider_resolver --> fleet + provider_resolver --> lib + provider_resolver --> llm_client + provider_resolver --> providers + providers --> economy + providers --> fleet + providers --> lib + setup_wizard --> prd_taskmaster + setup_wizard --> fleet + setup_wizard --> lib + setup_wizard --> mode_recommend + setup_wizard --> providers + status --> prd_taskmaster + status --> lib + status --> pipeline + status --> render + status --> shipcheck + suggestions --> lib + task_state --> prd_taskmaster + task_state --> fleet + task_state --> lib + task_state --> parallel + taskmaster --> lib + tasks --> lib + templates --> lib + validation --> lib + validation --> pipeline diff --git a/docs/architecture/generated/packages_AtlasEngine.svg b/docs/architecture/generated/packages_AtlasEngine.svg new file mode 100644 index 0000000..238e5d2 --- /dev/null +++ b/docs/architecture/generated/packages_AtlasEngine.svg @@ -0,0 +1,649 @@ + + + + + + +packages_AtlasEngine + + + +prd_taskmaster + +prd_taskmaster + + + +prd_taskmaster.backend + +prd_taskmaster.backend + + + +prd_taskmaster.backend->prd_taskmaster + + + + + +prd_taskmaster.cli_agent + +prd_taskmaster.cli_agent + + + +prd_taskmaster.backend->prd_taskmaster.cli_agent + + + + + +prd_taskmaster.economy + +prd_taskmaster.economy + + + +prd_taskmaster.backend->prd_taskmaster.economy + + + + + +prd_taskmaster.fleet + +prd_taskmaster.fleet + + + +prd_taskmaster.backend->prd_taskmaster.fleet + + + + + +prd_taskmaster.lib + +prd_taskmaster.lib + + + +prd_taskmaster.backend->prd_taskmaster.lib + + + + + +prd_taskmaster.llm_client + +prd_taskmaster.llm_client + + + +prd_taskmaster.backend->prd_taskmaster.llm_client + + + + + +prd_taskmaster.parallel + +prd_taskmaster.parallel + + + +prd_taskmaster.backend->prd_taskmaster.parallel + + + + + +prd_taskmaster.provider_resolver + +prd_taskmaster.provider_resolver + + + +prd_taskmaster.backend->prd_taskmaster.provider_resolver + + + + + +prd_taskmaster.validation + +prd_taskmaster.validation + + + +prd_taskmaster.backend->prd_taskmaster.validation + + + + + +prd_taskmaster.batch + +prd_taskmaster.batch + + + +prd_taskmaster.batch->prd_taskmaster + + + + + +prd_taskmaster.batch->prd_taskmaster.backend + + + + + +prd_taskmaster.capabilities + +prd_taskmaster.capabilities + + + +prd_taskmaster.batch->prd_taskmaster.capabilities + + + + + +prd_taskmaster.batch->prd_taskmaster.fleet + + + + + +prd_taskmaster.batch->prd_taskmaster.lib + + + + + +prd_taskmaster.preflight + +prd_taskmaster.preflight + + + +prd_taskmaster.batch->prd_taskmaster.preflight + + + + + +prd_taskmaster.providers + +prd_taskmaster.providers + + + +prd_taskmaster.batch->prd_taskmaster.providers + + + + + +prd_taskmaster.capabilities->prd_taskmaster.lib + + + + + +prd_taskmaster.mode_recommend + +prd_taskmaster.mode_recommend + + + +prd_taskmaster.capabilities->prd_taskmaster.mode_recommend + + + + + +prd_taskmaster.cli + +prd_taskmaster.cli + + + +prd_taskmaster.cli->prd_taskmaster + + + + + +prd_taskmaster.cli->prd_taskmaster.backend + + + + + +prd_taskmaster.cli->prd_taskmaster.batch + + + + + +prd_taskmaster.cli->prd_taskmaster.capabilities + + + + + +prd_taskmaster.context_pack + +prd_taskmaster.context_pack + + + +prd_taskmaster.cli->prd_taskmaster.context_pack + + + + + +prd_taskmaster.cli->prd_taskmaster.economy + + + + + +prd_taskmaster.feedback + +prd_taskmaster.feedback + + + +prd_taskmaster.cli->prd_taskmaster.feedback + + + + + +prd_taskmaster.cli->prd_taskmaster.fleet + + + + + +prd_taskmaster.cli->prd_taskmaster.lib + + + + + +prd_taskmaster.cli->prd_taskmaster.parallel + + + + + +prd_taskmaster.cli->prd_taskmaster.preflight + + + + + +prd_taskmaster.cli->prd_taskmaster.providers + + + + + +prd_taskmaster.setup_wizard + +prd_taskmaster.setup_wizard + + + +prd_taskmaster.cli->prd_taskmaster.setup_wizard + + + + + +prd_taskmaster.status + +prd_taskmaster.status + + + +prd_taskmaster.cli->prd_taskmaster.status + + + + + +prd_taskmaster.task_state + +prd_taskmaster.task_state + + + +prd_taskmaster.cli->prd_taskmaster.task_state + + + + + +prd_taskmaster.taskmaster + +prd_taskmaster.taskmaster + + + +prd_taskmaster.cli->prd_taskmaster.taskmaster + + + + + +prd_taskmaster.tasks + +prd_taskmaster.tasks + + + +prd_taskmaster.cli->prd_taskmaster.tasks + + + + + +prd_taskmaster.templates + +prd_taskmaster.templates + + + +prd_taskmaster.cli->prd_taskmaster.templates + + + + + +prd_taskmaster.cli->prd_taskmaster.validation + + + + + +prd_taskmaster.cli_agent->prd_taskmaster.economy + + + + + +prd_taskmaster.cli_agent->prd_taskmaster.llm_client + + + + + +prd_taskmaster.economy->prd_taskmaster.lib + + + + + +prd_taskmaster.feedback->prd_taskmaster.lib + + + + + +prd_taskmaster.fleet->prd_taskmaster + + + + + +prd_taskmaster.fleet->prd_taskmaster.economy + + + + + +prd_taskmaster.fleet->prd_taskmaster.lib + + + + + +prd_taskmaster.fleet->prd_taskmaster.parallel + + + + + +prd_taskmaster.llm_client->prd_taskmaster.economy + + + + + +prd_taskmaster.llm_client->prd_taskmaster.lib + + + + + +prd_taskmaster.mode_recommend->prd_taskmaster.fleet + + + + + +prd_taskmaster.mode_recommend->prd_taskmaster.providers + + + + + +prd_taskmaster.pipeline + +prd_taskmaster.pipeline + + + +prd_taskmaster.pipeline->prd_taskmaster.lib + + + + + +prd_taskmaster.preflight->prd_taskmaster.lib + + + + + +prd_taskmaster.provider_resolver->prd_taskmaster.fleet + + + + + +prd_taskmaster.provider_resolver->prd_taskmaster.lib + + + + + +prd_taskmaster.provider_resolver->prd_taskmaster.llm_client + + + + + +prd_taskmaster.provider_resolver->prd_taskmaster.providers + + + + + +prd_taskmaster.providers->prd_taskmaster.economy + + + + + +prd_taskmaster.providers->prd_taskmaster.fleet + + + + + +prd_taskmaster.providers->prd_taskmaster.lib + + + + + +prd_taskmaster.render + +prd_taskmaster.render + + + +prd_taskmaster.setup_wizard->prd_taskmaster + + + + + +prd_taskmaster.setup_wizard->prd_taskmaster.fleet + + + + + +prd_taskmaster.setup_wizard->prd_taskmaster.lib + + + + + +prd_taskmaster.setup_wizard->prd_taskmaster.mode_recommend + + + + + +prd_taskmaster.setup_wizard->prd_taskmaster.providers + + + + + +prd_taskmaster.shipcheck + +prd_taskmaster.shipcheck + + + +prd_taskmaster.status->prd_taskmaster + + + + + +prd_taskmaster.status->prd_taskmaster.lib + + + + + +prd_taskmaster.status->prd_taskmaster.pipeline + + + + + +prd_taskmaster.status->prd_taskmaster.render + + + + + +prd_taskmaster.status->prd_taskmaster.shipcheck + + + + + +prd_taskmaster.suggestions + +prd_taskmaster.suggestions + + + +prd_taskmaster.suggestions->prd_taskmaster.lib + + + + + +prd_taskmaster.task_state->prd_taskmaster + + + + + +prd_taskmaster.task_state->prd_taskmaster.fleet + + + + + +prd_taskmaster.task_state->prd_taskmaster.lib + + + + + +prd_taskmaster.task_state->prd_taskmaster.parallel + + + + + +prd_taskmaster.taskmaster->prd_taskmaster.lib + + + + + +prd_taskmaster.tasks->prd_taskmaster.lib + + + + + +prd_taskmaster.templates->prd_taskmaster.lib + + + + + +prd_taskmaster.validation->prd_taskmaster.lib + + + + + +prd_taskmaster.validation->prd_taskmaster.pipeline + + + + + diff --git a/docs/architecture/rendered/00-system-overview.svg b/docs/architecture/rendered/00-system-overview.svg new file mode 100644 index 0000000..bb2d6ff --- /dev/null +++ b/docs/architecture/rendered/00-system-overview.svg @@ -0,0 +1,856 @@ +

Atlas engine — system overview

+

hexagon = LLM step · rectangle = deterministic Python · blue = interface · oval = external · person = you

+
you1 · LLM / agent layer — non-deterministic (prompts the model executes)2 · interface boundary — fail-closed3 · deterministic engine — Python (state in .atlas-ai/)4 · external — out of processSKILL.md · /atlas · /go entrypointphases/*.md · DISCOVER · GENERATE · HANDOFFskills/* · discover · generate · handoff · setup · expand-tasks · execute-task · execute-fleetmcp-server/server.py · _HardenedMCP · ~31 MCP toolscli.py · DISPATCH · CLI subcommandspipeline.py · phase state-machine · check_gate · advance_phase CASGENERATE engine · backend · provider_resolver · cli_agent · llm_client · economy → 11task graph · tasks · task_state · validation · parallel → 10detect / setup · preflight · batch · mode_recommend · providers · setup_wizard · fleet → 12shipcheck.py · 5 gates → SHIP_CHECK_OKlib.py · locked_update · atomic IO · shared substratemodel CLIs · claude · codex · gemini · keylessraw vendor APIs · anthropic · openai · googletask-master backend · optional one-line goalthe LLM only reaches the core through thesetranslate calls → deterministic opscli tierapi tierliveness probeoptional backend + + + + + + + + +
diff --git a/docs/architecture/rendered/10-component-deterministic-core.svg b/docs/architecture/rendered/10-component-deterministic-core.svg new file mode 100644 index 0000000..0d9abf5 --- /dev/null +++ b/docs/architecture/rendered/10-component-deterministic-core.svg @@ -0,0 +1,144 @@ +prd-taskmaster · deterministic core — phase state-machine, gates, task graphPhase State Machine · GatesSpec GraderTask Graph OpsScheduler · Parallel MergeSubstrate Floor (leaf)tasks.json · pipeline.json · validation.jsonpipeline.pyshipcheck.pyvalidation.pytasks.pytask_state.pyfleet.pyparallel.py · agent-parallel research; hybrid SINGLE-WRITER mergelib.pyadvance_phase() · CAS on expected_currentLEGAL_TRANSITIONS · SETUP DISCOVER GENERATE HANDOFF EXECUTEcheck_gate(phase, evidence)preflight() · recommended_action ladderGate1 · pipeline.json current_phase == EXECUTEGate2 · every task status == doneGate3 · CDD card per task idGate4 · plan.md existsGate5 HARD · no non-zero Exit status Nrun_ship_check() · run_all_gates()SHIP_CHECK_OKrun_validate_prd() · 13 checks · score to gradeplaceholder detect · HARD FAIL floors NEEDS_WORKrun_validate_tasks() · structural task lintrun_calc_tasks() · ceil(req x 1.5) clamped to scale bandrun_enrich_tasks() · write phaseConfig_classify_task() · SIMPLE MEDIUM COMPLEX RESEARCH VALIDATIONrun_next_task() · in-progress then ready selectrun_claim_task() · select plus mark in-progressrun_set_status() · update under flockcompute_waves() · dependency-ordered frontier chunksready_set() · pending with deps doneroute_task() · tier to backend:modelrun_fleet_waves() · waves plus routingbuild_packets() · one research packet per taskapply_results() · merge subtasks plus complexity in ONE atomic writeextract · inject · tag bridge to flat tasks.jsonlocked_update() · flock LOCK_EX read-modify-writeatomic_write() · tmp plus os.replace_resolve_tasks_payload() · tagged-over-flatread_json · write_json · CommandError · VAGUE_PATTERN validates transitionmust pass before advancereads staterunsrunsrunsrunsrunsall pass emitsreads current_phasereads task statusplaceholder hard-failvalidation_grade evidencetask_count evidenceusesresults backexpanded tasks then enrichuses ready_setselect then claimcomputesper-task routingtier of pendingimports fleetimports parallel · get_tasks · TASKSfleet imports parallellocked_update CASread_json_resolve_tasks_payloadtagged-over-flatVAGUE_PATTERN · sectionsclaim under flockset-status under flockCommandError · emitown atomic write · single-writeratomic CAS writesatomic writes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/architecture/rendered/11-component-backend-provider-engine.svg b/docs/architecture/rendered/11-component-backend-provider-engine.svg new file mode 100644 index 0000000..d26a9c7 --- /dev/null +++ b/docs/architecture/rendered/11-component-backend-provider-engine.svg @@ -0,0 +1,123 @@ +prd-taskmaster GENERATE engine · backend.NativeBackend -> resolve_provider tier -> cli | api | planGeneration Engine (backend.py)provider_resolver.pyeconomy.py · tier ladder + cost ledger (wraps every call)CLI tier · keyless (host session auth)API tier · raw key (urllib)Plan floor · agent_action_requiredNativeBackend · parse_prd · expand · rateThreadPoolExecutor fan-out · _expand_packet per packetresolve_provider(main) · single tier decision · cli | api | planeconomy_profile · structured_gen_start tiershift_tier · escalation step on invalid_jsonappend_telemetry · telemetry.jsonl cost ledgercli_agent.generate_json_via_cliexternal model CLI · claude · codex · geminillm_client.generate_jsonvendor API · anthropic · openai · googlereturns agent_action_required packetin-session LLM · decomposes by hand expand() submits packetssubprocess spawn · no API keyHTTP POST · x-api-keyhand-off · NATIVE_PARSE_STEPSeconomy_profile(config) sets start tierstart tierresolve_provider(main)per-packet handle.kindkind == clikind == apikind == plan (floor)append_telemetry (native-cli)append_telemetry (native-api)invalid_json · escalate one tierretry at higher tier (ceiling clamp) + + + + + + + + + + + + + + + + + diff --git a/docs/architecture/rendered/12-component-detect-setup-mode.svg b/docs/architecture/rendered/12-component-detect-setup-mode.svg new file mode 100644 index 0000000..3c7f857 --- /dev/null +++ b/docs/architecture/rendered/12-component-detect-setup-mode.svg @@ -0,0 +1,134 @@ +Detect · Setup · Mode-recommend (Phase 0/1 environment detection)batch.py · orchestratorpreflight.py · environment detectionmode_recommend.py · MCP-wired detect+validate corecapabilities.py · batch-wired capability scanproviders.py · provider resolutionsetup_wizard.py · atlas setup wizardExternal model CLIs (PATH)MCP · CLI surfacesrun_engine_preflight(configure) · one-call Phase-1_backend_block · _backend_summary · build summaryrun_preflight · .taskmaster · PRD · task counts · crash staterun_detect_taskmaster · MCP then CLI then nonevalidate_setup(provider_mode) · 6 SETUP checksdetect_capabilities · exec modes A-J · tierdetect_taskmaster · detect_atlas_launcherrun_detect_capabilities · scan skills+plugins · recommend moderun_configure_providers · repair-on-detectrun_detect_providers · main · fallback · research_provider_usable · _probe_spawn · nested-claude checkrun_setup · detect+recommend · accept · customise · add-key_recommend · _panel · per-role recommendation_run_validate_step · validate_setup plus live probe_live_probe · one-token liveness probeclaude -p okcodex --versiongemini --versionMCP engine_preflightMCP validate_setupMCP detect_capabilitiesCLI atlas setup delegateswires mode_recommend.validate_setupwires mode_recommend.detect_capabilitiescmd_setup1 · probe env2 · probe taskmaster3 · if has_taskmaster4 · resolve providers5 · scan capabilities6 · build summarydetect_atlas_launcher · ATLAS_FLEET_REASON_detect_taskmaster_methodreachability per check 5/6tier · recommended_modebuild panelaccept · customisevalidatedetected providersdetect_capabilities · tiercredential-aware checksper chosen providerspawnspawnspawn_probe_spawn_probe_spawn + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/architecture/rendered/40-runflow-script-vs-llm.svg b/docs/architecture/rendered/40-runflow-script-vs-llm.svg new file mode 100644 index 0000000..abff5ee --- /dev/null +++ b/docs/architecture/rendered/40-runflow-script-vs-llm.svg @@ -0,0 +1,136 @@ +/atlas run · where determinism starts and stops · hexagon=LLM blue=script diamond=gateUser · 'I want to build ...'skills/go · orchestratorPhase 0 · SETUP (mostly script)Phase 1 · DISCOVER (mostly LLM)Phase 2 · GENERATE (hybrid)Phase 3 · HANDOFF (split)Phase 4 · EXECUTE · skills/execute-task (40) · code-gated, LLM-executedcheck_gate('SETUP') · advance_phasecheck_gate('DISCOVER') · advance_phasecheck_gate('GENERATE') · task_count + coverage + gradecheck_gate('HANDOFF') · mode + plan_fileSkill('sync') · refresh memory bank · MANDATORY pre-tokenship-check.py · 5 gates · phase + all-done + CDD + plan + no-nonzero-exitSHIP_CHECK_OK · unfakable token · stdout once · TERMINALgo SKILL · pure routing · reads current_phase · dispatchespreflight() · current_phase() · MCP server.pyscript.py init-project · scaffold .taskmaster · .atlas-aiscript.py backend-detect · resolve_provider · setup wizardvalidate_setup() · probe pipeline wiredsuperpowers:brainstorming · adaptive Q+A · constraints · scaleUser approval gate · AskUserQuestionload_template() · canonical spec shapefill prd.md · LLM writes from discovery + constraintsvalidate_prd() · regex checks · grade · placeholders_foundparse_prd() · backend op · provider-or-CLI, else agentrate_tasks() · complexity report · provider-or-CLIexpand_tasks() · subtasks · provider-or-CLI, else agentdetect_capabilities() · tier · compute_fleet_waves()User mode picker · AskUserQuestion · Mode A/B/C/Dappend_workflow() · idempotent CLAUDE.md writescript.py next-task · pick ready task · deterministicbuild CDD card · script.py set-status in-progressdispatch implementer subagent · LLM does the worktriple verify · /doubt + /validate + Opus merge-checkship-check.py --dry-run · HARD exit-code gatescript.py set-status done · subtask writeback · pipeline.json goalread stateroute · null/SETUPevidence-> DISCOVERapproved · constraints · scale-> GENERATEprd.mdgrade ok · 0 placeholderstasks.jsoncomplexity reportsubtask coverage 1.0-> HANDOFFMode A/B/C/Dmode + plan-> EXECUTEsubagent DONEexit-code cleanloop · next taskall tasks donememory refreshed5 gates pass · exit 0 agent_action_required · regen placeholders_found > 0 · fix specagent fallback · idempotent re-expanddisagree · re-dispatch SHIP_CHECK_FAIL · task-fix-N Mode D locked · re-prompt free + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/architecture/rendered/50-swimlane-setup.svg b/docs/architecture/rendered/50-swimlane-setup.svg new file mode 100644 index 0000000..51d0f61 --- /dev/null +++ b/docs/architecture/rendered/50-swimlane-setup.svg @@ -0,0 +1 @@ +

external model execution

deterministic engine — Python

LLM / agent — non-deterministic

all roles set — skip mutate

not ready — reconfigure providers

fail — fix provider config

pass → advance_phase(DISCOVER)

1 · enter SETUP, run go router preflight
skills/go · skills/setup · check_gate diagnostic

4 · ship-check skel bootstrap
copy customizations + ship-check.py if absent

6 · DETECT-FIRST judgment
do not clobber a working provider config

10 · read rendered panel, announce
MCP-vs-CLI backend, then advance_phase

2 · batch.run_engine_preflight
preflight.run_preflight (env + taskmaster probe)

3 · backend.NativeBackend.init_project
taskmaster.init_taskmaster (vestigial file format)

5 · mode_recommend.detect_capabilities
tier = free | cli | api

7 · providers.run_configure_providers
fill empty roles only · run_detect_providers

8 · setup_wizard.run_setup (probe pipeline)
fleet.save_engine_config (persist backend)

9 · mode_recommend.validate_setup
6 checks · ready + 0 critical failures

11 · pipeline.check_gate('SETUP')
validate_setup.ready · critical_failures == 0

8a · setup_wizard._live_probe
claude / codex / gemini CLI · keyless liveness

DISCOVER ▶

\ No newline at end of file diff --git a/docs/architecture/rendered/51-swimlane-discover.svg b/docs/architecture/rendered/51-swimlane-discover.svg new file mode 100644 index 0000000..d38fbf0 --- /dev/null +++ b/docs/architecture/rendered/51-swimlane-discover.svg @@ -0,0 +1 @@ +

deterministic engine — Python

LLM / agent — non-deterministic

Autonomous mode (no user)

self-approve · assumptions documented

refine — re-ask, update summary

fail — gather missing evidence

pass → advance_phase(GENERATE)

1 · capture goal from skill args
detect Interactive vs Autonomous mode
phases/DISCOVER.md · skills/discover

3 · superpowers:brainstorming
adaptive one-question-at-a-time Q and A

4 · INTERCEPT before writing-plans
capture design / requirements / decisions

5 · extract constraints (CONSTRAINTS CAPTURED)
calibrate scale: Solo / Team / Enterprise

6 · AskUserQuestion approval gate
present discovery summary

3a · Autonomous self-brainstorm
write discovery notes · document assumptions

2 · pipeline.check_gate('DISCOVER')
entry diagnostic (exit gate, expect false)

7 · pipeline.check_gate('DISCOVER')
user_approved OR auto_classification==CLEAR
+ assumptions_documented

GENERATE ▶

\ No newline at end of file diff --git a/docs/architecture/rendered/52-swimlane-generate.svg b/docs/architecture/rendered/52-swimlane-generate.svg new file mode 100644 index 0000000..b5d95a3 --- /dev/null +++ b/docs/architecture/rendered/52-swimlane-generate.svg @@ -0,0 +1 @@ +

external model execution

deterministic engine — Python

LLM / agent — non-deterministic

placeholders / low grade — regenerate

cli tier

api tier

plan tier (no key + no CLI)

hand-built task JSON

fail — regenerate

pass → advance_phase(HANDOFF)

1 · write PRD prose, replace every placeholder
phases/GENERATE.md · skills/generate

6c · plan-floor: decompose to schema by hand
(when engine returns agent_action_required)

2 · templates.run_load_template

3 · validation.run_validate_prd
13 checks + placeholder HARD FAIL

4 · tasks.run_calc_tasks (count formula)

5 · provider_resolver.resolve_provider
tier = cli | api | plan

7 · backend.NativeBackend.parse_prd / expand
economy.shift_tier (escalate) · append_telemetry

8 · parallel.apply_results (atomic merge)
tasks.run_enrich_tasks (complexity)

9 · validation.run_validate_tasks

10 · pipeline.check_gate('GENERATE')
grade ≥ GOOD · tasks>0 · 100% subtasks

6a · cli_agent.generate_json_via_cli
claude / codex / gemini CLI · keyless

6b · llm_client.generate_json
raw vendor API

HANDOFF ▶

\ No newline at end of file diff --git a/docs/architecture/rendered/53-swimlane-handoff.svg b/docs/architecture/rendered/53-swimlane-handoff.svg new file mode 100644 index 0000000..0bfe1af --- /dev/null +++ b/docs/architecture/rendered/53-swimlane-handoff.svg @@ -0,0 +1 @@ +

deterministic engine — Python

LLM / agent — non-deterministic

premium · parallel graph

free / serial · pick free mode

pass → advance_phase(EXECUTE)

1 · phases/HANDOFF.md via skills/handoff ·
enter HANDOFF, copy checklist

7 · recommend ONE mode ·
M / D / C / A / B with reason

8 · write Task Execution Workflow
block into CLAUDE.md

9 · AskUserQuestion ·
structured mode picker

11 · dispatch chosen mode ·
writing-plans / ralph-loop / atlas-loop

2 · mode_recommend.detect_capabilities ·
tier premium/free gating of Mode D / Atlas Fleet

3 · fleet.compute_waves ·
topological waves plus deadlock detect

4 · fleet.route_task plus resolve_backend plus task_tier ·
per-task backend routing

5 · render.handoff_panel ·
spec, task count, capabilities summary

6 · economy.summarize_telemetry ·
cost/usage ledger

10 · fleet.run_fleet_waves ·
wave scheduling JSON (premium dispatch)

12 · pipeline.check_gate('HANDOFF') ·
user_mode_choice plus plan_file_exists

EXECUTE ▶

\ No newline at end of file diff --git a/docs/architecture/rendered/54-swimlane-execute.svg b/docs/architecture/rendered/54-swimlane-execute.svg new file mode 100644 index 0000000..d9a3c3d --- /dev/null +++ b/docs/architecture/rendered/54-swimlane-execute.svg @@ -0,0 +1 @@ +

deterministic engine — Python

LLM / agent — non-deterministic

claimed task JSON

claim/set-status calls

fail → bounce back, fix task

pass → print SHIP_CHECK_OK

loop while tasks remain

all done → SHIP_CHECK_OK + final PR

1 · skills/execute-task solo CDD loop
OR skills/execute-fleet parallel workers

4 · implement task code
CDD RED → GREEN → BLUE

5 · judge GREEN/RED/BLUE evidence
write CDD card + evidence files

2 · fleet.ready_set / fleet.compute_waves
order work into ready set / waves

3 · task_state.run_claim_task · _select_next_task
claim next under lib.locked_update flock → in-progress

6 · task_state.run_set_status
mark task/subtask done

7 · shipcheck.gate_pipeline / gate_tasks / gate_cdd / gate_plan

8 · shipcheck.gate_exit_codes
EXIT_STATUS_RE over .atlas-ai/evidence/** · anti-fake

9 · shipcheck.run_all_gates
all gates pass?

10 · render.execute_panel / shipcheck_panel
feedback.append_feedback · suggestions.append_suggestion

COMPLETE ▶

\ No newline at end of file diff --git a/docs/architecture/rendered/60-sequence-generate-parse-prd.svg b/docs/architecture/rendered/60-sequence-generate-parse-prd.svg new file mode 100644 index 0000000..9824d62 --- /dev/null +++ b/docs/architecture/rendered/60-sequence-generate-parse-prd.svg @@ -0,0 +1 @@ +economy.append_telemetrytasks.json write · _write_tasks_into_tagvalidation.run_validate_tasksVendor APIllm_client.generate_jsonExternal CLI · claude/codex/geminicli_agent.generate_json_via_cliprovider_resolver.resolve_providerNativeBackend.parse_prdInterface · cli.run_parse_prdeconomy.append_telemetrytasks.json write · _write_tasks_into_tagvalidation.run_validate_tasksVendor APIllm_client.generate_jsonExternal CLI · claude/codex/geminicli_agent.generate_json_via_cliprovider_resolver.resolve_providerNativeBackend.parse_prdInterface · cli.run_parse_prdrole config + CLI/key presence + cached spawn probeno spawn, no network hereone parse-retry on bad JSONCliAgentError on no_cli / timeout / nonzero / invalid_jsonalt[CLI succeeds][CliAgentError]one parse-retry · one retry on 429/5xx401/403 fail fast · LLMError kindalt[API succeeds][LLMError no_key][LLMError other]engine does NOT read the PRD or generate — it returns the plan-floorin-session LLM decomposes the PRD by hand,writes Native-shape tasks.json, then runs validate-tasksalt[Tier 1 · kind=cli · keyless CLI-agent][Tier 2 · kind=api · raw-key vendor API][Tier 3 · kind=plan · plan-floor]opt[candidate generated · cli or api tier]In-session LLM · GENERATE.mdparse_prd · prd_path, num_tasks, tag1run_parse_prd2resolve_provider main3ProviderHandle · kind = cli | api | plan4generate_json_via_cli · prompt + schema_hint5subprocess.run · argv, stdin, timeout6stdout JSON envelope7append_telemetry · native-cli, exit, wall_ms8candidate tasks JSON · ai=cli9raise CliAgentError10ok=false · agent_action_required11LLM decomposes by hand12generate_json · prompt + schema_hint + tier13HTTP POST · messages / chat / generateContent14response JSON · content + usage15append_telemetry · native-api, http_status, tokens16candidate + telemetry_ref · ai=api17raise LLMError no_key18ok=false · agent_action_required19LLM decomposes by hand20raise LLMError kind21ok=false · error, kind22ok=false · agent_action_required = _agent_parse_action23agent_action_required · schema_hint + NATIVE_PARSE_STEPS24run_validate_tasks · allow_empty_subtasks=false25ok · or raise CommandError26write tasks atomically into tag27resolved tag28ok=true · task_count, tag, ai, validation29parse result · tasks written30In-session LLM · GENERATE.md \ No newline at end of file diff --git a/docs/architecture/src/00-system-overview.d2 b/docs/architecture/src/00-system-overview.d2 new file mode 100644 index 0000000..336b984 --- /dev/null +++ b/docs/architecture/src/00-system-overview.d2 @@ -0,0 +1,54 @@ +# Atlas / prd-taskmaster — system overview (the whole engine on one page). +# Four layers: LLM/agent prompts → fail-closed interface → deterministic Python core → external. +# Detail for each core area lives in 10/11/12-component-*.d2. Render: d2 --layout elk 00-system-overview.d2 00-system-overview.svg +direction: down + +classes: { + llm: { style.fill: "#E69F00"; style.stroke: "#8a6100"; style.font-color: "#111111"; shape: hexagon } + code: { style.fill: "#56B4E9"; style.stroke: "#1f6f9c"; style.font-color: "#111111" } + ext: { style.fill: "#009E73"; style.stroke: "#00674c"; style.font-color: "#ffffff"; shape: oval } + iface: { style.fill: "#0072B2"; style.stroke: "#013a5e"; style.font-color: "#ffffff" } + gate: { style.fill: "#F0E442"; style.stroke: "#9a8f00"; style.font-color: "#111111"; shape: diamond } + human: { style.fill: "#CC79A7"; style.font-color: "#111111"; shape: person } +} + +title: |md + # Atlas engine — system overview + hexagon = LLM step · rectangle = deterministic Python · blue = interface · oval = external · person = you +| { near: top-center } + +human: "you" { class: human } + +llm_layer: "1 · LLM / agent layer — non-deterministic (prompts the model executes)" { + skillmd: "SKILL.md · /atlas · /go entrypoint" { class: llm } + phases: "phases/*.md · DISCOVER · GENERATE · HANDOFF" { class: llm } + skills: "skills/* · discover · generate · handoff · setup · expand-tasks · execute-task · execute-fleet" { class: llm } +} + +iface_layer: "2 · interface boundary — fail-closed" { + mcp: "mcp-server/server.py · _HardenedMCP · ~31 MCP tools" { class: iface } + cli: "cli.py · DISPATCH · CLI subcommands" { class: iface } +} + +core_layer: "3 · deterministic engine — Python (state in .atlas-ai/)" { + pipeline: "pipeline.py · phase state-machine · check_gate · advance_phase CAS" { class: code } + gen: "GENERATE engine · backend · provider_resolver · cli_agent · llm_client · economy → 11" { class: code } + graph: "task graph · tasks · task_state · validation · parallel → 10" { class: code } + detect: "detect / setup · preflight · batch · mode_recommend · providers · setup_wizard · fleet → 12" { class: code } + ship: "shipcheck.py · 5 gates → SHIP_CHECK_OK" { class: code } + lib: "lib.py · locked_update · atomic IO · shared substrate" { class: code } +} + +ext_layer: "4 · external — out of process" { + clis: "model CLIs · claude · codex · gemini · keyless" { class: ext } + apis: "raw vendor APIs · anthropic · openai · google" { class: ext } + tm: "task-master backend · optional" { class: ext } +} + +human -> llm_layer: "one-line goal" +llm_layer -> iface_layer: "the LLM only reaches the core through these" +iface_layer -> core_layer: "translate calls → deterministic ops" +core_layer.gen -> ext_layer.clis: "cli tier" +core_layer.gen -> ext_layer.apis: "api tier" +core_layer.detect -> ext_layer.clis: "liveness probe" +core_layer.graph -> ext_layer.tm: "optional backend" diff --git a/docs/architecture/src/10-component-deterministic-core.d2 b/docs/architecture/src/10-component-deterministic-core.d2 new file mode 100644 index 0000000..b2786a4 --- /dev/null +++ b/docs/architecture/src/10-component-deterministic-core.d2 @@ -0,0 +1,128 @@ +direction: right +classes: { + llm: { style.fill: "#E69F00"; style.stroke: "#8a6100"; style.font-color: "#111111"; shape: hexagon } + code: { style.fill: "#56B4E9"; style.stroke: "#1f6f9c"; style.font-color: "#111111" } + ext: { style.fill: "#009E73"; style.stroke: "#00674c"; style.font-color: "#ffffff"; shape: oval } + iface: { style.fill: "#0072B2"; style.stroke: "#013a5e"; style.font-color: "#ffffff" } + gate: { style.fill: "#F0E442"; style.stroke: "#9a8f00"; style.font-color: "#111111"; shape: diamond } + human: { style.fill: "#CC79A7"; style.font-color: "#111111"; shape: person } +} + +title: "prd-taskmaster · deterministic core — phase state-machine, gates, task graph" { near: top-center; shape: text; style.font-size: 22 } + +sm: "Phase State Machine · Gates" { + pipeline: "pipeline.py" { + advance: "advance_phase() · CAS on expected_current" { class: code } + legal: "LEGAL_TRANSITIONS · SETUP DISCOVER GENERATE HANDOFF EXECUTE" { class: code } + gate: "check_gate(phase, evidence)" { class: gate } + pre: "preflight() · recommended_action ladder" { class: code } + } + ship: "shipcheck.py" { + g1: "Gate1 · pipeline.json current_phase == EXECUTE" { class: gate } + g2: "Gate2 · every task status == done" { class: gate } + g3: "Gate3 · CDD card per task id" { class: gate } + g4: "Gate4 · plan.md exists" { class: gate } + g5: "Gate5 HARD · no non-zero Exit status N" { class: gate } + run: "run_ship_check() · run_all_gates()" { class: code } + token: "SHIP_CHECK_OK" { class: iface } + } +} + +grade: "Spec Grader" { + valid: "validation.py" { + vprd: "run_validate_prd() · 13 checks · score to grade" { class: code } + ph: "placeholder detect · HARD FAIL floors NEEDS_WORK" { class: gate } + vtasks: "run_validate_tasks() · structural task lint" { class: code } + } +} + +graph: "Task Graph Ops" { + tasks: "tasks.py" { + calc: "run_calc_tasks() · ceil(req x 1.5) clamped to scale band" { class: code } + enrich: "run_enrich_tasks() · write phaseConfig" { class: code } + classify: "_classify_task() · SIMPLE MEDIUM COMPLEX RESEARCH VALIDATION" { class: code } + } + tstate: "task_state.py" { + next: "run_next_task() · in-progress then ready select" { class: code } + claim: "run_claim_task() · select plus mark in-progress" { class: code } + setst: "run_set_status() · update under flock" { class: code } + } +} + +sched: "Scheduler · Parallel Merge" { + fleet: "fleet.py" { + waves: "compute_waves() · dependency-ordered frontier chunks" { class: code } + ready: "ready_set() · pending with deps done" { class: code } + route: "route_task() · tier to backend:model" { class: code } + runwaves: "run_fleet_waves() · waves plus routing" { class: code } + } + par: "parallel.py · agent-parallel research; hybrid SINGLE-WRITER merge" { + packets: "build_packets() · one research packet per task" { class: code } + apply: "apply_results() · merge subtasks plus complexity in ONE atomic write" { class: code } + flat: "extract · inject · tag bridge to flat tasks.json" { class: code } + } +} + +substrate: "Substrate Floor (leaf)" { + lib: "lib.py" { + locked: "locked_update() · flock LOCK_EX read-modify-write" { class: code } + atomic: "atomic_write() · tmp plus os.replace" { class: code } + resolve: "_resolve_tasks_payload() · tagged-over-flat" { class: code } + helpers: "read_json · write_json · CommandError · VAGUE_PATTERN" { class: code } + } +} + +tasksjson: "tasks.json · pipeline.json · validation.json" { class: ext } + +# ── phase machine internal flow ── +sm.pipeline.advance -> sm.pipeline.legal: "validates transition" +sm.pipeline.gate -> sm.pipeline.advance: "must pass before advance" +sm.pipeline.pre -> tasksjson: "reads state" + +# ── ship-check gate chain ── +sm.ship.run -> sm.ship.g1: "runs" +sm.ship.run -> sm.ship.g2: "runs" +sm.ship.run -> sm.ship.g3: "runs" +sm.ship.run -> sm.ship.g4: "runs" +sm.ship.run -> sm.ship.g5: "runs" +sm.ship.g5 -> sm.ship.token: "all pass emits" +sm.ship.g1 -> tasksjson: "reads current_phase" +sm.ship.g2 -> tasksjson: "reads task status" + +# ── GENERATE gate consumes grader + calc + enrich ── +grade.valid.vprd -> grade.valid.ph: "placeholder hard-fail" +grade.valid.vprd -> sm.pipeline.gate: "validation_grade evidence" +graph.tasks.calc -> sm.pipeline.gate: "task_count evidence" +graph.tasks.enrich -> graph.tasks.classify: "uses" + +# ── parallel expand feeds enrich + classify ── +sched.par.packets -> sched.par.apply: "results back" +sched.par.apply -> graph.tasks.enrich: "expanded tasks then enrich" + +# ── selection / scheduling consume the graph ── +graph.tstate.next -> sched.fleet.ready: "uses ready_set" +graph.tstate.claim -> graph.tstate.next: "select then claim" +sched.fleet.runwaves -> sched.fleet.waves: "computes" +sched.fleet.runwaves -> sched.fleet.route: "per-task routing" +sched.fleet.route -> sched.fleet.ready: "tier of pending" + +# ── task_state depends on fleet + parallel ── +graph.tstate.next -> sched.fleet.waves: "imports fleet" +graph.tstate.claim -> sched.par.apply: "imports parallel · get_tasks · TASKS" +sched.fleet.route -> sched.par.apply: "fleet imports parallel" + +# ── EVERYTHING points to the lib.py substrate floor ── +sm.pipeline.advance -> substrate.lib.locked: "locked_update CAS" +sm.pipeline.pre -> substrate.lib.helpers: "read_json" +graph.tasks.enrich -> substrate.lib.resolve: "_resolve_tasks_payload" +grade.valid.vtasks -> substrate.lib.resolve: "tagged-over-flat" +grade.valid.vprd -> substrate.lib.helpers: "VAGUE_PATTERN · sections" +graph.tstate.claim -> substrate.lib.locked: "claim under flock" +graph.tstate.setst -> substrate.lib.locked: "set-status under flock" +sched.fleet.waves -> substrate.lib.helpers: "CommandError · emit" +sched.par.apply -> substrate.lib.atomic: "own atomic write · single-writer" + +# ── persisted state lives behind the locked/atomic substrate ── +substrate.lib.locked -> tasksjson: "atomic CAS writes" +substrate.lib.atomic -> tasksjson: "atomic writes" + diff --git a/docs/architecture/src/11-component-backend-provider-engine.d2 b/docs/architecture/src/11-component-backend-provider-engine.d2 new file mode 100644 index 0000000..e36ca05 --- /dev/null +++ b/docs/architecture/src/11-component-backend-provider-engine.d2 @@ -0,0 +1,65 @@ +direction: right +classes: { + llm: { style.fill: "#E69F00"; style.stroke: "#8a6100"; style.font-color: "#111111"; shape: hexagon } + code: { style.fill: "#56B4E9"; style.stroke: "#1f6f9c"; style.font-color: "#111111" } + ext: { style.fill: "#009E73"; style.stroke: "#00674c"; style.font-color: "#ffffff"; shape: oval } + iface: { style.fill: "#0072B2"; style.stroke: "#013a5e"; style.font-color: "#ffffff" } + gate: { style.fill: "#F0E442"; style.stroke: "#9a8f00"; style.font-color: "#111111"; shape: diamond } + human: { style.fill: "#CC79A7"; style.font-color: "#111111"; shape: person } +} + +title: "prd-taskmaster GENERATE engine · backend.NativeBackend -> resolve_provider tier -> cli | api | plan" { near: top-center; shape: text; style.font-size: 18 } + +engine: "Generation Engine (backend.py)" { + backend: "NativeBackend · parse_prd · expand · rate" { class: code } + fanout: "ThreadPoolExecutor fan-out · _expand_packet per packet" { class: code } + backend -> fanout: "expand() submits packets" +} + +resolver: "provider_resolver.py" { + resolve: "resolve_provider(main) · single tier decision · cli | api | plan" { class: gate } +} + +economy: "economy.py · tier ladder + cost ledger (wraps every call)" { + profile: "economy_profile · structured_gen_start tier" { class: code } + shift: "shift_tier · escalation step on invalid_json" { class: code } + telemetry: "append_telemetry · telemetry.jsonl cost ledger" { class: code } +} + +cli_path: "CLI tier · keyless (host session auth)" { + cli_gen: "cli_agent.generate_json_via_cli" { class: code } + cli_model: "external model CLI · claude · codex · gemini" { class: ext } + cli_gen -> cli_model: "subprocess spawn · no API key" +} + +api_path: "API tier · raw key (urllib)" { + api_gen: "llm_client.generate_json" { class: code } + vendor: "vendor API · anthropic · openai · google" { class: ext } + api_gen -> vendor: "HTTP POST · x-api-key" +} + +plan_path: "Plan floor · agent_action_required" { + action: "returns agent_action_required packet" { class: iface } + insession: "in-session LLM · decomposes by hand" { class: llm } + action -> insession: "hand-off · NATIVE_PARSE_STEPS" +} + +# economy wraps every generation call (tier in, telemetry out) +engine.backend -> economy.profile: "economy_profile(config) sets start tier" +economy.profile -> resolver.resolve: "start tier" + +# the single tier decision, made per role at gen time +engine.backend -> resolver.resolve: "resolve_provider(main)" +engine.fanout -> resolver.resolve: "per-packet handle.kind" + +# THREE branches out of the gate +resolver.resolve -> cli_path.cli_gen: "kind == cli" +resolver.resolve -> api_path.api_gen: "kind == api" +resolver.resolve -> plan_path.action: "kind == plan (floor)" + +# economy wrapping: every call emits telemetry; api invalid_json triggers shift_tier escalation +cli_path.cli_gen -> economy.telemetry: "append_telemetry (native-cli)" +api_path.api_gen -> economy.telemetry: "append_telemetry (native-api)" +api_path.api_gen -> economy.shift: "invalid_json · escalate one tier" +economy.shift -> api_path.api_gen: "retry at higher tier (ceiling clamp)" + diff --git a/docs/architecture/src/12-component-detect-setup-mode.d2 b/docs/architecture/src/12-component-detect-setup-mode.d2 new file mode 100644 index 0000000..7baa785 --- /dev/null +++ b/docs/architecture/src/12-component-detect-setup-mode.d2 @@ -0,0 +1,104 @@ +direction: right +classes: { + llm: { style.fill: "#E69F00"; style.stroke: "#8a6100"; style.font-color: "#111111"; shape: hexagon } + code: { style.fill: "#56B4E9"; style.stroke: "#1f6f9c"; style.font-color: "#111111" } + ext: { style.fill: "#009E73"; style.stroke: "#00674c"; style.font-color: "#ffffff"; shape: oval } + iface: { style.fill: "#0072B2"; style.stroke: "#013a5e"; style.font-color: "#ffffff" } + gate: { style.fill: "#F0E442"; style.stroke: "#9a8f00"; style.font-color: "#111111"; shape: diamond } + human: { style.fill: "#CC79A7"; style.font-color: "#111111"; shape: person } +} + +title: "Detect · Setup · Mode-recommend (Phase 0/1 environment detection)" { near: top-center; shape: text; style.font-size: 22 } + +# ── Orchestrator entry ─────────────────────────────────────────────── +orch: "batch.py · orchestrator" { + engine_pf: "run_engine_preflight(configure) · one-call Phase-1" { class: code } + bblock: "_backend_block · _backend_summary · build summary" { class: code } +} + +# ── Environment / project detection ────────────────────────────────── +detect: "preflight.py · environment detection" { + preflight: "run_preflight · .taskmaster · PRD · task counts · crash state" { class: code } + detect_tm: "run_detect_taskmaster · MCP then CLI then none" { class: code } +} + +# ── mode_recommend.py — the MCP-wired core ─────────────────────────── +moderec: "mode_recommend.py · MCP-wired detect+validate core" { + mr_validate: "validate_setup(provider_mode) · 6 SETUP checks" { class: code } + mr_caps: "detect_capabilities · exec modes A-J · tier" { class: code } + mr_tm: "detect_taskmaster · detect_atlas_launcher" { class: code } +} + +# ── capabilities.py — the batch-wired capability scan ──────────────── +caps: "capabilities.py · batch-wired capability scan" { + run_caps: "run_detect_capabilities · scan skills+plugins · recommend mode" { class: code } +} + +# ── providers.py — provider config + detect ────────────────────────── +prov: "providers.py · provider resolution" { + configure: "run_configure_providers · repair-on-detect" { class: code } + detect_prov: "run_detect_providers · main · fallback · research" { class: code } + usable: "_provider_usable · _probe_spawn · nested-claude check" { class: code } +} + +# ── setup_wizard.py — atlas setup CLI ──────────────────────────────── +wizard: "setup_wizard.py · atlas setup wizard" { + run_setup: "run_setup · detect+recommend · accept · customise · add-key" { class: code } + recommend: "_recommend · _panel · per-role recommendation" { class: code } + validate_step: "_run_validate_step · validate_setup plus live probe" { class: code } + live_probe: "_live_probe · one-token liveness probe" { class: code } +} + +# ── External model CLIs (touched by live probes) ───────────────────── +clis: "External model CLIs (PATH)" { + claude_cli: "claude -p ok" { class: ext } + codex_cli: "codex --version" { class: ext } + gemini_cli: "gemini --version" { class: ext } +} + +# ── MCP / CLI surfaces ─────────────────────────────────────────────── +surfaces: "MCP · CLI surfaces" { + mcp_engine: "MCP engine_preflight" { class: iface } + mcp_validate: "MCP validate_setup" { class: iface } + mcp_caps: "MCP detect_capabilities" { class: iface } + cli_setup: "CLI atlas setup" { class: iface } +} + +# ── Surface wiring ─────────────────────────────────────────────────── +surfaces.mcp_engine -> orch.engine_pf: "delegates" +surfaces.mcp_validate -> moderec.mr_validate: "wires mode_recommend.validate_setup" +surfaces.mcp_caps -> moderec.mr_caps: "wires mode_recommend.detect_capabilities" +surfaces.cli_setup -> wizard.run_setup: "cmd_setup" + +# ── Orchestrator fan-out (batch calls the others) ──────────────────── +orch.engine_pf -> detect.preflight: "1 · probe env" +orch.engine_pf -> detect.detect_tm: "2 · probe taskmaster" +orch.engine_pf -> prov.configure: "3 · if has_taskmaster" +orch.engine_pf -> prov.detect_prov: "4 · resolve providers" +orch.engine_pf -> caps.run_caps: "5 · scan capabilities" +orch.engine_pf -> orch.bblock: "6 · build summary" + +# ── capabilities.py uses mode_recommend helpers ────────────────────── +caps.run_caps -> moderec.mr_tm: "detect_atlas_launcher · ATLAS_FLEET_REASON" +caps.run_caps -> detect.detect_tm: "_detect_taskmaster_method" + +# ── mode_recommend internal + provider reachability ────────────────── +moderec.mr_validate -> prov.usable: "reachability per check 5/6" +moderec.mr_caps -> moderec.mr_tm: "tier · recommended_mode" + +# ── setup_wizard internal wiring ───────────────────────────────────── +wizard.run_setup -> wizard.recommend: "build panel" +wizard.run_setup -> prov.configure: "accept · customise" +wizard.run_setup -> wizard.validate_step: "validate" +wizard.recommend -> prov.detect_prov: "detected providers" +wizard.recommend -> moderec.mr_caps: "detect_capabilities · tier" +wizard.validate_step -> moderec.mr_validate: "credential-aware checks" +wizard.validate_step -> wizard.live_probe: "per chosen provider" + +# ── live probes touch external CLIs ────────────────────────────────── +wizard.live_probe -> clis.claude_cli: "spawn" +wizard.live_probe -> clis.codex_cli: "spawn" +wizard.live_probe -> clis.gemini_cli: "spawn" +prov.usable -> clis.claude_cli: "_probe_spawn" +prov.usable -> clis.codex_cli: "_probe_spawn" + diff --git a/docs/architecture/src/40-runflow-script-vs-llm.d2 b/docs/architecture/src/40-runflow-script-vs-llm.d2 new file mode 100644 index 0000000..7fafd8a --- /dev/null +++ b/docs/architecture/src/40-runflow-script-vs-llm.d2 @@ -0,0 +1,117 @@ +direction: right +classes: { + llm: { style.fill: "#E69F00"; style.stroke: "#8a6100"; style.font-color: "#111111"; shape: hexagon } + code: { style.fill: "#56B4E9"; style.stroke: "#1f6f9c"; style.font-color: "#111111" } + ext: { style.fill: "#009E73"; style.stroke: "#00674c"; style.font-color: "#ffffff"; shape: oval } + iface: { style.fill: "#0072B2"; style.stroke: "#013a5e"; style.font-color: "#ffffff" } + gate: { style.fill: "#F0E442"; style.stroke: "#9a8f00"; style.font-color: "#111111"; shape: diamond } + human: { style.fill: "#CC79A7"; style.font-color: "#111111"; shape: person } +} + +title: "/atlas run · where determinism starts and stops · hexagon=LLM blue=script diamond=gate" { near: top-center; shape: text; style.font-size: 22 } + +# ── Entry: human goal + LLM router ────────────────────────────── +user: "User · 'I want to build ...'" { class: human } + +router: "skills/go · orchestrator" { + go: "go SKILL · pure routing · reads current_phase · dispatches" { class: llm } + pf: "preflight() · current_phase() · MCP server.py" { class: code } +} + +# ── Phase 0: SETUP (mostly code) ──────────────────────────────── +setup: "Phase 0 · SETUP (mostly script)" { + init: "script.py init-project · scaffold .taskmaster · .atlas-ai" { class: code } + cfg: "script.py backend-detect · resolve_provider · setup wizard" { class: code } + vs: "validate_setup() · probe pipeline wired" { class: code } +} + +# ── Phase 1: DISCOVER (mostly LLM) ────────────────────────────── +discover: "Phase 1 · DISCOVER (mostly LLM)" { + bs: "superpowers:brainstorming · adaptive Q+A · constraints · scale" { class: llm } + approve: "User approval gate · AskUserQuestion" { class: human } +} + +# ── Phase 2: GENERATE (hybrid) ────────────────────────────────── +generate: "Phase 2 · GENERATE (hybrid)" { + tmpl: "load_template() · canonical spec shape" { class: code } + spec: "fill prd.md · LLM writes from discovery + constraints" { class: llm } + val: "validate_prd() · regex checks · grade · placeholders_found" { class: code } + parse: "parse_prd() · backend op · provider-or-CLI, else agent" { class: llm } + rate: "rate_tasks() · complexity report · provider-or-CLI" { class: llm } + expand: "expand_tasks() · subtasks · provider-or-CLI, else agent" { class: llm } +} + +# ── Phase 3: HANDOFF (split) ──────────────────────────────────── +handoff: "Phase 3 · HANDOFF (split)" { + det: "detect_capabilities() · tier · compute_fleet_waves()" { class: code } + pick: "User mode picker · AskUserQuestion · Mode A/B/C/D" { class: human } + append: "append_workflow() · idempotent CLAUDE.md write" { class: code } +} + +# ── Phase 4: EXECUTE (code-gated · LLM-executed) ──────────────── +execute: "Phase 4 · EXECUTE · skills/execute-task (40) · code-gated, LLM-executed" { + nxt: "script.py next-task · pick ready task · deterministic" { class: code } + card: "build CDD card · script.py set-status in-progress" { class: code } + impl: "dispatch implementer subagent · LLM does the work" { class: llm } + triple: "triple verify · /doubt + /validate + Opus merge-check" { class: llm } + hardgate: "ship-check.py --dry-run · HARD exit-code gate" { class: gate } + done: "script.py set-status done · subtask writeback · pipeline.json" { class: code } +} + +# ── Inter-phase gates (deterministic check_gate diamonds) ─────── +g_setup: "check_gate('SETUP') · advance_phase" { class: gate } +g_disc: "check_gate('DISCOVER') · advance_phase" { class: gate } +g_gen: "check_gate('GENERATE') · task_count + coverage + grade" { class: gate } +g_hand: "check_gate('HANDOFF') · mode + plan_file" { class: gate } + +# ── Terminal deterministic gate ───────────────────────────────── +sync: "Skill('sync') · refresh memory bank · MANDATORY pre-token" { class: llm } +ship: "ship-check.py · 5 gates · phase + all-done + CDD + plan + no-nonzero-exit" { class: gate } +ok: "SHIP_CHECK_OK · unfakable token · stdout once · TERMINAL" { class: ext } + +# ── Main left-to-right flow ───────────────────────────────────── +user -> router.go: "goal" +router.go -> router.pf: "read state" +router.pf -> setup.init: "route · null/SETUP" + +setup.init -> setup.cfg +setup.cfg -> setup.vs +setup.vs -> g_setup: "evidence" +g_setup -> discover.bs: "-> DISCOVER" + +discover.bs -> discover.approve +discover.approve -> g_disc: "approved · constraints · scale" +g_disc -> generate.tmpl: "-> GENERATE" + +generate.tmpl -> generate.spec +generate.spec -> generate.val: "prd.md" +generate.val -> generate.parse: "grade ok · 0 placeholders" +generate.parse -> generate.rate: "tasks.json" +generate.rate -> generate.expand: "complexity report" +generate.expand -> g_gen: "subtask coverage 1.0" +g_gen -> handoff.det: "-> HANDOFF" + +handoff.det -> handoff.pick +handoff.pick -> handoff.append: "Mode A/B/C/D" +handoff.append -> g_hand: "mode + plan" +g_hand -> execute.nxt: "-> EXECUTE" + +execute.nxt -> execute.card +execute.card -> execute.impl +execute.impl -> execute.triple: "subagent DONE" +execute.triple -> execute.hardgate +execute.hardgate -> execute.done: "exit-code clean" + +execute.done -> execute.nxt: "loop · next task" +execute.nxt -> sync: "all tasks done" +sync -> ship: "memory refreshed" +ship -> ok: "5 gates pass · exit 0" + +# ── Dashed bounce-backs (agent_action_required · regenerate) ──── +generate.parse -> generate.spec: "agent_action_required · regen" { style.stroke-dash: 4; style.stroke: "#8a6100" } +generate.val -> generate.spec: "placeholders_found > 0 · fix spec" { style.stroke-dash: 4; style.stroke: "#1f6f9c" } +generate.expand -> generate.expand: "agent fallback · idempotent re-expand" { style.stroke-dash: 4; style.stroke: "#8a6100" } +execute.triple -> execute.impl: "disagree · re-dispatch" { style.stroke-dash: 4; style.stroke: "#8a6100" } +execute.hardgate -> execute.impl: "SHIP_CHECK_FAIL · task-fix-N" { style.stroke-dash: 4; style.stroke: "#9a8f00" } +handoff.pick -> handoff.pick: "Mode D locked · re-prompt free" { style.stroke-dash: 4; style.stroke: "#013a5e" } + diff --git a/docs/architecture/src/50-swimlane-setup.mmd b/docs/architecture/src/50-swimlane-setup.mmd new file mode 100644 index 0000000..49f4fcb --- /dev/null +++ b/docs/architecture/src/50-swimlane-setup.mmd @@ -0,0 +1,48 @@ +%% SETUP phase — script vs LLM swimlane. +%% Okabe-Ito colorblind-safe palette + shape redundancy: +%% hexagon = LLM / agent step (output varies run-to-run) +%% rectangle = deterministic engine code +%% stadium = external model execution +%% diamond = deterministic gate +%% Solid edge = deterministic control flow. Dashed edge = LLM-routed / bounce-back. +flowchart LR + classDef llm fill:#E69F00,stroke:#8a6100,color:#111111; + classDef code fill:#56B4E9,stroke:#1f6f9c,color:#111111; + classDef ext fill:#009E73,stroke:#00674c,color:#ffffff; + classDef gate fill:#F0E442,stroke:#9a8f00,color:#111111; + + subgraph LANE_LLM["LLM / agent — non-deterministic"] + direction TB + L1{{"1 · enter SETUP, run go router preflight
skills/go · skills/setup · check_gate diagnostic"}}:::llm + L2{{"4 · ship-check skel bootstrap
copy customizations + ship-check.py if absent"}}:::llm + L3{{"6 · DETECT-FIRST judgment
do not clobber a working provider config"}}:::llm + L4{{"10 · read rendered panel, announce
MCP-vs-CLI backend, then advance_phase"}}:::llm + end + + subgraph LANE_CODE["deterministic engine — Python"] + direction TB + C1["2 · batch.run_engine_preflight
preflight.run_preflight (env + taskmaster probe)"]:::code + C2["3 · backend.NativeBackend.init_project
taskmaster.init_taskmaster (vestigial file format)"]:::code + C3["5 · mode_recommend.detect_capabilities
tier = free | cli | api"]:::code + C4["7 · providers.run_configure_providers
fill empty roles only · run_detect_providers"]:::code + C5["8 · setup_wizard.run_setup (probe pipeline)
fleet.save_engine_config (persist backend)"]:::code + C6["9 · mode_recommend.validate_setup
6 checks · ready + 0 critical failures"]:::code + G1{"11 · pipeline.check_gate('SETUP')
validate_setup.ready · critical_failures == 0"}:::gate + end + + subgraph LANE_EXT["external model execution"] + direction TB + E1(["8a · setup_wizard._live_probe
claude / codex / gemini CLI · keyless liveness"]):::ext + end + + L1 --> C1 --> C2 --> L2 + L2 --> C3 --> L3 + L3 -. "all roles set — skip mutate" .-> C5 + L3 --> C4 --> C5 + C5 --> E1 + E1 --> C6 + C6 -. "not ready — reconfigure providers" .-> L3 + C6 --> L4 --> G1 + G1 -. "fail — fix provider config" .-> L3 + G1 == "pass → advance_phase(DISCOVER)" ==> DONE(["DISCOVER ▶"]):::code + diff --git a/docs/architecture/src/51-swimlane-discover.mmd b/docs/architecture/src/51-swimlane-discover.mmd new file mode 100644 index 0000000..59292e7 --- /dev/null +++ b/docs/architecture/src/51-swimlane-discover.mmd @@ -0,0 +1,39 @@ +%% DISCOVER phase — script vs LLM swimlane. +%% Okabe-Ito colorblind-safe palette + shape redundancy: +%% hexagon = LLM / agent step (output varies run-to-run) +%% rectangle = deterministic engine code +%% stadium = external model execution +%% diamond = deterministic gate +%% Solid edge = deterministic control flow. Dashed edge = LLM-routed / bounce-back. +%% DISCOVER is LLM-dominant: no external model lane. +flowchart LR + classDef llm fill:#E69F00,stroke:#8a6100,color:#111111; + classDef code fill:#56B4E9,stroke:#1f6f9c,color:#111111; + classDef ext fill:#009E73,stroke:#00674c,color:#ffffff; + classDef gate fill:#F0E442,stroke:#9a8f00,color:#111111; + + subgraph LANE_LLM["LLM / agent — non-deterministic"] + direction TB + L1{{"1 · capture goal from skill args
detect Interactive vs Autonomous mode
phases/DISCOVER.md · skills/discover"}}:::llm + L2{{"3 · superpowers:brainstorming
adaptive one-question-at-a-time Q and A"}}:::llm + L3{{"4 · INTERCEPT before writing-plans
capture design / requirements / decisions"}}:::llm + L4{{"5 · extract constraints (CONSTRAINTS CAPTURED)
calibrate scale: Solo / Team / Enterprise"}}:::llm + L5{{"6 · AskUserQuestion approval gate
present discovery summary"}}:::llm + L6{{"3a · Autonomous self-brainstorm
write discovery notes · document assumptions"}}:::llm + end + + subgraph LANE_CODE["deterministic engine — Python"] + direction TB + C1["2 · pipeline.check_gate('DISCOVER')
entry diagnostic (exit gate, expect false)"]:::code + G1{"7 · pipeline.check_gate('DISCOVER')
user_approved OR auto_classification==CLEAR
+ assumptions_documented"}:::gate + end + + L1 --> C1 --> L2 + L1 -. "Autonomous mode (no user)" .-> L6 + L2 --> L3 --> L4 --> L5 + L6 -. "self-approve · assumptions documented" .-> L4 + L5 -. "refine — re-ask, update summary" .-> L2 + L5 --> G1 + G1 -. "fail — gather missing evidence" .-> L4 + G1 == "pass → advance_phase(GENERATE)" ==> DONE(["GENERATE ▶"]):::code + diff --git a/docs/architecture/src/52-swimlane-generate.mmd b/docs/architecture/src/52-swimlane-generate.mmd new file mode 100644 index 0000000..2f522c9 --- /dev/null +++ b/docs/architecture/src/52-swimlane-generate.mmd @@ -0,0 +1,49 @@ +%% GENERATE phase — script vs LLM swimlane. +%% Okabe-Ito colorblind-safe palette + shape redundancy: +%% hexagon = LLM / agent step (output varies run-to-run) +%% rectangle = deterministic engine code +%% stadium = external model execution +%% diamond = deterministic gate +%% Solid edge = deterministic control flow. Dashed edge = LLM-routed / bounce-back. +flowchart LR + classDef llm fill:#E69F00,stroke:#8a6100,color:#111111; + classDef code fill:#56B4E9,stroke:#1f6f9c,color:#111111; + classDef ext fill:#009E73,stroke:#00674c,color:#ffffff; + classDef gate fill:#F0E442,stroke:#9a8f00,color:#111111; + + subgraph LANE_LLM["LLM / agent — non-deterministic"] + direction TB + L1{{"1 · write PRD prose, replace every placeholder
phases/GENERATE.md · skills/generate"}}:::llm + L2{{"6c · plan-floor: decompose to schema by hand
(when engine returns agent_action_required)"}}:::llm + end + + subgraph LANE_CODE["deterministic engine — Python"] + direction TB + C1["2 · templates.run_load_template"]:::code + C2["3 · validation.run_validate_prd
13 checks + placeholder HARD FAIL"]:::code + C3["4 · tasks.run_calc_tasks (count formula)"]:::code + C4["5 · provider_resolver.resolve_provider
tier = cli | api | plan"]:::code + C5["7 · backend.NativeBackend.parse_prd / expand
economy.shift_tier (escalate) · append_telemetry"]:::code + C6["8 · parallel.apply_results (atomic merge)
tasks.run_enrich_tasks (complexity)"]:::code + C7["9 · validation.run_validate_tasks"]:::code + G1{"10 · pipeline.check_gate('GENERATE')
grade ≥ GOOD · tasks>0 · 100% subtasks"}:::gate + end + + subgraph LANE_EXT["external model execution"] + direction TB + E1(["6a · cli_agent.generate_json_via_cli
claude / codex / gemini CLI · keyless"]):::ext + E2(["6b · llm_client.generate_json
raw vendor API"]):::ext + end + + L1 --> C1 --> C2 + C2 -. "placeholders / low grade — regenerate" .-> L1 + C2 --> C3 --> C4 + C4 -- "cli tier" --> E1 + C4 -- "api tier" --> E2 + C4 -. "plan tier (no key + no CLI)" .-> L2 + E1 --> C5 + E2 --> C5 + L2 -. "hand-built task JSON" .-> C5 + C5 --> C6 --> C7 --> G1 + G1 -. "fail — regenerate" .-> L1 + G1 == "pass → advance_phase(HANDOFF)" ==> DONE(["HANDOFF ▶"]):::code diff --git a/docs/architecture/src/53-swimlane-handoff.mmd b/docs/architecture/src/53-swimlane-handoff.mmd new file mode 100644 index 0000000..092c2b6 --- /dev/null +++ b/docs/architecture/src/53-swimlane-handoff.mmd @@ -0,0 +1,34 @@ +flowchart LR + classDef llm fill:#E69F00,stroke:#8a6100,color:#111111; + classDef code fill:#56B4E9,stroke:#1f6f9c,color:#111111; + classDef ext fill:#009E73,stroke:#00674c,color:#ffffff; + classDef gate fill:#F0E442,stroke:#9a8f00,color:#111111; + + subgraph LANE_LLM["LLM / agent — non-deterministic"] + direction TB + L1{{"1 · phases/HANDOFF.md via skills/handoff ·
enter HANDOFF, copy checklist"}}:::llm + L7{{"7 · recommend ONE mode ·
M / D / C / A / B with reason"}}:::llm + L8{{"8 · write Task Execution Workflow
block into CLAUDE.md"}}:::llm + L9{{"9 · AskUserQuestion ·
structured mode picker"}}:::llm + L11{{"11 · dispatch chosen mode ·
writing-plans / ralph-loop / atlas-loop"}}:::llm + end + + subgraph LANE_CODE["deterministic engine — Python"] + direction TB + C2["2 · mode_recommend.detect_capabilities ·
tier premium/free gating of Mode D / Atlas Fleet"]:::code + C3["3 · fleet.compute_waves ·
topological waves plus deadlock detect"]:::code + C4["4 · fleet.route_task plus resolve_backend plus task_tier ·
per-task backend routing"]:::code + C5["5 · render.handoff_panel ·
spec, task count, capabilities summary"]:::code + C6["6 · economy.summarize_telemetry ·
cost/usage ledger"]:::code + C10["10 · fleet.run_fleet_waves ·
wave scheduling JSON (premium dispatch)"]:::code + G12{"12 · pipeline.check_gate('HANDOFF') ·
user_mode_choice plus plan_file_exists"}:::gate + end + + L1 --> C2 --> C3 --> C4 --> C5 --> C6 --> L7 + L7 --> L8 --> L9 + L9 -. "premium · parallel graph" .-> C10 + C10 --> L11 + L9 -. "free / serial · pick free mode" .-> L11 + L11 --> G12 + G12 == "pass → advance_phase(EXECUTE)" ==> DONE(["EXECUTE ▶"]):::code + diff --git a/docs/architecture/src/54-swimlane-execute.mmd b/docs/architecture/src/54-swimlane-execute.mmd new file mode 100644 index 0000000..1be12a9 --- /dev/null +++ b/docs/architecture/src/54-swimlane-execute.mmd @@ -0,0 +1,31 @@ +flowchart LR + classDef llm fill:#E69F00,stroke:#8a6100,color:#111111; + classDef code fill:#56B4E9,stroke:#1f6f9c,color:#111111; + classDef ext fill:#009E73,stroke:#00674c,color:#ffffff; + classDef gate fill:#F0E442,stroke:#9a8f00,color:#111111; + subgraph LANE_LLM["LLM / agent — non-deterministic"] + direction TB + L1{{"1 · skills/execute-task solo CDD loop
OR skills/execute-fleet parallel workers"}}:::llm + L2{{"4 · implement task code
CDD RED → GREEN → BLUE"}}:::llm + L3{{"5 · judge GREEN/RED/BLUE evidence
write CDD card + evidence files"}}:::llm + end + subgraph LANE_CODE["deterministic engine — Python"] + direction TB + C1["2 · fleet.ready_set / fleet.compute_waves
order work into ready set / waves"]:::code + C2["3 · task_state.run_claim_task · _select_next_task
claim next under lib.locked_update flock → in-progress"]:::code + C3["6 · task_state.run_set_status
mark task/subtask done"]:::code + C4["7 · shipcheck.gate_pipeline / gate_tasks / gate_cdd / gate_plan"]:::code + C5["8 · shipcheck.gate_exit_codes
EXIT_STATUS_RE over .atlas-ai/evidence/** · anti-fake"]:::code + G1{"9 · shipcheck.run_all_gates
all gates pass?"}:::gate + C6["10 · render.execute_panel / shipcheck_panel
feedback.append_feedback · suggestions.append_suggestion"]:::code + end + L1 --> C1 --> C2 + C2 -. "claimed task JSON" .-> L2 + L2 --> L3 + L3 -. "claim/set-status calls" .-> C3 + C3 --> C4 --> C5 --> G1 + G1 -. "fail → bounce back, fix task" .-> L2 + G1 == "pass → print SHIP_CHECK_OK" ==> C6 + C6 == "loop while tasks remain" ==> C1 + C6 == "all done → SHIP_CHECK_OK + final PR" ==> DONE(["COMPLETE ▶"]):::code + diff --git a/docs/architecture/src/60-sequence-generate-parse-prd.mmd b/docs/architecture/src/60-sequence-generate-parse-prd.mmd new file mode 100644 index 0000000..407f596 --- /dev/null +++ b/docs/architecture/src/60-sequence-generate-parse-prd.mmd @@ -0,0 +1,66 @@ +%% prd-taskmaster engine: ONE parse-PRD operation across the three provider tiers. +%% Source: prd_taskmaster/{cli.py,backend.py,provider_resolver.py,cli_agent.py,llm_client.py,validation.py} +sequenceDiagram + autonumber + actor LLM as In-session LLM · GENERATE.md + participant IF as Interface · cli.run_parse_prd + participant BE as NativeBackend.parse_prd + participant RES as provider_resolver.resolve_provider + participant CLIA as cli_agent.generate_json_via_cli + participant EXT as External CLI · claude/codex/gemini + participant API as llm_client.generate_json + participant VEND as Vendor API + participant VAL as validation.run_validate_tasks + participant STORE as tasks.json write · _write_tasks_into_tag + participant TEL as economy.append_telemetry + + LLM->>IF: parse_prd · prd_path, num_tasks, tag + IF->>BE: run_parse_prd + BE->>RES: resolve_provider main + Note over RES: role config + CLI/key presence + cached spawn probe
no spawn, no network here + RES-->>BE: ProviderHandle · kind = cli | api | plan + + alt Tier 1 · kind=cli · keyless CLI-agent + BE->>CLIA: generate_json_via_cli · prompt + schema_hint + CLIA->>EXT: subprocess.run · argv, stdin, timeout + EXT-->>CLIA: stdout JSON envelope + CLIA->>TEL: append_telemetry · native-cli, exit, wall_ms + Note over CLIA: one parse-retry on bad JSON
CliAgentError on no_cli / timeout / nonzero / invalid_json + alt CLI succeeds + CLIA-->>BE: candidate tasks JSON · ai=cli + else CliAgentError + CLIA-->>BE: raise CliAgentError + BE-->>IF: ok=false · agent_action_required + IF-->>LLM: LLM decomposes by hand + end + else Tier 2 · kind=api · raw-key vendor API + BE->>API: generate_json · prompt + schema_hint + tier + API->>VEND: HTTP POST · messages / chat / generateContent + VEND-->>API: response JSON · content + usage + API->>TEL: append_telemetry · native-api, http_status, tokens + Note over API: one parse-retry · one retry on 429/5xx
401/403 fail fast · LLMError kind + alt API succeeds + API-->>BE: candidate + telemetry_ref · ai=api + else LLMError no_key + API-->>BE: raise LLMError no_key + BE-->>IF: ok=false · agent_action_required + IF-->>LLM: LLM decomposes by hand + else LLMError other + API-->>BE: raise LLMError kind + BE-->>IF: ok=false · error, kind + end + else Tier 3 · kind=plan · plan-floor + Note over BE,LLM: engine does NOT read the PRD or generate — it returns the plan-floor + BE-->>IF: ok=false · agent_action_required = _agent_parse_action + IF-->>LLM: agent_action_required · schema_hint + NATIVE_PARSE_STEPS + Note over LLM: in-session LLM decomposes the PRD by hand,
writes Native-shape tasks.json, then runs validate-tasks + end + + opt candidate generated · cli or api tier + BE->>VAL: run_validate_tasks · allow_empty_subtasks=false + VAL-->>BE: ok · or raise CommandError + BE->>STORE: write tasks atomically into tag + STORE-->>BE: resolved tag + BE-->>IF: ok=true · task_count, tag, ai, validation + IF-->>LLM: parse result · tasks written + end From af63d8e3108deb4634041acad4a2531b9ce69230 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 13:20:46 +0800 Subject: [PATCH 39/73] fix(validation): target technical considerations heading --- prd_taskmaster/validation.py | 9 ++- tests/core/test_validation.py | 130 ++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/prd_taskmaster/validation.py b/prd_taskmaster/validation.py index adbc95d..b9a5013 100644 --- a/prd_taskmaster/validation.py +++ b/prd_taskmaster/validation.py @@ -159,7 +159,14 @@ def run_validate_prd(input_path: str) -> dict: }) # Check 9: Technical considerations address architecture - tech_section = get_section_content(text, "Technical") + # Prefer the canonical "Technical Considerations" section. A bare substring + # match on "Technical" wrongly latches onto an earlier heading that merely + # *contains* the word (e.g. "### Goal 1: Enable non-technical editing"), + # capturing that subsection's prose instead of the real architecture text. + # Fall back to a bare "Technical" heading for PRDs that use that shorter name. + tech_section = get_section_content(text, "Technical Considerations") + if not tech_section: + tech_section = get_section_content(text, "Technical") has_arch = bool(re.search( r'(architecture|system\s+design|component|integration|diagram)', tech_section, re.IGNORECASE diff --git a/tests/core/test_validation.py b/tests/core/test_validation.py index 69d2578..a3a12ee 100644 --- a/tests/core/test_validation.py +++ b/tests/core/test_validation.py @@ -612,6 +612,51 @@ def test_lowercase_angle_token_file_not_flagged(self, tmp_path): f" in testStrategy was falsely flagged: {placeholder_probs}" ) + def test_html_jsx_tags_in_details_not_flagged(self, tmp_path): + """