You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Context for the stranger: this repo's SDLC pipeline takes a GitHub issue from idea to merged code through staged skills (ISSUE → PLAN → CRITIQUE → BUILD → TEST → REVIEW → DOCS → MERGE), dispatched to child agents and tracked in a Redis-backed ledger. This issue consolidates 51 separate issues the pipeline filed about its own control plane between 2026-06-11 and 2026-08-28. It replaces them.
The signal
The pipeline routes on facts it asserts, not facts it reads.
At every decision point — which lane am I, what stage am I in, did that gate pass, what did that stage do — the pipeline consults a value it minted earlier or a report a child handed it, when the ground truth (git, the branch, the ledger, the filesystem) is one command away. Every one of the 51 issues is an instance of that substitution, and it fails open in every direction: an unverified thing gets recorded as verified, and downstream stages route on it.
That is why the class regenerates. Each issue patches one asserted fact. The next lane asserts a different one.
The four shapes, each with a citable incident
1. Gates that are structurally incapable of firing.
On 2026-08-07, three independent gates in unrelated subsystems were each found reporting success while unable to detect the failure they existed to catch (#2658): a Verification-table sed row whose BRE address matched nothing on macOS yet printed its expected output and passed; an AST recurrence guard whose matcher matched zero call sites; a skill self-check that could not fail. Three same-day instances is a population property, not three bugs. Related: critique_cycle_count never increments, so the plan/critique loop has no cap (#2885); a registry probe counts an entry with a missing callable: key as resolved (#3038); the verification runner compares an expectation to an exit code rather than stdout, false-FAILing every prints N row (#2791).
No gate in this repo is required to demonstrate red before it is trusted green.
2. Stage reports that contradict the branch.
On 2026-08-10 (#2701), a BUILD child halted for lack of the Task tool, spawned a nested build agent carrying the same directive, and that nested agent wrote six commits to session/ask-me-telegram-polls (tip 80dcae8e9) outside its supervisor's loop. The supervisor's final report stated the branch had zero production commits (#3030, #2715). The pipeline then made routing decisions on that report. Nothing reconciles a stage report against the branch it claims to describe — git log on the named branch settles it in one command. Same shape: a forked /do-plan-critique silently degrades to a single reasoner while reporting a full roster, because a context: fork skill invoked from inside a subagent does not get the Agent tool (#3024, #2886, roster ran serially in 6/6 rounds).
3. Lane identity minted once, wrong, with no repair path. sdlc-tool session-ensure mints sdlc-N at lane start, before /do-plan names the lane. On issue #2708 the recorded slug became sdlc-2708 while the real branch was session/job-expectations-obligation-primitive. resolve_lane_slug treats the recorded value as authoritative and never re-derives; adopt_lane_slug is conditional-on-empty. A recorded-but-wrong slug is permanently authoritative (#2816). This recurred one day after PR #2792 closed the same class via a different cause (#2718/#2735/#2793) — a patched instance, not a fixed root. Same shape: SDLC_ISSUE_NUMBER goes stale within a long-lived dev session and silently points downstream stages at the wrong issue (#2849); the issue lock is not repo-scoped, so two repos sharing an issue number collide (#2813); current_stage can never return PATCH because SDLC_STAGES omits it, so slugged PATCH sessions resolve to main with no worktree (#3017).
4. The pipeline's own definition is not single-source. agent/pipeline_graph.py presents itself as canonical. A repo-wide sweep found 7 hardcoded code duplicates, ~30 prose restatements, 4 disagreeing stage-model tables, and only 2 clean importers. The duplicates have drifted and two carry live defects (#2491). validate_build.py carries two more private plan-document grammars (#2870).
Corroborating detail found during consolidation: 12 of these 51 issues link a docs/plans/*.md that does not exist on disk. #2870 links docs/plans/foo.md. The pipeline records plan references without checking the referent — the same asserted-fact failure, applied to its own paperwork.
Desired outcome
Not 51 patches. One property: a fact the pipeline routes on must be readable from ground truth at the moment it is used, or it must not be routed on.
Concretely, that means at minimum:
A gate must demonstrate red before it counts as green. An undemonstrated gate is an unverified gate.
A stage report is a claim until reconciled against the branch/ledger it describes.
Lane identity is derived from the branch/worktree that exists, not from a value minted before one did.
The pipeline graph has one definition and every consumer imports it.
Ownership of the closed issues
The 51 issues listed below are closed as consolidated into this one. Their bodies remain readable on GitHub; the incident detail above is drawn from them and nothing load-bearing was dropped. Plan documents already written for members of this set stay on disk and remain valid input.
Performed 2026-09-03 against main @ 00a3d93ca, scoping the residual after the
2026-09-02/03 seven-lane batch (supervisor report, comment 5521519215). Every claim below
was re-read in the source at the cited file:line; four were executed against main to
observe behavior rather than infer it.
Confirmed:
Three defects from this consolidation are already fixed on main and are out of residual
scope: critique cycle cap (bfa4a6f7d), crashed-PLAN NO_RULE (d9cf29dd6), crashed-PATCH
row 8g (3c689f211). All three verified present in git log with their tests.
Wrong recorded lane slug is unrepairable, and the G8 probe converts it into a forced /do-patch.resolve_lane_slug (tools/lane_identity.py:488-548) returns the recorded
slug unconditionally at rung 1 (:535-538) and allow_heal defaults to False; adopt_lane_slug (:436-480) is conditional-on-empty via _record_slug_if_empty. No path
re-derives from the real branch; the module docstring concedes this at :37-39. In tools/sdlc_next_skill.py, _check_branch_pushed (:181-198) cannot distinguish a
wrong-but-present slug from a genuinely missing branch, so :355-361 emits stage_artifacts_verified: False and guard_g8_artifact_verification
(agent/sdlc_router.py:550-600, listed at GUARDS:1071 ahead of G7/G5/G6) dispatches /do-patch on an OPEN approved PR.
G3 never sees the row the table would return.guard_g3_pr_lock
(agent/sdlc_router.py:469-488) reads only context["proposed_skill"] and meta["last_dispatched_skill"]; guards run to completion at :2246-2248 before the table
at :2250-2255, and there is no post-table re-validation. agent/session_runner/runner.py:1408
calls decide_next_dispatch(stage_states, meta) with no context at all, so proposed is
always "" on that path.
G3's redirect ladder has no /do-docs branch.:501-509 has exactly three arms
(/do-merge, /do-patch, else /do-pr-review). REVIEW completed + APPROVED + DOCS pending
falls to the else arm. Arm 1 also keys on the REVIEW marker, not on a recorded APPROVED
verdict, making it weaker than rows 9/10 (:1956, :1970).
Row 2b preempts DOCS unconditionally on a shipped lane._rule_critique_verdict_stale
(:1568-1592) → _critique_verdict_is_stale (:1431) reduces to a timestamp compare
(:1531-1563) and reads neither pr_number, nor REVIEW/DOCS, nor any review verdict
(grep-confirmed over :1431-1592). Row 2b sits at :2050-2055; row 9 (/do-docs) is at :2181-2186, fifteen rows later. Every guard steps aside in that state.
Four distinct verification runner/parser defects, all reproduced by execution on main:
(a) run_checks(timeout=120) (agent/verification_parser.py:387-392) renders a timeout as [FAIL] with no TIMEOUT status (:423-432, :499), while scripts/validate_build.py:238
hardcodes timeout=30 and emits SKIP (:260-263) — the two runners disagree on the same
event; (b) evaluate_expectation (:304-384) supports exactly six forms and returns False
at :384 for everything else, so prints `0`, >= 1, == 0, and empty output all
silently false-FAIL and are indistinguishable from real failures; (c) command = cells[1].strip("") (:286-288) strips backticks only, so trailing prose after an em-dash is executed as part of the shell command; (d) BRE/ERE escape guidance exists in agent/verification_parser.py:60-68anddocs/features/machine-readable-dod.md:118-139butgrep -rn "grep -E|BRE|ERE|alternation" .claude/skills-global/` returns zero verification
hits, so it is absent from the surface that generates rows.
Plan prose cannot gate merge. The merge decision reads exactly four inputs
(_rule_ready_to_merge, agent/sdlc_router.py:1962-1978) plus G6's six
(:910-957); tools/merge_predicate.py has zero occurrences of plan or docs/plans.
A plan document can declare exactly three machine-read keys today — tracking:
(tools/lane_identity.py:165-168), revision_applied: and revision_applied_at:
(tools/sdlc_stage_query.py:438-500) — each via a hand-rolled single-key regex. There is no
generic frontmatter parser. status:, type:, appetite:, owner:, slug:, covers: are
read by no code.
next-skill persisting nothing is intentional and documented
(tools/sdlc_next_skill.py:63-69, :88-89, :739-748; .claude/skills-global/do-sdlc/SKILL.md:299-303). The residual exposure is that compliance is
prose-enforced: nothing degrades if the caller skips sdlc-tool dispatch record, and the
consequence surfaces much later as a G4 oscillation block attributed to the wrong cause.
session-ensure destroys live leases, both halves confirmed (comment 5521461151). tools/sdlc_session_ensure.py:613-620 discards the handle it just saved (:589-595) and
re-queries by session_id, which is a plain Field() and not the primary key
(models/agent_session.py:156-157); Popoto resolves that filter by SMEMBERS
(popoto/models/query.py:1326,1345), so fresh_rows[0] is unordered and session_id is
explicitly non-unique (models/agent_session.py:132). Separately, candidate (:538-540)
carries no provenance flag distinguishing mint from adopt — reuse_run_id cannot serve as one
because it is overwritten at :526 — so the compare-and-delete in release_issue_lock
(models/session_lifecycle.py:1484-1520) matches by construction on an adopted id and deletes
a lease this call never created.
Revised:
Open finding 4 ("NO_RULE with CRITIQUE APPROVED+completed and BUILD in_progress, no PR")
is falsified as stated._rule_branch_exists_no_pr (row 5, agent/sdlc_router.py:1320-1325)
returns True for exactly that state and dispatches /do-build; rows 4a/4b/4c step aside
because they require BUILD in (None, pending, ready), so row 5 is reached in order. The build_status == STATUS_IN_PROGRESS clause has been present since c1e991972, so there is no
crashed-BUILD gap analogous to PLAN or PATCH. The observed NO_RULE therefore came from a state
other than the one reported. The actionable residual is observability, not routing: a NO_RULE blocked payload does not carry the stage_states/meta it decided on, so the field
report could not be checked against ground truth — itself an instance of this issue's property.
The G3 escape (finding 6) is narrower than reported. G3 does still trip without a --proposed-skill when last_dispatched_skill is itself a plan-family skill. The real hole is
PR open + last dispatch was a PR-stage skill (/do-pr-review, /do-patch, /do-docs) + no proposed_skill. Pre-existing write-up: docs/archive/plans-completed/sdlc-router-rereview-crash-and-row3-pr-guard.md:38-39.
None external. All work is inside this repo, needs no new secret, service, or dependency, and
touches no Popoto schema.
Test coverage gaps to close alongside the fixes: no test exists for an em-dash/trailing-prose
command cell, for run_checks timeout handling, or for the Verification-table parser executes non-check tables and reads the wrong column as the command #3022 header shape. tests/fixtures/verification/runner_agreement.md asserts per-check parity between the two
runners but contains no timeout row, which is why the FAIL-vs-SKIP divergence survives it. tests/unit/test_verification_parser.py:210 currently asserts the unknown-expectation
fall-through as intended behavior and must be revised, not merely extended.
Dropped:
Re-planning the three already-hotfixed defects (bfa4a6f7d, d9cf29dd6, 3c689f211).
A "crashed BUILD" routing row, per the Revised bucket above: row 5 already owns that state.
Re-litigating next-skill's non-persistence as a defect. It is a deliberate, documented
contract; only the silent-non-compliance exposure is residual.
Plan: https://github.com/tomcounsell/ai/blob/main/docs/plans/sdlc-control-plane-asserted-facts.md
The signal
The pipeline routes on facts it asserts, not facts it reads.
At every decision point — which lane am I, what stage am I in, did that gate pass, what did that stage do — the pipeline consults a value it minted earlier or a report a child handed it, when the ground truth (git, the branch, the ledger, the filesystem) is one command away. Every one of the 51 issues is an instance of that substitution, and it fails open in every direction: an unverified thing gets recorded as verified, and downstream stages route on it.
That is why the class regenerates. Each issue patches one asserted fact. The next lane asserts a different one.
The four shapes, each with a citable incident
1. Gates that are structurally incapable of firing.
On 2026-08-07, three independent gates in unrelated subsystems were each found reporting success while unable to detect the failure they existed to catch (#2658): a Verification-table
sedrow whose BRE address matched nothing on macOS yet printed its expected output and passed; an AST recurrence guard whose matcher matched zero call sites; a skill self-check that could not fail. Three same-day instances is a population property, not three bugs. Related:critique_cycle_countnever increments, so the plan/critique loop has no cap (#2885); a registry probe counts an entry with a missingcallable:key as resolved (#3038); the verification runner compares an expectation to an exit code rather than stdout, false-FAILing everyprints Nrow (#2791).No gate in this repo is required to demonstrate red before it is trusted green.
2. Stage reports that contradict the branch.
On 2026-08-10 (#2701), a BUILD child halted for lack of the Task tool, spawned a nested build agent carrying the same directive, and that nested agent wrote six commits to
session/ask-me-telegram-polls(tip80dcae8e9) outside its supervisor's loop. The supervisor's final report stated the branch had zero production commits (#3030, #2715). The pipeline then made routing decisions on that report. Nothing reconciles a stage report against the branch it claims to describe —git logon the named branch settles it in one command. Same shape: a forked/do-plan-critiquesilently degrades to a single reasoner while reporting a full roster, because acontext: forkskill invoked from inside a subagent does not get the Agent tool (#3024, #2886, roster ran serially in 6/6 rounds).3. Lane identity minted once, wrong, with no repair path.
sdlc-tool session-ensuremintssdlc-Nat lane start, before/do-plannames the lane. On issue #2708 the recorded slug becamesdlc-2708while the real branch wassession/job-expectations-obligation-primitive.resolve_lane_slugtreats the recorded value as authoritative and never re-derives;adopt_lane_slugis conditional-on-empty. A recorded-but-wrong slug is permanently authoritative (#2816). This recurred one day after PR #2792 closed the same class via a different cause (#2718/#2735/#2793) — a patched instance, not a fixed root. Same shape:SDLC_ISSUE_NUMBERgoes stale within a long-lived dev session and silently points downstream stages at the wrong issue (#2849); the issue lock is not repo-scoped, so two repos sharing an issue number collide (#2813);current_stagecan never return PATCH becauseSDLC_STAGESomits it, so slugged PATCH sessions resolve to main with no worktree (#3017).4. The pipeline's own definition is not single-source.
agent/pipeline_graph.pypresents itself as canonical. A repo-wide sweep found 7 hardcoded code duplicates, ~30 prose restatements, 4 disagreeing stage-model tables, and only 2 clean importers. The duplicates have drifted and two carry live defects (#2491).validate_build.pycarries two more private plan-document grammars (#2870).Corroborating detail found during consolidation: 12 of these 51 issues link a
docs/plans/*.mdthat does not exist on disk. #2870 linksdocs/plans/foo.md. The pipeline records plan references without checking the referent — the same asserted-fact failure, applied to its own paperwork.Desired outcome
Not 51 patches. One property: a fact the pipeline routes on must be readable from ground truth at the moment it is used, or it must not be routed on.
Concretely, that means at minimum:
Ownership of the closed issues
The 51 issues listed below are closed as consolidated into this one. Their bodies remain readable on GitHub; the incident detail above is drawn from them and nothing load-bearing was dropped. Plan documents already written for members of this set stay on disk and remain valid input.
sdlc-tool verdict finalizeonce every machine has run /update #2789 Drop the --blockers / --tech-debt aliases fromsdlc-tool verdict finalizeonce every machine has run /updatesdlc-Nslug names a branch that does not exist #2869 Lane slug adoption is not universal: a mintedsdlc-Nslug names a branch that does not existcallable:key as resolved #3038 Registry probe counts an entry with a missingcallable:key as resolvedRecon Summary
Performed 2026-09-03 against
main @ 00a3d93ca, scoping the residual after the2026-09-02/03 seven-lane batch (supervisor report, comment 5521519215). Every claim below
was re-read in the source at the cited
file:line; four were executed againstmaintoobserve behavior rather than infer it.
Confirmed:
mainand are out of residualscope: critique cycle cap (
bfa4a6f7d), crashed-PLAN NO_RULE (d9cf29dd6), crashed-PATCHrow 8g (
3c689f211). All three verified present ingit logwith their tests./do-patch.resolve_lane_slug(tools/lane_identity.py:488-548) returns the recordedslug unconditionally at rung 1 (
:535-538) andallow_healdefaults toFalse;adopt_lane_slug(:436-480) is conditional-on-empty via_record_slug_if_empty. No pathre-derives from the real branch; the module docstring concedes this at
:37-39. Intools/sdlc_next_skill.py,_check_branch_pushed(:181-198) cannot distinguish awrong-but-present slug from a genuinely missing branch, so
:355-361emitsstage_artifacts_verified: Falseandguard_g8_artifact_verification(
agent/sdlc_router.py:550-600, listed atGUARDS:1071ahead of G7/G5/G6) dispatches/do-patchon an OPEN approved PR.guard_g3_pr_lock(
agent/sdlc_router.py:469-488) reads onlycontext["proposed_skill"]andmeta["last_dispatched_skill"]; guards run to completion at:2246-2248before the tableat
:2250-2255, and there is no post-table re-validation.agent/session_runner/runner.py:1408calls
decide_next_dispatch(stage_states, meta)with no context at all, soproposedisalways
""on that path./do-docsbranch.:501-509has exactly three arms(
/do-merge,/do-patch, else/do-pr-review). REVIEW completed + APPROVED + DOCS pendingfalls to the else arm. Arm 1 also keys on the REVIEW marker, not on a recorded APPROVED
verdict, making it weaker than rows 9/10 (
:1956,:1970)._rule_critique_verdict_stale(
:1568-1592) →_critique_verdict_is_stale(:1431) reduces to a timestamp compare(
:1531-1563) and reads neitherpr_number, norREVIEW/DOCS, nor any review verdict(grep-confirmed over
:1431-1592). Row 2b sits at:2050-2055; row 9 (/do-docs) is at:2181-2186, fifteen rows later. Every guard steps aside in that state.main:(a)
run_checks(timeout=120)(agent/verification_parser.py:387-392) renders a timeout as[FAIL]with no TIMEOUT status (:423-432,:499), whilescripts/validate_build.py:238hardcodes
timeout=30and emitsSKIP(:260-263) — the two runners disagree on the sameevent; (b)
evaluate_expectation(:304-384) supports exactly six forms and returnsFalseat
:384for everything else, soprints `0`,>= 1,== 0, andempty outputallsilently false-FAIL and are indistinguishable from real failures; (c)
command = cells[1].strip("")(:286-288) strips backticks only, so trailing prose after an em-dash is executed as part of the shell command; (d) BRE/ERE escape guidance exists inagent/verification_parser.py:60-68anddocs/features/machine-readable-dod.md:118-139butgrep -rn "grep -E|BRE|ERE|alternation" .claude/skills-global/` returns zero verificationhits, so it is absent from the surface that generates rows.
(
_rule_ready_to_merge,agent/sdlc_router.py:1962-1978) plus G6's six(
:910-957);tools/merge_predicate.pyhas zero occurrences ofplanordocs/plans.A plan document can declare exactly three machine-read keys today —
tracking:(
tools/lane_identity.py:165-168),revision_applied:andrevision_applied_at:(
tools/sdlc_stage_query.py:438-500) — each via a hand-rolled single-key regex. There is nogeneric frontmatter parser.
status:,type:,appetite:,owner:,slug:,covers:areread by no code.
next-skillpersisting nothing is intentional and documented(
tools/sdlc_next_skill.py:63-69,:88-89,:739-748;.claude/skills-global/do-sdlc/SKILL.md:299-303). The residual exposure is that compliance isprose-enforced: nothing degrades if the caller skips
sdlc-tool dispatch record, and theconsequence surfaces much later as a G4 oscillation block attributed to the wrong cause.
session-ensuredestroys live leases, both halves confirmed (comment 5521461151).tools/sdlc_session_ensure.py:613-620discards the handle it just saved (:589-595) andre-queries by
session_id, which is a plainField()and not the primary key(
models/agent_session.py:156-157); Popoto resolves that filter bySMEMBERS(
popoto/models/query.py:1326,1345), sofresh_rows[0]is unordered andsession_idisexplicitly non-unique (
models/agent_session.py:132). Separately,candidate(:538-540)carries no provenance flag distinguishing mint from adopt —
reuse_run_idcannot serve as onebecause it is overwritten at
:526— so the compare-and-delete inrelease_issue_lock(
models/session_lifecycle.py:1484-1520) matches by construction on an adopted id and deletesa lease this call never created.
Revised:
in_progress, no PR")is falsified as stated.
_rule_branch_exists_no_pr(row 5,agent/sdlc_router.py:1320-1325)returns True for exactly that state and dispatches
/do-build; rows 4a/4b/4c step asidebecause they require BUILD in
(None, pending, ready), so row 5 is reached in order. Thebuild_status == STATUS_IN_PROGRESSclause has been present sincec1e991972, so there is nocrashed-BUILD gap analogous to PLAN or PATCH. The observed NO_RULE therefore came from a state
other than the one reported. The actionable residual is observability, not routing: a
NO_RULEblocked payload does not carry thestage_states/metait decided on, so the fieldreport could not be checked against ground truth — itself an instance of this issue's property.
--proposed-skillwhenlast_dispatched_skillis itself a plan-family skill. The real hole isPR open + last dispatch was a PR-stage skill (
/do-pr-review,/do-patch,/do-docs) + noproposed_skill. Pre-existing write-up:docs/archive/plans-completed/sdlc-router-rereview-crash-and-row3-pr-guard.md:38-39.mechanism is genuinely gone (
scripts/validate_build.py:243-245now delegates toevaluate_expectation, converged in05a444b1b/Converge verification-table parsing and expectation evaluation #2871) but its symptom survives. Verification-table parser executes non-check tables and reads the wrong column as the command #3022 isentirely unfixed:
_is_check_table_header(:178-183) is still name-based and position-blind,and the issue's exact table shape still gets its "Observed stdout" column executed as a shell
command with no
SkippedTablediagnostic. Per-block table scoping (_iter_pipe_blocks:156)was fixed for parse_verification_table merges any second table in ## Verification into the executable checks, unconditionally failing the gate #2836 and is correct; classification and column selection are not.
Pre-requisites:
touches no Popoto schema.
command cell, for
run_checkstimeout handling, or for the Verification-table parser executes non-check tables and reads the wrong column as the command #3022 header shape.tests/fixtures/verification/runner_agreement.mdasserts per-check parity between the tworunners but contains no timeout row, which is why the FAIL-vs-SKIP divergence survives it.
tests/unit/test_verification_parser.py:210currently asserts the unknown-expectationfall-through as intended behavior and must be revised, not merely extended.
Dropped:
bfa4a6f7d,d9cf29dd6,3c689f211).next-skill's non-persistence as a defect. It is a deliberate, documentedcontract; only the silent-non-compliance exposure is residual.