8000 fix(gates): scaffold-symbols in the report shape; the main-tree reviewer gets its contract (run 19) by adeelahmad · Pull Request #32 · adeelahmad/aloop · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ tag in lockstep. Tag releases as `vMAJOR.MINOR.PATCH`.
- `budget hook pre` seeds the story's `output.md` with its `type: output` frontmatter before the
worker's first tool call, so a bare `cat >>` of the report block no longer fails the comms gate
(live run 18: 4 blocks). An existing `output.md` is never touched.
- `budget hook`: a structural reviewer or final gate dispatched without a worktree gets the contract
copy in the main tree for its dispatch (removed when it stops), so its own `selfcheck` finds the
task (live run 19: "STORY_DIR unset").
- `gate-scaffold-verify` reads `.aladdin/scaffold-symbols` lines written in the `### Scaffold` report
shape (`+ create fn crate::Foo @ src/lib.rs`): markers, verbs and keywords are skipped and a crate
path is reduced to its name (live runs 18–19: `create` was "defined 0 times", 4 blocks in run 19);
a zero-count block now names the file's expected shape.
- `symbol_count` grep fallback (no `ctx-symbols`): a no-match grep under `pipefail` printed a second
`0`, so the scaffold gate's block text broke mid-sentence on CI-like hosts; it prints one number.

### Changed
- SKILL.md: a RED mandate names `tests/common/mod.rs` as the shims' home, never a `src/` path
Expand Down
2 changes: 1 addition & 1 deletion docs/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ The model can ignore a skill; it cannot ignore an exit code.
- The north-star run, `make e2e-live YES=1 [INTENT=wordfreq]`: a one-line intent becomes
planned, test-first, gate-verified, merged code in a real Claude Code session with the
approval given up front. Two intents pass every check (semver parser: 11/11, ~$0.65–1.15,
11–19 min; word frequencies with two dependent stories: 13/13, $0.79–1.56, 13–45 min; the last two runs $0.95 and $1.03, 2–6 supervisor wakes, 0–2 plugin-source reads,
11–19 min; word frequencies with two dependent stories: 13/13, $0.79–1.56, 13–45 min; the last three runs $0.95–1.03, 1–6 supervisor wakes, 0–5 plugin-source reads,
every merged commit traced to its task by `events trace`). Every run so far has found a gate false positive or a
dead path that unit tests missed; ROADMAP M0.8 lists them.
- One measured real run (*TheNoteBook*, a Go backend): ~17K LOC, 322 tests, 74 commits in 6
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions plugin/bin/_gatelib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,11 @@ symbol_count() {
if have_ctx_symbols; then
ctx-symbols count "$sym" --tree "$root"
else
# grep fallback (fragile: matches fn/struct/etc. definitions only, name-anchored)
# grep fallback (fragile: matches fn/struct/etc. definitions only, name-anchored). Under
# pipefail a no-match grep fails the pipeline, so `|| echo 0` printed a second line ("0\n0")
# and the block text broke mid-sentence; `|| true` keeps it one number.
{ grep -rohE "\\b(fn|struct|enum|trait|type|const|static|mod)\\s+$sym\\b" \
--include='*.rs' "$root" 2>/dev/null | wc -l | tr -d ' '; } || echo 0
--include='*.rs' "$root" 2>/dev/null || true; } | wc -l | tr -d ' '
fi
}

Expand Down
20 changes: 17 additions & 3 deletions plugin/bin/budget
< 8000 td id="diff-8b6e436ad962d17bc982240b375d655569731e1bdc512a1ae04ed455054c2751R195" data-line-number="195" class="blob-num blob-num-context js-linkable-line-number js-blob-rnum">
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,15 @@ def contract(cwd, task_id):
return read_kv(_paths.contract_path(cwd, task_id))


def copy_contract(cwd, top, task_id):
def copy_contract(cwd, top, task_id, main_tree=False):
"""Put a copy of the supervisor's contract in the worker's worktree (Claude Code makes the
worktree at dispatch, so no one could write it before), so `selfcheck` there finds the
task. The gates compare the copy with the contract, so editing it gains nothing."""
task. The gates compare the copy with the contract, so editing it gains nothing.
main_tree=True allows the project root itself: an observed role (reviewer, final gate)
dispatched without a worktree runs there (live run 19), and its stop removes the copy."""
src = _paths.contract_path(cwd, task_id)
if not os.path.isfile(os.path.join(top, ".git")) or not os.path.isfile(src):
git = os.path.join(top, ".git")
if not (os.path.isfile(git) or (main_tree and os.path.isdir(git))) or not os.path.isfile(src):
return
dst = _paths.marker(top, "task.env")
if os.path.exists(dst) and os.path.getmtime(dst) >= os.path.getmtime(src):
Expand Down Expand Up @@ -524,9 +527,13 @@ def observe(mode, payload, agent_id, role):
# A reviewer or final gate dispatched into a host worktree needs the contract copy as much
# as a worker does: its own `selfcheck` finds the task only through it (live runs 15–17).
wt, how = find_worktree(payload, cwd, agent_id, role)
project = _paths.project(cwd)
host_tid = host_task_id(payload, agent_id) if mode != "stop" else ""
if host_tid and wt:
copy_contract(cwd, wt, host_tid)
elif host_tid:
copy_contract(cwd, project, host_tid, main_tree=True) # no worktree: it runs in the main tree
how = how or "main tree"
if host_tid and mode != "stop":
seed_output(cwd, host_tid)
if st is None:
Expand All @@ -541,6 +548,13 @@ def observe(mode, payload, agent_id, role):
if mode == "stop":
st["done"] = now
save_state(spath, st)
if st.get("task") and not st.get("worktree"):
mc = _paths.marker(project, "task.env") # the main-tree copy is this dispatch's only
if os.path.isfile(mc) and read_kv(mc).get("TASK_ID") == st["task"]:
try:
os.remove(mc)
except OSError:
pass
return
st["done"] = None
b = st["budget"]
Expand Down
20 changes: 14 additions & 6 deletions plugin/bin/gate-scaffold-verify
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,29 @@ symbols="${SCAFFOLD_SYMBOLS:-}"
if [ -z "$symbols" ]; then
sf="$(agentic_marker "$root" scaffold-symbols)" || true
if [ -f "$sf" ]; then
# one symbol per line; anything after it on the line is a note ("Foo (enum) @ src/lib.rs")
# one symbol per line; anything after it on the line is a note ("Foo (enum) @ src/lib.rs").
# Workers also write the "### Scaffold" report shape ("+ create fn crate::Foo @ src/lib.rs",
# "- delete tests/common/mod.rs"): a leading marker, verb or keyword is skipped, the first
# identifier after them is the symbol (live runs 18–19: `create` was "defined 0 times").
symbols="$(grep -vE '^[[:space:]]*(#|$)' "$sf" | awk '
$2 == "for" { next } # "Display for Foo": a trait impl, not a new symbol
/(^|[[:space:]])for([[:space:]]|$)/ { next } # "Display for Foo": a trait impl, not a new symbol
NR == 1 && tolower($1) ~ /^(symbol|name)$/ && tolower($2) ~ /^(path|kind|file)$/ { next } # table header
{ print $1 }' | sort -u)"
{ i = 1
while (i <= NF && ($i ~ /^[-+~*|]$/ || tolower($i) ~ /^(create|created|add|added|stub|stubbed|new|delete|deleted|remove|removed|update|updated|change|changed|pub|fn|struct|enum|trait|type|mod|const|static|impl|unsafe|async)$/)) i++
if (i <= NF) print $i }' | sort -u)"
fi
fi
# keep only identifiers / paths (`Foo`, `crate::Foo`): punctuation is never a symbol
symbols="$(printf '%s\n' $symbols | tr -d '`,' | grep -E '^[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)*$' | sort -u)"
# keep only identifiers / paths (`Foo`, `crate::Foo`): punctuation is never a symbol. A path is
# reduced to its last segment: `ctx-symbols count` and the grep fallback count by name.
symbols="$(printf '%s\n' $symbols | tr -d '`,' | grep -E '^[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)*$' | sed 's/.*:://' | sort -u)"
[ -z "$symbols" ] && warn "no scaffold symbols resolved (set SCAFFOLD_SYMBOLS or write .aladdin/scaffold-symbols in the worktree); uniqueness check skipped"

# 2) Each symbol defined exactly once across the tree.
for sym in $symbols; do
n="$(symbol_count "$sym" "$root")"
[ "$n" = "1" ] || fail "symbol \`$sym\` defined $n times (must be exactly 1 — duplicate/parallel definition)"
hint=""
[ "${n:-0}" = "0" ] && hint=". .aladdin/scaffold-symbols is one production symbol per line (\`count_words @ src/lib.rs\`): no verbs, keywords or test paths"
[ "$n" = "1" ] || fail "symbol \`$sym\` defined ${n:-0} times (must be exactly 1 — duplicate/parallel definition)$hint"
done

# 3) No MARKED shim remains. RED shims carry `// agentic:shim`; a normal
Expand Down
11 changes: 10 additions & 1 deletion scripts/conformance/claude-code.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def gate(name, cwd, extra=None, agent_type=None, transcript=None):
def case(item, title, name, cwd, expect, must_say=None, extra=None, agent_type=None, transcript=None):
rc, lines = gate(name, cwd, extra, agent_type, transcript)
blocks = [l for l in lines if l.startswith("BLOCK[")]
good = rc == expect and (must_say is None or any(must_say in l for l in blocks))
good = rc == expect and (must_say is None or any(must_say in l for l in (blocks if expect else lines)))
detail = blocks[0] if blocks else (lines[-1] if lines else "")
results.append((item, title, good, "exit %d (want %d) %s" % (rc, expect, detail[:140])))

Expand Down Expand Up @@ -172,6 +172,15 @@ def red_ok():
# and `Display` were each "defined 0 times").
write(os.path.join(wt, ".aladdin", "scaffold-symbols"), "symbol\tpath\tkind\nsub\tsrc/lib.rs\tfn\nDisplay for Sub\tsrc/lib.rs\timpl\n")
case(3, "SCAFFOLD with a header row and trait-impl lines in scaffold-symbols is accepted", "gate-scaffold-verify", wt, 0)
# ...or the "### Scaffold" report shape with a title line, a verb, a keyword, a crate path and a
# deletion line (live e2e runs 18–19: `create` was "defined 0 times", four blocks in run 19).
write(os.path.join(wt, ".aladdin", "scaffold-symbols"),
"S1-01-T1 scaffold\n+ create fn mycrate::sub @ src/lib.rs\n- delete tests/common/mod.rs (redundant shim)\n")
case(3, "SCAFFOLD whose scaffold-symbols carry verbs, keywords, crate paths and deletions is accepted", "gate-scaffold-verify", wt, 0, "1 symbol(s) unique")
write(os.path.join(wt, ".aladdin", "scaffold-symbols"), "create fn mycrate::nosuch @ src/lib.rs\n")
case(3, "SCAFFOLD naming an undefined symbol is still rejected, with the file's shape in the block",
"gate-scaffold-verify", wt, 2, "one production symbol per line")
write(os.path.join(wt, ".aladdin", "scaffold-symbols"), "sub\n")

# §6.4 RED cannot write production code
wt = red_ok()
Expand Down
20 changes: 20 additions & 0 deletions scripts/test/budget-roles.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,24 @@ def call(agent, role, tokens, mode="post"):
assert open(out6).read().endswith("red-worker · now\n"), "an existing output.md is never rewritten"
ok("TASK_ID is read past a session-context line; the story's output.md is seeded with its frontmatter once")

# 7) a structural reviewer or final gate dispatched WITHOUT a worktree runs in the main tree, where
# nothing ever copied the contract (live run 19: its self-check blocked "STORY_DIR unset"). The
# copy goes to the main tree's marker dir for the dispatch and is removed when the agent stops.
sprint = os.path.join(T, "docs", "agents", "sprint1")
os.remove(copy := os.path.join(T, ".claude", "worktrees", "agent-r2", ".aladdin", "task.env")) # case 5's reviewer is gone
open(os.path.join(T, ".agentic", "state", "tasks", "S1-SR-W3.env"), "w").write(
"TASK_ID=S1-SR-W3\nATTEMPT=1\nAGENT_ROLE=structural-reviewer\nSTORY_DIR=%s\n" % sprint)
sub = os.path.join(T, "cc", "sess", "subagents", "agent-r7.jsonl")
open(sub, "w").write(json.dumps({"type": "user", "message": {"role": "user", "content": "TASK_ID: S1-SR-W3\nReview wave 2."}}) + "\n")
payload.update(cwd=T, agent_id="r7", agent_type="agentic-agile:structural-reviewer")
assert sh(os.path.join(BIN, "budget"), "hook", "pre", inp=json.dumps(payload)) == ""
cands = [os.path.join(T, h, "task.env") for h in (".aladdin", ".agentic")] # this project keeps .agentic/
main_copy = next((c for c in cands if os.path.isfile(c)), cands[0])
assert os.path.isfile(main_copy) and "TASK_ID=S1-SR-W3" in open(main_copy).read(), main_copy
assert state("r7")["task"] == "S1-SR-W3" and state("r7")["story_dir"] == sprint
payload["hook_event_name"] = "SubagentStop"
sh(os.path.join(BIN, "budget"), "hook", "stop", inp=json.dumps(payload))
assert not os.path.exists(main_copy), "the main-tree copy is removed when the observed agent stops"
ok("an observed role in the main tree gets the contract copy for its dispatch; stop removes it")

print("\n%d checks passed" % PASS[0])
Loading
0