@julioborges/gantry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.agents/skills/gantry/SKILL.md +166 -0
  2. package/.agents/skills/gantry/capabilities/claude-code.json +15 -0
  3. package/.agents/skills/gantry/capabilities/codex.json +14 -0
  4. package/.agents/skills/gantry/capabilities/opencode.json +15 -0
  5. package/.agents/skills/gantry/dashboard/static/app.js +100 -0
  6. package/.agents/skills/gantry/dashboard/static/index.html +16 -0
  7. package/.agents/skills/gantry/dashboard/static/style.css +74 -0
  8. package/.agents/skills/gantry/hooks/claude-code.settings.json +56 -0
  9. package/.agents/skills/gantry/hooks/codex.hooks.json +4 -0
  10. package/.agents/skills/gantry/hooks/git/pre-commit +77 -0
  11. package/.agents/skills/gantry/hooks/git/pre-push +123 -0
  12. package/.agents/skills/gantry/hooks/git/skipscan.py +88 -0
  13. package/.agents/skills/gantry/hooks/opencode.plugin.js +44 -0
  14. package/.agents/skills/gantry/reference/plan-workflow.md +383 -0
  15. package/.agents/skills/gantry/reference/round-workflow.md +755 -0
  16. package/.agents/skills/gantry/schemas/critic.json +93 -0
  17. package/.agents/skills/gantry/schemas/implementer.json +52 -0
  18. package/.agents/skills/gantry/schemas/learner.json +35 -0
  19. package/.agents/skills/gantry/schemas/plan-critic.json +39 -0
  20. package/.agents/skills/gantry/schemas/planner.json +64 -0
  21. package/.agents/skills/gantry/schemas/requirement-critic.json +48 -0
  22. package/.agents/skills/gantry/schemas/reviewer.json +52 -0
  23. package/.agents/skills/gantry/scripts/acceptance.py +66 -0
  24. package/.agents/skills/gantry/scripts/budget.py +162 -0
  25. package/.agents/skills/gantry/scripts/cleanup.py +186 -0
  26. package/.agents/skills/gantry/scripts/common.py +361 -0
  27. package/.agents/skills/gantry/scripts/dashboard.py +233 -0
  28. package/.agents/skills/gantry/scripts/frontier.py +192 -0
  29. package/.agents/skills/gantry/scripts/gates.py +401 -0
  30. package/.agents/skills/gantry/scripts/guard.py +568 -0
  31. package/.agents/skills/gantry/scripts/learner.py +99 -0
  32. package/.agents/skills/gantry/scripts/result.py +104 -0
  33. package/.agents/skills/gantry/scripts/roadmap.py +212 -0
  34. package/.agents/skills/gantry/scripts/runlog.py +491 -0
  35. package/.agents/skills/gantry/scripts/setup.py +139 -0
  36. package/.agents/skills/gantry/scripts/spec.py +252 -0
  37. package/.agents/skills/gantry/templates/issue.md +32 -0
  38. package/.agents/skills/gantry/templates/prd.md +26 -0
  39. package/.agents/skills/gantry/templates/spec.md +48 -0
  40. package/.agents/skills/gantry-dashboard/SKILL.md +55 -0
  41. package/.agents/skills/gantry-setup/SKILL.md +30 -0
  42. package/LICENSE +201 -0
  43. package/README.md +437 -0
  44. package/bin/gantry.mjs +45 -0
  45. package/package.json +36 -0
  46. package/scripts/ensure-npm-author.mjs +29 -0
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """Git pre-push hook: reject any non-fast-forward ref update, however it is spelled.
3
+
4
+ Per `docs/adr/0005-git-hooks-enforce-git-rules.md`, force-push detection lives here
5
+ instead of in `guard.py`'s Bash-command parsing: a blocklist over free-form shell
6
+ text never converges (three ASDLC rounds each closed one spelling of `--force` and
7
+ opened another), but the *ref update itself* has no spelling to hide behind. Git
8
+ feeds this hook, on stdin, one line per ref update: `<local ref> <local sha1>
9
+ <remote ref> <remote sha1>`. An update is non-fast-forward exactly when the remote
10
+ SHA is not an ancestor of the local SHA -- `git merge-base --is-ancestor <remote-sha>
11
+ <local-sha>` failing is the definition, checked directly against the actual SHAs
12
+ git is about to push, never inferred from `--force`, `-f`, a combined short flag,
13
+ a `+refspec`, `--mirror`, `--force-with-lease`, or any other way the command could
14
+ have been typed. A brand-new or deleted ref (either SHA all zero) trivially passes:
15
+ there is no prior tip to rewrite. Denying one ref update aborts the whole push.
16
+
17
+ A fast-forward update is then scanned for the content `pre-commit` refuses: `git commit
18
+ -n` is git's own short `--no-verify` and skips `pre-commit` altogether, so the push is
19
+ the next point at which git shows the content itself instead of a command string. Only
20
+ what the push adds is compared -- the remote's tip, or the parent of the oldest commit
21
+ this push introduces -- so history the remote already carries is never re-judged.
22
+
23
+ Activated by `core.hooksPath` pointing at this directory
24
+ (`.agents/skills/gantry/hooks/git/`); `gantry-setup` writes that config into the
25
+ operator's repository (`gantry-migration#10`) -- this hook only proves itself in
26
+ temporary repositories where a test sets `core.hooksPath` directly.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import datetime
31
+ import subprocess
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
36
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "scripts"))
37
+
38
+ import runlog # noqa: E402
39
+ import skipscan # noqa: E402
40
+
41
+ ZERO_SHA = {"0" * 40, "0" * 64}
42
+
43
+
44
+ def now_iso() -> str:
45
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
46
+
47
+
48
+ def is_fast_forward(remote_sha: str, local_sha: str) -> bool:
49
+ """True when `remote_sha` is an ancestor of `local_sha`, or the ref is being created or deleted."""
50
+ if remote_sha in ZERO_SHA or local_sha in ZERO_SHA:
51
+ return True
52
+ result = subprocess.run(
53
+ ["git", "merge-base", "--is-ancestor", remote_sha, local_sha],
54
+ capture_output=True,
55
+ check=False,
56
+ )
57
+ return result.returncode == 0
58
+
59
+
60
+ def record_denied(cwd: Path, ref: str, rule: str = "no-force-push") -> None:
61
+ """Append the `hook.denied` event for this denial; a logging failure never changes the decision.
62
+
63
+ The Run is resolved by `runlog.resolve_hook_run`: `GANTRY_RUN_ID` when the caller
64
+ exported it, otherwise this worktree's own current-Run marker, which the round workflow
65
+ writes before any agent works there. A denial inside a Run is therefore recorded whether
66
+ or not the shell that invoked `git` carried any Gantry environment.
67
+ """
68
+ run_id, state_root_value = runlog.resolve_hook_run(cwd)
69
+ if not run_id:
70
+ return
71
+ try:
72
+ root = runlog.state_root(state_root_value)
73
+ unit = runlog.unit_id(cwd)
74
+ log_path = runlog.run_log_path(root, unit, run_id)
75
+ if not log_path.exists():
76
+ return
77
+ event = runlog.validate_event(
78
+ {
79
+ "ts": now_iso(),
80
+ "run": run_id,
81
+ "event": "hook.denied",
82
+ "data": {"rule": rule, "path": ref},
83
+ }
84
+ )
85
+ runlog.append_event(log_path, event)
86
+ except (runlog.EventError, OSError, ValueError):
87
+ return
88
+
89
+
90
+ def main() -> int:
91
+ cwd = Path.cwd()
92
+ remote_name = sys.argv[1] if len(sys.argv) > 1 else "origin"
93
+ denied = False
94
+ for line in sys.stdin:
95
+ parts = line.strip().split()
96
+ if len(parts) != 4:
97
+ continue
98
+ _local_ref, local_sha, remote_ref, remote_sha = parts
99
+ if not is_fast_forward(remote_sha, local_sha):
100
+ message = f"deny: no-force-push {remote_ref}"
101
+ print(message, file=sys.stderr)
102
+ record_denied(cwd, remote_ref)
103
+ denied = True
104
+ continue
105
+ if local_sha in ZERO_SHA:
106
+ continue
107
+ # `git commit -n` (git's own short `--no-verify`) skips `pre-commit` entirely, so a
108
+ # test-skip pattern can reach a local commit; the push is the next point where git
109
+ # shows the content itself rather than a command string. Only what this push adds is
110
+ # scanned -- the remote's own tip, or the parent of the oldest commit the push
111
+ # introduces -- so history the remote already carries is never re-judged.
112
+ base = skipscan.push_base(local_sha, remote_sha, remote_name, ZERO_SHA)
113
+ matched_file = skipscan.range_skip_match(base, local_sha) if base else None
114
+ if matched_file:
115
+ message = f"deny: no-test-skip-commit {matched_file}"
116
+ print(message, file=sys.stderr)
117
+ record_denied(cwd, matched_file, "no-test-skip-commit")
118
+ denied = True
119
+ return 1 if denied else 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ sys.exit(main())
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """Shared test-skip detection for the tracked git hooks.
3
+
4
+ `pre-commit` scans what a commit is about to write (`git diff --cached`); `pre-push` scans
5
+ what a push is about to publish (the diff between the remote tip and the local tip). Both
6
+ ask the same question -- does this content *introduce* a line matching a test-skip pattern
7
+ -- so the patterns and the diff reader live here once rather than twice.
8
+
9
+ This module is a helper next to the hooks, not a hook: `core.hooksPath` only ever executes
10
+ files whose name is a git hook name, so git never runs it.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import subprocess
16
+
17
+ TEST_SKIP_PATTERNS = [
18
+ re.compile(pattern)
19
+ for pattern in (
20
+ r"^\s*@unittest\.skip",
21
+ r"^\s*@pytest\.mark\.(skip|xfail)",
22
+ r"^\s*@Disabled",
23
+ r"^\s*(xit|xdescribe)\(",
24
+ r"\b(it|describe|test)\.skip\(",
25
+ r"\bt\.Skip\(",
26
+ )
27
+ ]
28
+ # git's own empty-tree object: the base for a first commit that has no parent.
29
+ EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
30
+
31
+
32
+ def git(*args: str) -> subprocess.CompletedProcess[str]:
33
+ """Run git in the hook's own environment, so `GIT_INDEX_FILE` and friends are honoured."""
34
+ return subprocess.run(["git", *args], capture_output=True, text=True, check=False)
35
+
36
+
37
+ def added_skip_file(diff: str) -> str | None:
38
+ """The first file in a unified diff whose *added* lines introduce a test-skip pattern."""
39
+ current_file: str | None = None
40
+ for line in diff.splitlines():
41
+ if line.startswith("+++ "):
42
+ current_file = line[6:] if line.startswith("+++ b/") else line[4:]
43
+ continue
44
+ if line.startswith("+++"):
45
+ continue
46
+ if line.startswith("+"):
47
+ added = line[1:]
48
+ if any(pattern.search(added) for pattern in TEST_SKIP_PATTERNS):
49
+ return current_file
50
+ return None
51
+
52
+
53
+ def staged_skip_match() -> str | None:
54
+ """The first staged file introducing a test-skip pattern, whatever put it in the index."""
55
+ result = git("diff", "--cached", "--unified=0")
56
+ if result.returncode != 0:
57
+ return None
58
+ return added_skip_file(result.stdout)
59
+
60
+
61
+ def push_base(local_sha: str, remote_sha: str, remote_name: str, zero_shas: set[str]) -> str | None:
62
+ """The commit a pushed ref update should be compared against, or `None` when there is none.
63
+
64
+ For an existing remote ref that is the remote's own tip. For a ref the remote does not
65
+ have yet, it is the parent of the oldest commit this push actually adds (every commit
66
+ already reachable from another ref of the same remote is excluded), so history the
67
+ remote already carries is never rescanned; a root commit is compared against git's
68
+ empty tree.
69
+ """
70
+ if remote_sha not in zero_shas:
71
+ return remote_sha
72
+ listed = git("rev-list", local_sha, "--not", f"--remotes={remote_name}")
73
+ if listed.returncode != 0:
74
+ return None
75
+ commits = listed.stdout.split()
76
+ if not commits:
77
+ return None
78
+ oldest = commits[-1]
79
+ parent = git("rev-parse", "--verify", "--quiet", f"{oldest}^")
80
+ return parent.stdout.strip() if parent.returncode == 0 and parent.stdout.strip() else EMPTY_TREE
81
+
82
+
83
+ def range_skip_match(base: str, tip: str) -> str | None:
84
+ """The first file a push introduces a test-skip pattern in, between `base` and `tip`."""
85
+ result = git("diff", "--unified=0", base, tip)
86
+ if result.returncode != 0:
87
+ return None
88
+ return added_skip_file(result.stdout)
@@ -0,0 +1,44 @@
1
+ // Gantry guard hook wiring for OpenCode.
2
+ //
3
+ // Per ADR-0003, this plugin never decides whether work is ready or done; it forwards
4
+ // each declared event to `guard.py <event>` with the event payload on standard input
5
+ // and applies only the decision guard.py returns (allow or deny) to `tool.execute.before`.
6
+ // `session.compacted` is a record-only event: guard.py's exit code is ignored and it is
7
+ // always allowed to proceed, because compaction has already happened by the time the
8
+ // event fires.
9
+ "use strict";
10
+
11
+ const { spawnSync } = require("node:child_process");
12
+ const path = require("node:path");
13
+
14
+ function skillDir() {
15
+ return path.resolve(__dirname, "..");
16
+ }
17
+
18
+ function runGuard(event, payload, projectDir) {
19
+ const script = path.join(skillDir(), "scripts", "guard.py");
20
+ const result = spawnSync("python3", [script, event, "--cwd", projectDir || process.cwd()], {
21
+ input: JSON.stringify(payload || {}),
22
+ encoding: "utf8",
23
+ });
24
+ return {
25
+ allow: result.status === 0,
26
+ message: (result.stdout || result.stderr || "").trim(),
27
+ };
28
+ }
29
+
30
+ module.exports = ({ project } = {}) => {
31
+ const projectDir = (project && project.directory) || process.cwd();
32
+ return {
33
+ "tool.execute.before": async (input, output) => {
34
+ const payload = { ...(output || {}), ...(input || {}) };
35
+ const decision = runGuard("tool.execute.before", payload, projectDir);
36
+ if (!decision.allow) {
37
+ throw new Error(`gantry guard: ${decision.message || "denied by a protected rule"}`);
38
+ }
39
+ },
40
+ "session.compacted": async (input, output) => {
41
+ runGuard("session.compacted", { ...(output || {}), ...(input || {}) }, projectDir);
42
+ },
43
+ };
44
+ };
@@ -0,0 +1,383 @@
1
+ # Canonical planning workflow
2
+
3
+ Use this workflow only for an unplanned Spec, an Issue with no criteria, or an approved free-text goal.
4
+ The caller resolves and supplies `args.skillDir`, `args.repoRoot`, effective `args.policy`, rendered
5
+ `args.paths`, and `args.models.plan` / `args.models.critic`; this file assumes no repository location.
6
+ For the approval transition, the host supplies its command runner as `runCommand(command, { cwd })`.
7
+ `args.operatorApproved` is `true` only after the host has obtained explicit operator approval; an omitted
8
+ or any other value leaves the plan awaiting approval.
9
+
10
+ ## Roles and sequence
11
+
12
+ 1. `spec.py --check` validates the Spec structurally (required sections, order, placeholders,
13
+ scenarios). It is objective and pass/fail; it does not approve planning.
14
+ 2. The read-only Requirement Critic (Critic model) reads the Spec and assesses ambiguity, coherence,
15
+ verifiability and non-goal coverage. A blocking finding stops the run before any research or Issue
16
+ is written, quotes the finding, and tells the operator to amend the Spec; the Critic never edits it
17
+ and does not approve planning either. Like every other role, its result is validated against its
18
+ Result Contract through `requestRole`; a missing or invalid result is a Protocol Failure that stops
19
+ the run instead of silently proceeding with zero blocking findings.
20
+ 3. Run research in parallel: repository conventions, the Spec and settled decisions, and an exemplar Issue.
21
+ 4. The Planner writes vertical-slice Issues in the effective issue template, each with `Status: draft`,
22
+ observable acceptance criteria and real non-cyclic blockers.
23
+ 5. The Plan Critic attempts to refute granularity, coverage, criterion observability, contract ownership,
24
+ format and `frontier.py --scope <slug> --include-parked`.
25
+ 6. Allow exactly one Planner revision when refuted, then present the result and **stop for explicit operator
26
+ approval**.
27
+
28
+ Structural validation and Requirement Review are both read-only checks that a Spec must clear before
29
+ research and slicing; neither one, alone or together, approves planning. Every Requirement Critic finding,
30
+ like every other generated artifact and operator report, is in English.
31
+
32
+ ## Planner contract
33
+
34
+ The Planner returns `filesWritten`, `issues` (`ref`, `path`, `title`, `criteriaCount`, `blockedBy`),
35
+ `roadmapAdditions` and `openDecisions`. It must:
36
+
37
+ - write only draft Issue files under `args.paths.issueDir`;
38
+ - use `args.paths.specPath`, `decisions`, `context`, `adrs`, `issueTracker` and `exemplarIssue` rather
39
+ than inferred paths;
40
+ - when an Issue needs additional initial-context files, put them under `## What to build` in a
41
+ `### Files to read` section. Each list item must be exactly one repository-relative path in a code
42
+ span (`- \`path/to/file\``); prose, code spans outside that list and paths under another heading are
43
+ not declarations and are not counted by `budget.py`;
44
+ - never write `ROADMAP.md`, set an Issue to `ready-for-agent`, or implement any issue;
45
+ - use English for every artifact.
46
+
47
+ ## Approval transition
48
+
49
+ After, and only after, an operator approves the exact breakdown, the host harness calls:
50
+
51
+ ```sh
52
+ python3 "$skillDir/scripts/roadmap.py" status <ref> ready-for-agent
53
+ python3 "$skillDir/scripts/roadmap.py" waves
54
+ python3 "$skillDir/scripts/roadmap.py" check
55
+ ```
56
+
57
+ The last command must exit 0 before the Run recomputes the frontier. Planning approval does not permit
58
+ silent issue additions, splits, merges or dependency changes.
59
+
60
+ ## Executable Claude Code Workflow
61
+
62
+ The following is the executable Workflow script. Other harnesses execute the same prompt functions and
63
+ phase order manually; they do not substitute isolated script calls for the workflow transitions.
64
+
65
+ ```js
66
+ export const meta = {
67
+ name: 'gantry-plan',
68
+ description: 'Gantry planning: research, draft Issues, adversarial critique, operator stop',
69
+ phases: [
70
+ { title: 'Research', detail: 'repository, Spec, decisions and Issue format' },
71
+ { title: 'Plan', detail: 'write draft vertical-slice Issues' },
72
+ { title: 'Critique', detail: 'refute the draft before approval' },
73
+ ],
74
+ }
75
+
76
+ const A = args
77
+ const scripts = `${A.skillDir}/scripts`
78
+ const t = A.target
79
+ const paths = A.paths
80
+ const policy = A.policy
81
+ const targetText = t.kind === 'goal' ? `the goal "${t.goal}" (new slug: ${t.slug})`
82
+ : t.kind === 'issue' ? `the Issue at ${t.issuePath} (Spec ${t.specPath})`
83
+ : `the Spec at ${t.specPath} (slug ${t.slug})`
84
+
85
+ async function validateSpecBeforePlanning() {
86
+ if (t.kind === 'goal') return null
87
+ const specPath = t.specPath || paths.specPath
88
+ const check = await runCommand(`python3 "${scripts}/spec.py" --check "${specPath}" --json`, { cwd: A.repoRoot })
89
+ let findings = {}
90
+ try {
91
+ findings = JSON.parse(check.stdout || '{}')
92
+ } catch {
93
+ findings = { error: 'spec.py did not return JSON findings' }
94
+ }
95
+ if (check && check.exitCode === 0 && findings.valid === true) return findings
96
+ const details = ['missing', 'out_of_order', 'placeholders', 'malformed_scenarios']
97
+ .flatMap((key) => findings[key] || [])
98
+ .map((item) => typeof item === 'string' ? item : JSON.stringify(item))
99
+ return {
100
+ ...findings,
101
+ valid: false,
102
+ report: `Spec structural validation failed: ${details.join('; ') || findings.error || check.stderr || 'unknown finding'}`,
103
+ }
104
+ }
105
+
106
+ function requirementCriticPrompt(specPath) {
107
+ return `You are the read-only Requirement Critic reviewing the Spec at ${specPath} before ${targetText}
108
+ is sliced into Issues. Read the whole Spec. Assess:
109
+ - ambiguity: every Definition of Done item and acceptance criterion must state a verifiable threshold,
110
+ not a vague quality word ("fast", "robust", "user-friendly") with no measurable test;
111
+ - coherence: no internal contradiction between the Blueprint, Contract and Out of Scope sections;
112
+ - verifiability: a human or a script must be able to check each item as met or not met;
113
+ - non-goal coverage: nothing the Out of Scope section excludes is silently required elsewhere.
114
+ Default to a blocking finding when uncertain. Never edit the Spec.
115
+ Return structured output: a \`blocking\` array of {quote, reason} for findings that must stop planning
116
+ before slicing, and a \`findings\` array of {quote, reason} for non-blocking observations. An empty
117
+ \`blocking\` array means the Spec may proceed to research and slicing.`
118
+ }
119
+
120
+ async function roleSchema(role) {
121
+ const result = await runCommand(
122
+ `python3 "${scripts}/result.py" --role "${role}" --schema`,
123
+ { cwd: A.repoRoot },
124
+ )
125
+ if (!result || result.exitCode !== 0) throw new Error(`could not load ${role} result schema`)
126
+ return JSON.parse(result.stdout)
127
+ }
128
+
129
+ async function validRoleResult(role, result) {
130
+ if (!result) return false
131
+ if (A.structuredOutput === true) return true
132
+ if (typeof runCommand !== 'function') return true
133
+ const validation = await runCommand(
134
+ `python3 "${scripts}/result.py" --role "${role}" --json`,
135
+ { cwd: A.repoRoot, input: JSON.stringify(result) },
136
+ )
137
+ return Boolean(validation && validation.exitCode === 0)
138
+ }
139
+
140
+ async function requestRole(role, prompt, options) {
141
+ const native = A.structuredOutput === true
142
+ const result = await agent(prompt, {
143
+ ...options,
144
+ ...(native ? { schema: await roleSchema(role) } : {}),
145
+ })
146
+ if (await validRoleResult(role, result)) return result
147
+ const retry = await agent(`${prompt}\nYour prior result was invalid. Return the complete ${role} result contract.`, {
148
+ ...options,
149
+ label: `${options.label}:retry`,
150
+ ...(native ? { schema: await roleSchema(role) } : {}),
151
+ })
152
+ return (await validRoleResult(role, retry)) ? retry : null
153
+ }
154
+
155
+ function shellQuote(value) {
156
+ return `"${String(value).replace(/(["\\$`])/g, '\\$1')}"`
157
+ }
158
+
159
+ async function measureBudgets(plan) {
160
+ if (!plan || !Array.isArray(plan.issues) || typeof runCommand !== 'function') return []
161
+ const measurements = []
162
+ for (const [index, issue] of plan.issues.entries()) {
163
+ const ref = issue && typeof issue.ref === 'string' && issue.ref.trim()
164
+ ? issue.ref.trim()
165
+ : `issues[${index}]`
166
+ if (!issue || typeof issue.path !== 'string' || !issue.path.trim()) {
167
+ throw new Error(`planned Issue ${ref} has no valid path`)
168
+ }
169
+ const command = `python3 ${shellQuote(`${scripts}/budget.py`)} ${shellQuote(issue.path.trim())} --model ${shellQuote(A.models.plan)} --json`
170
+ const result = await runCommand(command, { cwd: A.repoRoot })
171
+ if (!result || ![0, 1].includes(result.exitCode)) {
172
+ throw new Error(`context budget command failed: ${command}`)
173
+ }
174
+ let payload
175
+ try {
176
+ payload = JSON.parse(result.stdout)
177
+ } catch {
178
+ throw new Error(`context budget command returned invalid JSON: ${command}`)
179
+ }
180
+ if (!payload || typeof payload !== 'object' || typeof payload.error === 'string' ||
181
+ !Number.isFinite(payload.estimatedTokens) || !Number.isFinite(payload.contextWindow) ||
182
+ !Number.isFinite(payload.contextShare) || !Number.isFinite(payload.budgetTokens) ||
183
+ typeof payload.overBudget !== 'boolean') {
184
+ throw new Error(`context budget configuration is invalid: ${command}`)
185
+ }
186
+ measurements.push(payload)
187
+ }
188
+ return measurements
189
+ }
190
+
191
+ function applyBudgetRefutations(critique, measurements) {
192
+ const overBudget = measurements.filter(measurement => measurement.overBudget)
193
+ if (!overBudget.length) return { ...critique, budgets: measurements }
194
+ const problems = Array.isArray(critique && critique.problems) ? critique.problems : []
195
+ const refutations = overBudget.map(measurement => ({
196
+ problem: `Initial Context Budget exceeded for ${measurement.issue}: ${measurement.estimatedTokens} estimated tokens exceeds ${measurement.budgetTokens} tokens (${measurement.contextShare} of ${measurement.contextWindow}).`,
197
+ fix: 'Reduce the Issue initial package or split the Issue before requesting approval.',
198
+ }))
199
+ return {
200
+ ...(critique || {}),
201
+ acceptable: false,
202
+ problems: [...problems, ...refutations],
203
+ frontierErrors: Array.isArray(critique && critique.frontierErrors) ? critique.frontierErrors : [],
204
+ budgets: measurements,
205
+ }
206
+ }
207
+
208
+ phase('Validation')
209
+ const structuralValidation = await validateSpecBeforePlanning()
210
+ if (structuralValidation && !structuralValidation.valid) {
211
+ return {
212
+ target: t, structuralValidation, plan: null, critique: null,
213
+ awaitingOperatorApproval: true, approved: false,
214
+ blocked: 'spec_structural_validation_failed',
215
+ report: structuralValidation.report,
216
+ }
217
+ }
218
+
219
+ phase('Requirement Review')
220
+ let requirementReview = null
221
+ if (t.kind !== 'goal') {
222
+ requirementReview = await requestRole('requirement-critic', requirementCriticPrompt(t.specPath || paths.specPath), {
223
+ label: 'requirement-critic', phase: 'Requirement Review', model: A.models.critic,
224
+ })
225
+ if (!requirementReview) {
226
+ return {
227
+ target: t, structuralValidation, requirementReview: null, plan: null, critique: null,
228
+ protocolFailure: { phase: 'Requirement Review', role: 'requirement-critic' },
229
+ awaitingOperatorApproval: false, approved: false,
230
+ }
231
+ }
232
+ const blocking = Array.isArray(requirementReview && requirementReview.blocking) ? requirementReview.blocking : []
233
+ if (blocking.length) {
234
+ const quotes = blocking
235
+ .map((finding) => (finding && finding.quote) ? `"${finding.quote}" (${finding.reason || 'no reason given'})` : JSON.stringify(finding))
236
+ .join('; ')
237
+ return {
238
+ target: t, structuralValidation, requirementReview, plan: null, critique: null,
239
+ awaitingOperatorApproval: true, approved: false,
240
+ blocked: 'requirement_review_failed',
241
+ report: `Requirement Critic found a blocking finding: ${quotes}. Amend the Spec and rerun planning; `
242
+ + 'structural validation and Requirement Review do not approve planning — only the operator does.',
243
+ }
244
+ }
245
+ }
246
+
247
+ function planPrompt(feedback) {
248
+ return `You are the Planner for ${targetText} in ${A.repoRoot}.
249
+
250
+ Research:
251
+ ${research.join('\n\n---\n\n')}
252
+
253
+ Write only draft Issue files under ${paths.issueDir}/NN-<slug>.md, using ${paths.exemplarIssue} and
254
+ ${paths.issueTracker}. Read ${paths.specPath}, ${paths.decisions}, ${paths.context} and ${paths.adrs}.
255
+ Every Issue is a demonstrable vertical slice with observable checkbox criteria and real, acyclic
256
+ \`## Blocked by\` refs. Use \`Status: draft\`, the rendered effective paths, and English artifacts.
257
+ When additional initial-context files are needed, declare them only under \`## What to build\` as
258
+ \`### Files to read\`, using list items exactly in the form \`- \`path/to/file\`\` with
259
+ repository-relative paths. \`budget.py\` counts only those list items, not prose, incidental code spans,
260
+ or paths under another heading.
261
+ The effective Git policy is target \`${policy.git.target}\`, prefix \`${policy.git.prefix}\`.
262
+ ${t.kind === 'goal' ? `First draft ${paths.specPath} in the existing Spec format.` : ''}
263
+ ${t.kind === 'issue' ? `Rewrite only ${t.issuePath}; preserve its number and slug.` : ''}
264
+ Never write ROADMAP.md or change any Issue to ready-for-agent. Return filesWritten, issues,
265
+ roadmapAdditions and openDecisions as structured output.
266
+ ${feedback ? `Address every previous critic finding:\n${feedback.map((item, index) => `${index + 1}. ${item.problem} → ${item.fix}`).join('\n')}` : ''}`
267
+ }
268
+
269
+ function critiquePrompt(plan, measurements) {
270
+ return `You are the adversarial Plan Critic for ${targetText}. Default to acceptable=false when uncertain.
271
+ Read every file in: ${plan.filesWritten.join(', ') || '(none)'}.
272
+ Refute non-vertical slices, unobservable criteria, invented or re-owned contracts, invalid format or
273
+ dependencies, and incomplete Spec coverage. Run:
274
+ \`python3 ${scripts}/frontier.py --scope ${t.slug} --include-parked --json\`
275
+ and report every graph error. The deterministic context-budget measurements are:
276
+ ${JSON.stringify(measurements)}
277
+ Every entry with \`overBudget: true\` is a required numeric refutation: quote its \`estimatedTokens\`,
278
+ \`budgetTokens\`, \`contextShare\` and \`contextWindow\`, and set \`acceptable: false\`. Confirm every new
279
+ Issue remains \`Status: draft\`; neither ROADMAP.md nor ready-for-agent state may be written before explicit
280
+ operator approval. Do not edit files. Return acceptable, problems (with actionable fixes), frontierErrors
281
+ and budgets as structured output.`
282
+ }
283
+
284
+ phase('Research')
285
+ const research = await parallel([
286
+ () => agent(`Survey ${A.repoRoot}: conventions, current code/tests, ${paths.context}, and ${paths.adrs}.
287
+ Return facts and paths for the Planner.`, { label: 'research:codebase', phase: 'Research', model: A.models.plan }),
288
+ () => agent(`Read ${t.specPath || paths.specPath}, ${paths.decisions}, and the relevant repository documents.
289
+ Return owned and consumed contracts, settled decisions, and the testing seam.`, { label: 'research:spec', phase: 'Research', model: A.models.plan }),
290
+ () => agent(`Read ${paths.exemplarIssue} and ${paths.issueTracker}. Run
291
+ \`python3 ${scripts}/frontier.py --scope frontier --json\`. Return the exact Issue format and frontier facts.`,
292
+ { label: 'research:format', phase: 'Research', model: A.models.plan }),
293
+ ])
294
+
295
+ phase('Plan')
296
+ let plan = await requestRole('planner', planPrompt(null), {
297
+ label: 'plan', phase: 'Plan', model: A.models.plan,
298
+ })
299
+ if (!plan) {
300
+ return {
301
+ target: t, plan: null, critique: null,
302
+ protocolFailure: { phase: 'Plan', role: 'planner' },
303
+ awaitingOperatorApproval: false, approved: false,
304
+ }
305
+ }
306
+ phase('Critique')
307
+ let measurements
308
+ try {
309
+ measurements = await measureBudgets(plan)
310
+ } catch (error) {
311
+ return {
312
+ target: t, plan, critique: null,
313
+ budgetFailure: String(error.message || error),
314
+ awaitingOperatorApproval: false, approved: false,
315
+ }
316
+ }
317
+ let critique = await requestRole('plan-critic', critiquePrompt(plan, measurements), {
318
+ label: 'critique', phase: 'Critique', model: A.models.critic,
319
+ })
320
+ if (!critique) {
321
+ return {
322
+ target: t, plan, critique: null,
323
+ protocolFailure: { phase: 'Critique', role: 'plan-critic' },
324
+ awaitingOperatorApproval: false, approved: false,
325
+ }
326
+ }
327
+ critique = applyBudgetRefutations(critique, measurements)
328
+ if (!critique.acceptable) {
329
+ log(`plan refuted: ${critique.problems.length} problem(s) — one revision pass`)
330
+ const revisedPlan = await requestRole('planner', planPrompt(critique.problems), {
331
+ label: 'plan:revise', phase: 'Plan', model: A.models.plan,
332
+ })
333
+ if (!revisedPlan) {
334
+ return {
335
+ target: t, plan, critique,
336
+ protocolFailure: { phase: 'Plan', role: 'planner' },
337
+ awaitingOperatorApproval: false, approved: false,
338
+ }
339
+ }
340
+ plan = revisedPlan
341
+ try {
342
+ measurements = await measureBudgets(plan)
343
+ } catch (error) {
344
+ return {
345
+ target: t, plan, critique: null,
346
+ budgetFailure: String(error.message || error),
347
+ awaitingOperatorApproval: false, approved: false,
348
+ }
349
+ }
350
+ const revisedCritique = await requestRole('plan-critic', critiquePrompt(plan, measurements), {
351
+ label: 'critique:2', phase: 'Critique', model: A.models.critic,
352
+ })
353
+ if (!revisedCritique) {
354
+ return {
355
+ target: t, plan, critique: null,
356
+ protocolFailure: { phase: 'Critique', role: 'plan-critic' },
357
+ awaitingOperatorApproval: false, approved: false,
358
+ }
359
+ }
360
+ critique = applyBudgetRefutations(revisedCritique, measurements)
361
+ }
362
+
363
+ async function runApprovalCommand(command) {
364
+ const result = await runCommand(command, { cwd: A.repoRoot })
365
+ if (!result || result.exitCode !== 0) {
366
+ throw new Error(`planning approval transition failed: ${command}`)
367
+ }
368
+ return result
369
+ }
370
+
371
+ async function approvePlan() {
372
+ if (A.operatorApproved !== true || !plan || !critique || !critique.acceptable) return false
373
+ for (const issue of plan.issues) {
374
+ await runApprovalCommand(`python3 "${scripts}/roadmap.py" status ${issue.ref} ready-for-agent`)
375
+ }
376
+ await runApprovalCommand(`python3 "${scripts}/roadmap.py" waves`)
377
+ await runApprovalCommand(`python3 "${scripts}/roadmap.py" check`)
378
+ return true
379
+ }
380
+
381
+ const approved = await approvePlan()
382
+ return { target: t, structuralValidation, requirementReview, plan, critique, awaitingOperatorApproval: !approved, approved }
383
+ ```