@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.
- package/.agents/skills/gantry/SKILL.md +166 -0
- package/.agents/skills/gantry/capabilities/claude-code.json +15 -0
- package/.agents/skills/gantry/capabilities/codex.json +14 -0
- package/.agents/skills/gantry/capabilities/opencode.json +15 -0
- package/.agents/skills/gantry/dashboard/static/app.js +100 -0
- package/.agents/skills/gantry/dashboard/static/index.html +16 -0
- package/.agents/skills/gantry/dashboard/static/style.css +74 -0
- package/.agents/skills/gantry/hooks/claude-code.settings.json +56 -0
- package/.agents/skills/gantry/hooks/codex.hooks.json +4 -0
- package/.agents/skills/gantry/hooks/git/pre-commit +77 -0
- package/.agents/skills/gantry/hooks/git/pre-push +123 -0
- package/.agents/skills/gantry/hooks/git/skipscan.py +88 -0
- package/.agents/skills/gantry/hooks/opencode.plugin.js +44 -0
- package/.agents/skills/gantry/reference/plan-workflow.md +383 -0
- package/.agents/skills/gantry/reference/round-workflow.md +755 -0
- package/.agents/skills/gantry/schemas/critic.json +93 -0
- package/.agents/skills/gantry/schemas/implementer.json +52 -0
- package/.agents/skills/gantry/schemas/learner.json +35 -0
- package/.agents/skills/gantry/schemas/plan-critic.json +39 -0
- package/.agents/skills/gantry/schemas/planner.json +64 -0
- package/.agents/skills/gantry/schemas/requirement-critic.json +48 -0
- package/.agents/skills/gantry/schemas/reviewer.json +52 -0
- package/.agents/skills/gantry/scripts/acceptance.py +66 -0
- package/.agents/skills/gantry/scripts/budget.py +162 -0
- package/.agents/skills/gantry/scripts/cleanup.py +186 -0
- package/.agents/skills/gantry/scripts/common.py +361 -0
- package/.agents/skills/gantry/scripts/dashboard.py +233 -0
- package/.agents/skills/gantry/scripts/frontier.py +192 -0
- package/.agents/skills/gantry/scripts/gates.py +401 -0
- package/.agents/skills/gantry/scripts/guard.py +568 -0
- package/.agents/skills/gantry/scripts/learner.py +99 -0
- package/.agents/skills/gantry/scripts/result.py +104 -0
- package/.agents/skills/gantry/scripts/roadmap.py +212 -0
- package/.agents/skills/gantry/scripts/runlog.py +491 -0
- package/.agents/skills/gantry/scripts/setup.py +139 -0
- package/.agents/skills/gantry/scripts/spec.py +252 -0
- package/.agents/skills/gantry/templates/issue.md +32 -0
- package/.agents/skills/gantry/templates/prd.md +26 -0
- package/.agents/skills/gantry/templates/spec.md +48 -0
- package/.agents/skills/gantry-dashboard/SKILL.md +55 -0
- package/.agents/skills/gantry-setup/SKILL.md +30 -0
- package/LICENSE +201 -0
- package/README.md +437 -0
- package/bin/gantry.mjs +45 -0
- package/package.json +36 -0
- 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
|
+
```
|