release-pipeline-status.sh --ref returns a stale pipeline — a false green immediately before a production tag #47
Labels
No labels
component:adapters
component:lifecycle
component:qa
component:setup
component:shared
component:worktrees
phase/accepted
phase/backlog
phase/deployed
phase/developing
phase/integrating
phase/planning
phase/qa
phase/requirements
phase/uat
priority:critical
priority:critical
priority:high
priority:high
priority:low
priority:low
priority:medium
priority:medium
type:bug
type:chore
type:docs
type:feature
type:infra
type:tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jbr870/devwork-skills#47
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
PREQ: release-pipeline-status — an answer that says what it is an answer about
Created: 2026-08-25 · Reviewed: 2026-08-25 (Tier 3, five lenses)
Problem
An operator running
/dev:promotecannot trust what the pipeline monitor tells them, and the twoways it lies both land at the moment a production tag is placed.
Stale identity (#47). On 2026-08-14,
release-pipeline-status.sh --ref mainreported pipeline2756606369assuccessfor the freshly pushed commit59261bf. That pipeline was the previousday's v0.2.0 release — a lower pipeline ID than a develop pipeline watched an hour earlier. The real
pipeline for
59261bf(2760068906) was still running. The answer was about the branch name, not thecommit, and nothing in the answer said so: the response shape is
{state, url}, which carries no commitidentity for a caller to check against. The tag that fires the production deploy is placed on the commit
whose tests went green — so a false green here tags a commit whose pipeline had not finished, which is
the exact failure the gate exists to prevent. No helper and no skill text caught it; the operator did, by
noticing a pipeline ID going backwards.
Absent capability, reported as breakage (#55). On 2026-08-24, the same helper on this repo returned:
The active adapter ships no
pipeline_status; the dispatch invokes the primitive by path with noexistence check, so the caller gets a shell-level 127 and a message naming an internal helper path —
not information a calling skill can act on.
/dev:promoteStep 5 has a correct, explicit branch forexactly this case (
noneis not a failure — the requireddeploy_checkcarries the whole "is it live?"decision), and that branch is unreachable: an orchestrator following Step 5 literally sees a non-zero
exit and routes to Step 8, escalating a blocking decision and shipping nothing, which is the wrong answer
for a repo that legitimately has no CI. The 2026-08-24 run only proceeded because the operator could
establish "no pipeline exists" independently — by going around the helper, which is not a procedure.
Note that
pipeline_statusis already a capability-gated operation in the contract: §2.2 lists therelease ops as "capability-gated, not part of the always-required set", and states an adapter "MAY declare
any of these
unsupported". The classification exists; what does not exist is any way for that declarationto reach the dispatch at call time, so a gated op's absence and a broken install are the same 127.
Users:
/dev:promoteto a production release — the onewho must decide whether a commit is safe to tag.
required or capability-gated and what an adapter that cannot provide it must answer.
Current state: Both failures are handled by a human noticing something odd and reasoning around the
helper — an ID that went backwards, an adapter directory that lacks a file. Neither is a procedure, neither
is repeatable, and the first one was a near-miss on a production tag. The absent-capability shape is
mechanical and permanent for every
tea-cliandgh-cliproject:pipeline_statusis not merelyunimplemented for this repo, it is absent from those adapters, so
/dev:promoteStep 5'snonebranch isdead code on half the adapters in tree.
Proposed Solution
Make every pipeline answer attributable: it names the commit it describes, or it states the absence
it represents. It is never a shell error.
Four changes, each at the layer that owns the defect:
The commit-scoped query is what fixes the defect; the stated commit is what lets
/dev:promoteassertidentity before it tags. That assertion is defence in depth — with the query scoped correctly it
should never fire through the normal path, and it exists to catch an adapter whose own scoping is wrong,
which is precisely the failure mode that produced #47.
creates a new way to be wrong: a freshly merged commit legitimately has no pipeline for a few seconds
while the runner starts, and today that is the same
nonethat means "this project has no CI" — onwhich the monitor breaks out of its wait. Left alone, the identity fix would re-create the false green
by a new route. "No CI" means exactly one thing: the active adapter cannot report pipelines at all.
Every other empty answer — runner not started yet, CI configured but skipping this ref — is "no pipeline
for this commit", on which the monitor keeps waiting and then escalates on timeout.
contract already marks optional,
pipeline_statusamong them) that an adapter does not ship resolves toa stated unsupported — a real answer the caller branches on, which is what makes Step 5's no-CI path
reachable, and which
/dev:promoterecords out loud in its Phase Outcome rather than skipping the gatesilently. A required op that is absent fails loudly with a diagnosis naming the adapter, the
primitive and where it was looked for — because an absent required primitive is a broken install, not a
capability boundary, and degrading it to a silent "no" would trade an opaque failure for an invisible one.
folded into "no pipeline" and never surfaces as a raw shell error. It is retried as a transient inside
the existing
ci_timeoutbound, and if it never clears it escalates naming the query failure ratherthan reporting a pipeline verdict.
Two things the answer must also pin down, because both are ways a correctly-attributed answer could still
be misread: which pipeline is meant when a commit has several, and what the monitor does with a state
that is neither green, red, nor absent.
Scope: Standard — both defects fixed at the layer that owns them, with nothing built for the
Forgejo instance being decommissioned. The contract, the shared dispatch,
glab-cli,local-fsand/dev:promoteStep 5. Deliberately not implementingpipeline_statusfortea-cli(redundant onmigration) or
gh-cli(the first slice of a six-primitive gap that deserves its own design). The shared-tierwork is adapter-neutral and lands ahead of the GitHub migration rather than behind it.
User Stories
/dev:promote, I want the pipeline answer to state which commit it describes,so that a green from an earlier release cannot be mistaken for a green on the commit I am tagging.
no pipeline yet, so that a race between the merge and the runner starting cannot be read as "no CI here".
/dev:promoteon a project with no CI, I want the suite to tell me there is nopipeline to gate on, so that I reach the required
deploy_checkthrough the documented path instead ofreasoning around a helper that looks broken.
rather than as an answer, so that a network blip cannot be mistaken for a verdict about my code.
the adapter and the primitive, so that a broken install is never mistaken for a deliberate capability
boundary.
required or capability-gated and what an adapter that cannot provide it must answer, so that an
adapter's boundary is a declared fact rather than an accident of which files happen to exist on disk.
Acceptance Criteria
Attribution to a commit
released, when the pipeline status for that release is requested, then the answer describes a
pipeline for the commit being released — not the earlier one — and states which commit it describes.
/dev:promoteis about to tag,when the run reaches the tag gate, then no tag is placed and a blocking decision is escalated naming
both commits. (Defence in depth: with the criterion above holding, this is unreachable through the
normal path; it exists to catch an adapter whose own scoping is wrong.)
merge-request pipeline, or a parent with children — when the status is requested, then the answer
describes the most recent pipeline for that commit, and states which commit it describes.
Waiting, and the two kinds of nothing
distinguishable from "this adapter cannot report pipelines", the monitor emits an observable poll
record naming the commit and the elapsed wait, and it continues waiting rather than concluding the
release is ungated.
ci_timeout— countedfrom entry into the wait, with the tag-target commit pinned at that same moment — when the bound is
hit, then the run escalates as a timeout, naming the commit it was waiting for.
manual/blocked, or skipped — when the monitor polls, then that state maps onto a stated monitor
branch; no state falls through to an unstated default.
polls, then the outcome is reported as a query failure — never as a pipeline verdict and never as
an absent pipeline — is retried within the existing
ci_timeoutbound, and if it never clears,escalates naming the query failure rather than a pipeline state.
Absence as a stated answer
/dev:promoteruns itsmonitor step, then it receives a stated "this adapter cannot report pipelines" answer, proceeds to
the required
deploy_check, and its Phase Outcome records that the pipeline gate was skipped and why.name the capability boundary, and no shell-level error and no internal helper file path reaches the
operator or the calling skill.
failure names the adapter, the primitive and where it was looked for, and is never reported as an
absent capability.
primitive is required or capability-gated, and what an adapter that cannot provide it must answer.
No regression at the gate
separately, given a pipeline canceled for that commit — when the monitor polls, then each
escalates as it does today.
scriptable adapter, when the feature's regression probe is run, then the stale answer is not
returned. (The probe proves the shared-tier and contract behaviour; the
glab-cliquery change isverified separately against live GitLab — a simulated probe cannot demonstrate it.)
Out of Scope
pipeline_statusfortea-cli. The Forgejo instance is being decommissioned in favourof GitHub; a primitive written against it is redundant on arrival. Neither issue asks for it — #55's own
hypothesis is that an adapter which cannot report pipelines is reporting "no pipeline". Known
consequence: a
tea-cliproject that does run Gitea Actions now gets a statedunsupportedand itsreal pipelines go unwatched, recorded only in the Phase Outcome. Accepted for the decommission.
gh-cli's release operations.gh-cliships none of the six (pr_create,pr_status,pr_merge,comment_on_pr,pipeline_status,release_create), so/dev:promotecannotrun on GitHub at all. That is implementation work of a size comparable to this whole feature, and doing
its
pipeline_statusslice in isolation here would mean designing GitHub's release surface twice. Filedas sibling #69.
to the pipeline poll. The suite-wide protocol is #46 and is untouched.
ref" case and rejected: it is new schema surface, a readiness item and a parseability contract for a case
nobody has hit. Such a project waits out
ci_timeoutonce and escalates; if it recurs, it earns its ownissue.
.gitea/workflows,.github/workflows,.gitlab-ci.yml) to decide whether CI exists. Rejected: it hardcodes per-forge paths into stack-agnosticshared code, and config that no runner picks up would answer the question wrongly.
twelve stubs across two adapters, six of them for the instance being retired.
/dev:promote's delegation hook for a project-owned releasecommand is #60 and is untouched here.
absent primitive answerable; it does not review what each present primitive returns.
the helper-bypass antipattern. The body carries the folded scope instead.
Dependencies
_shared/forge-contract.md§2.2), which alreadyclasses the release ops —
pipeline_statusincluded — as gated. This feature depends on thatclassification becoming something the dispatch can act on at call time. Contract changes must stay
additive within v1 (§2.3) — new state values and a new response field qualify; a signature change
would force v2.
glab-clicurrently readsGET /projects/:id/pipelines?ref=…. Whether that endpoint accepts a commit-scoped filter, whether itsresponse carries the pipeline's commit, and how it represents retried, merge-request and parent/child
pipelines must be verified against real GitLab before designing against it
(
/dev:technical-plan§2.2b). This is the dependency class that has burned us before: a feature builtconfidently against an assumed external shape.
local-fs's simulated pipeline files — the scriptable surface the smoke tests drive, and the onlyadapter where the regression probe can run without a live forge.
#62 (slot-isolation axes). #69 depends on this feature, not the reverse.
jq,git, POSIX utilities;skill-emitted glue is POSIX-only.
Timeline
Notes
must be adapter-neutral so it survives the Forgejo → GitHub migration untouched.
commit-identity fix has to change the same response shape that #55's absent-capability answer flows
through. Splitting them would mean touching the contract twice.
tea-cli'spipeline_status— the Forgejo instance is being retired; most repositoriesmove to GitHub.
unsupported; required ops failloudly. Rejected "absent always means unsupported" because it makes a broken install look like a
deliberate boundary, which is a quieter failure than the one being fixed.
commit-scoped query re-creates #47's false green by a new route (ask about the right commit, get
nothing back, read it as ungated, tag anyway).
gh-cli's six missing release ops — filed as sibling #69 rather than folded; GitHub is the migrationtarget, so it is real work, but it is implementation rather than honesty work.
(promote would be trusting each adapter's own filtering, which is the trust that failed) and
label-only (wrong-commit answers keep flowing and every caller must remember to check). This resolves
the AC-pair tension two lenses independently flagged: the identity assertion is defence in depth.
files. Machine-decidable, no new schema. The "CI exists but skips this ref" case waits out
ci_timeoutand escalates once.general protocol left alone.
state maps to a stated branch. Both are ways a correctly-attributed answer could still be misread,
and leaving them to each adapter would re-introduce the adapter-dependent safety decision rejected
earlier.
involved a required primitive, so that half is derived rather than observed. Kept because the split
costs only a classification the contract already publishes, and the operator confirmed.
/dev:promoterun that shipped v0.2.0 (rank 11 of 13 inthe #37 time study). #55 filed 2026-08-24 from the
/dev:promoterun that shipped #26 and #50 asdev--v0.1.6. The readiness report has carried the #55 gap as a standing ⚠ since 2026-08-24.reader must fail with a diagnosis, not a stack error). The unifying rule: an absent capability and a
broken call must not look alike — and, from #47, an output is trustworthy only once you can tell what
it is an output about.
Linked: this issue is sibling #69 (recorded by the devwork pipeline).
Linked: this issue is sibling #55 (recorded by the devwork pipeline).
Test Plan: release-pipeline-status-attributable-answer
Validation cases for issue #47 (with #55 folded in). Derived from the PREQ alone — no design exists yet.
Lane lines are deliberately absent: lanes are a design fact and are annotated by
/dev:technical-plan2.6b. An absent lane means the schema default.
Prerequisites
The state these scenarios need. Concrete environment details (instance URLs, tokens, seed commands)
belong to the UAT card, not here.
release:block, so/dev:promotewill actually run its monitor step.state, commit and count in place deliberately, rather than waiting for real CI to produce one.
system can produce (a genuine red, a retried pipeline, a merge-request pipeline).
Required Test Data
and a later one that is the commit actually being released. This pair is the whole of #47.
it, also a branch pipeline alongside a merge-request pipeline.
pending,running,canceled, and whatever the forge calls manual/blocked and skipped.never shipped a gated one.
Test Scenarios
Scenario 1: The stale-pipeline false green — the 2026-08-14 case
Acceptance criterion: Given the release ref's most recent pipeline belongs to an earlier commit than
the commit being released, when the pipeline status for that release is requested, then the answer
describes a pipeline for the commit being released — not the earlier one — and states which commit it
describes.
commit — the previous release's — and push a later commit that is the one being released.
success. If the later commit's pipeline isstill running, the answer says so; if it has not started, the answer is the not-yet case (Scenario 4).
Expected outcome: The previous release's green is no longer offered as an answer about a newer commit,
and the answer carries enough to tell which commit it is about without knowing how it was obtained.
Scenario 2: An answer about the wrong commit does not reach the tag
Acceptance criterion: Given a pipeline answer whose stated commit differs from the commit
/dev:promoteis about to tag, when the run reaches the tag gate, then no tag is placed and a blockingdecision is escalated naming both commits.
by scripting the scriptable adapter to return a successful pipeline labelled with the earlier commit.
about and the one that was about to be tagged.
Expected outcome: Even when the answer is wrong, the gate holds. This is the defence-in-depth case:
it should be unreachable through the normal path, and a tester has to stage it deliberately.
Scenario 3: A commit with several pipelines
Acceptance criterion: Given a commit with more than one pipeline — a retry, a branch pipeline alongside
a merge-request pipeline, or a parent with children — when the status is requested, then the answer
describes the most recent pipeline for that commit, and states which commit it describes.
with both present and verify the answer is the most recent of them and still names the commit.
Expected outcome: "Which pipeline" has one stated answer, and a tester gets the same one every time
rather than whichever the forge happened to list first.
Scenario 4: A commit whose pipeline has not started yet
Acceptance criterion: Given a commit for which no pipeline exists yet, when the monitor polls, then the
answer is distinguishable from "this adapter cannot report pipelines", the monitor emits an observable poll
record naming the commit and the elapsed wait, and it continues waiting rather than concluding the release
is ungated.
waiting, so a tester can tell waiting apart from hung.
place a tag.
picks it up and proceeds.
Expected outcome: The gap between a merge and the runner starting is a wait, not a verdict — and the
tester can see it waiting.
Scenario 5: Waiting for a pipeline that never appears
Acceptance criterion: Given the wait for a not-yet-created pipeline reaches the project's declared
ci_timeout— counted from entry into the wait, with the tag-target commit pinned at that same moment —when the bound is hit, then the run escalates as a timeout, naming the commit it was waiting for.
ci_timeoutso the case is executable.the merge or the first poll.
reporting a pipeline state.
and still escalates about — the commit it pinned when the wait began, not the new head.
Expected outcome: An indefinite wait is bounded, the bound starts where the PREQ says it starts, and a
concurrent push cannot quietly move what the run is gating on.
Scenario 6: Pipeline states that are neither green nor red nor absent
Acceptance criterion: Given a pipeline in a state that is neither success, nor failure, nor absent —
canceled, manual/blocked, or skipped — when the monitor polls, then that state maps onto a stated monitor
branch; no state falls through to an unstated default.
which state it saw.
name the state.
Expected outcome: Every state a real forge can return has a stated destination; a tester can enumerate
them and find no orphan.
Scenario 7: The forge cannot be reached mid-poll
Acceptance criterion: Given the forge is unreachable, authentication fails, or the API returns an
error, when the monitor polls, then the outcome is reported as a query failure — never as a pipeline
verdict and never as an absent pipeline — is retried within the existing
ci_timeoutbound, and if itnever clears, escalates naming the query failure rather than a pipeline state.
credentials) while the monitor is polling.
success, notfailed, and not "no pipeline".pipeline verdict.
and does not assert anything about the pipeline's state.
Expected outcome: A network blip is never mistaken for a verdict about the code, and a lasting outage
escalates as what it is.
Scenario 8: A project whose adapter cannot report pipelines at all
Acceptance criterion: Given a project whose active adapter cannot report pipelines at all, when
/dev:promoteruns its monitor step, then it receives a stated "this adapter cannot report pipelines"answer, proceeds to the required
deploy_check, and its Phase Outcome records that the pipeline gate wasskipped and why.
— not an error, and not a wait.
Expected outcome: The documented no-CI path is reachable by following the procedure, with no operator
having to establish the absence independently — which is exactly what #55 was filed about.
Scenario 9: What the operator sees when the capability is absent
Acceptance criterion: Given that same project, when the monitor step runs, then the helper's own exit
status and message name the capability boundary, and no shell-level error and no internal helper file path
reaches the operator or the calling skill.
file appears anywhere in what the operator or the calling skill sees.
Expected outcome: The output is information the calling skill can act on, in place of the 127 and the
internal path recorded on 2026-08-24.
Scenario 10: A required primitive is missing — a broken install
Acceptance criterion: Given an adapter that is missing a required primitive, when any skill invokes it,
then the failure names the adapter, the primitive and where it was looked for, and is never reported as an
absent capability.
capability-gated).
unsupported, not degraded into a "no", and does not let the callingskill proceed as though it had an answer.
alone.
Expected outcome: A broken install and a deliberate capability boundary look different — which is the
distinction the whole absent-primitive change exists to draw.
Scenario 11: The contract answers the question for every primitive
Acceptance criterion: Given the forge contract, for each primitive it lists, then the contract states
whether that primitive is required or capability-gated, and what an adapter that cannot provide it must
answer.
that cannot provide it answer?
Expected outcome: A skill author picking up a new adapter can classify every primitive from the
contract alone. (This is a documentation check, executable as an enumeration against the contract; it needs
no running system.)
Edge Cases and Regression
Scenario 12: A genuine red, and a genuine cancellation, still stop the release
Acceptance criterion: Given a project whose CI reports a genuine failure for the exact commit being
released — and, separately, given a pipeline canceled for that commit — when the monitor polls, then each
escalates as it does today.
Expected outcome: The attribution work adds no new route by which a real red is read as green or as
"nothing here" — the thing that must not regress.
Scenario 13: The originating sequence, replayed
Acceptance criterion: Given the merge-then-query sequence that produced the 2026-08-14 false green,
staged on the scriptable adapter, when the feature's regression probe is run, then the stale answer is not
returned.
then a merge putting a new commit at the head of the release ref, then an immediate status query.
successas the answer for the new head.system; the staged probe cannot demonstrate that half.
Expected outcome: The exact near-miss is captured as something that runs, and the split between what
the staged probe proves and what live verification proves is explicit rather than assumed.
Scenario 14: Both kinds of nothing, side by side
Acceptance criterion: (covers the "no pipeline yet" / "cannot report" distinction jointly — the
criteria behind Scenarios 4 and 8.)
pipeline yet, and once on an adapter that cannot report pipelines at all.
Expected outcome: The two states that used to collapse into one are distinguishable in practice, not
just in the contract — this is the case that would have caught the false green the identity fix could
otherwise have re-created.
Traceability
Forward — every acceptance criterion has at least one case:
ci_timeoutBackward — every case traces to a criterion. Scenario 14 has no criterion of its own; it exercises the
Scenario 4 and Scenario 8 criteria jointly, because the defect being guarded against is precisely their
collapse into one answer. It is kept as a paired case rather than deleted as an orphan.
Notes
e2e,a11yandsecurity-browsernot applicable; everyscenario above is executed against the suite's own helpers and a forge, not a rendered UI.
it is a legitimate validation case rather than an implementation-necessity exception.
manual/blocked states, and real API failures cannot be staged on a simulated adapter. Which suite or
which human owns them is a lane decision for
/dev:technical-plan.ci_timeoutto be executable at all; that is a projectdeclaration, not an environment detail.
nonebranch unreachable #55Software Requirements: release-pipeline-status-attributable-answer
Issue #47 (with #55 folded in). Tier 3 — four-lens expert panel.
Context
An operator running
/dev:promotecannot trust what the pipeline monitor tells them, and both ways itlies land at the moment a production tag is placed. #47:
release-pipeline-status.sh --ref mainreported a previous release's pipeline as
successfor a freshly pushed commit — the answer was aboutthe branch name, not the commit, and the response shape
{state, url}carries no commit identity acaller could check. #55: on an adapter that ships no
pipeline_statusprimitive the dispatch invokesthe primitive by path with no existence check, so the caller gets a shell-level exit 127 naming an
internal helper path — which makes
/dev:promoteStep 5's correct "this project has no CI" branchunreachable. The unifying requirement: every pipeline answer names the commit it describes, or states
the absence it represents. It is never a shell error.
Both failures are reproducible at HEAD. #55 was reproduced offline in this worktree in seconds
(
release-pipeline-status.sh --ref mainon atea-cliproject → exit 127, stderr.../tea-cli/bin/pipeline_status.sh: No such file or directory). #47's data was recovered from the realforge during external-contract capture (see External Data Contracts).
Approaches Considered
Approach A: Label only, at the shared tier
Summary: Leave the adapters alone; have
release-pipeline-status.shannotate the answer with thecommit the caller asked about.
Pros: One file changes; no contract signature movement.
Cons: The shared tier cannot know which commit the forge actually selected, so annotating with the
commit we asked about is a lie by construction — it would have stamped
59261bfonto the previous day'spipeline and made the #47 false green look more trustworthy. Fixes nothing about which pipeline is returned.
Effort: Low — and wrong.
Approach B: Filter and label in the adapter; classify absence in the shared tier — selected
Summary: The adapter scopes the query to the commit and reports the commit the returned pipeline
belongs to; the shared dispatch learns the required/capability-gated distinction and converts an absent
gated primitive and a failed query into stated answers;
/dev:promotepins the commit, asserts identity,and branches on every state.
Pros: Fixes each defect at the layer that owns it. Verified against the real GitLab API at plan time:
the
shafilter exists, composes withref, and the list entry already carriessha— so both halvescost one flag and one field. Adapter-neutral shared tier, so it survives the Forgejo→GitHub migration.
Cons: Touches five surfaces (contract,
_lib.sh, two adapters, promote text) and changes whatnonemeans for the monitor.
Effort: Medium.
Approach C: Parse each adapter's
capabilities:manifestSummary: Adapters declare
pipeline_status: unsupportedin theirSKILL.mdYAML block; the dispatchparses it before invoking.
Pros: The boundary is an explicit declaration rather than an inference.
Cons: The block is YAML inside markdown — a new bash/awk parser and a new parseability contract, which
this repo's conventions explicitly warn against. Worse, today
tea-cliandgh-cliomit the releaseops entirely rather than declaring them
unsupported, so "absent from manifest" ≠ "declared unsupported";every adapter would have to declare every op before absence became meaningful. Same outcome, far more
surface, and a second source of truth for a classification that belongs to the operation, not the adapter.
Effort: High.
Approach D: Capability handshake on the primitive
Summary: Every primitive gains a
--capabilityself-description mode the dispatch calls first.Pros: Fully dynamic.
Cons: Touches every primitive on every adapter, doubles the process count on every dispatch, and still
needs an absent-file branch — the case it was meant to remove.
Effort: High.
Decision
Selected: Approach B. Required-vs-capability-gated is a property of the operation, so it is declared
once in the forge contract and mirrored in one shared-tier list; whether a given adapter provides it is
answered by the file being on disk. Absence-on-disk is the declaration — which is why no stub files are
needed (the PREQ rejects them) and no manifest parsing is introduced. The commit-scoping and commit-labelling
halves are both taken, deliberately: scoping is what fixes the defect, labelling is what lets
/dev:promoteassert identity before it tags, catching an adapter whose own scoping is wrong.
Architecture
Component Overview
Data Flow
/dev:promotepinsTAG_TARGET_SHA(git rev-parse "$INTEGRATION_BRANCH", full 40-hex) and adeadlineat the moment it enters a wait.--refand--sha, capturing stdout andrcseparately (never a pipeline).--sha, dispatches, and emits exactly one well-formed JSON answer.(ref, sha), takes the single most recently created pipeline,and reports
stateplus the commit the returned pipeline belongs to./dev:promoteassertsanswer.commit == TAG_TARGET_SHA, then branches onanswer.state. Everynon-terminal branch emits a poll record; every terminal branch either proceeds or escalates.
External Data Contracts
Verified against the real system at plan time (
/dev:technical-plan2.2b) — this is the dependency classthe PREQ flags as having burned the project before, so it was captured before the design was drawn, not
after. No precondition is raised: the boundary is verified by construction.
GET /projects/:id/pipelines?ref=&sha=&order_by=&sort=&per_page=(read-only){id, iid, project_id, sha, ref, status, source, created_at, updated_at, web_url, name}— seeexternal-contracts/gitlab-pipelines.sample.jsonrecorded→external-contracts/gitlab-pipelines.provenance.jsonFacts the capture established, each of which the design depends on:
sha. Labelling the answer costs no extra call.?sha=works and narrows — and requires the full 40-hex sha. An abbreviated sha returns[],which is indistinguishable from "no pipeline for this commit". This is a booby trap: it would route a
correct release into the wait-then-timeout branch. Hence AC-18.
?sha=and?ref=compose.sha=X&ref=v0.2.1returned exactly 1 of the 2 pipelines on that sha.This matters more than it looks — see Key Decisions.
[]+ HTTP 200 +glabexit 0. A failed query returns a JSON object ({"message":"401 Unauthorized"}) withglabexit 1 — so the current… | jq '.[0]'is a parse error, not an empty answer.one sha (exactly what
/dev:promote's tag strategy creates) and push+merge_request_event on one sha.updated_at-order disagree on 2 of those 7.created_at-order agreed with id-order in both. Hence the pinned ordering key in Key Decisions.59261bfcarries pipeline2760068906(ref=main,created 09:30:11Z) and
2760097999(ref=v0.2.1, created 09:41:54Z). A ref-scoped query issued before09:30:11 returns the previous day's main pipeline — the stale green. A sha-scoped query returns
[]inthat same window, which is why "no pipeline yet" must be a wait and not a verdict: without that, the
identity fix re-creates the false green by a new route.
created, waiting_for_resource, preparing, pending, running, success, failed, canceled, canceling, skipped, manual, scheduled— the currentelse "pending"catch-all silently swallows six of them.
Observability & Audit
Not applicable. The project declares
observability: mode: none("skill suite — no runtime services; runtraceability is the forge record"), and this feature touches no logged or audited category. The
operator-facing transcript requirements (poll records, escalation messages) are functional acceptance
criteria (AC-4, AC-19), not observability-policy obligations.
Key Decisions
shainput; new response fields; new state values--shastill returns acommitthe caller can check — so the identity assertion catches it, which is exactly the defence-in-depth the PREQ asks for.(ref, sha)together, not sha alonemain's pipeline and the tag's. A sha-only query would returnmain's already-green pipeline while the tag's deploy pipeline had not started — a new false green by a new route.ref+shareturned exactly the tag pipeline.(ref, sha)—order_by=id&sort=desc&per_page=1updated_atlets an older pipeline that was retried later win over a newer one.idis monotonic with creation and agreed withcreated_atin every observed case. GitLab's own retry reuses the pipeline id, so a retry is picked up naturally._GATED_PRIMSlist in_lib.shunsupportedbin/*.sh. If that fell into "absent → unsupported", an environment regression would silently become a fail-open release gate (pipeline gate skipped, tag placed). Classified loudly instead.state. The exit code is a redundant, informational echounsupportedand the correct branch would be unreachable again, one layer up. Exit codes are kept because AC-9 wants a stated, crash-distinguishable status; promote's text says in terms that 8 and 9 are answers to branch on.ci_timeoutbudgetci_timeout, pinned on entry to that waitnonesemanticsnonemeant both that and "no CI here", and the monitor broke out on it. "No CI" is nowunsupported, keyed off adapter capability alone.unsupportedis dispatch-emitted only;query_failedis adapter-emitted; the pipeline states are adapter-emittedunsupporteditself and blur the layer boundary the fix depends on.git tag, poll--ref $INTEGRATION_BRANCH --sha $TAG_TARGET_SHAto terminalDetailed design, by layer
1. Forge contract (
_shared/forge-contract.md).Add a Kind column (
required|capability-gated) to the §2.1 and §2.2 operation tables, for everyprimitive, plus a normative paragraph: an adapter that cannot provide a capability-gated primitive does
not ship the file, and the dispatch answers
unsupported; a required primitive MUST exist and beexecutable, and its absence is a broken install that fails naming adapter, primitive and search path.
release.pipeline_statusbecomes(ref, sha?) → {state, commit, ref, pipeline_id, url, raw_state?}, withthe state enum grown to
pending | running | success | failed | canceled | skipped | manual | none | unsupported | query_failed | unknown, each carrying a stated meaning, a stated emitting layer, and astated monitor branch. The four adapters'
capabilities:blocks are brought into line (tea-cliandgh-clilist the release ops asunsupported) — documentation parity, not the mechanism.2. Shared dispatch (
_lib.sh)._GATED_PRIMS= the contract's capability-gated set.EXIT_UNSUPPORTED=8and
EXIT_QUERY=9reserved in the exit-code comment block (2–7 are taken)._primchecks the primitive ispresent and executable before invoking, and dies with the classified diagnosis above. No 127 and no
No such file or directoryreaches a caller. Other gated-op callers (pr_create,release_create, thewiki ops) are not taught a new branch in this slice; they see exit 8 with a clear message instead of
127 — strictly better than today, and stated so rather than left implicit.
3. Skill-facing helper (
release-pipeline-status.sh).--refrequired,--shaoptional and validatedas exactly 40 hex characters. Runs the primitive with output to a file and
rccaptured from the command,never a pipeline. Emits exactly one well-formed JSON answer: a verdict (exit 0),
{state:"unsupported",…}(exit 8), or
{state:"query_failed", error, …}(exit 9); usage (2) and broken install (5) propagate and arenot answers. When
--shawas passed and the answer'scommitis null or absent, the state is forced tounknown— a green with no commit is never accepted as green.4. Adapters. glab-cli:
-f sha=$shaalongside the retained-f ref=$ref;order_by=id&sort=desc& per_page=1; response carriescommit/ref/pipeline_id/raw_state; the API call is rc-checked ratherthan piped, and any non-zero
glab, non-JSON body (proxy HTML, empty output) or unparseable responsebecomes
query_failedwith a classified error — HTTP status plus a fixed phrase plus the adapter name,never raw CLI stderr.
GL_JQ_PIPEMAPis rewritten with no catch-all:success/failed/runningdirect;
canceled|canceling → canceled;skipped → skipped;manual → manual;created|waiting_for_resource|preparing|pending|scheduled → pending; anything else →unknownwith theforge's literal value preserved in
raw_state. (pr_statusshares this map and inherits the fall-throughfix; its
pipeline_stategains no commit attribution in this slice — deferred, see Findings.)local-fs: with
--sha, readpipelines/{refkey}@{sha}.jsonand fall back topipelines/{refkey}.json;the record may declare
{state, commit, pipeline_id}; the answer echoes the declaredcommit, so awrong-commit answer can be staged deliberately for the defence-in-depth case, and a
query_failedrecord lets the transient path be staged too.
refkeykeeps its existing encoding (/and space →__);@is never produced by that encoding, so the@{sha}suffix is unambiguous.refis validated before itbecomes a path component. Existing fixtures without a
commitfield parse ascommit: null.tea-cli / gh-cli: unchanged on disk; they answer
unsupportedthrough the dispatch.5.
/dev:promote. Step 4 (tag) gains the pre-tag gate; Step 5 gains the pinned sha, the identityassertion, and a branch table with no fall-through:
success→green ·failed|canceled→Step 8 ·skipped→Step 8 (the gate did not run) ·manual→Step 8 (blocked on a human) ·pending|running→sleepand re-poll ·
none→keep waiting ·query_failed→retry inside the bound, and on the bound escalatenaming the query failure, never a pipeline state ·
unsupported→break to Step 6 and print the skipline in the transcript at that moment ·
unknown→Step 8 namingraw_state. Every non-terminal branchemits a poll record in one stated one-line format (state, commit, ref, elapsed, remaining). Escalations
carry the pipeline
url; the timeout escalation names the commit and the last observed state("never saw a pipeline for
<sha>" and "stuck pending" imply different operator actions). The loop followswait-discipline.md§2 — one bounded loop owning the whole wait, rc captured, output to a file.6. Regression probe.
_shared/procedures/test/pipeline-identity.sh(new), driving the real helpersthrough local-fs and, for the capability cases, a temp project declaring
adapter: tea-cli(which genuinelyships no
pipeline_status.sh, so no network is touched).Technical Risks
nonesemantics flip (break → wait) changes behaviour for a project whose ref legitimately never gets a pipelineci_timeoutonce and escalates. A sweep confirmed/dev:promoteis the only skill-text consumer of this reading —/dev:integratedoes not consume it._GATED_PRIMSdrifts from the contract's Kind columnci_timeout;unsupportedshort-circuits it entirely.glab-cliquery change is verified live, not by the staged probe_lib.shtops out atEXIT_ENV=7) and reserved in the comment block.Expert Review
Tier 3 — four lenses,
fable, one response each.Reviewers
_GATED_PRIMSduplicates the contract with no sync mechanism.ci_timeoutis ambiguous now that there are two waits.query_failederrorfield's sanitization is asserted rather than specified.nonebranch, so a long wait stays indistinguishable from a hang; no format was given for the record.Changes Made
stateis authoritative forcallers; exit codes are a redundant echo, and promote's text says so explicitly and shows the rc-tolerant
capture pattern. Without this the fix would have re-created the #55 trap one layer up.
_GATED_PRIMSdrift check (architect + backend, independently): promoted to a mechanical AC (AC-14)in
lint-conventions.sh, not review discipline.this repo's own recorded learning (dropped exec bits on
bin/*.sh) makes concretely likely.query_failed.errorsanitization specified, not asserted (security): a classified error (HTTP statusabsolute path survives (AC-16).
commiton a green verdict under a pinned--shais never green (security): forced tounknown(AC-17), so a buggy adapter omitting the field cannot silently restore commit-blind behaviour.
pending,running,noneandquery_failedretries all emit one (AC-19).ci_timeoutper wait (backend): each of the two waits gets its own full budget, pinned at its entry.change is requested for them in this slice, and that disposition is written down rather than implied.
unsupportedis dispatch-emitted only andquery_failedis adapter-emitted.query_failed, not a jq parse death.refkeyencoding and old-fixture compatibility stated (backend);refvalidated before it becomes apath component (security).
url; the timeout escalation names the lastobserved state as well as the commit.
/dev:promoteis the only skill-text consumer of thenonereading;
/dev:integratedoes not consume it. Recorded in Technical Risks rather than left open.Noted (not actioned)
pr_status.pipeline_stategains no commit attribution. It inherits the fall-through fix (sharedGL_JQ_PIPEMAP) but stays ref/head-scoped. The PREQ scopes this slice to the monitor primitive.lint-conventions.sh's "gate piped into a filter" check covers only skill markdown, not the helpertier — which is why
glab-cli/bin/pipeline_status.shshipped that exact antipattern. Extending thecheck to
bin/*.shis a real follow-up, larger than this slice.human-uatbecause no harness executes skill markdown. Statedas a lane fact, not a defect.
plugin/skills/**/test/*.shand_shared/procedures/test/*.shexist and pass — documentation drift found while confirming laneexecutability.
Acceptance Criteria
_shared/procedures/test/pipeline-identity.shstages an earlier-commit green on the ref plus a later released commit; asserts the answer'scommitis the released one and the earliersuccessis not returned/dev:promoterun (owner: operator) against a staged wrong-commit answer; the helper-side half (answer states a commit ≠ the pinned target) asserted bypipeline-identity.shpipeline-identity.shasserts the pinned ordering rule (most recently created for the(ref, sha)pair) over several staged records; the real multi-pipeline shapes verified on a dogfood release against GitLabpipeline-identity.shassertsnoneandunsupportedare different answers with different exit codesci_timeout— counted from entry into the wait, tag-target commit pinned at that same moment → escalates as a timeout, naming the commit it waited forci_timeout; includes pushing a further commit mid-wait and confirming the run still gates on the pinned shapipeline-identity.shfeeds every status in the captured GitLab vocabulary through the mapping and asserts each yields a stated enum value, and an unrecognized value yieldsunknown+raw_state; the monitor branch table desk-checked against promote Step 5ci_timeoutbound; escalates naming the query failurepipeline-identity.shdrives an unreachable instance host and an invalid credential and assertsquery_failed(nevernone, never a verdict); the retry-then-escalate half on a dogfood rundeploy_check, and the Phase Outcome records that the pipeline gate was skipped and whytea-cliproject; the stated answer itself asserted bypipeline-identity.shpipeline-identity.shcaptures stdout+stderr and asserts exit 8,state:"unsupported", and the absence ofNo such file or directory, any line-number reference, and any path under the skills rootpipeline-identity.shremoves a required primitive from a temp adapter tree and asserts exit 5, the three named elements, and that the output is notunsupportedscripts/lint-conventions.shasserts every primitive row in §2.1/§2.2 carries a Kind value, and that the normative "what must it answer" paragraph existsfailed/canceledsurface unchanged with the commit stated) asserted bypipeline-identity.sh; existingrelease-flow.shcontinues to assert the failure pathpipeline-identity.shstages the sequence and re-runs it twice without manual re-staging; the live-forge half verified separately on a dogfood GitLab release_GATED_PRIMSin_lib.shmatches the contract's capability-gated set exactlyscripts/lint-conventions.shparses the contract's Kind column and diffs it against the list; drift fails the gate. Review discipline is not accepted here — the list is a deliberate second copyunsupportedpipeline-identity.shchmod -xa gated primitive and asserts exit 5 with the broken-install diagnosis, and that the pipeline gate is not skippedquery_failedanswer'serrorcarries no credential-shaped string and no absolute filesystem pathpipeline-identity.shdrives an auth failure and greps the full answer and the emitted transcript line for token-shaped strings and for any absolute path under the skills root--shawas given and the answer'scommitis null or absent, the state isunknownand is never treated as greenpipeline-identity.shstages asuccessrecord with nocommitunder a pinned--shaand assertsunknown--shamust be a full 40-hex sha; a short sha is refused, never silently answerednonepipeline-identity.shpasses an 8-char sha and asserts a usage failure (exit 2) naming the requirementpending,running,none,query_failedretry) emits a poll record in one stated one-line format carrying state, commit, ref, elapsed and remaining_shared/procedures/test/release-flow.shstill passes unchanged, and local-fs fixtures without acommitfield parse ascommit: nullrelease-flow.sh(green at HEAD today — verified during planning)Nothing in the PREQ's acceptance list is left unmapped; AC-1…AC-13 are its thirteen criteria in order, and
AC-14…AC-20 are additions forced by the expert panel and by the external-contract capture.
Implementation Scope
Areas
plugin/skills/_shared/forge-contract.md(§2.1, §2.2, §4)plugin/skills/_shared/procedures/bin/_lib.sh_GATED_PRIMS,EXIT_UNSUPPORTED/EXIT_QUERY,_primexistence + executability classificationplugin/skills/_shared/procedures/bin/release-pipeline-status.sh--sha, validation, rc-checked dispatch, stated answersplugin/skills/glab-cli/bin/pipeline_status.sh,bin/_lib.sh(GL_JQ_PIPEMAP),SKILL.mdplugin/skills/local-fs/bin/pipeline_status.sh,SKILL.mdcommit/query_failedstaging, ref validationplugin/skills/{tea-cli,gh-cli}/SKILL.md(capabilities:)unsupported(documentation parity)plugin/skills/promote/SKILL.md(Steps 4, 5, 8)plugin/skills/_shared/procedures/test/pipeline-identity.shscripts/lint-conventions.sh_GATED_PRIMSdrift checkFile Boundaries
Three groups can proceed in parallel once the contract text settles:
_lib.sh+release-pipeline-status.sh(one author; they share the exit-code vocabulary).glab-cliandlocal-fsare disjoint file sets and can be worked concurrently.promote/SKILL.mdandscripts/lint-conventions.shtouch nothing the others do.The regression probe touches only its own new file but consumes all three, so it lands last.
Dependencies & Sequencing
classification that every other area encodes. Nothing else should start against a guess.
scripts/lint-conventions.sh's Kind check depends on the contract's table shape, so it follows step 1 too.Constraints & Non-Goals
Constraints:
required new input or a removed field forces v2 and is out of bounds.
jq,git, POSIX utilities. Skill-emitted glue:POSIX only — no bashisms, no
python/node, no zsh-reserved variable names.the current
glabcall violates and is part of what is being fixed._shared/procedures/bin/*and the active adapter — never inline forge CLI calls.Non-goals (do NOT build):
pipeline_statusfortea-cli— the Forgejo instance is being decommissioned. Known and acceptedconsequence: a
tea-cliproject that does run Gitea Actions gets a statedunsupportedand its realpipelines go unwatched, recorded in the Phase Outcome.
gh-cli's release operations — all six are missing; filed as sibling #69, which depends on this.query-failure handling here is scoped to the pipeline poll.
readiness item and a parseability contract for a case nobody has hit.
.gitea/workflows,.github/workflows,.gitlab-ci.yml) — hardcodes per-forge paths into stack-agnostic shared code and would answer wrongly forconfig no runner picks up.
Test Plan: release-pipeline-status-attributable-answer
Validation cases for issue #47 (with #55 folded in). The scenarios were derived from the PREQ alone,
blind to any design. Lanes were annotated by
/dev:technical-plan2.6b against the SREQ — scenarios arePREQ-derived, lanes are SREQ-derived. No scenario's steps or expected observation were changed.
Lane assignment — the rule applied here, and why
This project declares
e2e,a11yandsecurity-browsernot applicable, soe2e-browseris notassignable at all — the schema default lane has no executor here. Two executors exist, and both were
confirmed to run in this environment before anything was routed to them:
plugin/skills/**/test/*.sh,plugin/skills/_shared/procedures/test/*.sh) —confirmed by running
_shared/procedures/test/release-flow.sh, which passes green, offline, in thisworktree. The capability cases were additionally confirmed executable offline by reproducing #55 itself
(exit 127 against a
tea-cliproject, no network touched)./dev:promote— because no harness executes skill markdown. This is amarkdown+shell suite;
/dev:promote's Step 5 is prose an agent follows, not code a runner runs.So: a scenario whose assertions land on the helper / adapter / contract tier is
integration-covered;a scenario whose assertions land on
/dev:promote's own flow ishuman-uat, owner: the operator, on adogfood release run. Seven of the fourteen fall on each side. Where a
human-uatscenario has amechanically-checkable half, the covering probe is named in its lane line — the lane records who owns the
criterion, and the annotation records what is additionally asserted for free.
Prerequisites
The state these scenarios need. Concrete environment details (instance URLs, tokens, seed commands)
belong to the UAT card, not here.
release:block, so/dev:promotewill actually run its monitor step.state, commit and count in place deliberately, rather than waiting for real CI to produce one.
system can produce (a genuine red, a retried pipeline, a merge-request pipeline).
Required Test Data
and a later one that is the commit actually being released. This pair is the whole of #47.
it, also a branch pipeline alongside a merge-request pipeline.
pending,running,canceled, and whatever the forge calls manual/blocked and skipped.never shipped a gated one.
Test Scenarios
Scenario 1: The stale-pipeline false green — the 2026-08-14 case
Acceptance criterion: Given the release ref's most recent pipeline belongs to an earlier commit than
the commit being released, when the pipeline status for that release is requested, then the answer
describes a pipeline for the commit being released — not the earlier one — and states which commit it
describes.
Lane: integration-covered —
plugin/skills/_shared/procedures/test/pipeline-identity.shcommit — the previous release's — and push a later commit that is the one being released.
success. If the later commit's pipeline isstill running, the answer says so; if it has not started, the answer is the not-yet case (Scenario 4).
Expected outcome: The previous release's green is no longer offered as an answer about a newer commit,
and the answer carries enough to tell which commit it is about without knowing how it was obtained.
Scenario 2: An answer about the wrong commit does not reach the tag
Acceptance criterion: Given a pipeline answer whose stated commit differs from the commit
/dev:promoteis about to tag, when the run reaches the tag gate, then no tag is placed and a blockingdecision is escalated naming both commits.
Lane: human-uat — the operator, on a dogfood
/dev:promoterelease run: the criterion is about promote's tag gate, and no harness executes skill markdown. The helper-side half — the answer stating a commit ≠ the pinned tag target — is asserted byplugin/skills/_shared/procedures/test/pipeline-identity.sh.by scripting the scriptable adapter to return a successful pipeline labelled with the earlier commit.
about and the one that was about to be tagged.
Expected outcome: Even when the answer is wrong, the gate holds. This is the defence-in-depth case:
it should be unreachable through the normal path, and a tester has to stage it deliberately.
Scenario 3: A commit with several pipelines
Acceptance criterion: Given a commit with more than one pipeline — a retry, a branch pipeline alongside
a merge-request pipeline, or a parent with children — when the status is requested, then the answer
describes the most recent pipeline for that commit, and states which commit it describes.
Lane: human-uat — the operator, on a dogfood
/dev:promoterelease against GitLab: only a live forge produces retried, merge-request and branch+tag pipelines on one sha. The selection rule itself (most recently created for the(ref, sha)pair) is asserted mechanically byplugin/skills/_shared/procedures/test/pipeline-identity.sh.with both present and verify the answer is the most recent of them and still names the commit.
Expected outcome: "Which pipeline" has one stated answer, and a tester gets the same one every time
rather than whichever the forge happened to list first.
Scenario 4: A commit whose pipeline has not started yet
Acceptance criterion: Given a commit for which no pipeline exists yet, when the monitor polls, then the
answer is distinguishable from "this adapter cannot report pipelines", the monitor emits an observable poll
record naming the commit and the elapsed wait, and it continues waiting rather than concluding the release
is ungated.
Lane: human-uat — the operator, on a dogfood
/dev:promoterun: the continued waiting and the poll record are promote's flow. The "two kinds of nothing are different answers" half is asserted byplugin/skills/_shared/procedures/test/pipeline-identity.sh.waiting, so a tester can tell waiting apart from hung.
place a tag.
picks it up and proceeds.
Expected outcome: The gap between a merge and the runner starting is a wait, not a verdict — and the
tester can see it waiting.
Scenario 5: Waiting for a pipeline that never appears
Acceptance criterion: Given the wait for a not-yet-created pipeline reaches the project's declared
ci_timeout— counted from entry into the wait, with the tag-target commit pinned at that same moment —when the bound is hit, then the run escalates as a timeout, naming the commit it was waiting for.
Lane: human-uat — the operator, on a dogfood
/dev:promoterun with a short declaredci_timeout.ci_timeoutso the case is executable.the merge or the first poll.
reporting a pipeline state.
and still escalates about — the commit it pinned when the wait began, not the new head.
Expected outcome: An indefinite wait is bounded, the bound starts where the PREQ says it starts, and a
concurrent push cannot quietly move what the run is gating on.
Scenario 6: Pipeline states that are neither green nor red nor absent
Acceptance criterion: Given a pipeline in a state that is neither success, nor failure, nor absent —
canceled, manual/blocked, or skipped — when the monitor polls, then that state maps onto a stated monitor
branch; no state falls through to an unstated default.
Lane: integration-covered —
plugin/skills/_shared/procedures/test/pipeline-identity.sh: every status in the captured GitLab vocabulary maps to a stated enum value, and an unrecognized value yieldsunknown+raw_state. Which monitor branch each maps onto is desk-checked against/dev:promoteStep 5's branch table.which state it saw.
name the state.
Expected outcome: Every state a real forge can return has a stated destination; a tester can enumerate
them and find no orphan.
Scenario 7: The forge cannot be reached mid-poll
Acceptance criterion: Given the forge is unreachable, authentication fails, or the API returns an
error, when the monitor polls, then the outcome is reported as a query failure — never as a pipeline
verdict and never as an absent pipeline — is retried within the existing
ci_timeoutbound, and if itnever clears, escalates naming the query failure rather than a pipeline state.
Lane: integration-covered —
plugin/skills/_shared/procedures/test/pipeline-identity.sh: an unreachable instance host and an invalid credential each yieldquery_failed, nevernoneand never a verdict. The retry-then-escalate half rides the same dogfood run as Scenario 5.credentials) while the monitor is polling.
success, notfailed, and not "no pipeline".pipeline verdict.
and does not assert anything about the pipeline's state.
Expected outcome: A network blip is never mistaken for a verdict about the code, and a lasting outage
escalates as what it is.
Scenario 8: A project whose adapter cannot report pipelines at all
Acceptance criterion: Given a project whose active adapter cannot report pipelines at all, when
/dev:promoteruns its monitor step, then it receives a stated "this adapter cannot report pipelines"answer, proceeds to the required
deploy_check, and its Phase Outcome records that the pipeline gate wasskipped and why.
Lane: human-uat — the operator, on a dogfood
/dev:promoterun against atea-cliproject: "proceeds to the deploy check and records the skip in the Phase Outcome" is promote's flow. The statedunsupportedanswer itself is asserted byplugin/skills/_shared/procedures/test/pipeline-identity.sh.— not an error, and not a wait.
Expected outcome: The documented no-CI path is reachable by following the procedure, with no operator
having to establish the absence independently — which is exactly what #55 was filed about.
Scenario 9: What the operator sees when the capability is absent
Acceptance criterion: Given that same project, when the monitor step runs, then the helper's own exit
status and message name the capability boundary, and no shell-level error and no internal helper file path
reaches the operator or the calling skill.
Lane: integration-covered —
plugin/skills/_shared/procedures/test/pipeline-identity.shfile appears anywhere in what the operator or the calling skill sees.
Expected outcome: The output is information the calling skill can act on, in place of the 127 and the
internal path recorded on 2026-08-24.
Scenario 10: A required primitive is missing — a broken install
Acceptance criterion: Given an adapter that is missing a required primitive, when any skill invokes it,
then the failure names the adapter, the primitive and where it was looked for, and is never reported as an
absent capability.
Lane: integration-covered —
plugin/skills/_shared/procedures/test/pipeline-identity.shcapability-gated).
unsupported, not degraded into a "no", and does not let the callingskill proceed as though it had an answer.
alone.
Expected outcome: A broken install and a deliberate capability boundary look different — which is the
distinction the whole absent-primitive change exists to draw.
Scenario 11: The contract answers the question for every primitive
Acceptance criterion: Given the forge contract, for each primitive it lists, then the contract states
whether that primitive is required or capability-gated, and what an adapter that cannot provide it must
answer.
Lane: integration-covered —
scripts/lint-conventions.sh: a mechanical enumeration over the contract's own primitive tables.that cannot provide it answer?
Expected outcome: A skill author picking up a new adapter can classify every primitive from the
contract alone. (This is a documentation check, executable as an enumeration against the contract; it needs
no running system.)
Edge Cases and Regression
Scenario 12: A genuine red, and a genuine cancellation, still stop the release
Acceptance criterion: Given a project whose CI reports a genuine failure for the exact commit being
released — and, separately, given a pipeline canceled for that commit — when the monitor polls, then each
escalates as it does today.
Lane: human-uat — the operator, on a dogfood
/dev:promoterun. The helper-side half (failedandcanceledsurface unchanged, with the commit stated) is asserted byplugin/skills/_shared/procedures/test/pipeline-identity.sh, and the existingplugin/skills/_shared/procedures/test/release-flow.shcontinues to assert the failure path.Expected outcome: The attribution work adds no new route by which a real red is read as green or as
"nothing here" — the thing that must not regress.
Scenario 13: The originating sequence, replayed
Acceptance criterion: Given the merge-then-query sequence that produced the 2026-08-14 false green,
staged on the scriptable adapter, when the feature's regression probe is run, then the stale answer is not
returned.
Lane: integration-covered —
plugin/skills/_shared/procedures/test/pipeline-identity.sh. Step 5's live-forge half is human-uat, on the same dogfood GitLab release as Scenario 3.then a merge putting a new commit at the head of the release ref, then an immediate status query.
successas the answer for the new head.system; the staged probe cannot demonstrate that half.
Expected outcome: The exact near-miss is captured as something that runs, and the split between what
the staged probe proves and what live verification proves is explicit rather than assumed.
Scenario 14: Both kinds of nothing, side by side
Acceptance criterion: (covers the "no pipeline yet" / "cannot report" distinction jointly — the
criteria behind Scenarios 4 and 8.)
Lane: integration-covered —
plugin/skills/_shared/procedures/test/pipeline-identity.shpipeline yet, and once on an adapter that cannot report pipelines at all.
Expected outcome: The two states that used to collapse into one are distinguishable in practice, not
just in the contract — this is the case that would have caught the false green the identity fix could
otherwise have re-created.
Traceability
Forward — every acceptance criterion has at least one case:
ci_timeoutBackward — every case traces to a criterion. Scenario 14 has no criterion of its own; it exercises the
Scenario 4 and Scenario 8 criteria jointly, because the defect being guarded against is precisely their
collapse into one answer. It is kept as a paired case rather than deleted as an orphan.
Notes
e2e,a11yandsecurity-browsernot applicable; everyscenario above is executed against the suite's own helpers and a forge, not a rendered UI.
it is a legitimate validation case rather than an implementation-necessity exception.
manual/blocked states, and real API failures cannot be staged on a simulated adapter. Which suite or
which human owns them is a lane decision for
/dev:technical-plan. Resolved at 2.6b, and the split isnarrower than this note assumed: Scenario 6's mapping and Scenario 7's query-failure answer turned
out to be mechanically checkable without a live forge (a status vocabulary fed through the map; an
unreachable host and an invalid credential), so both are
integration-covered. Only Scenario 3's realmulti-pipeline shapes and Scenario 13's step 5 genuinely need live GitLab, and both ride one dogfood
release run.
/dev:technical-plan2.2b →
external-contracts/gitlab-pipelines.provenance.json). Two facts recorded there change what thesescenarios must assert, and both are now design decisions rather than open questions: a commit's pipelines
can be ordered differently by id and by
updated_at(so "the most recent pipeline" needed a pinnedordering key), and
?sha=silently returns[]for an abbreviated sha (so a short sha must be refused,not answered
none). The second is a new validation case in the SREQ's AC table (AC-18), not in thisplan — it was not derivable from the PREQ.
ci_timeoutto be executable at all; that is a projectdeclaration, not an environment detail.
Linked: this issue is sibling #103 (recorded by the devwork pipeline).
Linked: this issue is sibling #104 (recorded by the devwork pipeline).
Linked: this issue is sibling #105 (recorded by the devwork pipeline).
Linked: this issue is sibling #106 (recorded by the devwork pipeline).
Decomposition rationale
Seven units in four waves, following the SREQ's Dependencies & Sequencing exactly: the contract text
fixes the state enum, the response fields and the required-vs-capability-gated classification that every
other unit encodes, so nothing starts against a guess.
Wave 1 — WU-47-3-1 (contract + manifests). One unit rather than two: the
tea-cli/gh-clicapabilities:edits are documentation parity with the very paragraph being written, and both areartifact-shape doc changes with no behavioural surface. Splitting them would serialize two agents over
one decision.
Wave 2 — the four independent implementation surfaces.
_lib.sh+release-pipeline-status.share oneunit because they share the exit-code vocabulary (
EXIT_UNSUPPORTED=8/EXIT_QUERY=9) and the SREQ saysso in terms.
local-fsandglab-cliare disjoint file sets.promote/SKILL.mdtouches nothing theothers do.
Wave 3 — WU-47-3-6 (lint). Not in wave 2: its
_GATED_PRIMSdrift check reads both the contract'sKind column (wave 1) and the list in
_lib.sh(WU-47-3-2), so it cannot go green before both exist.Wave 4 — WU-47-3-7 (probe). Asserts the composed behaviour end to end; it consumes all of the above,
which is why the SREQ lands it last.
Execution is sequential, not parallel. This repo is a single shared worktree
(
.claude/worktrees/devwork-47-55-pipeline-status-identity), so within-wave parallelism is unsafe —concurrent Implementer commits contend on the git index lock. The wave structure above is the real
dependency graph; intra-wave execution serializes for safety, per
/dev:develop§3.1.Two units are artifact-shape and spawn an Implementer only (no Test Writer): WU-47-3-1 (contract and
manifest markdown) and WU-47-3-5 (
promote/SKILL.md). No harness executes skill markdown, so their ACsare verified by desk-check and by the dogfood
/dev:promoterun, which is what the SREQ's verificationcolumn already says for AC-2/AC-4/AC-5/AC-8/AC-12/AC-19. Every unit with a behavioural surface spawns
both teammates.
Regression gate for every unit:
bash scripts/lint-conventions.sh, the eight_shared/procedures/test/*.shsuites, and the threelocal-fs/test/*.shsuites — all green at baseline.The three remote-adapter smokes (
glab-cli/test/smoke.sh,glab-cli/test/release-smoke.sh,tea-cli/test/smoke.sh, plusgh-cli/test/smoke.sh) require live forge credentials or a scratch issue andare red at baseline for environmental reasons; they are outside the offline gate.
Dev journal — WU-47-3-1 (forge contract: required vs capability-gated)
2026-08-25T17:40 DISCOVERY: three primitives are doc-only, so "MUST exist and be executable" needed a carve-out
Context: Writing the normative paragraph that says a
requiredprimitive MUST exist on disk and beexecutable, with its absence a broken install.
Pattern: That claim is false as stated for
bootstrap_project: no in-tree adapter ships abootstrap_project.sh— it is doc-only on all four (local-fs,glab-cli,tea-cli,gh-cli), executedby the agent from its documented steps. The deferred wiki ops are the same.
_primin${CLAUDE_PLUGIN_ROOT}/skills/_shared/procedures/bin/_lib.shonly ever dispatches a primitive by path, so adoc-only primitive never reaches the existence check — but the contract sentence would still have been
literally wrong, and the WU-47-3-2 implementer could reasonably have read it as "add
bootstrap_projecttothe existence sweep", which would fail every adapter.
Why it matters: The Kind column stays complete (every row carries one — AC-11), and a separate
Scriptable vs doc-only paragraph bounds the existence-and-executability rule to primitives an adapter
wires as a
bin/script. Kind and scriptability are orthogonal:bootstrap_projectisrequiredanddoc-only;
pipeline_statusiscapability-gatedand scriptable.2026-08-25T17:52 DISCOVERY: the contract's own §4 example manifest omitted two required operations
Context: Rewriting §4's prose to say a
requiredop MUST be declarednativeorpolyfill.Pattern: §4's example
capabilities:block listed neitherclose_issuenorreopen_issue, though bothwere added as required operations in §2.2 (2026-06-19, the
/dev:requirementsfan-in flow) and all fouradapters declare them. The contract's own illustrative manifest therefore violated the rule the section
states. Added both to the example.
Why it matters: A product defect, small but real: §4's example is what an adapter author copies. It
is also a live instance of the drift the new
capabilities:parity work is about — the manifest isdocumentation, it drifts silently, and nothing mechanical catches it. The
_GATED_PRIMSdrift check(AC-14, WU-47-3-6) covers the shared-tier copy of the classification; nothing checks an adapter's
capabilities:block against its ownbin/directory. Worth a Finding: a manifest-vs-disk parity check ischeap (list
bin/*.sh, diff against theoperations:keys declared non-unsupported) and would havecaught this and the
tea-cli/gh-cliomissions this work unit just fixed by hand.2026-08-25T18:05 DISCOVERY: the state-vocabulary table shares §2.2 with the operation tables — anchor the AC-11 parser on the header row
Context: Dry-running the AC-11 check that WU-47-3-6 will add, to prove the Kind column is
machine-parseable before handing it on.
Pattern: A parser that bounds the operation tables by section (
## 2. Operations…### 2.3) alsoswallows the new
pipeline_statusstate vocabulary table, whose rows also open with a backticked cell(
| `pending` |) — eleven false "missing Kind" hits. Bounding on the header row instead(
^\| *Operation *\| *Kind *\|, running until the first line not starting with|) is exact: 3 tables, 34rows, 20
required, 14capability-gated, 0 bad. A parser wanting only the primitive set (for_GATED_PRIMS) must additionally bound on### 2.2…### 2.3— the §2.1 tables carry skill-facingoperation names (
release.pipeline_status), not primitives.Why it matters: The §2.2-only capability-gated set is exactly eight, and this is what
_GATED_PRIMSmust equal:
pr_create pr_merge pr_status comment_on_pr pipeline_status release_create read_wiki_page write_wiki_page.2026-08-25T18:15 COMPLETE
Hardest part: Deciding where the normative text draws its boundaries —
unsupportedis a state valuein
pipeline_status's answer but only a diagnostic + exit status for the other gated ops, which return nostatefield at all, so the paragraph had to say both without implying every gated op grows a JSON answer.If I did this again: Dry-run the machine check against the finished text before deciding table
placement, not after — the state-vocabulary table would have gone under its own sub-heading if I had known
a section-bounded parser was the obvious first thing an implementer reaches for.
Dev journal — WU-47-3-2 (shared dispatch + skill-facing pipeline helper)
2026-08-25T17:40 DISCOVERY:
_dieis anexit, so a helper that must TURN a classification into an answer has to dispatch in a subshellContext:
_primhad to die with exit 8 on an absent capability-gated primitive (so everyother gated-op caller gets a clear 8 instead of a raw 127), while
release-pipeline-status.shhad to survive that same 8 and render it as
{state:"unsupported"}on stdout.Pattern: call the primitive inside an explicit subshell with the output redirections on the
subshell, and take rc from it:
_die'sexitthen terminates only the subshell. A plain function call would have killed thewhole helper before it could emit anything, and
set -ewould have masked the difference. This isalso the rc-from-the-command-not-a-pipeline rule the SREQ asks for: the redirections are on the
command, no filter is in the way.
Why it matters: it keeps one classification site (
_prim) serving two different callercontracts — "die loudly" for the ops that have no answer shape, and "render as an answer" for the
one that does — without duplicating the required/gated table anywhere.
2026-08-25T17:45 DISCOVERY:
x=y && arr+=(…)as a bare statement is aset -elandmineContext: building the optional
--shaargv for the primitive.Pattern: written as
[[ -n "$sha" ]] && prim_args+=(--sha "$sha")on its own line, the wholestatement returns 1 whenever
$shais empty, andset -euo pipefailkills the script on theno-
--shapath — the exact pathrelease-flow.shexercises. Written as anifblock it is inert.Used
if [[ -n "$sha" ]]; then prim_args+=(--sha "$sha"); fi.Why it matters: the failure would only have shown up on the default invocation, not the new
one — a change that breaks the case it did not touch.
2026-08-25T17:55 DISCOVERY (finding, not a learning): AC-17 cannot go green until the
local-fsadapter accepts--shaContext: AC-17 (
--shapinned + an answer with nocommit⇒unknown) is exercised throughthe
local-fsadapter with a staged{state:"success"}record.Pattern:
local-fs/bin/pipeline_status.shat HEAD parses only--ref; anything else hits its*) _die "$EXIT_USAGE" "unknown arg: $1"branch. So the dispatch gets rc 2 from the primitive andcorrectly propagates it as a usage failure, not an answer — stdout is empty and
.stateis"", which is what the test reports.Why it matters: this is a sequencing fact, not a defect in either file, and the shared tier's
behaviour here is deliberately the right one: silently retrying without
--shawould hide a staleadapter and hand back the commit-blind answer this whole feature exists to remove. The forced-
unknownlogic is implemented and exercised (verified by hand against a fixture whose primitivedoes accept the flag); it goes green the moment WU-47-3-3's
local-fschange lands. Reported to thelead rather than worked around. Out of scope for this unit by the brief (
local-fs/bin/is anotherunit's file).
2026-08-25T18:05 DISCOVERY: the dispatch also refuses an adapter-emitted
unsupportedContext: contract §2.2 states
unsupportedis dispatch-emitted only, and warns that a futureadapter author will "helpfully" emit it from inside a primitive.
Pattern: the helper reclassifies an adapter-emitted
state:"unsupported"tounknown, keepsthe forge's word in
raw_state, and logs the layer violation.unknownescalates, so the rogueanswer cannot make
/dev:promoteskip the release gate.Why it matters: it makes the layer rule mechanical rather than a comment. An adapter that ships
a primitive has, by construction, the capability — so an
unsupportedfrom inside one is never atrue statement, and accepting it would be a fail-open release gate by the same shape AC-15 closes.
Worth a mention on the Phase Outcome as a small behaviour the contract implies but did not spell out
in the helper's brief.
2026-08-25T18:10 COMPLETE
Hardest part: getting one
_primclassification to serve both "die loudly for every other gatedcaller" and "become a JSON answer for this one caller" without a second copy of the required/gated
table — the subshell dispatch is what made it one site instead of two.
If I did this again: I would check the adapter side of every acceptance criterion's fixture
before writing a line — AC-17's dependency on
local-fslearning--shawas visible in the testfile from the start and would have been worth flagging to the lead in the first minute rather than
after the first full run.
Development Journal — WU-47-3-3 (local-fs
pipeline_status)2026-08-25T20:55 DISCOVERY: the single-object record cannot express AC-3, so the store grew an array shape
Context: The SREQ specifies one record object per key (
{state, commit, pipeline_id}), but AC-3requires several pipelines for one
(ref, sha)pair and asks for "the most recently created" — which oneobject cannot express. The lead settled this before the unit started: a record file holds either one
object (today's shape, every existing fixture) or an array of pipeline objects, and an array is
resolved by
max(created_at).Pattern: One jq program handles both, with an explicit type guard so a malformed record becomes a
stated answer instead of a jq death:
type == "object"→ the record itselftype == "array"→sort_by(.created_at // "") | lasttype == "null"→ null (reads asstate: "none")error(...), caught by the rc check and answeredquery_failed(exit 9)The selected record is bound with
as $rand every field read as$r.field, so a missing key and anull record both answer
nullnaturally — no per-field branching, and no way to invent a value.Why it matters: this is a finding for the Phase Outcome, not a CLAUDE.md learning. The
array shape is a real extension of the adapter's public fixture interface that the SREQ text does not
describe.
local-fs/SKILL.mdnow documents it (§ "Staging a pipeline record"); if the SREQ is everre-read as the spec of record, its
local-fsparagraph should be understood as amended by that section.Two details are load-bearing and easy to get wrong later:
created_at, notpipeline_id, not array position.glab-cliusesorder_by=id&sort=descbecause on GitLab id is a proxy for creation time;
local-fscan declarecreated_atoutright, so itcompares the real key. The committed tests stage the correct entry neither first nor last, and neither
at the max nor the min
pipeline_id—array[0],array[-1]and sort-by-id all fail on purpose.sort_by(.created_at // "")puts acreated_at-less entry below every entry that has one, and jq'ssort is stable so equal keys keep array order. That is the wanted behaviour (an undated fixture never
beats a dated one), but it is a consequence of the
// ""default rather than an explicit rule — it iswritten down in SKILL.md so it does not get "tidied" away.
2026-08-25T21:10 DISCOVERY:
--refhad to be validated in the primitive, because_lib.shis out of this unitContext: the security lens asked for
refto be validated before it becomes a path component. Theexisting encoding (
_lfs_ref_key:/and space →__) already neutralizes traversal —../../etc/xkeys as
..__..__etc__x— so the check adds nothing today.Pattern: validate anyway, in
pipeline_status.shitself (_lib.shbelongs to no work unit in thisslice). Charset via a variable-held ERE (
_REF_OK), then twocasearms for..and@.Why it matters: the safety currently rests on a property of an encoding that a later change could
relax without anyone noticing that a security control went with it. An explicit refusal is a check; an
encoding side effect is a coincidence. The
@refusal is separately load-bearing: it is what makes the{refkey}@{sha}.jsonkey unambiguous, which the SREQ asserts but nothing enforced.Bash detail worth keeping: the regex allows a space (
_lfs_ref_keymaps it, so it has always beenaccepted), and a space cannot appear in an unquoted
=~right-hand side — bash reads that operand as asingle word. Holding the pattern in a variable and expanding it unquoted is the bash-3.2-safe way to keep
the space.
2026-08-25T21:25 DISCOVERY:
[[ … ]] && exit Nas a script's last statement is a latent exit-1 bugContext: the primitive's tail is "print the answer, then exit 9 if the answer is
query_failed".Written as
[[ "$state" == query_failed ]] && exit "$EXIT_QUERY"it is a trap: when the test is falsethe compound returns 1, and as the script's last command that becomes the script's exit status — every
ordinary verdict would have exited 1, which the shared dispatch reads as a failed query. Under
set -e(inherited from
_lib.sh) it can also kill the script outright depending on position.Not in CLAUDE.md: the conventions already forbid
[[ … ]] && arr+=(…)as a bare statement — thesame defect is called out by name in
release-pipeline-status.sh's own comment aboutprim_args. Therule is general ("a bare
test && cmdinherits the test's exit status"), but CLAUDE.md's portabilitybaseline states only the array-append instance. Worth generalizing to: in a
set -ehelper, never writea conditional action as a bare
test && cmd— use anifblock; the two instances found so far are anarray append and a final
exit, and the second one fails on the success path, which is the harder halfto notice. Filed here for the retrospective rather than acted on, since CLAUDE.md is outside this unit.
2026-08-25T21:40 COMPLETE
Hardest part: deciding not to reclassify a staged
state: "unsupported"inside the primitive. Theunit's constraint says the primitive must never emit
unsupported, and reclassifying it locally wouldsatisfy that literally — but it would also make the shared dispatch's own reclassification guard
untestable, since
local-fsis the only scriptable surface the layer above can be driven from. Theprimitive never chooses
unsupported; it echoes a record an operator deliberately staged. Resolved byechoing verbatim and documenting in SKILL.md that staging it is not supported, with the reason.
If I did this again: write the type guard into the jq program from the first draft. The first version
went straight to
sort_by | lastand would have died with a raw jq parse error on a malformed fixture —exactly the "never a shell error" failure this whole feature exists to remove, reintroduced one layer
down. Verifying by executing the empty-array, non-JSON, wrong-type, missing-file and traversal cases (not
just the committed suite) is what caught it.
Development journal — WU-47-3-4 (glab-cli adapter: commit-scoped pipeline query + classified query failures)
Feature: release-pipeline-status-attributable-answer (issue #47, with #55 folded in).
Files owned:
plugin/skills/glab-cli/bin/pipeline_status.sh,plugin/skills/glab-cli/bin/_lib.sh(
GL_JQ_PIPEMAP),plugin/skills/glab-cli/SKILL.md.2026-08-26T14:40 DISCOVERY: jq's exit 5 collides with the shared tier's EXIT_ADAPTER=5
Context: Diagnosing why an invalid credential came out of the shared dispatch as exit 5 ("broken
install") instead of exit 9 (
query_failed).Pattern: The old line was
latest="$(_gl_api … | jq '.[0] // null')". A failed GitLab query answerswith a JSON object (
{"message":"401 Unauthorized"}) andglabexit 1..[0]on an object is a jqtype error, and jq exits 5. Under
pipefaila pipeline reports its last stage, so theprimitive's own status became jq's 5 — which is exactly
_shared/procedures/bin/_lib.sh'sEXIT_ADAPTER=5. An auth rejection was therefore read one layer up as a broken adapter install.Why it matters: This is a second, sharper edge on the repo's existing "a gate's rc comes from the
command, never a pipeline" rule. The known cost of that antipattern is a false green (a filter that
matched rewrites a red gate green). This case shows a second cost: the filter's exit status can collide
with a reserved code in the caller's vocabulary, so a correctly-detected failure is misrouted rather
than merely lost. Any helper whose exit codes are a shared vocabulary has to own its rc, not inherit a
tool's — jq's 1–5 range overlaps almost every hand-rolled scheme. Worth carrying into CLAUDE.md's
portability baseline as a clause on the existing rule, not a new rule.
2026-08-26T14:45 DISCOVERY: the shared dispatch forwards the adapter's stderr, so classification is the adapter's job
Context: AC-16 requires that neither the
query_failedanswer nor the emitted transcript carries acredential-shaped string or an absolute path. The staged auth failure puts both on
glab's stderr, whichis realistic — a real
glabauth message quotes the token it tried and the config file it read it from.Pattern:
release-pipeline-status.shforwards the primitive's stderr to the operator verbatim(
cat "$PRIM_ERR" >&2) on every path, and that is deliberate contract behaviour — the operator needsthe adapter's own diagnosis. The consequence is a layering rule that is easy to get backwards: the
sanitization boundary is the adapter, not the dispatch. A primitive that lets a CLI's raw stderr flow
through it has already leaked, whatever the dispatch does. So the primitive now captures
glab's stderrto a file, extracts only three digits of HTTP status from it (via a body-first lookup, with an
anchored
sedon stderr as fallback), and writes its own classified line.Why it matters: It generalizes to every adapter primitive that shells out — the classified-error
requirement in forge-contract §2.2 is not just about the
errorfield, it is about everything theprimitive writes to fd 2.
2026-08-26T14:52 DISCOVERY: removing a catch-all breaks the states that were only right by accident
Context:
GL_JQ_PIPEMAP'selse "pending"was wrong formanual,cancelingand an unrecognizedstatus — but it was also the only thing mapping
created,waiting_for_resource,preparingandscheduled, which were green in the suite before this change.Pattern: Four of the thirteen AC-6 cases were passing for a reason the change was about to delete.
The rewrite names all five pending-ish statuses explicitly before falling to
unknown. Checked byrunning the map over the whole captured vocabulary (plus
nulland an invented status) directly, notonly through the suite.
Why it matters: "Which of my green assertions depend on the code I am removing?" is a question worth
asking before deleting any default branch — the suite cannot ask it for you, because it goes red only
after the mistake.
2026-08-26T15:05 DISCOVERY: under a pinned
--shathe shared dispatch rewritesnonetounknownContext: Desk-checking shapes the WU-47-3-4 suite sections do not stage. With
--shapinned and theforge answering
[](no pipeline for that commit yet), the adapter emits{"state":"none","commit":null,…}— correct per contract §2.2.Pattern: The dispatch's AC-17 guard then fires on it:
and the answer becomes
{"state":"unknown","raw_state":"none"}. Reproduced through both adapters, soit is the shared tier, not an adapter:
glab-cli, empty list,--shapinned →{"state":"unknown","raw_state":"none"}local-fs, no staged record,--shapinned →{"state":"unknown","raw_state":"none"}Why it matters: This is a finding for the Phase Outcome, not a learning, and it is outside my unit's
files.
/dev:promotepolls with--shapinned on every iteration, so this is the normal path, andthe branch table sends
unknownto Step 8 (escalate, namingraw_state) whilenonemust keepwaiting. A release polled in the seconds before the forge creates the tag's pipeline would escalate
instead of waiting — which is AC-4's requirement, and the SREQ's own reason that "no pipeline yet must be
a wait and not a verdict". Contract §2.2's state table already scopes the guard correctly:
unknownis"a verdict arrived with no
commitwhileshawas pinned", andnoneis not a verdict. The fix isone condition in the shared helper — exempt
none(and arguablyquery_failed, which also legitimatelycarries a null
commit) from the force. Reported to the lead; not fixed here.2026-08-26T15:20 COMPLETE
Hardest part: Realizing that AC-7's "unreachable host" case and AC-16's two vacuous checks were green
for reasons the change had to remove — the old primitive died with no stdout and the shared dispatch's
"no parseable JSON" net answered
query_failedon its behalf, so the passing assertions were testing thenet, not the adapter; the invalid-credential case (which that net does not catch, because jq's 5 is
consumed as
EXIT_ADAPTERbefore the net is reached) is what proved the layer violation was real.If I did this again: I would extract the HTTP status from the response body first and treat the
stderr scrape as a strictly-digits fallback from the start — I wrote it stderr-first, then realized that
"parse a status out of the string that also contains the credential" is a class of code you want to avoid
writing at all, not one you want to write carefully.
Dev journal — WU-47-3-8 (corrective)
Exempt non-verdict states from the pinned-sha null-commit guard in
release-pipeline-status.sh.DISCOVERY — which states are verdicts (forge-contract §2.2)
Read the §2.2 state table as the authority. The guard's own comment scopes it to
"a verdict arrived with no
commitwhileshawas pinned". Splitting theenum by that word:
pending,running,success,failed,canceled,skipped,manual.A pipeline that exists has a commit, so a null
commithere IS theunassertable case AC-17 closes. Guard must keep firing.
none— "no pipeline exists for this ref/commit yet". There is no pipeline,so there is no commit to name;
commit: nullis the correct answer, not amissing one. Monitor branch is keep waiting, and rewriting it to
unknownsends it to escalate instead. Exempt.
query_failed— "the forge could not be asked". Also legitimately nullcommit, and its monitor branch is retry within the wait's bound, thenescalate naming the query failure — explicitly "never a pipeline verdict".
Rewriting it to
unknownloses the retry AND silently drops the exit statusfrom 9 to 0, because the dispatch's
exit "$EXIT_QUERY"is keyed off$state. Exempt.unsupported— dispatch-emitted; the dispatch's own path exits before theguard, and a primitive-emitted one is already reclassified to
unknownabovethe guard. Nothing to change.
unknown— not a verdict either, but the guard is a no-op on itsstateandpreserves any adapter-set
raw_statevia//. Left alone: minimal change.Implemented as an exclusion list, not an inclusion list of verdicts, so a
state added to the enum later defaults to guarded (escalate), never to
fail-open.
DISCOVERY — a second, hidden half of the same defect
The red run showed more than the reported symptom. Under a pinned
--shaaquery_failedanswer came back with exit status 0, not the reserved 9:the dispatch's last line is
if [[ "$state" == "query_failed" ]]; then exit "$EXIT_QUERY"; fi, and the guard had already rewritten$statetounknownbefore it ran. So the guard silently downgraded the query-failure exit code
too. The existing AC-7 checks never saw it because they all call unpinned.
Both halves are fixed by the same exemption. (Finding for the Phase Outcome,
not a CLAUDE.md learning.)
STRUGGLE
None material. Wrote the eight new assertions first and watched them fail
(8 FAIL / 77 ok), then made the one-condition guard change (85 ok, PASS).
COMPLETE
Fix + assertions in one unit (deliberate exception to the two-phase split, per
the work unit brief). Gates:
pipeline-identity.sh85 ok / PASS;lint-conventions.shclean; all 9 shared + 3 local-fs suites rc=0; directrepro of both reported commands now answers
noneon the pinned call.Development Journal — WU-47-3-5 (
/dev:promoteSteps 4, 5, 8)2026-08-26T10:20 DISCOVERY: one wait definition, invoked twice — not two branch tables
Context: The SREQ puts a pre-tag gate in Step 4 and the release monitor in Step 5. Both need the
same eleven-state branch table, the same identity assertion, the same poll-record format and the
same bounded loop.
Pattern: Define the wait once, in a new section between Step 3 and Step 4 (
## The pipeline wait), and have both steps invoke it. Steps 4 and 5 then carry only what actually differs: which ref,which pinned commit, and what "proceed" means at that site.
Why it matters: Two copies of a branch table drift, and this repo's own resolved-debt section says
so twice (red markers, observability policy): one canonical source, consumers reference it. A
desk-checker also gets one table to compare against contract §2.2 instead of two.
2026-08-26T10:35 DISCOVERY: the pre-tag gate must sit after the manifest write-back
Context: Placing the new pre-tag gate inside the
tagstrategy's numbered steps.Pattern: The gate goes after step 2 (manifest write-back + push) and before the tag. The
bump commit is the commit that gets tagged, so pinning
TAG_TARGET_SHAbefore the write-back wouldassert the pipeline of a commit that is not the released one — the same commit-blindness the feature
exists to remove, one step earlier.
Why it matters: Two consequential follow-ons, both applied:
git tag -a "$TAG" -m … "$TAG_TARGET_SHA"now names the asserted commit explicitly instead of re-resolving the branch, and
release-create.sh --targettakes$TAG_TARGET_SHAinstead of a secondgit rev-parse "$INTEGRATION_BRANCH". Eitherre-resolution could pick up a commit pushed between the gate and the tag.
This is a finding about the product (promote's existing text had two unpinned re-resolutions),
not a codebase learning.
2026-08-26T10:50 DISCOVERY:
${CLAUDE_PLUGIN_ROOT}cannot cross a quoted heredocContext: The loop is long enough that the baseline requires emitting it as a script file. The
natural shape is
cat > … <<'EOF'— quoted, so$state,$rcand$1survive to run time.Pattern: A quoted heredoc also stops
${CLAUDE_PLUGIN_ROOT}from expanding at write time, andthe plugin-root variable is not reliably exported into the
bashprocess that later runs the file.Pass it in instead: the script reads
PIPE_HELPER(and its six siblings) from the environment, andthe caller sets them on the
bashinvocation line, where"${CLAUDE_PLUGIN_ROOT}/…"is quotedexactly as lint's D12 rule requires.
Why it matters: The alternative — an unquoted heredoc with
\$escaping on every runtimevariable — is both unreadable and directly at odds with the lint rule that flags
\${CLAUDE_PLUGIN_ROOT}.2026-08-26T11:10 STRUGGLE: the result line's
state=field carried spacesProblem: AC-19 asks for one stated one-line format, key=value. The
no-answerbranch (helperexit 2 or 5, nothing on stdout) initially set
state="no answer (helper exit $rc)".Expected:
pipeline-wait pre-tag outcome=no-answer state=<token> …— parseable by eye and byawk.Actual:
state=no answer (helper exit 2) commit=none …— three apparent fields where one was meant.Solution:
state="helper-exit-$rc". Found only by executing case M, not by reading the text.Key insight: A "stated format" claim is only as good as the worst branch that emits it, and the
worst branch is the one no happy-path test reaches.
2026-08-26T11:25 COMPLETE
Hardest part: Deciding that
nonemust keep the loop alive whileunsupportedmust end it — thetwo answers look alike in a transcript and mean opposite things, so the text has to say why at the
point of the branch, not only in a table.
If I did this again: I would extract-and-execute the emitted script from the markdown from the
first draft rather than the third. Every real defect I found in this unit (the spaces in
state=,the sleep overshooting the deadline) came out of running the extracted text, and none of them was
visible while reading it.
Development Journal — WU-47-3-6 (mechanical drift checks: AC-11, AC-14)
2026-08-26T16:55 DISCOVERY: anchor the table parser on the header row, not the section
Context: parsing the Kind column out of the forge contract's three operation tables.
Pattern: match the literal header row
| Operation | Kind |to enter a table, and leave it onthe first line that does not start with
|, skipping the|---|separator. The section-boundedalternative (
## 2. Operations…### 2.3) also swallows the state-vocabulary table at line 125,whose rows open with a backticked cell exactly like an operation row — 11 false rows.
Why it matters: the wave-1 implementer hit this for real while writing the tables and passed the
warning on; taking it saved re-deriving it. §2.2 still needs a section bound on top of the header
anchor, because §2.1 lists skill-facing names (
release.pipeline_status) that are not primitives.2026-08-26T17:05 DISCOVERY: parse the expected set, never restate it
Context: AC-14 diffs
_GATED_PRIMSin_lib.shagainst the contract's §2.2 capability-gated set.Pattern: the expected set is parsed from the contract on every run and diffed with
comm; theeight primitive names appear nowhere in the linter.
_GATED_PRIMSis read by grepping the singletop-level assignment and
sed-ing the quoted value out — never by sourcing_lib.sh.Why it matters: hardcoding the expected list would make the check a third copy of the same
declaration, so a contract change would leave two copies agreeing with each other and both wrong.
The check also asserts there is exactly one
^_GATED_PRIMS=assignment, so a second one cannot hide.2026-08-26T17:20 DISCOVERY:
report's hit lines carry no payload pastpath:linenoContext: wanting each violation to name the offending operation.
Pattern:
reportprintscut -d: -f1-2of each hit line, so any detail after the line number isdiscarded. To name the operation, call
reportonce per offending row with the name interpolated intothe message. The loop must read from a file (
< "$bad_kinds"), not a pipe — a pipedwhilerunsin a subshell and the
violationscounter would not survive it.Why it matters: this is the difference between a green run and a silently-uncounted violation.
2026-08-26T17:40 COMPLETE
Verification performed: linter clean at HEAD (rc 0), examined counts 34 operation rows / 3 tables
and 8 vs 8 gated primitives. Six staged drifts each observed failing with rc 1 and the expected
message: empty Kind cell, invalid Kind value (
optional), a removed normative anchor, an extra_GATED_PRIMSentry (set_status), a removed one (pr_merge), and a renamed table header (bothnon-vacuity guards). Every perturbation was restored from a byte-identical backup;
git statusshowsonly
scripts/lint-conventions.sh. All 12 shell suites pass;pipeline-identity.sh85 ok / 0 not-ok.Hardest part: getting the empty-Kind case to report as an empty cell rather than shifting the
fields — solved by rejoining fields 3..NF as the Kind so a malformed multi-word cell is reported
verbatim, and an absent cell reports as
"".If I did this again: I would write the drift-proving script before the parser, not after — the six staged drifts are the only evidence the checks work, and having them ready would have let me test each parser revision instead of desk-checking it.
Development Journal — WU-47-3-7
Final integration unit: AC-12 coverage, the probe's coverage-boundary block, and a hardening pass
over
plugin/skills/_shared/procedures/test/pipeline-identity.sh.2026-08-26T14:05 DISCOVERY:
raw_stateabsence is not a portable "the guard did not fire" witnessContext: Writing the AC-12 assertions, I wanted a second, independent witness that the pinned-
--shanull-commit guard in
release-pipeline-status.shhad not rewritten afailed/canceledverdict. Theguard sets
state = "unknown"andraw_state = <previous state>, so "raw_stateis absent" lookedlike a clean negative check. It holds on
local-fs— that adapter echoesraw_stateonly when the stagedrecord declares one — and I wrote it for both adapters.
Pattern: It fails on
glab-cli, which emitsraw_stateon every answer by design(
pipeline_status.sh:141), not only whenstateisunknown. Contract §2.2 requiresraw_stateforunknownand merely permits it elsewhere, so both adapters are conformant and the two shapes are bothcorrect. The portable claim is the state assertion itself — the guard's output is
unknown, so a statethat is still
failedis proof it did not fire. Onglab-clithe check was replaced with the other half ofthe contract's rule that this fixture can actually witness: the forge's literal status survives verbatim in
raw_state.Why it matters: A cross-adapter probe must assert the contract's claim, not one adapter's incidental
shape. My first version was a test bug that would have failed for a correct adapter — caught only because
this suite drives two adapters over the same criterion. Keep that habit: an assertion that passes on one
adapter and fails on another is a signal about the assertion, not about the adapter.
2026-08-26T14:20 DISCOVERY: two AC checks were passing on empty-vs-empty
Context: The hardening pass looked for checks that could go green for the wrong reason.
Pattern: AC-15 and AC-18 each asserted
jq -r '.state // empty' <<< "$out"equals"". Both branches(broken install, usage failure) emit nothing on stdout by contract, so
jqwas being fed an emptydocument and returned empty for any reason at all — including the helper having crashed before it
produced anything, and including a malformed non-JSON answer. Both now compare the raw stdout capture
to
"", which is the claimrelease-pipeline-status.sh's own header actually makes ("NOT an answer,nothing on stdout") and which also fails on a partial or malformed answer.
Why it matters: Piping a capture through
jqbefore comparing it to the empty string converts "thehelper said nothing" and "the helper died" into the same observation. Where the contract's claim is about
the absence of output, assert on the capture, not on a parse of it.
2026-08-26T14:40 COMPLETE
Hardest part: Deciding which of the two AC-12 negative witnesses was the contract's claim and which was
one adapter's incidental shape — the failing check looked like a production defect in
glab-cliuntil thecontract's "MUST be present whenever
stateisunknown" turned out to be a floor, not a ceiling.If I did this again: Read both adapters' emission of every field I intend to assert on before writing
a cross-adapter negative check, rather than generalizing from the adapter I staged first.
Linked: this issue is sibling #355 (recorded by the devwork pipeline).
Linked: this issue is sibling #356 (recorded by the devwork pipeline).
Linked: this issue is sibling #357 (recorded by the devwork pipeline).
Linked: this issue is spawned-from #415 (recorded by the devwork pipeline).
Linked: this issue is spawned-from #416 (recorded by the devwork pipeline).
Linked: this issue is spawned-from #417 (recorded by the devwork pipeline).
Linked: this issue is sibling #106 (recorded by the devwork pipeline).
Linked: this issue is sibling #484 (recorded by the devwork pipeline).
UAT prerequisites card — preserved before worktree teardown
The QA playbook classes this card as a local reference artifact, not a forge deliverable, so it
was written to
.devwork/…/test-prerequisites.md— which is gitignored under this project'stea-cliadapter and therefore exists only in the worktree that produced it.That worktree is about to be removed, and the card still has forward value: six of the fourteen
validation scenarios are unverified and route to a dogfood
/dev:promoterun that has nothappened yet. Losing the card would mean re-deriving that scenario list from the test plan.
Posted verbatim below so it survives. It is a copy for convenience — the normative validation
cases remain
test-plan:v1, and this card carries no schema.One correction to the card since it was written: its §4 warns that the main checkout was not
yet fast-forwarded. That is still true as of this comment —
~/Coding/devwork-skillssits atd22f1e3, andmainis now4a3e661. Pull it before walking anything, or the run exercises thepre-merge helpers.
UAT prerequisites — #47 release-pipeline-status-attributable-answer
Everything you need to walk this feature, so you do not have to go and find any of it.
Written after QA round 1 + three sweep rounds; the branch is at
1abf4ad.1. Where the reviewable build is
The project declares
uat.mode: local,uat.open_pr: false, and:So there is nothing to click. This is a markdown + shell skill suite: the deliverable is the
text and the helpers. Reviewing it means (a) reading the changed skill text, and (b) running the
suite against a real project.
The branch is
feature/47-release-pipeline-status-attributable-answerin this worktree(
.claude/worktrees/devwork-47-55-pipeline-status-identity). Note the executing plugin for everysession is the main checkout (
~/.claude/skills/devsymlinks to it), so a dogfood run exercisesthis branch's code only after it merges — see §4.
2. Authentication
Nothing new. The suite uses credentials you already have:
git.wihslon.com,jbr870/devwork-skills)teaCLI's configured loginGITLAB_TOKENfrom~/.config/gitlab-tokenglab-clismoke suites are red here for this reasonNo personas, no seeded user accounts — the suite has no auth surface of its own.
3. Scenario-specific test data
The test plan's fourteen validation cases split into two lanes. Eight are already executed and
green; you do not need to re-walk them by hand unless you want to.
Automated, green at
1abf4ad— nothing to preparepipeline-identity.shpipeline-identity.shpipeline-identity.shpipeline-identity.shpipeline-identity.shlint-conventions.shpipeline-identity.shpipeline-identity.shRun them all with:
Needs you — six scenarios, and what each needs staged
local-fsrecord whosecommit≠ the tag targetci_timeoutci_timeouttea-cliproject with arelease:blockScenario 8 is the one to walk first. This repo declares
tea-cliand arelease:block, andtea-cliships nopipeline_statusprimitive — so a/dev:promoterun here exercises thecapability-boundary path end to end. That path is also where QA found its worst regression (a guard
made every
unsupportedanswer unreachable, which would have made every release of this repoescalate instead of tagging), so it is the highest-value thing for a human to confirm by eye.
Expect to see, verbatim:
…followed by the tag being placed and the deploy check running. If instead you see a blocking
decision about an unattributable answer, that regression is back.
4. Setup commands beyond a normal start
One thing, and it is easy to get wrong: the executing plugin is the main checkout, not this
worktree. A
/dev:promotedogfood run exercises whatever is onmain. So either:/dev:integratethen walk the scenarios), orThe declared deploy check is verified working as of this card:
For Scenario 5 you will need to temporarily lower
release.ci_timeoutin CLAUDE.md; put it backafterwards.
5. What changed, if you only want to look at the text
plugin/skills/_shared/forge-contract.md— §2.2: thepipeline_statusanswer shape, requiredvs capability-gated, the sha form rule, and setup warns (no longer "refuses") on a
no-release-ops adapter paired with a
release:block.plugin/skills/promote/SKILL.md— the biggest diff, and the one QA kept finding defects in.The pre-tag gate, the pinned tag target, the wait loop and its six outcomes, the per-run scratch
directory, and the new optional
release.integration_ci: none.plugin/skills/_shared/procedures/bin/release-pipeline-status.sh— one stated JSON answer,never a shell error.
plugin/skills/{local-fs,glab-cli}/bin/pipeline_status.sh— commit-scoped queries.Known gaps — stated rather than hidden
gh-cli,glab-cli,tea-clineed a throwawayscratch issue;
glab-cli release-smokeneeds a real GitLab project and returns HTTP 400). They arered at the branch base too, so they are an environment property and not a regression — but it does
mean no automated lane covers the adapters' real forge round-trips.
until someone runs a release on a GitLab project. This split was accepted at plan time
(D-PO-47-2-12) and is not new.
/dev:setupdoes not yet know how to propose the newrelease.integration_cifield, so a projectthat needs it will discover it only after a release burns a full
ci_timeout(recorded asF-PO-47-4-5).