release-pipeline-status.sh --ref returns a stale pipeline — a false green immediately before a production tag #47

Open
opened 2026-08-14 13:01:53 +00:00 by jbr870 · 77 comments
Owner

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)

Scope note. This PREQ folds #55 into #47. They are one defect in two costumes: the same
helper, release-pipeline-status.sh, returning an answer nobody can attribute — to a commit (#47)
or to a stated absence (#55). #55 stays open as the folded source and is linked here; its none-branch
requirement is carried by the acceptance criteria below. The issue title still reads as #47 alone
because the forge contract has no title-edit primitive (§2.2) and hand-editing the title through a
native CLI call is the helper-bypass antipattern — the body is the scope of record.

Problem

An operator running /dev:promote cannot trust what the pipeline monitor tells them, and the two
ways it lies both land at the moment a production tag is placed.

Stale identity (#47). On 2026-08-14, release-pipeline-status.sh --ref main reported pipeline
2756606369 as success for the freshly pushed commit 59261bf. That pipeline was the previous
day'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 the
commit, and nothing in the answer said so: the response shape is {state, url}, which carries no commit
identity 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:

_shared/procedures/bin/_lib.sh: line 108: .../tea-cli/bin/pipeline_status.sh: No such file or directory
$ echo $?
127

The active adapter ships no pipeline_status; the dispatch invokes the primitive by path with no
existence 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:promote Step 5 has a correct, explicit branch for
exactly this case (none is not a failure — the required deploy_check carries 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_status is already a capability-gated operation in the contract: §2.2 lists the
release 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 declaration
to reach the dispatch at call time, so a gated op's absence and a broken install are the same 127.

Users:

  • Primary: the operator (or unattended run) driving /dev:promote to a production release — the one
    who must decide whether a commit is safe to tag.
  • Secondary: skill authors extending the forge contract, who need to know whether a primitive is
    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-cli and gh-cli project: pipeline_status is not merely
unimplemented for this repo, it is absent from those adapters, so /dev:promote Step 5's none branch is
dead 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:

  1. The monitor asks about a commit, and the answer says which commit it is about. Both, not either.
    The commit-scoped query is what fixes the defect; the stated commit is what lets /dev:promote assert
    identity 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.
  2. "No pipeline yet" and "no CI at all" become different answers. Scoping the query to a commit
    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 none that means "this project has no CI" — on
    which 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.
  3. An absent primitive answers by kind. A capability-gated op (the release and wiki ops the
    contract already marks optional, pipeline_status among them) that an adapter does not ship resolves to
    a stated unsupported — a real answer the caller branches on, which is what makes Step 5's no-CI path
    reachable, and which /dev:promote records out loud in its Phase Outcome rather than skipping the gate
    silently. 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.
  4. A failed query is its own answer. An unreachable forge, an auth failure or an API error is never
    folded into "no pipeline" and never surfaces as a raw shell error. It is retried as a transient inside
    the existing ci_timeout bound, and if it never clears it escalates naming the query failure rather
    than 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-fs and
/dev:promote Step 5. Deliberately not implementing pipeline_status for tea-cli (redundant on
migration) or gh-cli (the first slice of a six-primitive gap that deserves its own design). The shared-tier
work is adapter-neutral and lands ahead of the GitHub migration rather than behind it.

User Stories

  • As an operator running /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.
  • As an operator waiting on a just-merged commit, I want the monitor to keep waiting while that commit has
    no pipeline yet, so that a race between the merge and the runner starting cannot be read as "no CI here".
  • As an operator running /dev:promote on a project with no CI, I want the suite to tell me there is no
    pipeline to gate on, so that I reach the required deploy_check through the documented path instead of
    reasoning around a helper that looks broken.
  • As an operator whose forge is briefly unreachable mid-release, I want that reported as a failure to ask
    rather than as an answer, so that a network blip cannot be mistaken for a verdict about my code.
  • As an operator on any adapter, I want a missing required primitive to fail with a diagnosis naming
    the adapter and the primitive, so that a broken install is never mistaken for a deliberate capability
    boundary.
  • As a skill author extending the forge contract, I want it to state for each primitive whether it is
    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

  • 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.
  • Given a pipeline answer whose stated commit differs from the commit /dev:promote is 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.)
  • 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.

Waiting, and the two kinds of nothing

  • 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.
  • 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.
  • 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.
  • 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_timeout bound, and if it never clears,
    escalates naming the query failure rather than a pipeline state.

Absence as a stated answer

  • Given a project whose active adapter cannot report pipelines at all, when /dev:promote runs 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 was skipped and why.
  • 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.
  • 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.
  • 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.

No regression at the gate

  • 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.
  • 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. (The probe proves the shared-tier and contract behaviour; the glab-cli query change is
    verified separately against live GitLab — a simulated probe cannot demonstrate it.)

Out of Scope

  • Implementing pipeline_status for tea-cli. The Forgejo instance is being decommissioned in favour
    of 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-cli project that does run Gitea Actions now gets a stated unsupported and its
    real pipelines go unwatched, recorded only in the Phase Outcome. Accepted for the decommission.
  • Implementing gh-cli's release operations. gh-cli ships none of the six (pr_create,
    pr_status, pr_merge, comment_on_pr, pipeline_status, release_create), so /dev:promote cannot
    run on GitHub at all. That is implementation work of a size comparable to this whole feature, and doing
    its pipeline_status slice in isolation here would mean designing GitHub's release surface twice. Filed
    as sibling #69.
  • A general retry/resume protocol for transient API errors. The query-failure handling here is scoped
    to the pipeline poll. The suite-wide protocol is #46 and is untouched.
  • A project declaration for legitimately unpiped refs. Considered for the "CI exists but skips this
    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_timeout once and escalates; if it recurs, it earns its own
    issue.
  • Probing the repository for CI configuration (.gitea/workflows, .github/workflows,
    .gitlab-ci.yml) to decide whether CI exists. Rejected: it hardcodes per-forge paths into stack-agnostic
    shared code, and config that no runner picks up would answer the question wrongly.
  • Stub files for every capability-gated op on every adapter as the mechanism for declaring a boundary:
    twelve stubs across two adapters, six of them for the instance being retired.
  • Changing how a release is triggered. /dev:promote's delegation hook for a project-owned release
    command is #60 and is untouched here.
  • Auditing the other primitive dispatches for correctness beyond existence. This feature makes an
    absent primitive answerable; it does not review what each present primitive returns.
  • Retitling #47. The contract has no title-edit primitive and inlining a native forge call to do it is
    the helper-bypass antipattern. The body carries the folded scope instead.

Dependencies

  • The forge contract's capability-gated operation set (_shared/forge-contract.md §2.2), which already
    classes the release ops — pipeline_status included — as gated. This feature depends on that
    classification 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.
  • GitLab's pipelines API — an external system of record. glab-cli currently reads
    GET /projects/:id/pipelines?ref=…. Whether that endpoint accepts a commit-scoped filter, whether its
    response 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 built
    confidently against an assumed external shape.
  • local-fs's simulated pipeline files — the scriptable surface the smoke tests drive, and the only
    adapter where the regression probe can run without a live forge.
  • No dependency on #46 (general transient-error protocol), #60 (release-trigger delegation) or
    #62 (slot-isolation axes). #69 depends on this feature, not the reverse.
  • Runtime constraint: no new dependencies. The helper tier is bash ≥ 3.2, jq, git, POSIX utilities;
    skill-emitted glue is POSIX-only.

Timeline

Milestone Date Notes
Requirements complete 2026-08-25 #55 folded in; Tier 3 panel run, all concerns answered
Development complete
QA complete
UAT approved

Notes

  • Constraints: contract changes additive within v1; no new runtime dependencies; the shared-tier fix
    must be adapter-neutral so it survives the Forgejo → GitHub migration untouched.
  • Open questions: none outstanding.
  • Key decisions (this session, 2026-08-25):
    • Fold #55 into #47 — same helper, same defect class ("the answer is not attributable"), and the
      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.
    • Do not build tea-cli's pipeline_status — the Forgejo instance is being retired; most repositories
      move to GitHub.
    • Absent primitive: split by kind — gated ops resolve to a stated unsupported; required ops fail
      loudly. 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.
    • Separate "no pipeline yet" from "no CI" — discovered while working the fix through: without it, the
      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 migration
      target, so it is real work, but it is implementation rather than honesty work.
    • Post-panel (Tier 3, five lenses, all concerns answered by the operator):
      • Filter and label — query by commit and state the commit in the answer. Rejected filter-only
        (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.
      • "No CI" keys off adapter capability only — not a project declaration, not a probe for CI config
        files. Machine-decidable, no new schema. The "CI exists but skips this ref" case waits out
        ci_timeout and escalates once.
      • Query failure is a distinct state, retried then escalated — scoped to the pipeline poll; #46's
        general protocol left alone.
      • Pin down pipeline selection and non-binary states — most recent pipeline for the commit; every
        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.
    • Required-op diagnostics retained — the Simpler-alternative lens correctly noted neither incident
      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.
  • Provenance: #47 filed 2026-08-14 from the /dev:promote run that shipped v0.2.0 (rank 11 of 13 in
    the #37 time study). #55 filed 2026-08-24 from the /dev:promote run that shipped #26 and #50 as
    dev--v0.1.6. The readiness report has carried the #55 gap as a standing ⚠ since 2026-08-24.
  • Family: same class as #49 (a gate's failure output must identify the failure) and #54 (a
    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
    .
# 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) > **Scope note.** This PREQ folds **#55** into **#47**. They are one defect in two costumes: the same > helper, `release-pipeline-status.sh`, returning an answer nobody can attribute — to a commit (#47) > or to a stated absence (#55). #55 stays open as the folded source and is linked here; its `none`-branch > requirement is carried by the acceptance criteria below. The issue *title* still reads as #47 alone > because the forge contract has no title-edit primitive (§2.2) and hand-editing the title through a > native CLI call is the helper-bypass antipattern — the body is the scope of record. ## Problem **An operator running `/dev:promote` cannot trust what the pipeline monitor tells them**, and the two ways it lies both land at the moment a production tag is placed. **Stale identity (#47).** On 2026-08-14, `release-pipeline-status.sh --ref main` reported pipeline `2756606369` as `success` for the freshly pushed commit `59261bf`. That pipeline was **the previous day'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 the commit, and nothing in the answer said so: the response shape is `{state, url}`, which carries no commit identity 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: ``` _shared/procedures/bin/_lib.sh: line 108: .../tea-cli/bin/pipeline_status.sh: No such file or directory $ echo $? 127 ``` The active adapter ships no `pipeline_status`; the dispatch invokes the primitive by path with no existence 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:promote` Step 5 has a correct, explicit branch for exactly this case (`none` is *not* a failure — the required `deploy_check` carries 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_status` **is already a capability-gated operation** in the contract: §2.2 lists the release 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 declaration to reach the dispatch at call time, so a gated op's absence and a broken install are the same 127. **Users:** - **Primary:** the operator (or unattended run) driving `/dev:promote` to a production release — the one who must decide whether a commit is safe to tag. - **Secondary:** skill authors extending the forge contract, who need to know whether a primitive is 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-cli` and `gh-cli` project: `pipeline_status` is not merely unimplemented for this repo, it is absent from those adapters, so `/dev:promote` Step 5's `none` branch is dead 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: 1. **The monitor asks about a commit, and the answer says which commit it is about.** Both, not either. The commit-scoped query is what fixes the defect; the stated commit is what lets `/dev:promote` assert identity 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. 2. **"No pipeline yet" and "no CI at all" become different answers.** Scoping the query to a commit 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 `none` that means "this project has no CI" — on which 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. 3. **An absent primitive answers by kind.** A **capability-gated** op (the release and wiki ops the contract already marks optional, `pipeline_status` among them) that an adapter does not ship resolves to a stated *unsupported* — a real answer the caller branches on, which is what makes Step 5's no-CI path reachable, and which `/dev:promote` records out loud in its Phase Outcome rather than skipping the gate silently. 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. 4. **A failed query is its own answer.** An unreachable forge, an auth failure or an API error is never folded into "no pipeline" and never surfaces as a raw shell error. It is retried as a transient inside the existing `ci_timeout` bound, and if it never clears it escalates naming the *query failure* rather than 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-fs` and `/dev:promote` Step 5. Deliberately **not** implementing `pipeline_status` for `tea-cli` (redundant on migration) or `gh-cli` (the first slice of a six-primitive gap that deserves its own design). The shared-tier work is adapter-neutral and lands ahead of the GitHub migration rather than behind it. ## User Stories - As an operator running `/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. - As an operator waiting on a just-merged commit, I want the monitor to keep waiting while that commit has no pipeline yet, so that a race between the merge and the runner starting cannot be read as "no CI here". - As an operator running `/dev:promote` on a project with no CI, I want the suite to tell me there is no pipeline to gate on, so that I reach the required `deploy_check` through the documented path instead of reasoning around a helper that looks broken. - As an operator whose forge is briefly unreachable mid-release, I want that reported as a failure to ask rather than as an answer, so that a network blip cannot be mistaken for a verdict about my code. - As an operator on any adapter, I want a missing **required** primitive to fail with a diagnosis naming the adapter and the primitive, so that a broken install is never mistaken for a deliberate capability boundary. - As a skill author extending the forge contract, I want it to state for each primitive whether it is 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** - [ ] 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. - [ ] Given a pipeline answer whose stated commit differs from the commit `/dev:promote` is 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.)* - [ ] 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. **Waiting, and the two kinds of nothing** - [ ] 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. - [ ] 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. - [ ] 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. - [ ] 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_timeout` bound, and if it never clears, escalates naming the query failure rather than a pipeline state. **Absence as a stated answer** - [ ] Given a project whose active adapter cannot report pipelines at all, when `/dev:promote` runs 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 was skipped and why. - [ ] 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. - [ ] 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. - [ ] 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. **No regression at the gate** - [ ] 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. - [ ] 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. *(The probe proves the shared-tier and contract behaviour; the `glab-cli` query change is verified separately against live GitLab — a simulated probe cannot demonstrate it.)* ## Out of Scope - **Implementing `pipeline_status` for `tea-cli`.** The Forgejo instance is being decommissioned in favour of 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-cli` project that *does* run Gitea Actions now gets a stated `unsupported` and its real pipelines go unwatched, recorded only in the Phase Outcome. Accepted for the decommission. - **Implementing `gh-cli`'s release operations.** `gh-cli` ships **none** of the six (`pr_create`, `pr_status`, `pr_merge`, `comment_on_pr`, `pipeline_status`, `release_create`), so `/dev:promote` cannot run on GitHub at all. That is implementation work of a size comparable to this whole feature, and doing its `pipeline_status` slice in isolation here would mean designing GitHub's release surface twice. Filed as sibling **#69**. - **A general retry/resume protocol for transient API errors.** The query-failure handling here is scoped to the pipeline poll. The suite-wide protocol is **#46** and is untouched. - **A project declaration for legitimately unpiped refs.** Considered for the "CI exists but skips this 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_timeout` once and escalates; if it recurs, it earns its own issue. - **Probing the repository for CI configuration** (`.gitea/workflows`, `.github/workflows`, `.gitlab-ci.yml`) to decide whether CI exists. Rejected: it hardcodes per-forge paths into stack-agnostic shared code, and config that no runner picks up would answer the question wrongly. - **Stub files for every capability-gated op on every adapter** as the mechanism for declaring a boundary: twelve stubs across two adapters, six of them for the instance being retired. - **Changing how a release is triggered.** `/dev:promote`'s delegation hook for a project-owned release command is **#60** and is untouched here. - **Auditing the other primitive dispatches for correctness beyond existence.** This feature makes an absent primitive answerable; it does not review what each present primitive returns. - **Retitling #47.** The contract has no title-edit primitive and inlining a native forge call to do it is the helper-bypass antipattern. The body carries the folded scope instead. ## Dependencies - **The forge contract's capability-gated operation set** (`_shared/forge-contract.md` §2.2), which already classes the release ops — `pipeline_status` included — as gated. This feature depends on that classification 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. - **GitLab's pipelines API — an external system of record.** `glab-cli` currently reads `GET /projects/:id/pipelines?ref=…`. Whether that endpoint accepts a commit-scoped filter, whether its response 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 built confidently against an assumed external shape. - **`local-fs`'s simulated pipeline files** — the scriptable surface the smoke tests drive, and the only adapter where the regression probe can run without a live forge. - **No dependency on #46** (general transient-error protocol), **#60** (release-trigger delegation) or **#62** (slot-isolation axes). **#69** depends on *this* feature, not the reverse. - **Runtime constraint:** no new dependencies. The helper tier is bash ≥ 3.2, `jq`, `git`, POSIX utilities; skill-emitted glue is POSIX-only. ## Timeline | Milestone | Date | Notes | |-----------|------|-------| | Requirements complete | 2026-08-25 | #55 folded in; Tier 3 panel run, all concerns answered | | Development complete | | | | QA complete | | | | UAT approved | | | ## Notes - **Constraints:** contract changes additive within v1; no new runtime dependencies; the shared-tier fix must be adapter-neutral so it survives the Forgejo → GitHub migration untouched. - **Open questions:** none outstanding. - **Key decisions (this session, 2026-08-25):** - *Fold #55 into #47* — same helper, same defect class ("the answer is not attributable"), and the 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. - *Do not build `tea-cli`'s `pipeline_status`* — the Forgejo instance is being retired; most repositories move to GitHub. - *Absent primitive: split by kind* — gated ops resolve to a stated `unsupported`; required ops fail loudly. 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. - *Separate "no pipeline yet" from "no CI"* — discovered while working the fix through: without it, the 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 migration target, so it is real work, but it is implementation rather than honesty work. - **Post-panel (Tier 3, five lenses, all concerns answered by the operator):** - *Filter **and** label* — query by commit *and* state the commit in the answer. Rejected filter-only (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. - *"No CI" keys off adapter capability only* — not a project declaration, not a probe for CI config files. Machine-decidable, no new schema. The "CI exists but skips this ref" case waits out `ci_timeout` and escalates once. - *Query failure is a distinct state, retried then escalated* — scoped to the pipeline poll; #46's general protocol left alone. - *Pin down pipeline selection and non-binary states* — most recent pipeline for the commit; every 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. - *Required-op diagnostics retained* — the Simpler-alternative lens correctly noted neither incident 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. - **Provenance:** #47 filed 2026-08-14 from the `/dev:promote` run that shipped v0.2.0 (rank 11 of 13 in the #37 time study). #55 filed 2026-08-24 from the `/dev:promote` run that shipped #26 and #50 as `dev--v0.1.6`. The readiness report has carried the #55 gap as a standing ⚠ since 2026-08-24. - **Family:** same class as **#49** (a gate's failure output must identify the failure) and **#54** (a 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*.
Author
Owner

Linked: this issue is sibling #69 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #69 (recorded by the devwork pipeline).
Author
Owner

Linked: this issue is sibling #55 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #55 (recorded by the devwork pipeline).
Author
Owner

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-plan
2.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.

  • A project that declares a release: block, so /dev:promote will actually run its monitor step.
  • An adapter whose pipeline state is scriptable — one where a tester can put a chosen pipeline
    state, commit and count in place deliberately, rather than waiting for real CI to produce one.
  • An adapter that can report pipelines against a real forge, for the cases that only a live CI
    system can produce (a genuine red, a retried pipeline, a merge-request pipeline).
  • An adapter that cannot report pipelines at all — the capability-boundary case.
  • The ability to make a forge unreachable or unauthenticated for the duration of a poll.
  • The forge contract document, for the enumerable documentation check.

Required Test Data

  • Two commits on the release ref: an earlier one that already has a finished, successful pipeline,
    and a later one that is the commit actually being released. This pair is the whole of #47.
  • One commit with no pipeline at all.
  • One commit with several pipelines: at least a first attempt and a retry; where the forge supports
    it, also a branch pipeline alongside a merge-request pipeline.
  • Pipelines that can be placed in each non-terminal and non-binary state the contract names —
    pending, running, canceled, and whatever the forge calls manual/blocked and skipped.
  • One commit whose pipeline genuinely fails.
  • An adapter installation with a required primitive removed, distinct from an adapter that simply
    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.

  1. Arrange the release ref so its most recent pipeline is a successful one belonging to an earlier
    commit — the previous release's — and push a later commit that is the one being released.
  2. Ask for the pipeline status of that release.
  3. Verify: the answer states a commit, and that commit is the one being released.
  4. Verify: the answer does not report the earlier commit's success. If the later commit's pipeline is
    still 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:promote is about to tag, when the run reaches the tag gate, then no tag is placed and a blocking
decision is escalated naming both commits.

  1. Arrange for the pipeline answer to state a commit other than the one being released — for example
    by scripting the scriptable adapter to return a successful pipeline labelled with the earlier commit.
  2. Run the release through to the tag gate.
  3. Verify: no tag is created.
  4. Verify: a blocking decision is raised, and its text names both commits — the one the answer was
    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.

  1. Arrange a commit with a first pipeline that failed and a later retry that succeeded.
  2. Ask for its pipeline status.
  3. Verify: the answer describes the retry — the most recent — not the earlier failure.
  4. Where the forge produces both a branch pipeline and a merge-request pipeline for one commit, repeat
    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.

  1. Arrange a commit on an adapter that can report pipelines, with no pipeline for that commit.
  2. Start the release and watch the monitor.
  3. Verify: the monitor keeps polling — a poll record appears naming the commit and how long it has been
    waiting, so a tester can tell waiting apart from hung.
  4. Verify: the run does not proceed to the deploy check as though there were no CI, and does not
    place a tag.
  5. While it is still waiting, put a successful pipeline in place for that commit and verify the monitor
    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.

  1. Declare a short ci_timeout so the case is executable.
  2. Arrange a commit that never gets a pipeline, and start the release.
  3. Verify: the run escalates once the declared bound elapses — measured from when the wait began, not from
    the merge or the first poll.
  4. Verify: the escalation names the commit it was waiting for, and says it timed out rather than
    reporting a pipeline state.
  5. While the wait is in progress, push a further commit to the ref. Verify the run is still waiting on —
    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.

  1. For each such state the forge can produce, put a pipeline for the released commit into that state.
  2. Run the monitor against it.
  3. Verify: for each one, the run does something the PREQ names — waits, escalates, or proceeds — and says
    which state it saw.
  4. Verify: none of them produces an unexplained fall-through, a silent proceed, or an answer that does not
    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_timeout bound, and if it
never clears, escalates naming the query failure rather than a pipeline state.

  1. Start a release where the commit's pipeline is running, then make the forge unreachable (or invalidate
    credentials) while the monitor is polling.
  2. Verify: the monitor reports a failure to ask — not success, not failed, and not "no pipeline".
  3. Verify: it retries rather than escalating on the first occurrence.
  4. Restore access while still inside the bound, and verify the run continues normally and reaches the real
    pipeline verdict.
  5. Repeat, leaving the forge unreachable past the bound. Verify the escalation names the query failure
    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:promote runs 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 was
skipped and why.

  1. Run a release on a project whose adapter ships no pipeline reporting.
  2. Verify: the monitor step ends promptly with a stated answer that the adapter cannot report pipelines
    — not an error, and not a wait.
  3. Verify: the run proceeds to the required deploy check rather than escalating a blocking decision.
  4. Verify: the run's Phase Outcome records that the pipeline gate was skipped, and why.

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.

  1. On the same project as Scenario 8, capture everything the monitor step produces.
  2. Verify: the message names the adapter and the fact that it cannot report pipelines.
  3. Verify: no "No such file or directory", no line-number reference, and no path to an internal helper
    file appears anywhere in what the operator or the calling skill sees.
  4. Verify: the exit status is one that says "answered: unsupported", distinguishable from a crash.

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.

  1. Remove a required primitive from an adapter installation (one the contract does not class as
    capability-gated).
  2. Invoke a skill that uses it.
  3. Verify: the run fails, and the message names the adapter, the primitive, and where it looked.
  4. Verify: it is not reported as unsupported, not degraded into a "no", and does not let the calling
    skill proceed as though it had an answer.
  5. Contrast directly with Scenario 8/9: confirm a tester can tell the two situations apart from the output
    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.

  1. Enumerate every primitive the contract lists.
  2. For each, read the contract for two facts: is it required or capability-gated, and what must an adapter
    that cannot provide it answer?
  3. Verify: both facts are present for every primitive — no entry leaves either to inference.

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.

  1. Arrange a failing pipeline for the exact commit being released. Run the release.
  2. Verify: the run escalates, no tag is placed, and the escalation identifies the failure.
  3. Repeat with a pipeline that is canceled for that commit.
  4. Verify: it likewise escalates rather than being read as green, as absent, or as still-waiting.

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.

  1. Stage the sequence on the scriptable adapter: an earlier commit with a finished successful pipeline,
    then a merge putting a new commit at the head of the release ref, then an immediate status query.
  2. Run the feature's regression probe.
  3. Verify: the probe does not return the earlier commit's success as the answer for the new head.
  4. Verify: the probe is repeatable — a second run gives the same result without manual re-staging.
  5. Separately, verify the same sequence against a live forge on an adapter that queries a real CI
    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.)

  1. Run the same release flow twice: once on an adapter that can report pipelines against a commit with no
    pipeline yet, and once on an adapter that cannot report pipelines at all.
  2. Verify: the two produce different answers.
  3. Verify: the first waits; the second proceeds to the deploy check.
  4. Verify: a tester reading only the output can say which situation they are in.

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:

PREQ criterion Scenario(s)
Stale pipeline belongs to an earlier commit 1, 13
Answer's commit differs from the tag target 2
Commit with more than one pipeline 3
No pipeline for this commit yet 4, 14
Wait reaches ci_timeout 5
Neither success, failure, nor absent 6
Forge unreachable / auth fails / API error 7
Adapter cannot report pipelines 8, 14
Clean surface on the capability boundary 9
Required primitive missing 10
Contract states kind + required answer per primitive 11
Genuine failure and cancellation still escalate 12
Merge-then-query regression probe 13

Backward — 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

  • No browser surface. This project declares e2e, a11y and security-browser not applicable; every
    scenario above is executed against the suite's own helpers and a forge, not a rendered UI.
  • Scenario 11 is a documentation enumeration, not a running check. It is observable and exhaustive, so
    it is a legitimate validation case rather than an implementation-necessity exception.
  • Scenarios 3, 6, 7 and the second half of 13 need a live forge — retried and merge-request pipelines,
    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.
  • Scenario 5 requires a short declared ci_timeout to be executable at all; that is a project
    declaration, not an environment detail.
<!-- test-plan:v1 issue=47 skill=requirements --> # 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-plan` 2.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. - [ ] A project that declares a `release:` block, so `/dev:promote` will actually run its monitor step. - [ ] An adapter whose pipeline state is **scriptable** — one where a tester can put a chosen pipeline state, commit and count in place deliberately, rather than waiting for real CI to produce one. - [ ] An adapter that **can** report pipelines against a real forge, for the cases that only a live CI system can produce (a genuine red, a retried pipeline, a merge-request pipeline). - [ ] An adapter that **cannot** report pipelines at all — the capability-boundary case. - [ ] The ability to make a forge unreachable or unauthenticated for the duration of a poll. - [ ] The forge contract document, for the enumerable documentation check. ### Required Test Data - [ ] Two commits on the release ref: an **earlier** one that already has a finished, successful pipeline, and a **later** one that is the commit actually being released. This pair is the whole of #47. - [ ] One commit with **no** pipeline at all. - [ ] One commit with **several** pipelines: at least a first attempt and a retry; where the forge supports it, also a branch pipeline alongside a merge-request pipeline. - [ ] Pipelines that can be placed in each non-terminal and non-binary state the contract names — `pending`, `running`, `canceled`, and whatever the forge calls manual/blocked and skipped. - [ ] One commit whose pipeline genuinely **fails**. - [ ] An adapter installation with a **required** primitive removed, distinct from an adapter that simply 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. 1. Arrange the release ref so its most recent pipeline is a **successful** one belonging to an **earlier** commit — the previous release's — and push a later commit that is the one being released. 2. Ask for the pipeline status of that release. 3. Verify: the answer **states a commit**, and that commit is the one being released. 4. Verify: the answer does **not** report the earlier commit's `success`. If the later commit's pipeline is still 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:promote` is about to tag, when the run reaches the tag gate, then no tag is placed and a blocking decision is escalated naming both commits. 1. Arrange for the pipeline answer to state a commit **other** than the one being released — for example by scripting the scriptable adapter to return a successful pipeline labelled with the earlier commit. 2. Run the release through to the tag gate. 3. Verify: **no tag is created.** 4. Verify: a blocking decision is raised, and its text names **both** commits — the one the answer was 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. 1. Arrange a commit with a first pipeline that **failed** and a later retry that **succeeded**. 2. Ask for its pipeline status. 3. Verify: the answer describes the **retry** — the most recent — not the earlier failure. 4. Where the forge produces both a branch pipeline and a merge-request pipeline for one commit, repeat 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. 1. Arrange a commit on an adapter that **can** report pipelines, with no pipeline for that commit. 2. Start the release and watch the monitor. 3. Verify: the monitor **keeps polling** — a poll record appears naming the commit and how long it has been waiting, so a tester can tell waiting apart from hung. 4. Verify: the run does **not** proceed to the deploy check as though there were no CI, and does **not** place a tag. 5. While it is still waiting, put a successful pipeline in place for that commit and verify the monitor 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. 1. Declare a short `ci_timeout` so the case is executable. 2. Arrange a commit that never gets a pipeline, and start the release. 3. Verify: the run escalates once the declared bound elapses — measured from when the wait began, not from the merge or the first poll. 4. Verify: the escalation names the **commit** it was waiting for, and says it timed out rather than reporting a pipeline state. 5. While the wait is in progress, push a further commit to the ref. Verify the run is still waiting on — 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. 1. For **each** such state the forge can produce, put a pipeline for the released commit into that state. 2. Run the monitor against it. 3. Verify: for each one, the run does something the PREQ names — waits, escalates, or proceeds — and says which state it saw. 4. Verify: none of them produces an unexplained fall-through, a silent proceed, or an answer that does not 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_timeout` bound, and if it never clears, escalates naming the query failure rather than a pipeline state. 1. Start a release where the commit's pipeline is running, then make the forge unreachable (or invalidate credentials) while the monitor is polling. 2. Verify: the monitor reports a **failure to ask** — not `success`, not `failed`, and not "no pipeline". 3. Verify: it **retries** rather than escalating on the first occurrence. 4. Restore access while still inside the bound, and verify the run continues normally and reaches the real pipeline verdict. 5. Repeat, leaving the forge unreachable past the bound. Verify the escalation names the **query failure** 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:promote` runs 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 was skipped and why. 1. Run a release on a project whose adapter ships no pipeline reporting. 2. Verify: the monitor step ends promptly with a **stated** answer that the adapter cannot report pipelines — not an error, and not a wait. 3. Verify: the run **proceeds to the required deploy check** rather than escalating a blocking decision. 4. Verify: the run's Phase Outcome records that the pipeline gate was skipped, and why. **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. 1. On the same project as Scenario 8, capture everything the monitor step produces. 2. Verify: the message names the **adapter** and the fact that it cannot report pipelines. 3. Verify: **no** "No such file or directory", no line-number reference, and no path to an internal helper file appears anywhere in what the operator or the calling skill sees. 4. Verify: the exit status is one that says "answered: unsupported", distinguishable from a crash. **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. 1. Remove a **required** primitive from an adapter installation (one the contract does not class as capability-gated). 2. Invoke a skill that uses it. 3. Verify: the run **fails**, and the message names the adapter, the primitive, and where it looked. 4. Verify: it is **not** reported as `unsupported`, not degraded into a "no", and does not let the calling skill proceed as though it had an answer. 5. Contrast directly with Scenario 8/9: confirm a tester can tell the two situations apart from the output 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. 1. Enumerate every primitive the contract lists. 2. For each, read the contract for two facts: is it required or capability-gated, and what must an adapter that cannot provide it answer? 3. Verify: **both** facts are present for **every** primitive — no entry leaves either to inference. **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. 1. Arrange a **failing** pipeline for the exact commit being released. Run the release. 2. Verify: the run escalates, no tag is placed, and the escalation identifies the failure. 3. Repeat with a pipeline that is **canceled** for that commit. 4. Verify: it likewise escalates rather than being read as green, as absent, or as still-waiting. **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. 1. Stage the sequence on the scriptable adapter: an earlier commit with a finished successful pipeline, then a merge putting a new commit at the head of the release ref, then an immediate status query. 2. Run the feature's regression probe. 3. Verify: the probe does **not** return the earlier commit's `success` as the answer for the new head. 4. Verify: the probe is repeatable — a second run gives the same result without manual re-staging. 5. Separately, verify the same sequence against a **live** forge on an adapter that queries a real CI 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.)* 1. Run the same release flow twice: once on an adapter that can report pipelines against a commit with no pipeline yet, and once on an adapter that cannot report pipelines at all. 2. Verify: the two produce **different** answers. 3. Verify: the first waits; the second proceeds to the deploy check. 4. Verify: a tester reading only the output can say which situation they are in. **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:** | PREQ criterion | Scenario(s) | |---|---| | Stale pipeline belongs to an earlier commit | 1, 13 | | Answer's commit differs from the tag target | 2 | | Commit with more than one pipeline | 3 | | No pipeline for this commit yet | 4, 14 | | Wait reaches `ci_timeout` | 5 | | Neither success, failure, nor absent | 6 | | Forge unreachable / auth fails / API error | 7 | | Adapter cannot report pipelines | 8, 14 | | Clean surface on the capability boundary | 9 | | Required primitive missing | 10 | | Contract states kind + required answer per primitive | 11 | | Genuine failure and cancellation still escalate | 12 | | Merge-then-query regression probe | 13 | **Backward — 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 - **No browser surface.** This project declares `e2e`, `a11y` and `security-browser` not applicable; every scenario above is executed against the suite's own helpers and a forge, not a rendered UI. - **Scenario 11 is a documentation enumeration**, not a running check. It is observable and exhaustive, so it is a legitimate validation case rather than an implementation-necessity exception. - **Scenarios 3, 6, 7 and the second half of 13 need a live forge** — retried and merge-request pipelines, 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`. - **Scenario 5 requires a short declared `ci_timeout`** to be executable at all; that is a project declaration, not an environment detail.
Author
Owner
{
  "next_state": "planning",
  "produced": [
    {
      "kind": "preq",
      "ref": "issue-body",
      "summary": "PREQ for release-pipeline-status-attributable-answer (#55 folded into #47)"
    },
    {
      "kind": "test-plan",
      "ref": "comment:1470",
      "summary": "14 validation cases covering 13/13 acceptance criteria"
    }
  ],
  "findings": [
    {
      "category": "out-of-scope",
      "summary": "tea-cli's pipeline_status stays unimplemented, so a tea-cli project that DOES run Gitea Actions will now get a stated 'unsupported' and its real pipelines will go unwatched.",
      "reasoning": "The Forgejo instance is being decommissioned in favour of GitHub (operator decision 2026-08-25), so a primitive written against it is redundant on arrival; neither #47 nor #55 asks for it, and #55's own hypothesis is that an adapter which cannot report pipelines IS reporting 'no pipeline'. The unwatched-pipeline consequence is real but bounded: it is recorded in every affected run's Phase Outcome, and this repo is the only tea-cli project and runs no Actions.",
      "id": "F-PO-47-1-1"
    },
    {
      "category": "out-of-scope",
      "summary": "gh-cli implements NONE of the six release operations (pr_create, pr_status, pr_merge, comment_on_pr, pipeline_status, release_create), so /dev:promote cannot run on GitHub at all — and /dev:integrate's review path breaks too on any project declaring uat.open_pr: true.",
      "reasoning": "Implementation work comparable in size to this whole feature, and building its pipeline_status slice here would mean designing GitHub's release surface twice. Filed as sibling #69 and linked; #69 depends on this feature landing first, because this feature changes the answer shape a gh-cli implementation would be written against. Urgency is real — GitHub is the migration target.",
      "id": "F-PO-47-1-2"
    },
    {
      "category": "out-of-scope",
      "summary": "The query-failure handling in this feature is scoped to the pipeline poll only; the suite still has no general retry/resume protocol for transient API errors or quota stalls.",
      "reasoning": "Already filed as #46. Generalising here would widen a release-gate fix into a suite-wide wait-discipline change, and the two want different evidence bases.",
      "id": "F-PO-47-1-3"
    },
    {
      "category": "out-of-scope",
      "summary": "A project whose CI is configured but legitimately skips the release ref will wait out ci_timeout and escalate once, rather than being able to declare the ref as unpiped.",
      "reasoning": "Considered during the panel round and deliberately rejected: a declaration is new schema surface, a readiness-check item and a parseability contract, for a case nobody has actually hit. The escalation is loud and recoverable, and if it recurs it earns its own issue with real evidence behind it.",
      "id": "F-PO-47-1-4"
    },
    {
      "category": "out-of-scope",
      "summary": "The forge contract has no title-edit primitive, so #47's title still reads as the #47 defect alone even though its body now carries the folded #47+#55 scope.",
      "reasoning": "Editing the title would mean an inline forge-native CLI call, which is the helper-bypass antipattern that broke portability in three dogfood runs. The body is the scope of record. Whether edit_issue_title belongs in the contract is a genuine suite question, but it is not this feature's problem to solve.",
      "id": "F-PO-47-1-5"
    },
    {
      "category": "pre-existing",
      "summary": "The readiness guard now demands a parallel_dev recipe on this repo, because linked worktrees exist — but CLAUDE.md deliberately rules that the slot machinery is not used here (no ports, no databases, nothing to provision). Recorded as an accepted gap in the readiness report rather than papered over with a recipe nothing would execute.",
      "reasoning": "Already filed as #62 ('slot-isolation: port and database axes are mandatory — a services-free project cannot declare a valid parallel_dev recipe'), so no new issue was created. Surfaced here because it blocked Phase 0 of this run and the operator had to rule on it; it will re-fire on every skill invocation in this repo until #62 lands.",
      "id": "F-PO-47-1-6"
    }
  ],
  "pending_decisions": [],
  "suite": {
    "source": "git",
    "sha": "2ceacfe8490eb7efb2258c1d62981eea06acb97b",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-47-1 skill=requirements --> ```json { "next_state": "planning", "produced": [ { "kind": "preq", "ref": "issue-body", "summary": "PREQ for release-pipeline-status-attributable-answer (#55 folded into #47)" }, { "kind": "test-plan", "ref": "comment:1470", "summary": "14 validation cases covering 13/13 acceptance criteria" } ], "findings": [ { "category": "out-of-scope", "summary": "tea-cli's pipeline_status stays unimplemented, so a tea-cli project that DOES run Gitea Actions will now get a stated 'unsupported' and its real pipelines will go unwatched.", "reasoning": "The Forgejo instance is being decommissioned in favour of GitHub (operator decision 2026-08-25), so a primitive written against it is redundant on arrival; neither #47 nor #55 asks for it, and #55's own hypothesis is that an adapter which cannot report pipelines IS reporting 'no pipeline'. The unwatched-pipeline consequence is real but bounded: it is recorded in every affected run's Phase Outcome, and this repo is the only tea-cli project and runs no Actions.", "id": "F-PO-47-1-1" }, { "category": "out-of-scope", "summary": "gh-cli implements NONE of the six release operations (pr_create, pr_status, pr_merge, comment_on_pr, pipeline_status, release_create), so /dev:promote cannot run on GitHub at all — and /dev:integrate's review path breaks too on any project declaring uat.open_pr: true.", "reasoning": "Implementation work comparable in size to this whole feature, and building its pipeline_status slice here would mean designing GitHub's release surface twice. Filed as sibling #69 and linked; #69 depends on this feature landing first, because this feature changes the answer shape a gh-cli implementation would be written against. Urgency is real — GitHub is the migration target.", "id": "F-PO-47-1-2" }, { "category": "out-of-scope", "summary": "The query-failure handling in this feature is scoped to the pipeline poll only; the suite still has no general retry/resume protocol for transient API errors or quota stalls.", "reasoning": "Already filed as #46. Generalising here would widen a release-gate fix into a suite-wide wait-discipline change, and the two want different evidence bases.", "id": "F-PO-47-1-3" }, { "category": "out-of-scope", "summary": "A project whose CI is configured but legitimately skips the release ref will wait out ci_timeout and escalate once, rather than being able to declare the ref as unpiped.", "reasoning": "Considered during the panel round and deliberately rejected: a declaration is new schema surface, a readiness-check item and a parseability contract, for a case nobody has actually hit. The escalation is loud and recoverable, and if it recurs it earns its own issue with real evidence behind it.", "id": "F-PO-47-1-4" }, { "category": "out-of-scope", "summary": "The forge contract has no title-edit primitive, so #47's title still reads as the #47 defect alone even though its body now carries the folded #47+#55 scope.", "reasoning": "Editing the title would mean an inline forge-native CLI call, which is the helper-bypass antipattern that broke portability in three dogfood runs. The body is the scope of record. Whether edit_issue_title belongs in the contract is a genuine suite question, but it is not this feature's problem to solve.", "id": "F-PO-47-1-5" }, { "category": "pre-existing", "summary": "The readiness guard now demands a parallel_dev recipe on this repo, because linked worktrees exist — but CLAUDE.md deliberately rules that the slot machinery is not used here (no ports, no databases, nothing to provision). Recorded as an accepted gap in the readiness report rather than papered over with a recipe nothing would execute.", "reasoning": "Already filed as #62 ('slot-isolation: port and database axes are mandatory — a services-free project cannot declare a valid parallel_dev recipe'), so no new issue was created. Surfaced here because it blocked Phase 0 of this run and the operator had to rule on it; it will re-fire on every skill invocation in this repo until #62 lands.", "id": "F-PO-47-1-6" } ], "pending_decisions": [], "suite": { "source": "git", "sha": "2ceacfe8490eb7efb2258c1d62981eea06acb97b", "dirty": false } } ```
Author
Owner

Software Requirements: release-pipeline-status-attributable-answer

Issue #47 (with #55 folded in). Tier 3 — four-lens expert panel.

Context

An operator running /dev:promote cannot trust what the pipeline monitor tells them, and both ways it
lies land at the moment a production tag is placed. #47: release-pipeline-status.sh --ref main
reported a previous release's pipeline as success for a freshly pushed commit — the answer was about
the branch name, not the commit, and the response shape {state, url} carries no commit identity a
caller could check. #55: on an adapter that ships no pipeline_status primitive the dispatch invokes
the primitive by path with no existence check, so the caller gets a shell-level exit 127 naming an
internal helper path — which makes /dev:promote Step 5's correct "this project has no CI" branch
unreachable. 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 main on a tea-cli project → exit 127, stderr
.../tea-cli/bin/pipeline_status.sh: No such file or directory). #47's data was recovered from the real
forge 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.sh annotate the answer with the
commit 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 59261bf onto the previous day's
pipeline 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:promote pins 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 sha filter exists, composes with ref, and the list entry already carries sha — so both halves
cost 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 what none
means for the monitor.
Effort: Medium.

Approach C: Parse each adapter's capabilities: manifest

Summary: Adapters declare pipeline_status: unsupported in their SKILL.md YAML block; the dispatch
parses 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-cli and gh-cli omit the release
ops 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 --capability self-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:promote
assert identity before it tags, catching an adapter whose own scoping is wrong.

Architecture

Component Overview

/dev:promote Step 4/5  (skill markdown — POSIX glue, no harness executes it)
        │  --ref <ref> --sha <pinned 40-hex>        ▲ one JSON answer + a stated exit code
        ▼                                            │
_shared/procedures/bin/release-pipeline-status.sh   ─┘   validates --sha, rc-checks the primitive,
        │                                                 converts rc 8/9 into stated answers
        ▼  _prim pipeline_status
_shared/procedures/bin/_lib.sh :: _prim             ← NEW: existence + executability check,
        │                                              classified by _GATED_PRIMS (mirrors contract §2.2)
        ├── file present  ──────────► adapter primitive
        ├── gated  + absent ───────► exit 8  "capability boundary"
        ├── gated  + non-exec ─────► exit 5  "broken install"   (never 8 — see Key Decisions)
        └── required + absent/non-exec ► exit 5  "broken install", names adapter/primitive/path
                 │
                 ▼
   glab-cli/bin/pipeline_status.sh          local-fs/bin/pipeline_status.sh
   GET /projects/:id/pipelines              .devwork/forge/pipelines/{refkey}[@{sha}].json
     ?ref=&sha=&order_by=id&sort=desc         (the scriptable surface the regression probe drives)
     &per_page=1
                 │                                       tea-cli, gh-cli: no file → exit 8
                 ▼
            GitLab.com

Data Flow

  1. /dev:promote pins TAG_TARGET_SHA (git rev-parse "$INTEGRATION_BRANCH", full 40-hex) and a
    deadline at the moment it enters a wait.
  2. It calls the helper with --ref and --sha, capturing stdout and rc separately (never a pipeline).
  3. The helper validates --sha, dispatches, and emits exactly one well-formed JSON answer.
  4. The adapter scopes the forge query to (ref, sha), takes the single most recently created pipeline,
    and reports state plus the commit the returned pipeline belongs to.
  5. /dev:promote asserts answer.commit == TAG_TARGET_SHA, then branches on answer.state. Every
    non-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-plan 2.2b) — this is the dependency class
the 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.

Boundary / source Operation Real shape (verified) Provenance
GitLab.com pipelines API (gitlab.com 19.4.0-pre) GET /projects/:id/pipelines?ref=&sha=&order_by=&sort=&per_page= (read-only) list of {id, iid, project_id, sha, ref, status, source, created_at, updated_at, web_url, name} — see external-contracts/gitlab-pipelines.sample.json recordedexternal-contracts/gitlab-pipelines.provenance.json

Facts the capture established, each of which the design depends on:

  1. The list entry already carries sha. Labelling the answer costs no extra call.
  2. ?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.
  3. ?sha= and ?ref= compose. sha=X&ref=v0.2.1 returned exactly 1 of the 2 pipelines on that sha.
    This matters more than it looks — see Key Decisions.
  4. Empty is [] + HTTP 200 + glab exit 0. A failed query returns a JSON object ({"message":"401 Unauthorized"}) with glab exit 1 — so the current … | jq '.[0]' is a parse error, not an empty answer.
  5. One commit routinely has several pipelines — 7 of the last 100 on the dogfood project: branch+tag on
    one sha (exactly what /dev:promote's tag strategy creates) and push+merge_request_event on one sha.
  6. "Most recent" is ambiguous in real data: id-order and 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.
  7. The #47 incident is reproduced in the data. Sha 59261bf carries pipeline 2760068906 (ref=main,
    created 09:30:11Z) and 2760097999 (ref=v0.2.1, created 09:41:54Z). A ref-scoped query issued before
    09:30:11 returns the previous day's main pipeline — the stale green. A sha-scoped query returns [] in
    that 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.
  8. The accepted status vocabulary is created, waiting_for_resource, preparing, pending, running, success, failed, canceled, canceling, skipped, manual, scheduled — the current else "pending"
    catch-all silently swallows six of them.

Observability & Audit

Not applicable. The project declares observability: mode: none ("skill suite — no runtime services; run
traceability 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

Decision Choice Rationale
Contract change shape Optional sha input; new response fields; new state values Additive within v1 (§2.3). A required new input would be a signature change and force v2, which the PREQ forbids. As a bonus, an adapter that ignores --sha still returns a commit the caller can check — so the identity assertion catches it, which is exactly the defence-in-depth the PREQ asks for.
Query scope (ref, sha) together, not sha alone Capture fact 5/7: after tagging, the released sha carries both main's pipeline and the tag's. A sha-only query would return main's already-green pipeline while the tag's deploy pipeline had not started — a new false green by a new route. ref + sha returned exactly the tag pipeline.
Which pipeline, when several The most recently created for that (ref, sha)order_by=id&sort=desc&per_page=1 Capture fact 6: updated_at lets an older pipeline that was retried later win over a newer one. id is monotonic with creation and agreed with created_at in every observed case. GitLab's own retry reuses the pipeline id, so a retry is picked up naturally.
Where required-vs-gated is declared The contract, mirrored in one _GATED_PRIMS list in _lib.sh It is a property of the operation, not of an adapter. Whether a given adapter provides it is answered by the file on disk — so no stubs (PREQ-rejected) and no manifest parsing (Approach C). The duplication is bounded by a mechanical lint check (AC-14).
Gated primitive present but not executable Broken install (exit 5) — never unsupported Raised by the security lens against this repo's own recorded learning: file-rewriting edit techniques drop the exec bit on bin/*.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.
Which encoding is authoritative The JSON state. The exit code is a redundant, informational echo Raised independently by the backend and UX lenses: returning non-zero for a valid answer is the very trap #55 fell into — an orchestrator following "non-zero → escalate" literally would escalate on unsupported and 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_timeout budget Each wait gets its own full ci_timeout, pinned on entry to that wait There are now two waits (pre-tag gate, post-tag monitor). Sharing one budget would let a slow pre-tag gate starve the post-tag monitor, and the AC says the bound is "counted from entry into the wait".
none semantics Sharpened to "no pipeline for this ref/commit (yet)" → the monitor waits Previously none meant both that and "no CI here", and the monitor broke out on it. "No CI" is now unsupported, keyed off adapter capability alone.
Who may emit which state unsupported is dispatch-emitted only; query_failed is adapter-emitted; the pipeline states are adapter-emitted Raised by the architect lens: without this, a future adapter author will "helpfully" emit unsupported itself and blur the layer boundary the fix depends on.
Pre-tag gate New. Before git tag, poll --ref $INTEGRATION_BRANCH --sha $TAG_TARGET_SHA to terminal AC-2 speaks about "the tag gate", and promote today consumes no pipeline answer before tagging — so without this the criterion is vacuous. It is also where #47 actually did harm.

Detailed 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 every
primitive, 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 be
executable, and its absence is a broken install that fails naming adapter, primitive and search path.
release.pipeline_status becomes (ref, sha?) → {state, commit, ref, pipeline_id, url, raw_state?}, with
the 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 a
stated monitor branch. The four adapters' capabilities: blocks are brought into line (tea-cli and
gh-cli list the release ops as unsupported) — documentation parity, not the mechanism.

2. Shared dispatch (_lib.sh). _GATED_PRIMS = the contract's capability-gated set. EXIT_UNSUPPORTED=8
and EXIT_QUERY=9 reserved in the exit-code comment block (2–7 are taken). _prim checks the primitive is
present and executable before invoking, and dies with the classified diagnosis above. No 127 and no
No such file or directory reaches a caller. Other gated-op callers (pr_create, release_create, the
wiki 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). --ref required, --sha optional and validated
as exactly 40 hex characters. Runs the primitive with output to a file and rc captured 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 are
not answers. When --sha was passed and the answer's commit is null or absent, the state is forced to
unknown — a green with no commit is never accepted as green.

4. Adapters. glab-cli: -f sha=$sha alongside the retained -f ref=$ref; order_by=id&sort=desc& per_page=1; response carries commit/ref/pipeline_id/raw_state; the API call is rc-checked rather
than piped, and any non-zero glab, non-JSON body (proxy HTML, empty output) or unparseable response
becomes query_failed with a classified error — HTTP status plus a fixed phrase plus the adapter name,
never raw CLI stderr. GL_JQ_PIPEMAP is rewritten with no catch-all: success/failed/running
direct; canceled|canceling → canceled; skipped → skipped; manual → manual;
created|waiting_for_resource|preparing|pending|scheduled → pending; anything else → unknown with the
forge's literal value preserved in raw_state. (pr_status shares this map and inherits the fall-through
fix; its pipeline_state gains no commit attribution in this slice — deferred, see Findings.)
local-fs: with --sha, read pipelines/{refkey}@{sha}.json and fall back to pipelines/{refkey}.json;
the record may declare {state, commit, pipeline_id}; the answer echoes the declared commit, so a
wrong-commit answer can be staged deliberately for the defence-in-depth case, and a query_failed
record lets the transient path be staged too. refkey keeps its existing encoding (/ and space → __);
@ is never produced by that encoding, so the @{sha} suffix is unambiguous. ref is validated before it
becomes a path component. Existing fixtures without a commit field parse as commit: null.
tea-cli / gh-cli: unchanged on disk; they answer unsupported through the dispatch.

5. /dev:promote. Step 4 (tag) gains the pre-tag gate; Step 5 gains the pinned sha, the identity
assertion, 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→sleep
and re-poll · nonekeep waiting · query_failed→retry inside the bound, and on the bound escalate
naming the query failure, never a pipeline state · unsupported→break to Step 6 and print the skip
line in the transcript at that moment · unknown→Step 8 naming raw_state. Every non-terminal branch
emits 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 follows
wait-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 helpers
through local-fs and, for the capability cases, a temp project declaring adapter: tea-cli (which genuinely
ships no pipeline_status.sh, so no network is touched).

Technical Risks

Risk Likelihood Impact Mitigation
The none semantics flip (break → wait) changes behaviour for a project whose ref legitimately never gets a pipeline Low Medium The PREQ accepts this explicitly: such a project waits out ci_timeout once and escalates. A sweep confirmed /dev:promote is the only skill-text consumer of this reading — /dev:integrate does not consume it.
_GATED_PRIMS drifts from the contract's Kind column Medium Medium Mechanical lint check (AC-14), not review discipline.
The pre-tag gate lengthens a release on slow CI Medium Low Bounded by its own ci_timeout; unsupported short-circuits it entirely.
The glab-cli query change is verified live, not by the staged probe Certain Low Stated, not assumed: the split is written into the test plan (Scenario 13 step 5) and the lane annotations. The API behaviour it relies on was captured from the real forge at plan time.
Two exit codes are added to a shared helper tier Low Low 8 and 9 verified free (_lib.sh tops out at EXIT_ENV=7) and reserved in the comment block.

Not every risk belongs here. The external data contract is not a risk row — it was enumerated,
verified against a real read at plan time, and recorded in External Data Contracts above. No
precondition is raised because nothing about that boundary is still assumed.

Expert Review

Tier 3 — four lenses, fable, one response each.

Reviewers

  • Solution Architect: the dispatch change silently alters the failure contract of every gated op, and _GATED_PRIMS duplicates the contract with no sync mechanism.
  • Backend Developer: a non-zero exit for a valid answer re-creates #55 one layer up; ci_timeout is ambiguous now that there are two waits.
  • Security Specialist: gated-but-non-executable is unclassified and would become a fail-open release gate; the query_failed error field's sanitization is asserted rather than specified.
  • UX Expert: poll records were specified only on the none branch, so a long wait stays indistinguishable from a hang; no format was given for the record.

Changes Made

  • Exit-code authority stated (backend + UX, independently): the JSON state is authoritative for
    callers; 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_PRIMS drift check (architect + backend, independently): promoted to a mechanical AC (AC-14)
    in lint-conventions.sh, not review discipline.
  • Gated + present + non-executable → broken install (exit 5) (security): closes a fail-open path that
    this repo's own recorded learning (dropped exec bits on bin/*.sh) makes concretely likely.
  • query_failed.error sanitization specified, not asserted (security): a classified error (HTTP status
    • fixed phrase + adapter), never raw CLI stderr, with a probe asserting no credential-shaped string and no
      absolute path survives (AC-16).
  • Null commit on a green verdict under a pinned --sha is never green (security): forced to unknown
    (AC-17), so a buggy adapter omitting the field cannot silently restore commit-blind behaviour.
  • Poll records on every non-terminal branch, with a stated one-line format (UX): pending, running,
    none and query_failed retries all emit one (AC-19).
  • ci_timeout per wait (backend): each of the two waits gets its own full budget, pinned at its entry.
  • Gated-op fallout stated (architect): other gated-op callers keep escalating on exit 8; no behaviour
    change is requested for them in this slice, and that disposition is written down rather than implied.
  • Emitting-layer discipline (architect): the contract states unsupported is dispatch-emitted only and
    query_failed is adapter-emitted.
  • Non-JSON error bodies (backend): proxy HTML / empty output map to query_failed, not a jq parse death.
  • refkey encoding and old-fixture compatibility stated (backend); ref validated before it becomes a
    path component
    (security).
  • Escalation content (UX): escalations carry the pipeline url; the timeout escalation names the last
    observed state as well as the commit.
  • Sweep performed (architect, "missing"): /dev:promote is the only skill-text consumer of the none
    reading; /dev:integrate does not consume it. Recorded in Technical Risks rather than left open.

Noted (not actioned)

  • pr_status.pipeline_state gains no commit attribution. It inherits the fall-through fix (shared
    GL_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 helper
    tier
    — which is why glab-cli/bin/pipeline_status.sh shipped that exact antipattern. Extending the
    check to bin/*.sh is a real follow-up, larger than this slice.
  • Half the validation scenarios route to human-uat because no harness executes skill markdown. Stated
    as a lane fact, not a defect.
  • CLAUDE.md's Verification section says "There is no unit suite" while plugin/skills/**/test/*.sh and
    _shared/procedures/test/*.sh exist and pass — documentation drift found while confirming lane
    executability.

Acceptance Criteria

ID Criterion (from PREQ) Verification approach
AC-1 Release ref's most recent pipeline belongs to an earlier commit → the answer describes a pipeline for the commit being released and states which commit it describes Integration: _shared/procedures/test/pipeline-identity.sh stages an earlier-commit green on the ref plus a later released commit; asserts the answer's commit is the released one and the earlier success is not returned
AC-2 Answer's stated commit ≠ the commit about to be tagged → no tag placed, blocking decision naming both commits Dogfood /dev:promote run (owner: operator) against a staged wrong-commit answer; the helper-side half (answer states a commit ≠ the pinned target) asserted by pipeline-identity.sh
AC-3 Commit with more than one pipeline (retry, branch+MR, parent/child) → the answer describes the most recent pipeline for that commit and states the commit Integration: pipeline-identity.sh asserts 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 GitLab
AC-4 No pipeline yet → distinguishable from "cannot report pipelines"; monitor emits an observable poll record naming the commit and elapsed wait; keeps waiting rather than concluding the release is ungated Dogfood run for the wait + poll record; pipeline-identity.sh asserts none and unsupported are different answers with different exit codes
AC-5 Wait reaches ci_timeout — counted from entry into the wait, tag-target commit pinned at that same moment → escalates as a timeout, naming the commit it waited for Dogfood run with a short declared ci_timeout; includes pushing a further commit mid-wait and confirming the run still gates on the pinned sha
AC-6 A state that is neither success, failure, nor absent (canceled, manual/blocked, skipped) → maps onto a stated monitor branch; no state falls through to an unstated default Integration: pipeline-identity.sh feeds every status in the captured GitLab vocabulary through the mapping and asserts each yields a stated enum value, and an unrecognized value yields unknown + raw_state; the monitor branch table desk-checked against promote Step 5
AC-7 Forge unreachable / auth fails / API error → reported as a query failure, never a pipeline verdict and never an absent pipeline; retried within the ci_timeout bound; escalates naming the query failure Integration: pipeline-identity.sh drives an unreachable instance host and an invalid credential and asserts query_failed (never none, never a verdict); the retry-then-escalate half on a dogfood run
AC-8 Adapter cannot report pipelines at all → stated answer, run proceeds to the required deploy_check, and the Phase Outcome records that the pipeline gate was skipped and why Dogfood run on a tea-cli project; the stated answer itself asserted by pipeline-identity.sh
AC-9 Same project → the helper's own exit status and message name the capability boundary; no shell-level error and no internal helper file path reaches the operator or the calling skill Integration: pipeline-identity.sh captures stdout+stderr and asserts exit 8, state:"unsupported", and the absence of No such file or directory, any line-number reference, and any path under the skills root
AC-10 Adapter missing a required primitive → failure names the adapter, the primitive and where it was looked for, and is never reported as an absent capability Integration: pipeline-identity.sh removes a required primitive from a temp adapter tree and asserts exit 5, the three named elements, and that the output is not unsupported
AC-11 For each primitive the contract lists → the contract states whether it is required or capability-gated, and what an adapter that cannot provide it must answer Mechanical: scripts/lint-conventions.sh asserts every primitive row in §2.1/§2.2 carries a Kind value, and that the normative "what must it answer" paragraph exists
AC-12 A genuine failure for the exact released commit — and, separately, a canceled pipeline — each escalate as they do today Dogfood run; helper-side half (failed/canceled surface unchanged with the commit stated) asserted by pipeline-identity.sh; existing release-flow.sh continues to assert the failure path
AC-13 The 2026-08-14 merge-then-query sequence, staged on the scriptable adapter → the stale answer is not returned, repeatably Integration: pipeline-identity.sh stages the sequence and re-runs it twice without manual re-staging; the live-forge half verified separately on a dogfood GitLab release
AC-14 (derived — expert review) _GATED_PRIMS in _lib.sh matches the contract's capability-gated set exactly Mechanical: scripts/lint-conventions.sh parses 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 copy
AC-15 (derived — expert review) A capability-gated primitive that is present but not executable is reported as a broken install (exit 5), never as unsupported Integration: pipeline-identity.sh chmod -x a gated primitive and asserts exit 5 with the broken-install diagnosis, and that the pipeline gate is not skipped
AC-16 (derived — expert review) The query_failed answer's error carries no credential-shaped string and no absolute filesystem path Mechanical: pipeline-identity.sh drives 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
AC-17 (derived — expert review) When --sha was given and the answer's commit is null or absent, the state is unknown and is never treated as green Integration: pipeline-identity.sh stages a success record with no commit under a pinned --sha and asserts unknown
AC-18 (derived — external-contract capture) --sha must be a full 40-hex sha; a short sha is refused, never silently answered none Integration: pipeline-identity.sh passes an 8-char sha and asserts a usage failure (exit 2) naming the requirement
AC-19 (derived — expert review) Every non-terminal poll (pending, running, none, query_failed retry) emits a poll record in one stated one-line format carrying state, commit, ref, elapsed and remaining Desk-check of promote Step 5's stated format against every emitting branch; observed on the dogfood run
AC-20 (derived — no-regression) _shared/procedures/test/release-flow.sh still passes unchanged, and local-fs fixtures without a commit field parse as commit: null Integration: run release-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

Area Files / directories involved Nature of change
Forge contract plugin/skills/_shared/forge-contract.md (§2.1, §2.2, §4) modify — Kind column, normative absence paragraph, grown state enum, new signature
Shared dispatch plugin/skills/_shared/procedures/bin/_lib.sh modify — _GATED_PRIMS, EXIT_UNSUPPORTED/EXIT_QUERY, _prim existence + executability classification
Shared helper plugin/skills/_shared/procedures/bin/release-pipeline-status.sh modify — --sha, validation, rc-checked dispatch, stated answers
glab-cli adapter plugin/skills/glab-cli/bin/pipeline_status.sh, bin/_lib.sh (GL_JQ_PIPEMAP), SKILL.md modify — sha filter, commit label, rc-checked call, catch-all removal
local-fs adapter plugin/skills/local-fs/bin/pipeline_status.sh, SKILL.md modify — sha-keyed records, commit/query_failed staging, ref validation
Adapter manifests plugin/skills/{tea-cli,gh-cli}/SKILL.md (capabilities:) modify — declare the release ops unsupported (documentation parity)
Release skill plugin/skills/promote/SKILL.md (Steps 4, 5, 8) modify — pinned sha, pre-tag gate, identity assertion, branch table, poll records
Regression probe plugin/skills/_shared/procedures/test/pipeline-identity.sh new
Repo lint scripts/lint-conventions.sh modify — contract Kind coverage + _GATED_PRIMS drift check

File Boundaries

Three groups can proceed in parallel once the contract text settles:

  • Shared tier_lib.sh + release-pipeline-status.sh (one author; they share the exit-code vocabulary).
  • Adaptersglab-cli and local-fs are disjoint file sets and can be worked concurrently.
  • Skill text + lintpromote/SKILL.md and scripts/lint-conventions.sh touch nothing the others do.

The regression probe touches only its own new file but consumes all three, so it lands last.

Dependencies & Sequencing

  1. Contract text first. It fixes the state enum, the response fields and the required/gated
    classification that every other area encodes. Nothing else should start against a guess.
  2. Then, in parallel: shared tier · glab-cli · local-fs · promote text · lint.
  3. Probe last — it asserts the composed behaviour end to end.

scripts/lint-conventions.sh's Kind check depends on the contract's table shape, so it follows step 1 too.

Constraints & Non-Goals

Constraints:

  • Contract changes additive within v1 — new optional input, new response fields, new state values. A
    required new input or a removed field forces v2 and is out of bounds.
  • No new runtime dependencies. Helper tier: bash ≥ 3.2, jq, git, POSIX utilities. Skill-emitted glue:
    POSIX only — no bashisms, no python/node, no zsh-reserved variable names.
  • A gate's output goes to a file; its exit code comes from the command, never a pipeline. This is what
    the current glab call violates and is part of what is being fixed.
  • The shared-tier fix must be adapter-neutral so it survives the Forgejo → GitHub migration untouched.
  • Forge mutations route through _shared/procedures/bin/* and the active adapter — never inline forge CLI calls.

Non-goals (do NOT build):

  • pipeline_status for tea-cli — the Forgejo instance is being decommissioned. Known and accepted
    consequence: a tea-cli project that does run Gitea Actions gets a stated unsupported and its real
    pipelines go unwatched, recorded in the Phase Outcome.
  • gh-cli's release operations — all six are missing; filed as sibling #69, which depends on this.
  • A general retry/resume protocol for transient API errors — that is #46, untouched. The
    query-failure handling here is scoped to the pipeline poll.
  • A project declaration for legitimately unpiped refs — rejected in the PREQ: new schema surface, a
    readiness item and a parseability contract for a case nobody has hit.
  • Probing the repository for CI configuration (.gitea/workflows, .github/workflows,
    .gitlab-ci.yml) — hardcodes per-forge paths into stack-agnostic shared code and would answer wrongly for
    config no runner picks up.
  • Stub files for every capability-gated op on every adapter.
  • Changing how a release is triggered — that is #60.
  • Auditing the other primitive dispatches for correctness beyond existence.
  • Retitling #47 — the contract has no title-edit primitive; the body carries the folded scope.
<!-- sreq:v1 issue=47 skill=technical-plan --> # Software Requirements: release-pipeline-status-attributable-answer Issue #47 (with **#55** folded in). Tier 3 — four-lens expert panel. ## Context An operator running `/dev:promote` cannot trust what the pipeline monitor tells them, and both ways it lies land at the moment a production tag is placed. **#47:** `release-pipeline-status.sh --ref main` reported a *previous release's* pipeline as `success` for a freshly pushed commit — the answer was about the **branch name**, not the commit, and the response shape `{state, url}` carries no commit identity a caller could check. **#55:** on an adapter that ships no `pipeline_status` primitive the dispatch invokes the primitive by path with no existence check, so the caller gets a shell-level **exit 127** naming an internal helper path — which makes `/dev:promote` Step 5's correct "this project has no CI" branch unreachable. 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 main` on a `tea-cli` project → exit 127, stderr `.../tea-cli/bin/pipeline_status.sh: No such file or directory`). #47's data was recovered from the real forge 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.sh` annotate the answer with the commit 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 `59261bf` onto the previous day's pipeline 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:promote` pins 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 `sha` filter exists, composes with `ref`, and the list entry already carries `sha` — so both halves cost 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 what `none` means for the monitor. **Effort:** Medium. ### Approach C: Parse each adapter's `capabilities:` manifest **Summary:** Adapters declare `pipeline_status: unsupported` in their `SKILL.md` YAML block; the dispatch parses 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-cli` and `gh-cli` **omit** the release ops 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 `--capability` self-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:promote` assert identity before it tags, catching an adapter whose own scoping is wrong. ## Architecture ### Component Overview ``` /dev:promote Step 4/5 (skill markdown — POSIX glue, no harness executes it) │ --ref <ref> --sha <pinned 40-hex> ▲ one JSON answer + a stated exit code ▼ │ _shared/procedures/bin/release-pipeline-status.sh ─┘ validates --sha, rc-checks the primitive, │ converts rc 8/9 into stated answers ▼ _prim pipeline_status _shared/procedures/bin/_lib.sh :: _prim ← NEW: existence + executability check, │ classified by _GATED_PRIMS (mirrors contract §2.2) ├── file present ──────────► adapter primitive ├── gated + absent ───────► exit 8 "capability boundary" ├── gated + non-exec ─────► exit 5 "broken install" (never 8 — see Key Decisions) └── required + absent/non-exec ► exit 5 "broken install", names adapter/primitive/path │ ▼ glab-cli/bin/pipeline_status.sh local-fs/bin/pipeline_status.sh GET /projects/:id/pipelines .devwork/forge/pipelines/{refkey}[@{sha}].json ?ref=&sha=&order_by=id&sort=desc (the scriptable surface the regression probe drives) &per_page=1 │ tea-cli, gh-cli: no file → exit 8 ▼ GitLab.com ``` ### Data Flow 1. `/dev:promote` pins `TAG_TARGET_SHA` (`git rev-parse "$INTEGRATION_BRANCH"`, full 40-hex) and a `deadline` at the moment it enters a wait. 2. It calls the helper with `--ref` **and** `--sha`, capturing stdout and `rc` separately (never a pipeline). 3. The helper validates `--sha`, dispatches, and emits exactly one well-formed JSON answer. 4. The adapter scopes the forge query to `(ref, sha)`, takes the single most recently **created** pipeline, and reports `state` plus the commit **the returned pipeline belongs to**. 5. `/dev:promote` asserts `answer.commit == TAG_TARGET_SHA`, then branches on `answer.state`. Every non-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-plan` 2.2b) — this is the dependency class the 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.** | Boundary / source | Operation | Real shape (verified) | Provenance | | ----------------- | --------- | --------------------- | ---------- | | GitLab.com pipelines API (gitlab.com 19.4.0-pre) | `GET /projects/:id/pipelines?ref=&sha=&order_by=&sort=&per_page=` (read-only) | list of `{id, iid, project_id, sha, ref, status, source, created_at, updated_at, web_url, name}` — see `external-contracts/gitlab-pipelines.sample.json` | `recorded` → `external-contracts/gitlab-pipelines.provenance.json` | Facts the capture established, each of which the design depends on: 1. **The list entry already carries `sha`.** Labelling the answer costs no extra call. 2. **`?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. 3. **`?sha=` and `?ref=` compose.** `sha=X&ref=v0.2.1` returned exactly 1 of the 2 pipelines on that sha. This matters more than it looks — see Key Decisions. 4. **Empty is `[]` + HTTP 200 + `glab` exit 0. A failed query returns a JSON *object*** (`{"message":"401 Unauthorized"}`) with `glab` exit 1 — so the current `… | jq '.[0]'` is a parse error, not an empty answer. 5. **One commit routinely has several pipelines** — 7 of the last 100 on the dogfood project: branch+tag on one sha (exactly what `/dev:promote`'s tag strategy creates) and push+merge_request_event on one sha. 6. **"Most recent" is ambiguous in real data**: id-order and `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. 7. **The #47 incident is reproduced in the data.** Sha `59261bf` carries pipeline `2760068906` (ref=main, created 09:30:11Z) and `2760097999` (ref=v0.2.1, created 09:41:54Z). A ref-scoped query issued *before* 09:30:11 returns the previous day's main pipeline — the stale green. A sha-scoped query returns `[]` in that 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. 8. **The accepted status vocabulary** is `created, waiting_for_resource, preparing, pending, running, success, failed, canceled, canceling, skipped, manual, scheduled` — the current `else "pending"` catch-all silently swallows six of them. ### Observability & Audit Not applicable. The project declares `observability: mode: none` ("skill suite — no runtime services; run traceability 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 | Decision | Choice | Rationale | | --- | --- | --- | | Contract change shape | **Optional** `sha` input; **new response fields**; **new state values** | Additive within v1 (§2.3). A *required* new input would be a signature change and force v2, which the PREQ forbids. As a bonus, an adapter that ignores `--sha` still returns a `commit` the caller can check — so the identity assertion catches it, which is exactly the defence-in-depth the PREQ asks for. | | Query scope | `(ref, sha)` **together**, not sha alone | Capture fact 5/7: after tagging, the released sha carries *both* `main`'s pipeline and the tag's. A sha-only query would return `main`'s already-green pipeline while the tag's deploy pipeline had not started — a **new** false green by a new route. `ref` + `sha` returned exactly the tag pipeline. | | Which pipeline, when several | The most recently **created** for that `(ref, sha)` — `order_by=id&sort=desc&per_page=1` | Capture fact 6: `updated_at` lets an *older* pipeline that was retried later win over a newer one. `id` is monotonic with creation and agreed with `created_at` in every observed case. GitLab's own retry reuses the pipeline id, so a retry is picked up naturally. | | Where required-vs-gated is declared | The **contract**, mirrored in one `_GATED_PRIMS` list in `_lib.sh` | It is a property of the *operation*, not of an adapter. Whether a given adapter provides it is answered by the file on disk — so no stubs (PREQ-rejected) and no manifest parsing (Approach C). The duplication is bounded by a mechanical lint check (AC-14). | | Gated primitive **present but not executable** | **Broken install (exit 5)** — never `unsupported` | Raised by the security lens against this repo's own recorded learning: file-rewriting edit techniques drop the exec bit on `bin/*.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. | | Which encoding is authoritative | **The JSON `state`.** The exit code is a redundant, informational echo | Raised independently by the backend and UX lenses: returning non-zero for a *valid answer* is the very trap #55 fell into — an orchestrator following "non-zero → escalate" literally would escalate on `unsupported` and 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_timeout` budget | **Each wait gets its own full `ci_timeout`**, pinned on entry to that wait | There are now two waits (pre-tag gate, post-tag monitor). Sharing one budget would let a slow pre-tag gate starve the post-tag monitor, and the AC says the bound is "counted from entry into the wait". | | `none` semantics | Sharpened to **"no pipeline for this ref/commit (yet)"** → the monitor **waits** | Previously `none` meant both that and "no CI here", and the monitor broke out on it. "No CI" is now `unsupported`, keyed off adapter capability alone. | | Who may emit which state | `unsupported` is **dispatch-emitted only**; `query_failed` is **adapter-emitted**; the pipeline states are adapter-emitted | Raised by the architect lens: without this, a future adapter author will "helpfully" emit `unsupported` itself and blur the layer boundary the fix depends on. | | Pre-tag gate | **New.** Before `git tag`, poll `--ref $INTEGRATION_BRANCH --sha $TAG_TARGET_SHA` to terminal | AC-2 speaks about "the tag gate", and promote today consumes **no** pipeline answer before tagging — so without this the criterion is vacuous. It is also where #47 actually did harm. | ### Detailed 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 *every* primitive, 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 be executable, and its absence is a broken install that fails naming adapter, primitive and search path. `release.pipeline_status` becomes `(ref, sha?) → {state, commit, ref, pipeline_id, url, raw_state?}`, with the 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 a stated monitor branch. The four adapters' `capabilities:` blocks are brought into line (`tea-cli` and `gh-cli` list the release ops as `unsupported`) — documentation parity, **not** the mechanism. **2. Shared dispatch (`_lib.sh`).** `_GATED_PRIMS` = the contract's capability-gated set. `EXIT_UNSUPPORTED=8` and `EXIT_QUERY=9` reserved in the exit-code comment block (2–7 are taken). `_prim` checks the primitive is present **and executable** before invoking, and dies with the classified diagnosis above. No 127 and no `No such file or directory` reaches a caller. Other gated-op callers (`pr_create`, `release_create`, the wiki 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`).** `--ref` required, `--sha` optional and validated as exactly 40 hex characters. Runs the primitive with output to a file and `rc` captured 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 are **not** answers. When `--sha` was passed and the answer's `commit` is null or absent, the state is forced to `unknown` — a green with no commit is never accepted as green. **4. Adapters.** *glab-cli*: `-f sha=$sha` alongside the retained `-f ref=$ref`; `order_by=id&sort=desc& per_page=1`; response carries `commit`/`ref`/`pipeline_id`/`raw_state`; the API call is rc-checked rather than piped, and any non-zero `glab`, non-JSON body (proxy HTML, empty output) or unparseable response becomes `query_failed` with a **classified** error — HTTP status plus a fixed phrase plus the adapter name, never raw CLI stderr. `GL_JQ_PIPEMAP` is rewritten with **no catch-all**: `success`/`failed`/`running` direct; `canceled|canceling → canceled`; `skipped → skipped`; `manual → manual`; `created|waiting_for_resource|preparing|pending|scheduled → pending`; anything else → `unknown` with the forge's literal value preserved in `raw_state`. (`pr_status` shares this map and inherits the fall-through fix; its `pipeline_state` gains no commit attribution in this slice — deferred, see Findings.) *local-fs*: with `--sha`, read `pipelines/{refkey}@{sha}.json` and fall back to `pipelines/{refkey}.json`; the record may declare `{state, commit, pipeline_id}`; the answer echoes the declared `commit`, so a **wrong-commit** answer can be staged deliberately for the defence-in-depth case, and a `query_failed` record lets the transient path be staged too. `refkey` keeps its existing encoding (`/` and space → `__`); `@` is never produced by that encoding, so the `@{sha}` suffix is unambiguous. `ref` is validated before it becomes a path component. Existing fixtures without a `commit` field parse as `commit: null`. *tea-cli / gh-cli*: unchanged on disk; they answer `unsupported` through the dispatch. **5. `/dev:promote`.** Step 4 (`tag`) gains the pre-tag gate; Step 5 gains the pinned sha, the identity assertion, 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`→sleep and re-poll · `none`→**keep waiting** · `query_failed`→retry inside the bound, and on the bound escalate naming the **query failure**, never a pipeline state · `unsupported`→break to Step 6 and print the skip line in the transcript at that moment · `unknown`→Step 8 naming `raw_state`. Every non-terminal branch emits 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 follows `wait-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 helpers through local-fs and, for the capability cases, a temp project declaring `adapter: tea-cli` (which genuinely ships no `pipeline_status.sh`, so **no network is touched**). ## Technical Risks | Risk | Likelihood | Impact | Mitigation | | --- | --- | --- | --- | | The `none` semantics flip (break → wait) changes behaviour for a project whose ref legitimately never gets a pipeline | Low | Medium | The PREQ accepts this explicitly: such a project waits out `ci_timeout` once and escalates. A sweep confirmed `/dev:promote` is the **only** skill-text consumer of this reading — `/dev:integrate` does not consume it. | | `_GATED_PRIMS` drifts from the contract's Kind column | Medium | Medium | Mechanical lint check (AC-14), not review discipline. | | The pre-tag gate lengthens a release on slow CI | Medium | Low | Bounded by its own `ci_timeout`; `unsupported` short-circuits it entirely. | | The `glab-cli` query change is verified live, not by the staged probe | Certain | Low | Stated, not assumed: the split is written into the test plan (Scenario 13 step 5) and the lane annotations. The API behaviour it relies on was captured from the real forge at plan time. | | Two exit codes are added to a shared helper tier | Low | Low | 8 and 9 verified free (`_lib.sh` tops out at `EXIT_ENV=7`) and reserved in the comment block. | > **Not every risk belongs here.** The external data contract is *not* a risk row — it was enumerated, > verified against a real read at plan time, and recorded in *External Data Contracts* above. No > `precondition` is raised because nothing about that boundary is still assumed. ## Expert Review Tier 3 — four lenses, `fable`, one response each. ### Reviewers - **Solution Architect:** the dispatch change silently alters the failure contract of *every* gated op, and `_GATED_PRIMS` duplicates the contract with no sync mechanism. - **Backend Developer:** a non-zero exit for a *valid answer* re-creates #55 one layer up; `ci_timeout` is ambiguous now that there are two waits. - **Security Specialist:** gated-but-non-executable is unclassified and would become a fail-open release gate; the `query_failed` `error` field's sanitization is asserted rather than specified. - **UX Expert:** poll records were specified only on the `none` branch, so a long wait stays indistinguishable from a hang; no format was given for the record. ### Changes Made - **Exit-code authority stated** (backend + UX, independently): the JSON `state` is authoritative for callers; 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_PRIMS` drift check** (architect + backend, independently): promoted to a mechanical AC (AC-14) in `lint-conventions.sh`, not review discipline. - **Gated + present + non-executable → broken install (exit 5)** (security): closes a fail-open path that this repo's own recorded learning (dropped exec bits on `bin/*.sh`) makes concretely likely. - **`query_failed.error` sanitization specified, not asserted** (security): a classified error (HTTP status + fixed phrase + adapter), never raw CLI stderr, with a probe asserting no credential-shaped string and no absolute path survives (AC-16). - **Null `commit` on a green verdict under a pinned `--sha` is never green** (security): forced to `unknown` (AC-17), so a buggy adapter omitting the field cannot silently restore commit-blind behaviour. - **Poll records on every non-terminal branch, with a stated one-line format** (UX): `pending`, `running`, `none` and `query_failed` retries all emit one (AC-19). - **`ci_timeout` per wait** (backend): each of the two waits gets its own full budget, pinned at its entry. - **Gated-op fallout stated** (architect): other gated-op callers keep escalating on exit 8; no behaviour change is requested for them in this slice, and that disposition is written down rather than implied. - **Emitting-layer discipline** (architect): the contract states `unsupported` is dispatch-emitted only and `query_failed` is adapter-emitted. - **Non-JSON error bodies** (backend): proxy HTML / empty output map to `query_failed`, not a jq parse death. - **`refkey` encoding and old-fixture compatibility stated** (backend); **`ref` validated before it becomes a path component** (security). - **Escalation content** (UX): escalations carry the pipeline `url`; the timeout escalation names the last observed state as well as the commit. - **Sweep performed** (architect, "missing"): `/dev:promote` is the only skill-text consumer of the `none` reading; `/dev:integrate` does not consume it. Recorded in Technical Risks rather than left open. ### Noted (not actioned) - **`pr_status.pipeline_state` gains no commit attribution.** It inherits the fall-through fix (shared `GL_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 helper tier** — which is why `glab-cli/bin/pipeline_status.sh` shipped that exact antipattern. Extending the check to `bin/*.sh` is a real follow-up, larger than this slice. - **Half the validation scenarios route to `human-uat`** because no harness executes skill markdown. Stated as a lane fact, not a defect. - **CLAUDE.md's Verification section says "There is no unit suite"** while `plugin/skills/**/test/*.sh` and `_shared/procedures/test/*.sh` exist and pass — documentation drift found while confirming lane executability. ## Acceptance Criteria | ID | Criterion (from PREQ) | Verification approach | | --- | --- | --- | | AC-1 | Release ref's most recent pipeline belongs to an **earlier** commit → the answer describes a pipeline for the commit being released and **states which commit** it describes | Integration: `_shared/procedures/test/pipeline-identity.sh` stages an earlier-commit green on the ref plus a later released commit; asserts the answer's `commit` is the released one and the earlier `success` is not returned | | AC-2 | Answer's stated commit ≠ the commit about to be tagged → **no tag placed**, blocking decision naming **both** commits | Dogfood `/dev:promote` run (owner: operator) against a staged wrong-commit answer; the helper-side half (answer states a commit ≠ the pinned target) asserted by `pipeline-identity.sh` | | AC-3 | Commit with **more than one** pipeline (retry, branch+MR, parent/child) → the answer describes the **most recent** pipeline for that commit and states the commit | Integration: `pipeline-identity.sh` asserts 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 GitLab | | AC-4 | No pipeline **yet** → distinguishable from "cannot report pipelines"; monitor emits an **observable poll record naming the commit and elapsed wait**; keeps waiting rather than concluding the release is ungated | Dogfood run for the wait + poll record; `pipeline-identity.sh` asserts `none` and `unsupported` are different answers with different exit codes | | AC-5 | Wait reaches `ci_timeout` — counted **from entry into the wait**, tag-target commit **pinned at that same moment** → escalates as a **timeout**, naming the commit it waited for | Dogfood run with a short declared `ci_timeout`; includes pushing a further commit mid-wait and confirming the run still gates on the pinned sha | | AC-6 | A state that is neither success, failure, nor absent (canceled, manual/blocked, skipped) → maps onto a **stated monitor branch**; **no state falls through to an unstated default** | Integration: `pipeline-identity.sh` feeds every status in the captured GitLab vocabulary through the mapping and asserts each yields a stated enum value, and an unrecognized value yields `unknown` + `raw_state`; the monitor branch table desk-checked against promote Step 5 | | AC-7 | Forge unreachable / auth fails / API error → reported as a **query failure**, never a pipeline verdict and never an absent pipeline; **retried** within the `ci_timeout` bound; escalates naming the query failure | Integration: `pipeline-identity.sh` drives an unreachable instance host and an invalid credential and asserts `query_failed` (never `none`, never a verdict); the retry-then-escalate half on a dogfood run | | AC-8 | Adapter cannot report pipelines at all → stated answer, run **proceeds to the required `deploy_check`**, and the **Phase Outcome records that the pipeline gate was skipped and why** | Dogfood run on a `tea-cli` project; the stated answer itself asserted by `pipeline-identity.sh` | | AC-9 | Same project → the helper's own **exit status and message name the capability boundary**; **no shell-level error and no internal helper file path** reaches the operator or the calling skill | Integration: `pipeline-identity.sh` captures stdout+stderr and asserts exit 8, `state:"unsupported"`, and the absence of `No such file or directory`, any line-number reference, and any path under the skills root | | AC-10 | Adapter missing a **required** primitive → failure names the **adapter, the primitive and where it was looked for**, and is **never** reported as an absent capability | Integration: `pipeline-identity.sh` removes a required primitive from a temp adapter tree and asserts exit 5, the three named elements, and that the output is not `unsupported` | | AC-11 | For **each primitive the contract lists** → the contract states whether it is required or capability-gated, and what an adapter that cannot provide it must answer | Mechanical: `scripts/lint-conventions.sh` asserts every primitive row in §2.1/§2.2 carries a Kind value, and that the normative "what must it answer" paragraph exists | | AC-12 | A genuine **failure** for the exact released commit — and, separately, a **canceled** pipeline — each escalate as they do today | Dogfood run; helper-side half (`failed`/`canceled` surface unchanged with the commit stated) asserted by `pipeline-identity.sh`; existing `release-flow.sh` continues to assert the failure path | | AC-13 | The 2026-08-14 merge-then-query sequence, staged on the scriptable adapter → the stale answer is **not** returned, repeatably | Integration: `pipeline-identity.sh` stages the sequence and re-runs it twice without manual re-staging; the live-forge half verified separately on a dogfood GitLab release | | AC-14 | *(derived — expert review)* `_GATED_PRIMS` in `_lib.sh` matches the contract's capability-gated set exactly | **Mechanical**: `scripts/lint-conventions.sh` parses 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 copy | | AC-15 | *(derived — expert review)* A **capability-gated** primitive that is present but **not executable** is reported as a broken install (exit 5), never as `unsupported` | Integration: `pipeline-identity.sh` `chmod -x` a gated primitive and asserts exit 5 with the broken-install diagnosis, and that the pipeline gate is **not** skipped | | AC-16 | *(derived — expert review)* The `query_failed` answer's `error` carries **no credential-shaped string and no absolute filesystem path** | **Mechanical**: `pipeline-identity.sh` drives 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 | | AC-17 | *(derived — expert review)* When `--sha` was given and the answer's `commit` is null or absent, the state is `unknown` and is **never** treated as green | Integration: `pipeline-identity.sh` stages a `success` record with no `commit` under a pinned `--sha` and asserts `unknown` | | AC-18 | *(derived — external-contract capture)* `--sha` must be a full 40-hex sha; a short sha is **refused**, never silently answered `none` | Integration: `pipeline-identity.sh` passes an 8-char sha and asserts a usage failure (exit 2) naming the requirement | | AC-19 | *(derived — expert review)* Every non-terminal poll (`pending`, `running`, `none`, `query_failed` retry) emits a poll record in one stated one-line format carrying state, commit, ref, elapsed and remaining | Desk-check of promote Step 5's stated format against every emitting branch; observed on the dogfood run | | AC-20 | *(derived — no-regression)* `_shared/procedures/test/release-flow.sh` still passes unchanged, and local-fs fixtures without a `commit` field parse as `commit: null` | Integration: run `release-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 | Area | Files / directories involved | Nature of change | | --- | --- | --- | | Forge contract | `plugin/skills/_shared/forge-contract.md` (§2.1, §2.2, §4) | modify — Kind column, normative absence paragraph, grown state enum, new signature | | Shared dispatch | `plugin/skills/_shared/procedures/bin/_lib.sh` | modify — `_GATED_PRIMS`, `EXIT_UNSUPPORTED`/`EXIT_QUERY`, `_prim` existence + executability classification | | Shared helper | `plugin/skills/_shared/procedures/bin/release-pipeline-status.sh` | modify — `--sha`, validation, rc-checked dispatch, stated answers | | glab-cli adapter | `plugin/skills/glab-cli/bin/pipeline_status.sh`, `bin/_lib.sh` (`GL_JQ_PIPEMAP`), `SKILL.md` | modify — sha filter, commit label, rc-checked call, catch-all removal | | local-fs adapter | `plugin/skills/local-fs/bin/pipeline_status.sh`, `SKILL.md` | modify — sha-keyed records, `commit`/`query_failed` staging, ref validation | | Adapter manifests | `plugin/skills/{tea-cli,gh-cli}/SKILL.md` (`capabilities:`) | modify — declare the release ops `unsupported` (documentation parity) | | Release skill | `plugin/skills/promote/SKILL.md` (Steps 4, 5, 8) | modify — pinned sha, pre-tag gate, identity assertion, branch table, poll records | | Regression probe | `plugin/skills/_shared/procedures/test/pipeline-identity.sh` | **new** | | Repo lint | `scripts/lint-conventions.sh` | modify — contract Kind coverage + `_GATED_PRIMS` drift check | ### File Boundaries Three groups can proceed in parallel once the contract text settles: - **Shared tier** — `_lib.sh` + `release-pipeline-status.sh` (one author; they share the exit-code vocabulary). - **Adapters** — `glab-cli` and `local-fs` are disjoint file sets and can be worked concurrently. - **Skill text + lint** — `promote/SKILL.md` and `scripts/lint-conventions.sh` touch nothing the others do. The regression probe touches only its own new file but **consumes all three**, so it lands last. ### Dependencies & Sequencing 1. **Contract text first.** It fixes the state enum, the response fields and the required/gated classification that every other area encodes. Nothing else should start against a guess. 2. Then, in parallel: shared tier · glab-cli · local-fs · promote text · lint. 3. **Probe last** — it asserts the composed behaviour end to end. `scripts/lint-conventions.sh`'s Kind check depends on the contract's table shape, so it follows step 1 too. ## Constraints & Non-Goals **Constraints:** - Contract changes **additive within v1** — new optional input, new response fields, new state values. A required new input or a removed field forces v2 and is out of bounds. - **No new runtime dependencies.** Helper tier: bash ≥ 3.2, `jq`, `git`, POSIX utilities. Skill-emitted glue: POSIX only — no bashisms, no `python`/`node`, no zsh-reserved variable names. - **A gate's output goes to a file; its exit code comes from the command, never a pipeline.** This is what the current `glab` call violates and is part of what is being fixed. - The shared-tier fix must be **adapter-neutral** so it survives the Forgejo → GitHub migration untouched. - Forge mutations route through `_shared/procedures/bin/*` and the active adapter — never inline forge CLI calls. **Non-goals (do NOT build):** - `pipeline_status` for **`tea-cli`** — the Forgejo instance is being decommissioned. Known and accepted consequence: a `tea-cli` project that *does* run Gitea Actions gets a stated `unsupported` and its real pipelines go unwatched, recorded in the Phase Outcome. - **`gh-cli`'s release operations** — all six are missing; filed as sibling **#69**, which depends on this. - A **general retry/resume protocol** for transient API errors — that is **#46**, untouched. The query-failure handling here is scoped to the pipeline poll. - A **project declaration for legitimately unpiped refs** — rejected in the PREQ: new schema surface, a readiness item and a parseability contract for a case nobody has hit. - **Probing the repository for CI configuration** (`.gitea/workflows`, `.github/workflows`, `.gitlab-ci.yml`) — hardcodes per-forge paths into stack-agnostic shared code and would answer wrongly for config no runner picks up. - **Stub files** for every capability-gated op on every adapter. - **Changing how a release is triggered** — that is **#60**. - **Auditing the other primitive dispatches** for correctness beyond existence. - **Retitling #47** — the contract has no title-edit primitive; the body carries the folded scope.
Author
Owner

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-plan 2.6b against the SREQ — scenarios are
PREQ-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, a11y and security-browser not applicable, so e2e-browser is not
assignable 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:

  • The shell test harness (plugin/skills/**/test/*.sh, plugin/skills/_shared/procedures/test/*.sh) —
    confirmed by running _shared/procedures/test/release-flow.sh, which passes green, offline, in this
    worktree. The capability cases were additionally confirmed executable offline by reproducing #55 itself
    (exit 127 against a tea-cli project, no network touched).
  • A person running a dogfood /dev:promote — because no harness executes skill markdown. This is a
    markdown+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 is human-uat, owner: the operator, on a
dogfood release run. Seven of the fourteen fall on each side. Where a human-uat scenario has a
mechanically-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.

One honest caveat, stated rather than hidden. plugin/skills/_shared/procedures/test/pipeline-identity.sh
does not exist yet — it is a deliverable of this slice, backed by acceptance criteria in the SREQ. Its
executor (bash + jq over the local-fs simulation) is confirmed working today; only the file is pending.
A lane naming a suite that will never exist would be a facade; this one is a commitment the AC table holds.

Prerequisites

The state these scenarios need. Concrete environment details (instance URLs, tokens, seed commands)
belong to the UAT card, not here.

  • A project that declares a release: block, so /dev:promote will actually run its monitor step.
  • An adapter whose pipeline state is scriptable — one where a tester can put a chosen pipeline
    state, commit and count in place deliberately, rather than waiting for real CI to produce one.
  • An adapter that can report pipelines against a real forge, for the cases that only a live CI
    system can produce (a genuine red, a retried pipeline, a merge-request pipeline).
  • An adapter that cannot report pipelines at all — the capability-boundary case.
  • The ability to make a forge unreachable or unauthenticated for the duration of a poll.
  • The forge contract document, for the enumerable documentation check.

Required Test Data

  • Two commits on the release ref: an earlier one that already has a finished, successful pipeline,
    and a later one that is the commit actually being released. This pair is the whole of #47.
  • One commit with no pipeline at all.
  • One commit with several pipelines: at least a first attempt and a retry; where the forge supports
    it, also a branch pipeline alongside a merge-request pipeline.
  • Pipelines that can be placed in each non-terminal and non-binary state the contract names —
    pending, running, canceled, and whatever the forge calls manual/blocked and skipped.
  • One commit whose pipeline genuinely fails.
  • An adapter installation with a required primitive removed, distinct from an adapter that simply
    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.sh

  1. Arrange the release ref so its most recent pipeline is a successful one belonging to an earlier
    commit — the previous release's — and push a later commit that is the one being released.
  2. Ask for the pipeline status of that release.
  3. Verify: the answer states a commit, and that commit is the one being released.
  4. Verify: the answer does not report the earlier commit's success. If the later commit's pipeline is
    still 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:promote is about to tag, when the run reaches the tag gate, then no tag is placed and a blocking
decision is escalated naming both commits.
Lane: human-uat — the operator, on a dogfood /dev:promote release 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 by plugin/skills/_shared/procedures/test/pipeline-identity.sh.

  1. Arrange for the pipeline answer to state a commit other than the one being released — for example
    by scripting the scriptable adapter to return a successful pipeline labelled with the earlier commit.
  2. Run the release through to the tag gate.
  3. Verify: no tag is created.
  4. Verify: a blocking decision is raised, and its text names both commits — the one the answer was
    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:promote release 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 by plugin/skills/_shared/procedures/test/pipeline-identity.sh.

  1. Arrange a commit with a first pipeline that failed and a later retry that succeeded.
  2. Ask for its pipeline status.
  3. Verify: the answer describes the retry — the most recent — not the earlier failure.
  4. Where the forge produces both a branch pipeline and a merge-request pipeline for one commit, repeat
    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:promote run: the continued waiting and the poll record are promote's flow. The "two kinds of nothing are different answers" half is asserted by plugin/skills/_shared/procedures/test/pipeline-identity.sh.

  1. Arrange a commit on an adapter that can report pipelines, with no pipeline for that commit.
  2. Start the release and watch the monitor.
  3. Verify: the monitor keeps polling — a poll record appears naming the commit and how long it has been
    waiting, so a tester can tell waiting apart from hung.
  4. Verify: the run does not proceed to the deploy check as though there were no CI, and does not
    place a tag.
  5. While it is still waiting, put a successful pipeline in place for that commit and verify the monitor
    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:promote run with a short declared ci_timeout.

  1. Declare a short ci_timeout so the case is executable.
  2. Arrange a commit that never gets a pipeline, and start the release.
  3. Verify: the run escalates once the declared bound elapses — measured from when the wait began, not from
    the merge or the first poll.
  4. Verify: the escalation names the commit it was waiting for, and says it timed out rather than
    reporting a pipeline state.
  5. While the wait is in progress, push a further commit to the ref. Verify the run is still waiting on —
    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 yields unknown + raw_state. Which monitor branch each maps onto is desk-checked against /dev:promote Step 5's branch table.

  1. For each such state the forge can produce, put a pipeline for the released commit into that state.
  2. Run the monitor against it.
  3. Verify: for each one, the run does something the PREQ names — waits, escalates, or proceeds — and says
    which state it saw.
  4. Verify: none of them produces an unexplained fall-through, a silent proceed, or an answer that does not
    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_timeout bound, and if it
never 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 yield query_failed, never none and never a verdict. The retry-then-escalate half rides the same dogfood run as Scenario 5.

  1. Start a release where the commit's pipeline is running, then make the forge unreachable (or invalidate
    credentials) while the monitor is polling.
  2. Verify: the monitor reports a failure to ask — not success, not failed, and not "no pipeline".
  3. Verify: it retries rather than escalating on the first occurrence.
  4. Restore access while still inside the bound, and verify the run continues normally and reaches the real
    pipeline verdict.
  5. Repeat, leaving the forge unreachable past the bound. Verify the escalation names the query failure
    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:promote runs 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 was
skipped and why.
Lane: human-uat — the operator, on a dogfood /dev:promote run against a tea-cli project: "proceeds to the deploy check and records the skip in the Phase Outcome" is promote's flow. The stated unsupported answer itself is asserted by plugin/skills/_shared/procedures/test/pipeline-identity.sh.

  1. Run a release on a project whose adapter ships no pipeline reporting.
  2. Verify: the monitor step ends promptly with a stated answer that the adapter cannot report pipelines
    — not an error, and not a wait.
  3. Verify: the run proceeds to the required deploy check rather than escalating a blocking decision.
  4. Verify: the run's Phase Outcome records that the pipeline gate was skipped, and why.

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.sh

  1. On the same project as Scenario 8, capture everything the monitor step produces.
  2. Verify: the message names the adapter and the fact that it cannot report pipelines.
  3. Verify: no "No such file or directory", no line-number reference, and no path to an internal helper
    file appears anywhere in what the operator or the calling skill sees.
  4. Verify: the exit status is one that says "answered: unsupported", distinguishable from a crash.

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.sh

  1. Remove a required primitive from an adapter installation (one the contract does not class as
    capability-gated).
  2. Invoke a skill that uses it.
  3. Verify: the run fails, and the message names the adapter, the primitive, and where it looked.
  4. Verify: it is not reported as unsupported, not degraded into a "no", and does not let the calling
    skill proceed as though it had an answer.
  5. Contrast directly with Scenario 8/9: confirm a tester can tell the two situations apart from the output
    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.

  1. Enumerate every primitive the contract lists.
  2. For each, read the contract for two facts: is it required or capability-gated, and what must an adapter
    that cannot provide it answer?
  3. Verify: both facts are present for every primitive — no entry leaves either to inference.

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:promote run. The helper-side half (failed and canceled surface unchanged, with the commit stated) is asserted by plugin/skills/_shared/procedures/test/pipeline-identity.sh, and the existing plugin/skills/_shared/procedures/test/release-flow.sh continues to assert the failure path.

  1. Arrange a failing pipeline for the exact commit being released. Run the release.
  2. Verify: the run escalates, no tag is placed, and the escalation identifies the failure.
  3. Repeat with a pipeline that is canceled for that commit.
  4. Verify: it likewise escalates rather than being read as green, as absent, or as still-waiting.

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.

  1. Stage the sequence on the scriptable adapter: an earlier commit with a finished successful pipeline,
    then a merge putting a new commit at the head of the release ref, then an immediate status query.
  2. Run the feature's regression probe.
  3. Verify: the probe does not return the earlier commit's success as the answer for the new head.
  4. Verify: the probe is repeatable — a second run gives the same result without manual re-staging.
  5. Separately, verify the same sequence against a live forge on an adapter that queries a real CI
    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.sh

  1. Run the same release flow twice: once on an adapter that can report pipelines against a commit with no
    pipeline yet, and once on an adapter that cannot report pipelines at all.
  2. Verify: the two produce different answers.
  3. Verify: the first waits; the second proceeds to the deploy check.
  4. Verify: a tester reading only the output can say which situation they are in.

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:

PREQ criterion Scenario(s)
Stale pipeline belongs to an earlier commit 1, 13
Answer's commit differs from the tag target 2
Commit with more than one pipeline 3
No pipeline for this commit yet 4, 14
Wait reaches ci_timeout 5
Neither success, failure, nor absent 6
Forge unreachable / auth fails / API error 7
Adapter cannot report pipelines 8, 14
Clean surface on the capability boundary 9
Required primitive missing 10
Contract states kind + required answer per primitive 11
Genuine failure and cancellation still escalate 12
Merge-then-query regression probe 13

Backward — 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

  • No browser surface. This project declares e2e, a11y and security-browser not applicable; every
    scenario above is executed against the suite's own helpers and a forge, not a rendered UI.
  • Scenario 11 is a documentation enumeration, not a running check. It is observable and exhaustive, so
    it is a legitimate validation case rather than an implementation-necessity exception.
  • Scenarios 3, 6, 7 and the second half of 13 need a live forge — retried and merge-request pipelines,
    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 is
    narrower 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 real
    multi-pipeline shapes and Scenario 13's step 5 genuinely need live GitLab, and both ride one dogfood
    release run.
  • The GitLab pipelines API was captured against the real forge at plan time (/dev:technical-plan
    2.2b → external-contracts/gitlab-pipelines.provenance.json). Two facts recorded there change what these
    scenarios 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 pinned
    ordering 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 this
    plan — it was not derivable from the PREQ.
  • Scenario 5 requires a short declared ci_timeout to be executable at all; that is a project
    declaration, not an environment detail.
<!-- test-plan:v1 issue=47 skill=technical-plan --> # 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-plan` 2.6b** against the SREQ — scenarios are PREQ-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`, `a11y` and `security-browser` **not applicable**, so **`e2e-browser` is not assignable 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**: - **The shell test harness** (`plugin/skills/**/test/*.sh`, `plugin/skills/_shared/procedures/test/*.sh`) — confirmed by running `_shared/procedures/test/release-flow.sh`, which passes green, offline, in this worktree. The capability cases were additionally confirmed executable offline by reproducing #55 itself (exit 127 against a `tea-cli` project, no network touched). - **A person running a dogfood `/dev:promote`** — because **no harness executes skill markdown**. This is a markdown+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** is `human-uat`, owner: the operator, on a dogfood release run. Seven of the fourteen fall on each side. Where a `human-uat` scenario has a mechanically-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. > **One honest caveat, stated rather than hidden.** `plugin/skills/_shared/procedures/test/pipeline-identity.sh` > **does not exist yet** — it is a deliverable of this slice, backed by acceptance criteria in the SREQ. Its > *executor* (bash + jq over the `local-fs` simulation) is confirmed working today; only the file is pending. > A lane naming a suite that will never exist would be a facade; this one is a commitment the AC table holds. ## Prerequisites The *state* these scenarios need. Concrete environment details (instance URLs, tokens, seed commands) belong to the UAT card, not here. - [ ] A project that declares a `release:` block, so `/dev:promote` will actually run its monitor step. - [ ] An adapter whose pipeline state is **scriptable** — one where a tester can put a chosen pipeline state, commit and count in place deliberately, rather than waiting for real CI to produce one. - [ ] An adapter that **can** report pipelines against a real forge, for the cases that only a live CI system can produce (a genuine red, a retried pipeline, a merge-request pipeline). - [ ] An adapter that **cannot** report pipelines at all — the capability-boundary case. - [ ] The ability to make a forge unreachable or unauthenticated for the duration of a poll. - [ ] The forge contract document, for the enumerable documentation check. ### Required Test Data - [ ] Two commits on the release ref: an **earlier** one that already has a finished, successful pipeline, and a **later** one that is the commit actually being released. This pair is the whole of #47. - [ ] One commit with **no** pipeline at all. - [ ] One commit with **several** pipelines: at least a first attempt and a retry; where the forge supports it, also a branch pipeline alongside a merge-request pipeline. - [ ] Pipelines that can be placed in each non-terminal and non-binary state the contract names — `pending`, `running`, `canceled`, and whatever the forge calls manual/blocked and skipped. - [ ] One commit whose pipeline genuinely **fails**. - [ ] An adapter installation with a **required** primitive removed, distinct from an adapter that simply 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.sh` 1. Arrange the release ref so its most recent pipeline is a **successful** one belonging to an **earlier** commit — the previous release's — and push a later commit that is the one being released. 2. Ask for the pipeline status of that release. 3. Verify: the answer **states a commit**, and that commit is the one being released. 4. Verify: the answer does **not** report the earlier commit's `success`. If the later commit's pipeline is still 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:promote` is about to tag, when the run reaches the tag gate, then no tag is placed and a blocking decision is escalated naming both commits. **Lane:** human-uat — the operator, on a dogfood `/dev:promote` release 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 by `plugin/skills/_shared/procedures/test/pipeline-identity.sh`. 1. Arrange for the pipeline answer to state a commit **other** than the one being released — for example by scripting the scriptable adapter to return a successful pipeline labelled with the earlier commit. 2. Run the release through to the tag gate. 3. Verify: **no tag is created.** 4. Verify: a blocking decision is raised, and its text names **both** commits — the one the answer was 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:promote` release 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 by `plugin/skills/_shared/procedures/test/pipeline-identity.sh`. 1. Arrange a commit with a first pipeline that **failed** and a later retry that **succeeded**. 2. Ask for its pipeline status. 3. Verify: the answer describes the **retry** — the most recent — not the earlier failure. 4. Where the forge produces both a branch pipeline and a merge-request pipeline for one commit, repeat 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:promote` run: the continued waiting and the poll record are promote's flow. The "two kinds of nothing are different answers" half is asserted by `plugin/skills/_shared/procedures/test/pipeline-identity.sh`. 1. Arrange a commit on an adapter that **can** report pipelines, with no pipeline for that commit. 2. Start the release and watch the monitor. 3. Verify: the monitor **keeps polling** — a poll record appears naming the commit and how long it has been waiting, so a tester can tell waiting apart from hung. 4. Verify: the run does **not** proceed to the deploy check as though there were no CI, and does **not** place a tag. 5. While it is still waiting, put a successful pipeline in place for that commit and verify the monitor 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:promote` run with a short declared `ci_timeout`. 1. Declare a short `ci_timeout` so the case is executable. 2. Arrange a commit that never gets a pipeline, and start the release. 3. Verify: the run escalates once the declared bound elapses — measured from when the wait began, not from the merge or the first poll. 4. Verify: the escalation names the **commit** it was waiting for, and says it timed out rather than reporting a pipeline state. 5. While the wait is in progress, push a further commit to the ref. Verify the run is still waiting on — 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 yields `unknown` + `raw_state`. Which monitor branch each maps onto is desk-checked against `/dev:promote` Step 5's branch table. 1. For **each** such state the forge can produce, put a pipeline for the released commit into that state. 2. Run the monitor against it. 3. Verify: for each one, the run does something the PREQ names — waits, escalates, or proceeds — and says which state it saw. 4. Verify: none of them produces an unexplained fall-through, a silent proceed, or an answer that does not 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_timeout` bound, and if it never 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 yield `query_failed`, never `none` and never a verdict. The retry-then-escalate half rides the same dogfood run as Scenario 5. 1. Start a release where the commit's pipeline is running, then make the forge unreachable (or invalidate credentials) while the monitor is polling. 2. Verify: the monitor reports a **failure to ask** — not `success`, not `failed`, and not "no pipeline". 3. Verify: it **retries** rather than escalating on the first occurrence. 4. Restore access while still inside the bound, and verify the run continues normally and reaches the real pipeline verdict. 5. Repeat, leaving the forge unreachable past the bound. Verify the escalation names the **query failure** 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:promote` runs 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 was skipped and why. **Lane:** human-uat — the operator, on a dogfood `/dev:promote` run against a `tea-cli` project: "proceeds to the deploy check and records the skip in the Phase Outcome" is promote's flow. The stated `unsupported` answer itself is asserted by `plugin/skills/_shared/procedures/test/pipeline-identity.sh`. 1. Run a release on a project whose adapter ships no pipeline reporting. 2. Verify: the monitor step ends promptly with a **stated** answer that the adapter cannot report pipelines — not an error, and not a wait. 3. Verify: the run **proceeds to the required deploy check** rather than escalating a blocking decision. 4. Verify: the run's Phase Outcome records that the pipeline gate was skipped, and why. **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.sh` 1. On the same project as Scenario 8, capture everything the monitor step produces. 2. Verify: the message names the **adapter** and the fact that it cannot report pipelines. 3. Verify: **no** "No such file or directory", no line-number reference, and no path to an internal helper file appears anywhere in what the operator or the calling skill sees. 4. Verify: the exit status is one that says "answered: unsupported", distinguishable from a crash. **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.sh` 1. Remove a **required** primitive from an adapter installation (one the contract does not class as capability-gated). 2. Invoke a skill that uses it. 3. Verify: the run **fails**, and the message names the adapter, the primitive, and where it looked. 4. Verify: it is **not** reported as `unsupported`, not degraded into a "no", and does not let the calling skill proceed as though it had an answer. 5. Contrast directly with Scenario 8/9: confirm a tester can tell the two situations apart from the output 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. 1. Enumerate every primitive the contract lists. 2. For each, read the contract for two facts: is it required or capability-gated, and what must an adapter that cannot provide it answer? 3. Verify: **both** facts are present for **every** primitive — no entry leaves either to inference. **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:promote` run. The helper-side half (`failed` and `canceled` surface unchanged, with the commit stated) is asserted by `plugin/skills/_shared/procedures/test/pipeline-identity.sh`, and the existing `plugin/skills/_shared/procedures/test/release-flow.sh` continues to assert the failure path. 1. Arrange a **failing** pipeline for the exact commit being released. Run the release. 2. Verify: the run escalates, no tag is placed, and the escalation identifies the failure. 3. Repeat with a pipeline that is **canceled** for that commit. 4. Verify: it likewise escalates rather than being read as green, as absent, or as still-waiting. **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. 1. Stage the sequence on the scriptable adapter: an earlier commit with a finished successful pipeline, then a merge putting a new commit at the head of the release ref, then an immediate status query. 2. Run the feature's regression probe. 3. Verify: the probe does **not** return the earlier commit's `success` as the answer for the new head. 4. Verify: the probe is repeatable — a second run gives the same result without manual re-staging. 5. Separately, verify the same sequence against a **live** forge on an adapter that queries a real CI 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.sh` 1. Run the same release flow twice: once on an adapter that can report pipelines against a commit with no pipeline yet, and once on an adapter that cannot report pipelines at all. 2. Verify: the two produce **different** answers. 3. Verify: the first waits; the second proceeds to the deploy check. 4. Verify: a tester reading only the output can say which situation they are in. **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:** | PREQ criterion | Scenario(s) | |---|---| | Stale pipeline belongs to an earlier commit | 1, 13 | | Answer's commit differs from the tag target | 2 | | Commit with more than one pipeline | 3 | | No pipeline for this commit yet | 4, 14 | | Wait reaches `ci_timeout` | 5 | | Neither success, failure, nor absent | 6 | | Forge unreachable / auth fails / API error | 7 | | Adapter cannot report pipelines | 8, 14 | | Clean surface on the capability boundary | 9 | | Required primitive missing | 10 | | Contract states kind + required answer per primitive | 11 | | Genuine failure and cancellation still escalate | 12 | | Merge-then-query regression probe | 13 | **Backward — 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 - **No browser surface.** This project declares `e2e`, `a11y` and `security-browser` not applicable; every scenario above is executed against the suite's own helpers and a forge, not a rendered UI. - **Scenario 11 is a documentation enumeration**, not a running check. It is observable and exhaustive, so it is a legitimate validation case rather than an implementation-necessity exception. - **Scenarios 3, 6, 7 and the second half of 13 need a live forge** — retried and merge-request pipelines, 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 is narrower 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 real multi-pipeline shapes and Scenario 13's step 5 genuinely need live GitLab, and both ride one dogfood release run. - **The GitLab pipelines API was captured against the real forge at plan time** (`/dev:technical-plan` 2.2b → `external-contracts/gitlab-pipelines.provenance.json`). Two facts recorded there change what these scenarios 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 pinned ordering 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 this plan — it was not derivable from the PREQ. - **Scenario 5 requires a short declared `ci_timeout`** to be executable at all; that is a project declaration, not an environment detail.
Author
Owner
{
  "next_state": "developing",
  "produced": [
    {
      "kind": "sreq",
      "ref": "comment:1522",
      "summary": "SREQ for release-pipeline-status-attributable-answer (Tier 3)"
    },
    {
      "kind": "test-plan",
      "ref": "comment:1527",
      "summary": "Test plan for release-pipeline-status-attributable-answer — one+ scenario per acceptance criterion, lanes annotated"
    }
  ],
  "findings": [
    {
      "category": "out-of-scope",
      "summary": "Implementing pipeline_status for tea-cli",
      "reasoning": "Flagged as out of scope in the approved PREQ. The Forgejo instance is being decommissioned in favour of GitHub, so a primitive written against it is redundant on arrival. Known and accepted consequence, recorded in the PREQ: a tea-cli project that DOES run Gitea Actions now gets a stated `unsupported` and its real pipelines go unwatched, recorded only in the Phase Outcome. Recommending accept rather than a sibling issue because the PREQ rejects the work on its merits, not for capacity.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-47-2-1"
    },
    {
      "category": "out-of-scope",
      "summary": "Implementing gh-cli's six missing release operations (pr_create, pr_status, pr_merge, comment_on_pr, pipeline_status, release_create)",
      "reasoning": "Flagged as out of scope in the approved PREQ. gh-cli ships none of the six, so /dev:promote cannot run on GitHub at all — implementation work comparable in size to this whole feature, and doing its pipeline_status slice in isolation here would mean designing GitHub's release surface twice. ALREADY FILED as sibling #69, which depends on this feature rather than the reverse. Recommending accept because the follow-up ticket exists; spawning another would duplicate it.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-47-2-2"
    },
    {
      "category": "out-of-scope",
      "summary": "A general retry/resume protocol for transient API errors",
      "reasoning": "Flagged as out of scope in the approved PREQ. The query-failure handling in this slice is scoped to the pipeline poll; the suite-wide protocol is ALREADY FILED as #46 and is untouched here. Recommending accept because the follow-up ticket exists.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-47-2-3"
    },
    {
      "category": "out-of-scope",
      "summary": "A project declaration for legitimately unpiped refs (CI exists but skips this ref)",
      "reasoning": "Flagged as out of scope in the approved PREQ and rejected there on its merits: it is new schema surface, a readiness item and a parseability contract for a case nobody has hit. Such a project waits out ci_timeout once and escalates; if it recurs it earns its own issue. Recommending accept — filing a ticket now would pre-commit to schema the PREQ deliberately declined.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-47-2-4"
    },
    {
      "category": "out-of-scope",
      "summary": "Probing the repository for CI configuration (.gitea/workflows, .github/workflows, .gitlab-ci.yml) to decide whether CI exists",
      "reasoning": "Flagged as out of scope in the approved PREQ and rejected there on its merits: it hardcodes per-forge paths into stack-agnostic shared code, and config that no runner picks up would answer the question wrongly. The selected design keys 'no CI' off adapter capability alone, which is machine-decidable and needs no new schema. Recommending accept.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-47-2-5"
    },
    {
      "category": "out-of-scope",
      "summary": "Stub files for every capability-gated op on every adapter as the mechanism for declaring a boundary",
      "reasoning": "Flagged as out of scope in the approved PREQ — twelve stubs across two adapters, six of them for the instance being retired. The selected design makes this unnecessary rather than merely deferred: absence-on-disk IS the declaration, so there is nothing left for a stub to say. Recommending accept.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-47-2-6"
    },
    {
      "category": "out-of-scope",
      "summary": "Changing how a release is triggered (the delegation hook for a project-owned release command)",
      "reasoning": "Flagged as out of scope in the approved PREQ. ALREADY FILED as #60 and untouched here. Recommending accept because the follow-up ticket exists.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-47-2-7"
    },
    {
      "category": "out-of-scope",
      "summary": "Auditing the other primitive dispatches for correctness beyond existence",
      "reasoning": "Flagged as out of scope in the approved PREQ: this feature makes an absent primitive answerable, but does not review what each PRESENT primitive returns. Unlike the other out-of-scope items this one has no sibling issue and was not rejected on its merits — it is genuinely unexamined work of the same family as #47 (an output is trustworthy only once you can tell what it is an output about). Recommending defer-to-issue.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "id": "F-PO-47-2-8"
    },
    {
      "category": "out-of-scope",
      "summary": "Retitling #47 so the title reflects the folded #47+#55 scope",
      "reasoning": "Flagged as out of scope in the approved PREQ: the forge contract has no title-edit primitive (§2.2) and inlining a native forge call to do it is the helper-bypass antipattern. The issue body carries the folded scope instead. Recommending accept — the alternative is either a contract addition nobody has asked for or a deliberate convention violation.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-47-2-9"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "pr_status.pipeline_state gains no commit attribution in this slice",
      "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). pr_status also returns a pipeline_state drawn from the same enum, and /dev:promote's branch-pr path consumes it. It INHERITS the fall-through fix for free (it shares GL_JQ_PIPEMAP, which this slice rewrites with no catch-all), but stays ref/head-scoped rather than commit-attributed. Partially covered by this slice and partially real follow-up work; the PREQ scopes the feature to the monitor primitive. Proposing defer-to-issue for the attribution half.",
      "proposed_action": "defer-to-issue",
      "id": "F-PO-47-2-10"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "lint-conventions.sh's 'gate piped into a filter' check covers only skill markdown, not the helper tier",
      "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). The check that would have caught `_gl_api ... | jq '.[0]'` in glab-cli/bin/pipeline_status.sh runs over Tier 1 (shell fences in skill markdown) only; the helper tier is exempt from it, which is exactly why that antipattern shipped in a bin/*.sh and stayed. This slice fixes the one instance it trips over; extending the lint to bin/*.sh so the class cannot recur is larger than this slice and would surface unrelated hits across 117 helper scripts. Proposing defer-to-issue.",
      "proposed_action": "defer-to-issue",
      "id": "F-PO-47-2-11"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "Seven of the fourteen validation scenarios route to human-uat because no harness executes skill markdown",
      "reasoning": "Recorded as a lane fact by /dev:technical-plan 2.6b, not as a defect. Every criterion asserting /dev:promote's own flow (the tag gate, the wait, the poll record, the timeout bound, the deploy-check handoff, the no-regression escalations) has a person as its executor, because /dev:promote Step 5 is prose an agent follows rather than code a runner runs. The scenario set does NOT collapse to human-uat — seven are mechanically covered, and the environment runs the feature's primary surface (the helper tier) fine — so this is a non-blocking lane note, not a blocking precondition. Accepted as a stated property of a markdown+shell suite; the honest alternative would have been routing skill-flow criteria to a lane that cannot execute them, which is the facade 2.6b exists to prevent.",
      "proposed_action": "accept",
      "id": "F-PO-47-2-12"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "CLAUDE.md's Verification section says 'There is no unit suite' while nine shell test suites exist and pass",
      "reasoning": "Found while confirming lane executability, not raised by the panel — plugin/skills/_shared/procedures/test/*.sh (8 suites) and the adapter test/ trees exist and run, and release-flow.sh passes green offline. CLAUDE.md's Verification block names only scripts/lint-conventions.sh and states there is no unit suite, so a reader routing coverage from the project declaration alone would conclude the integration-covered lane has no executor here — the precise misreading that produces a false human-uat lane assignment. Documentation drift in the project's own declaration, outside this feature's file set and belonging to the meta lane. Proposing defer-to-issue.",
      "proposed_action": "defer-to-issue",
      "id": "F-PO-47-2-13"
    }
  ],
  "pending_decisions": [
    {
      "id": "D-PO-47-2-1",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Implementing pipeline_status for tea-cli'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-47-2-1",
      "reasoning": "Flagged as out of scope in the approved PREQ. The Forgejo instance is being decommissioned in favour of GitHub, so a primitive written against it is redundant on arrival. Known and accepted consequence, recorded in the PREQ: a tea-cli project that DOES run Gitea Actions now gets a stated `unsupported` and its real pipelines go unwatched, recorded only in the Phase Outcome. Recommending accept rather than a sibling issue because the PREQ rejects the work on its merits, not for capacity."
    },
    {
      "id": "D-PO-47-2-2",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Implementing gh-cli's six missing release operations (pr_create, pr_status, pr_merge, comment_on_pr, pipeline_status, release_create)'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-47-2-2",
      "reasoning": "Flagged as out of scope in the approved PREQ. gh-cli ships none of the six, so /dev:promote cannot run on GitHub at all — implementation work comparable in size to this whole feature, and doing its pipeline_status slice in isolation here would mean designing GitHub's release surface twice. ALREADY FILED as sibling #69, which depends on this feature rather than the reverse. Recommending accept because the follow-up ticket exists; spawning another would duplicate it."
    },
    {
      "id": "D-PO-47-2-3",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'A general retry/resume protocol for transient API errors'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-47-2-3",
      "reasoning": "Flagged as out of scope in the approved PREQ. The query-failure handling in this slice is scoped to the pipeline poll; the suite-wide protocol is ALREADY FILED as #46 and is untouched here. Recommending accept because the follow-up ticket exists."
    },
    {
      "id": "D-PO-47-2-4",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'A project declaration for legitimately unpiped refs (CI exists but skips this ref)'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-47-2-4",
      "reasoning": "Flagged as out of scope in the approved PREQ and rejected there on its merits: it is new schema surface, a readiness item and a parseability contract for a case nobody has hit. Such a project waits out ci_timeout once and escalates; if it recurs it earns its own issue. Recommending accept — filing a ticket now would pre-commit to schema the PREQ deliberately declined."
    },
    {
      "id": "D-PO-47-2-5",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Probing the repository for CI configuration (.gitea/workflows, .github/workflows, .gitlab-ci.yml) to decide whether CI exists'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-47-2-5",
      "reasoning": "Flagged as out of scope in the approved PREQ and rejected there on its merits: it hardcodes per-forge paths into stack-agnostic shared code, and config that no runner picks up would answer the question wrongly. The selected design keys 'no CI' off adapter capability alone, which is machine-decidable and needs no new schema. Recommending accept."
    },
    {
      "id": "D-PO-47-2-6",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Stub files for every capability-gated op on every adapter as the mechanism for declaring a boundary'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-47-2-6",
      "reasoning": "Flagged as out of scope in the approved PREQ — twelve stubs across two adapters, six of them for the instance being retired. The selected design makes this unnecessary rather than merely deferred: absence-on-disk IS the declaration, so there is nothing left for a stub to say. Recommending accept."
    },
    {
      "id": "D-PO-47-2-7",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Changing how a release is triggered (the delegation hook for a project-owned release command)'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-47-2-7",
      "reasoning": "Flagged as out of scope in the approved PREQ. ALREADY FILED as #60 and untouched here. Recommending accept because the follow-up ticket exists."
    },
    {
      "id": "D-PO-47-2-8",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Auditing the other primitive dispatches for correctness beyond existence'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-47-2-8",
      "reasoning": "Flagged as out of scope in the approved PREQ: this feature makes an absent primitive answerable, but does not review what each PRESENT primitive returns. Unlike the other out-of-scope items this one has no sibling issue and was not rejected on its merits — it is genuinely unexamined work of the same family as #47 (an output is trustworthy only once you can tell what it is an output about). Recommending defer-to-issue."
    },
    {
      "id": "D-PO-47-2-9",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Retitling #47 so the title reflects the folded #47+#55 scope'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-47-2-9",
      "reasoning": "Flagged as out of scope in the approved PREQ: the forge contract has no title-edit primitive (§2.2) and inlining a native forge call to do it is the helper-bypass antipattern. The issue body carries the folded scope instead. Recommending accept — the alternative is either a contract addition nobody has asked for or a deliberate convention violation."
    },
    {
      "id": "D-PO-47-2-10",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Expert-review note (not actioned): 'pr_status.pipeline_state gains no commit attribution in this slice'. Spawn a sibling issue, or accept the disposition?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-47-2-10",
      "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). pr_status also returns a pipeline_state drawn from the same enum, and /dev:promote's branch-pr path consumes it. It INHERITS the fall-through fix for free (it shares GL_JQ_PIPEMAP, which this slice rewrites with no catch-all), but stays ref/head-scoped rather than commit-attributed. Partially covered by this slice and partially real follow-up work; the PREQ scopes the feature to the monitor primitive. Proposing defer-to-issue for the attribution half."
    },
    {
      "id": "D-PO-47-2-11",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Expert-review note (not actioned): 'lint-conventions.sh's 'gate piped into a filter' check covers only skill markdown, not the helper tier'. Spawn a sibling issue, or accept the disposition?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-47-2-11",
      "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). The check that would have caught `_gl_api ... | jq '.[0]'` in glab-cli/bin/pipeline_status.sh runs over Tier 1 (shell fences in skill markdown) only; the helper tier is exempt from it, which is exactly why that antipattern shipped in a bin/*.sh and stayed. This slice fixes the one instance it trips over; extending the lint to bin/*.sh so the class cannot recur is larger than this slice and would surface unrelated hits across 117 helper scripts. Proposing defer-to-issue."
    },
    {
      "id": "D-PO-47-2-12",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Expert-review note (not actioned): 'Seven of the fourteen validation scenarios route to human-uat because no harness executes skill markdown'. Spawn a sibling issue, or accept the disposition?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-47-2-12",
      "reasoning": "Recorded as a lane fact by /dev:technical-plan 2.6b, not as a defect. Every criterion asserting /dev:promote's own flow (the tag gate, the wait, the poll record, the timeout bound, the deploy-check handoff, the no-regression escalations) has a person as its executor, because /dev:promote Step 5 is prose an agent follows rather than code a runner runs. The scenario set does NOT collapse to human-uat — seven are mechanically covered, and the environment runs the feature's primary surface (the helper tier) fine — so this is a non-blocking lane note, not a blocking precondition. Accepted as a stated property of a markdown+shell suite; the honest alternative would have been routing skill-flow criteria to a lane that cannot execute them, which is the facade 2.6b exists to prevent."
    },
    {
      "id": "D-PO-47-2-13",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Expert-review note (not actioned): 'CLAUDE.md's Verification section says 'There is no unit suite' while nine shell test suites exist and pass'. Spawn a sibling issue, or accept the disposition?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-47-2-13",
      "reasoning": "Found while confirming lane executability, not raised by the panel — plugin/skills/_shared/procedures/test/*.sh (8 suites) and the adapter test/ trees exist and run, and release-flow.sh passes green offline. CLAUDE.md's Verification block names only scripts/lint-conventions.sh and states there is no unit suite, so a reader routing coverage from the project declaration alone would conclude the integration-covered lane has no executor here — the precise misreading that produces a false human-uat lane assignment. Documentation drift in the project's own declaration, outside this feature's file set and belonging to the meta lane. Proposing defer-to-issue."
    }
  ],
  "suite": {
    "source": "git",
    "sha": "2ceacfe8490eb7efb2258c1d62981eea06acb97b",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-47-2 skill=technical-plan --> ```json { "next_state": "developing", "produced": [ { "kind": "sreq", "ref": "comment:1522", "summary": "SREQ for release-pipeline-status-attributable-answer (Tier 3)" }, { "kind": "test-plan", "ref": "comment:1527", "summary": "Test plan for release-pipeline-status-attributable-answer — one+ scenario per acceptance criterion, lanes annotated" } ], "findings": [ { "category": "out-of-scope", "summary": "Implementing pipeline_status for tea-cli", "reasoning": "Flagged as out of scope in the approved PREQ. The Forgejo instance is being decommissioned in favour of GitHub, so a primitive written against it is redundant on arrival. Known and accepted consequence, recorded in the PREQ: a tea-cli project that DOES run Gitea Actions now gets a stated `unsupported` and its real pipelines go unwatched, recorded only in the Phase Outcome. Recommending accept rather than a sibling issue because the PREQ rejects the work on its merits, not for capacity.", "proposed_action": "accept", "target": null, "id": "F-PO-47-2-1" }, { "category": "out-of-scope", "summary": "Implementing gh-cli's six missing release operations (pr_create, pr_status, pr_merge, comment_on_pr, pipeline_status, release_create)", "reasoning": "Flagged as out of scope in the approved PREQ. gh-cli ships none of the six, so /dev:promote cannot run on GitHub at all — implementation work comparable in size to this whole feature, and doing its pipeline_status slice in isolation here would mean designing GitHub's release surface twice. ALREADY FILED as sibling #69, which depends on this feature rather than the reverse. Recommending accept because the follow-up ticket exists; spawning another would duplicate it.", "proposed_action": "accept", "target": null, "id": "F-PO-47-2-2" }, { "category": "out-of-scope", "summary": "A general retry/resume protocol for transient API errors", "reasoning": "Flagged as out of scope in the approved PREQ. The query-failure handling in this slice is scoped to the pipeline poll; the suite-wide protocol is ALREADY FILED as #46 and is untouched here. Recommending accept because the follow-up ticket exists.", "proposed_action": "accept", "target": null, "id": "F-PO-47-2-3" }, { "category": "out-of-scope", "summary": "A project declaration for legitimately unpiped refs (CI exists but skips this ref)", "reasoning": "Flagged as out of scope in the approved PREQ and rejected there on its merits: it is new schema surface, a readiness item and a parseability contract for a case nobody has hit. Such a project waits out ci_timeout once and escalates; if it recurs it earns its own issue. Recommending accept — filing a ticket now would pre-commit to schema the PREQ deliberately declined.", "proposed_action": "accept", "target": null, "id": "F-PO-47-2-4" }, { "category": "out-of-scope", "summary": "Probing the repository for CI configuration (.gitea/workflows, .github/workflows, .gitlab-ci.yml) to decide whether CI exists", "reasoning": "Flagged as out of scope in the approved PREQ and rejected there on its merits: it hardcodes per-forge paths into stack-agnostic shared code, and config that no runner picks up would answer the question wrongly. The selected design keys 'no CI' off adapter capability alone, which is machine-decidable and needs no new schema. Recommending accept.", "proposed_action": "accept", "target": null, "id": "F-PO-47-2-5" }, { "category": "out-of-scope", "summary": "Stub files for every capability-gated op on every adapter as the mechanism for declaring a boundary", "reasoning": "Flagged as out of scope in the approved PREQ — twelve stubs across two adapters, six of them for the instance being retired. The selected design makes this unnecessary rather than merely deferred: absence-on-disk IS the declaration, so there is nothing left for a stub to say. Recommending accept.", "proposed_action": "accept", "target": null, "id": "F-PO-47-2-6" }, { "category": "out-of-scope", "summary": "Changing how a release is triggered (the delegation hook for a project-owned release command)", "reasoning": "Flagged as out of scope in the approved PREQ. ALREADY FILED as #60 and untouched here. Recommending accept because the follow-up ticket exists.", "proposed_action": "accept", "target": null, "id": "F-PO-47-2-7" }, { "category": "out-of-scope", "summary": "Auditing the other primitive dispatches for correctness beyond existence", "reasoning": "Flagged as out of scope in the approved PREQ: this feature makes an absent primitive answerable, but does not review what each PRESENT primitive returns. Unlike the other out-of-scope items this one has no sibling issue and was not rejected on its merits — it is genuinely unexamined work of the same family as #47 (an output is trustworthy only once you can tell what it is an output about). Recommending defer-to-issue.", "proposed_action": "defer-to-issue", "target": null, "id": "F-PO-47-2-8" }, { "category": "out-of-scope", "summary": "Retitling #47 so the title reflects the folded #47+#55 scope", "reasoning": "Flagged as out of scope in the approved PREQ: the forge contract has no title-edit primitive (§2.2) and inlining a native forge call to do it is the helper-bypass antipattern. The issue body carries the folded scope instead. Recommending accept — the alternative is either a contract addition nobody has asked for or a deliberate convention violation.", "proposed_action": "accept", "target": null, "id": "F-PO-47-2-9" }, { "category": "in-scope-deferrable", "summary": "pr_status.pipeline_state gains no commit attribution in this slice", "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). pr_status also returns a pipeline_state drawn from the same enum, and /dev:promote's branch-pr path consumes it. It INHERITS the fall-through fix for free (it shares GL_JQ_PIPEMAP, which this slice rewrites with no catch-all), but stays ref/head-scoped rather than commit-attributed. Partially covered by this slice and partially real follow-up work; the PREQ scopes the feature to the monitor primitive. Proposing defer-to-issue for the attribution half.", "proposed_action": "defer-to-issue", "id": "F-PO-47-2-10" }, { "category": "in-scope-deferrable", "summary": "lint-conventions.sh's 'gate piped into a filter' check covers only skill markdown, not the helper tier", "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). The check that would have caught `_gl_api ... | jq '.[0]'` in glab-cli/bin/pipeline_status.sh runs over Tier 1 (shell fences in skill markdown) only; the helper tier is exempt from it, which is exactly why that antipattern shipped in a bin/*.sh and stayed. This slice fixes the one instance it trips over; extending the lint to bin/*.sh so the class cannot recur is larger than this slice and would surface unrelated hits across 117 helper scripts. Proposing defer-to-issue.", "proposed_action": "defer-to-issue", "id": "F-PO-47-2-11" }, { "category": "in-scope-deferrable", "summary": "Seven of the fourteen validation scenarios route to human-uat because no harness executes skill markdown", "reasoning": "Recorded as a lane fact by /dev:technical-plan 2.6b, not as a defect. Every criterion asserting /dev:promote's own flow (the tag gate, the wait, the poll record, the timeout bound, the deploy-check handoff, the no-regression escalations) has a person as its executor, because /dev:promote Step 5 is prose an agent follows rather than code a runner runs. The scenario set does NOT collapse to human-uat — seven are mechanically covered, and the environment runs the feature's primary surface (the helper tier) fine — so this is a non-blocking lane note, not a blocking precondition. Accepted as a stated property of a markdown+shell suite; the honest alternative would have been routing skill-flow criteria to a lane that cannot execute them, which is the facade 2.6b exists to prevent.", "proposed_action": "accept", "id": "F-PO-47-2-12" }, { "category": "in-scope-deferrable", "summary": "CLAUDE.md's Verification section says 'There is no unit suite' while nine shell test suites exist and pass", "reasoning": "Found while confirming lane executability, not raised by the panel — plugin/skills/_shared/procedures/test/*.sh (8 suites) and the adapter test/ trees exist and run, and release-flow.sh passes green offline. CLAUDE.md's Verification block names only scripts/lint-conventions.sh and states there is no unit suite, so a reader routing coverage from the project declaration alone would conclude the integration-covered lane has no executor here — the precise misreading that produces a false human-uat lane assignment. Documentation drift in the project's own declaration, outside this feature's file set and belonging to the meta lane. Proposing defer-to-issue.", "proposed_action": "defer-to-issue", "id": "F-PO-47-2-13" } ], "pending_decisions": [ { "id": "D-PO-47-2-1", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Implementing pipeline_status for tea-cli'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-47-2-1", "reasoning": "Flagged as out of scope in the approved PREQ. The Forgejo instance is being decommissioned in favour of GitHub, so a primitive written against it is redundant on arrival. Known and accepted consequence, recorded in the PREQ: a tea-cli project that DOES run Gitea Actions now gets a stated `unsupported` and its real pipelines go unwatched, recorded only in the Phase Outcome. Recommending accept rather than a sibling issue because the PREQ rejects the work on its merits, not for capacity." }, { "id": "D-PO-47-2-2", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Implementing gh-cli's six missing release operations (pr_create, pr_status, pr_merge, comment_on_pr, pipeline_status, release_create)'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-47-2-2", "reasoning": "Flagged as out of scope in the approved PREQ. gh-cli ships none of the six, so /dev:promote cannot run on GitHub at all — implementation work comparable in size to this whole feature, and doing its pipeline_status slice in isolation here would mean designing GitHub's release surface twice. ALREADY FILED as sibling #69, which depends on this feature rather than the reverse. Recommending accept because the follow-up ticket exists; spawning another would duplicate it." }, { "id": "D-PO-47-2-3", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'A general retry/resume protocol for transient API errors'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-47-2-3", "reasoning": "Flagged as out of scope in the approved PREQ. The query-failure handling in this slice is scoped to the pipeline poll; the suite-wide protocol is ALREADY FILED as #46 and is untouched here. Recommending accept because the follow-up ticket exists." }, { "id": "D-PO-47-2-4", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'A project declaration for legitimately unpiped refs (CI exists but skips this ref)'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-47-2-4", "reasoning": "Flagged as out of scope in the approved PREQ and rejected there on its merits: it is new schema surface, a readiness item and a parseability contract for a case nobody has hit. Such a project waits out ci_timeout once and escalates; if it recurs it earns its own issue. Recommending accept — filing a ticket now would pre-commit to schema the PREQ deliberately declined." }, { "id": "D-PO-47-2-5", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Probing the repository for CI configuration (.gitea/workflows, .github/workflows, .gitlab-ci.yml) to decide whether CI exists'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-47-2-5", "reasoning": "Flagged as out of scope in the approved PREQ and rejected there on its merits: it hardcodes per-forge paths into stack-agnostic shared code, and config that no runner picks up would answer the question wrongly. The selected design keys 'no CI' off adapter capability alone, which is machine-decidable and needs no new schema. Recommending accept." }, { "id": "D-PO-47-2-6", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Stub files for every capability-gated op on every adapter as the mechanism for declaring a boundary'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-47-2-6", "reasoning": "Flagged as out of scope in the approved PREQ — twelve stubs across two adapters, six of them for the instance being retired. The selected design makes this unnecessary rather than merely deferred: absence-on-disk IS the declaration, so there is nothing left for a stub to say. Recommending accept." }, { "id": "D-PO-47-2-7", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Changing how a release is triggered (the delegation hook for a project-owned release command)'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-47-2-7", "reasoning": "Flagged as out of scope in the approved PREQ. ALREADY FILED as #60 and untouched here. Recommending accept because the follow-up ticket exists." }, { "id": "D-PO-47-2-8", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Auditing the other primitive dispatches for correctness beyond existence'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-47-2-8", "reasoning": "Flagged as out of scope in the approved PREQ: this feature makes an absent primitive answerable, but does not review what each PRESENT primitive returns. Unlike the other out-of-scope items this one has no sibling issue and was not rejected on its merits — it is genuinely unexamined work of the same family as #47 (an output is trustworthy only once you can tell what it is an output about). Recommending defer-to-issue." }, { "id": "D-PO-47-2-9", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Retitling #47 so the title reflects the folded #47+#55 scope'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-47-2-9", "reasoning": "Flagged as out of scope in the approved PREQ: the forge contract has no title-edit primitive (§2.2) and inlining a native forge call to do it is the helper-bypass antipattern. The issue body carries the folded scope instead. Recommending accept — the alternative is either a contract addition nobody has asked for or a deliberate convention violation." }, { "id": "D-PO-47-2-10", "type": "scope-disposition", "blocking": false, "question": "Expert-review note (not actioned): 'pr_status.pipeline_state gains no commit attribution in this slice'. Spawn a sibling issue, or accept the disposition?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-47-2-10", "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). pr_status also returns a pipeline_state drawn from the same enum, and /dev:promote's branch-pr path consumes it. It INHERITS the fall-through fix for free (it shares GL_JQ_PIPEMAP, which this slice rewrites with no catch-all), but stays ref/head-scoped rather than commit-attributed. Partially covered by this slice and partially real follow-up work; the PREQ scopes the feature to the monitor primitive. Proposing defer-to-issue for the attribution half." }, { "id": "D-PO-47-2-11", "type": "scope-disposition", "blocking": false, "question": "Expert-review note (not actioned): 'lint-conventions.sh's 'gate piped into a filter' check covers only skill markdown, not the helper tier'. Spawn a sibling issue, or accept the disposition?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-47-2-11", "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). The check that would have caught `_gl_api ... | jq '.[0]'` in glab-cli/bin/pipeline_status.sh runs over Tier 1 (shell fences in skill markdown) only; the helper tier is exempt from it, which is exactly why that antipattern shipped in a bin/*.sh and stayed. This slice fixes the one instance it trips over; extending the lint to bin/*.sh so the class cannot recur is larger than this slice and would surface unrelated hits across 117 helper scripts. Proposing defer-to-issue." }, { "id": "D-PO-47-2-12", "type": "scope-disposition", "blocking": false, "question": "Expert-review note (not actioned): 'Seven of the fourteen validation scenarios route to human-uat because no harness executes skill markdown'. Spawn a sibling issue, or accept the disposition?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-47-2-12", "reasoning": "Recorded as a lane fact by /dev:technical-plan 2.6b, not as a defect. Every criterion asserting /dev:promote's own flow (the tag gate, the wait, the poll record, the timeout bound, the deploy-check handoff, the no-regression escalations) has a person as its executor, because /dev:promote Step 5 is prose an agent follows rather than code a runner runs. The scenario set does NOT collapse to human-uat — seven are mechanically covered, and the environment runs the feature's primary surface (the helper tier) fine — so this is a non-blocking lane note, not a blocking precondition. Accepted as a stated property of a markdown+shell suite; the honest alternative would have been routing skill-flow criteria to a lane that cannot execute them, which is the facade 2.6b exists to prevent." }, { "id": "D-PO-47-2-13", "type": "scope-disposition", "blocking": false, "question": "Expert-review note (not actioned): 'CLAUDE.md's Verification section says 'There is no unit suite' while nine shell test suites exist and pass'. Spawn a sibling issue, or accept the disposition?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-47-2-13", "reasoning": "Found while confirming lane executability, not raised by the panel — plugin/skills/_shared/procedures/test/*.sh (8 suites) and the adapter test/ trees exist and run, and release-flow.sh passes green offline. CLAUDE.md's Verification block names only scripts/lint-conventions.sh and states there is no unit suite, so a reader routing coverage from the project declaration alone would conclude the integration-covered lane has no executor here — the precise misreading that produces a false human-uat lane assignment. Documentation drift in the project's own declaration, outside this feature's file set and belonging to the meta lane. Proposing defer-to-issue." } ], "suite": { "source": "git", "sha": "2ceacfe8490eb7efb2258c1d62981eea06acb97b", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "The PREQ rejects this on its merits, not for capacity: the Forgejo instance is being decommissioned in favour of GitHub, so a tea-cli pipeline_status would be redundant on arrival. The consequence is not silently dropped — it is written into the PREQ's Out of Scope section and into the SREQ's Non-goals: a tea-cli project that does run Gitea Actions gets a stated `unsupported` and its real pipelines go unwatched, recorded in the Phase Outcome. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "Spawning a sibling issue to build it later — declined because it would queue work against an instance that is being retired, which is how a backlog accumulates items nobody will ever action."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-1 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "The PREQ rejects this on its merits, not for capacity: the Forgejo instance is being decommissioned in favour of GitHub, so a tea-cli pipeline_status would be redundant on arrival. The consequence is not silently dropped — it is written into the PREQ's Out of Scope section and into the SREQ's Non-goals: a tea-cli project that does run Gitea Actions gets a stated `unsupported` and its real pipelines go unwatched, recorded in the Phase Outcome. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "Spawning a sibling issue to build it later — declined because it would queue work against an instance that is being retired, which is how a backlog accumulates items nobody will ever action." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "The follow-up ticket already exists: sibling #69 carries all six of gh-cli's missing release operations (pr_create, pr_status, pr_merge, comment_on_pr, pipeline_status, release_create), and the PREQ records that #69 depends on this feature rather than the reverse. Spawning another issue would duplicate it. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "defer-to-issue — declined only because it would create a second ticket for work #69 already tracks; the work itself is not being dropped."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-2 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "The follow-up ticket already exists: sibling #69 carries all six of gh-cli's missing release operations (pr_create, pr_status, pr_merge, comment_on_pr, pipeline_status, release_create), and the PREQ records that #69 depends on this feature rather than the reverse. Spawning another issue would duplicate it. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "defer-to-issue — declined only because it would create a second ticket for work #69 already tracks; the work itself is not being dropped." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "The follow-up ticket already exists: #46 is the suite-wide transient-error retry/resume protocol, and the PREQ explicitly scopes this slice's query-failure handling to the pipeline poll while leaving #46 untouched. Spawning another issue would duplicate it. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "defer-to-issue — declined only because #46 already tracks the general protocol."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-3 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "The follow-up ticket already exists: #46 is the suite-wide transient-error retry/resume protocol, and the PREQ explicitly scopes this slice's query-failure handling to the pipeline poll while leaving #46 untouched. Spawning another issue would duplicate it. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "defer-to-issue — declined only because #46 already tracks the general protocol." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "The PREQ considered and rejected this on its merits: a project declaration for legitimately unpiped refs is new schema surface, a new readiness item and a new parseability contract, for a case that has never occurred. The selected design gives such a project a defined behaviour anyway — it waits out ci_timeout once and escalates — so nothing is unhandled. Filing a ticket now would pre-commit to schema the PREQ deliberately declined; if the case ever occurs it earns its own issue then, with a real instance behind it. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "defer-to-issue — declined because a speculative schema ticket with no instance behind it is exactly the \"new forge features don't drive contract growth\" pattern forge-contract §2.3 forbids."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-4 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "The PREQ considered and rejected this on its merits: a project declaration for legitimately unpiped refs is new schema surface, a new readiness item and a new parseability contract, for a case that has never occurred. The selected design gives such a project a defined behaviour anyway — it waits out ci_timeout once and escalates — so nothing is unhandled. Filing a ticket now would pre-commit to schema the PREQ deliberately declined; if the case ever occurs it earns its own issue then, with a real instance behind it. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "defer-to-issue — declined because a speculative schema ticket with no instance behind it is exactly the \"new forge features don't drive contract growth\" pattern forge-contract §2.3 forbids." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "The PREQ rejected this on its merits and the project's own authoring conventions independently forbid it: probing .gitea/workflows / .github/workflows / .gitlab-ci.yml hardcodes per-forge paths into stack-agnostic shared code, which is the litmus test CLAUDE.md applies to every stack-touching detail. It is also wrong on the facts — CI config that no runner picks up would answer the question incorrectly. The selected design keys \"no CI\" off adapter capability alone, which is machine-decidable and needs no new schema. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "defer-to-issue — declined because the approach is rejected, not deferred; a ticket would imply it is still on the table."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-5 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "The PREQ rejected this on its merits and the project's own authoring conventions independently forbid it: probing .gitea/workflows / .github/workflows / .gitlab-ci.yml hardcodes per-forge paths into stack-agnostic shared code, which is the litmus test CLAUDE.md applies to every stack-touching detail. It is also wrong on the facts — CI config that no runner picks up would answer the question incorrectly. The selected design keys \"no CI\" off adapter capability alone, which is machine-decidable and needs no new schema. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "defer-to-issue — declined because the approach is rejected, not deferred; a ticket would imply it is still on the table." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "The selected design makes this unnecessary rather than merely deferred, which is a stronger reason to close it than the PREQ's original one. Absence-on-disk IS the declaration: the dispatch classifies a missing primitive by consulting the contract's required/capability-gated set, so there is nothing left for a stub file to say. The PREQ's own objection (twelve stubs across two adapters, six of them for the instance being retired) still stands on top of that. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "defer-to-issue — declined because the mechanism was designed out, not postponed; a ticket would track work that can no longer be needed."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-6 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "The selected design makes this unnecessary rather than merely deferred, which is a stronger reason to close it than the PREQ's original one. Absence-on-disk IS the declaration: the dispatch classifies a missing primitive by consulting the contract's required/capability-gated set, so there is nothing left for a stub file to say. The PREQ's own objection (twelve stubs across two adapters, six of them for the instance being retired) still stands on top of that. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "defer-to-issue — declined because the mechanism was designed out, not postponed; a ticket would track work that can no longer be needed." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "The follow-up ticket already exists: #60 is the delegation hook for a project-owned release command (this repo's own CLAUDE.md documents the gap and notes the `command:` field is currently read by the operator, not by the skill). Spawning another issue would duplicate it. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "defer-to-issue — declined only because #60 already tracks it."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-7 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "The follow-up ticket already exists: #60 is the delegation hook for a project-owned release command (this repo's own CLAUDE.md documents the gap and notes the `command:` field is currently read by the operator, not by the skill). Spawning another issue would duplicate it. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "defer-to-issue — declined only because #60 already tracks it." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Checked against the contract: forge-contract §2.2's adapter-facing table has no title-edit primitive — it carries edit_issue_body but nothing for the title — so retitling would require either a contract addition nobody has requested or an inline native forge call, which is the helper-bypass antipattern this repo's conventions explicitly forbid. The PREQ carries the folded #47+#55 scope in the issue body instead, which is the scope of record. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "defer-to-issue (a ticket to add a title-edit primitive to the contract) — declined because §2.3 says the contract grows when a skill genuinely needs an operation, and cosmetic retitling of one folded issue is not that need."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-9 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Checked against the contract: forge-contract §2.2's adapter-facing table has no title-edit primitive — it carries edit_issue_body but nothing for the title — so retitling would require either a contract addition nobody has requested or an inline native forge call, which is the helper-bypass antipattern this repo's conventions explicitly forbid. The PREQ carries the folded #47+#55 scope in the issue body instead, which is the scope of record. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "defer-to-issue (a ticket to add a title-edit primitive to the contract) — declined because §2.3 says the contract grows when a skill genuinely needs an operation, and cosmetic retitling of one folded issue is not that need." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "This is a recorded lane fact, not deferrable work. It was established by direct check during 2.6b rather than assumed: /dev:promote Step 5 is prose an agent follows, and no harness in this repo executes skill markdown, so every criterion asserting promote's own flow has a person as its executor. The scenario set does not collapse to human-uat — seven of fourteen are mechanically covered by a harness confirmed working in this worktree (release-flow.sh passes green offline), and the feature's primary surface, the helper tier, runs here fine — so this is non-blocking, not a precondition. The honest alternative would have been routing skill-flow criteria to a lane that cannot execute them, which is precisely the facade 2.6b exists to prevent. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "defer-to-issue (a ticket to build a harness that executes skill markdown) — declined as far larger than this finding and not something this feature's evidence justifies proposing; if it is ever wanted it should start from its own problem statement."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-12 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "This is a recorded lane fact, not deferrable work. It was established by direct check during 2.6b rather than assumed: /dev:promote Step 5 is prose an agent follows, and no harness in this repo executes skill markdown, so every criterion asserting promote's own flow has a person as its executor. The scenario set does not collapse to human-uat — seven of fourteen are mechanically covered by a harness confirmed working in this worktree (release-flow.sh passes green offline), and the feature's primary surface, the helper tier, runs here fine — so this is non-blocking, not a precondition. The honest alternative would have been routing skill-flow criteria to a lane that cannot execute them, which is precisely the facade 2.6b exists to prevent. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "defer-to-issue (a ticket to build a harness that executes skill markdown) — declined as far larger than this finding and not something this feature's evidence justifies proposing; if it is ever wanted it should start from its own problem statement." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "The other primitive dispatches have never been checked for what they RETURN, only for whether they exist — and this feature is direct evidence the gap is real: pipeline_status was present, dispatched fine, and returned an answer about the wrong commit for months. Unlike the other out-of-scope items on this issue, this one was not rejected on its merits and has no sibling tracking it, so accepting it would drop genuinely unexamined work. There is already at least one known instance in the tree (#18: issue-link.sh silently downgrades an unsupported relation), which is the same shape one layer over. Spawning rather than folding into #18: #18 is a specific instance, and folding the general audit into it would make a single-bug ticket look like the topical home and attract further wrong folds. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "accept — declined because nothing else tracks this and the feature that raised it is itself the proof that dispatch-returns-the-right-thing is unverified."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-8 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "The other primitive dispatches have never been checked for what they RETURN, only for whether they exist — and this feature is direct evidence the gap is real: pipeline_status was present, dispatched fine, and returned an answer about the wrong commit for months. Unlike the other out-of-scope items on this issue, this one was not rejected on its merits and has no sibling tracking it, so accepting it would drop genuinely unexamined work. There is already at least one known instance in the tree (#18: issue-link.sh silently downgrades an unsupported relation), which is the same shape one layer over. Spawning rather than folding into #18: #18 is a specific instance, and folding the general audit into it would make a single-bug ticket look like the topical home and attract further wrong folds. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "accept — declined because nothing else tracks this and the feature that raised it is itself the proof that dispatch-returns-the-right-thing is unverified." } ```
Author
Owner

Linked: this issue is sibling #103 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #103 (recorded by the devwork pipeline).
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "pr_status returns a pipeline_state drawn from the same enum and /dev:promote's branch-pr path gates on it, so the identical false-green mechanism #47 documents exists on that path — an answer about a head pipeline with no commit a caller can check. It inherits this slice's fall-through fix for free (it shares GL_JQ_PIPEMAP, rewritten here with no catch-all), which is precisely why the remaining gap needs its own ticket rather than an accept: half of it silently got fixed, and the unfixed half would otherwise look done. The PREQ scopes this feature to the monitor primitive, so it cannot be widened here. No eligible candidate covers pr_status or commit attribution, so this spawns rather than folds. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "accept — declined because the attribution gap on the branch-pr path is a real instance of the very defect this feature exists to fix, just on the path the PREQ did not scope."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-10 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "pr_status returns a pipeline_state drawn from the same enum and /dev:promote's branch-pr path gates on it, so the identical false-green mechanism #47 documents exists on that path — an answer about a head pipeline with no commit a caller can check. It inherits this slice's fall-through fix for free (it shares GL_JQ_PIPEMAP, rewritten here with no catch-all), which is precisely why the remaining gap needs its own ticket rather than an accept: half of it silently got fixed, and the unfixed half would otherwise look done. The PREQ scopes this feature to the monitor primitive, so it cannot be widened here. No eligible candidate covers pr_status or commit attribution, so this spawns rather than folds. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "accept — declined because the attribution gap on the branch-pr path is a real instance of the very defect this feature exists to fix, just on the path the PREQ did not scope." } ```
Author
Owner

Linked: this issue is sibling #104 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #104 (recorded by the devwork pipeline).
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "The lint check that exists to catch a gate piped into a filter runs over Tier 1 only (shell fences in skill markdown), and this feature is the proof that the exemption costs something real: glab-cli/bin/pipeline_status.sh shipped that exact antipattern in a bin/*.sh and the gate never saw it. CLAUDE.md states the rule for the whole suite, so a check that enforces it in one tier and not the other is the gap, not the rule. This slice fixes the single instance it trips over; making the class unrepeatable means extending the population to 117 helper scripts, which will surface unrelated hits that need triage of their own — real work, larger than this slice, and it would derail the feature to absorb. Spawning rather than folding into #67: #67 is also about scripts/lint-conventions.sh but is a different defect (a new check line-regexes instead of reusing the fence tracker), and sharing a file is a weak match — folding would make #67 look like the lint-conventions collector and attract further wrong folds. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "accept — declined because the exemption has now demonstrably let the very antipattern it names ship into the helper tier, so leaving it untracked means accepting a known recurrence path."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-11 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "The lint check that exists to catch a gate piped into a filter runs over Tier 1 only (shell fences in skill markdown), and this feature is the proof that the exemption costs something real: glab-cli/bin/pipeline_status.sh shipped that exact antipattern in a bin/*.sh and the gate never saw it. CLAUDE.md states the rule for the whole suite, so a check that enforces it in one tier and not the other is the gap, not the rule. This slice fixes the single instance it trips over; making the class unrepeatable means extending the population to 117 helper scripts, which will surface unrelated hits that need triage of their own — real work, larger than this slice, and it would derail the feature to absorb. Spawning rather than folding into #67: #67 is also about scripts/lint-conventions.sh but is a different defect (a new check line-regexes instead of reusing the fence tracker), and sharing a file is a weak match — folding would make #67 look like the lint-conventions collector and attract further wrong folds. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "accept — declined because the exemption has now demonstrably let the very antipattern it names ship into the helper tier, so leaving it untracked means accepting a known recurrence path." } ```
Author
Owner

Linked: this issue is sibling #105 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #105 (recorded by the devwork pipeline).
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "This is checkable and currently wrong: CLAUDE.md's Verification block names only scripts/lint-conventions.sh and states there is no unit suite, while plugin/skills/_shared/procedures/test/ holds eight suites and the adapter test/ trees hold more — release-flow.sh was run during this planning phase and passes green offline. The drift is not cosmetic: 2.6b routes coverage by asking which executors exist in this environment, and a reader taking the project's own declaration at face value would conclude the integration-covered lane has no executor and route mechanically-checkable criteria to human-uat. That is the false-lane facade 2.6b exists to prevent, produced by the project's own documentation. It sits outside this feature's file set and belongs to the meta lane, so it cannot be fixed here. No eligible candidate covers CLAUDE.md accuracy, so this spawns rather than folds. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.",
  "rejected_alternative": "accept — declined because the drift actively misroutes lane assignment, which is a correctness consequence rather than a documentation nicety."
}
<!-- decision-resolution:v1 ref=D-PO-47-2-13 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "This is checkable and currently wrong: CLAUDE.md's Verification block names only scripts/lint-conventions.sh and states there is no unit suite, while plugin/skills/_shared/procedures/test/ holds eight suites and the adapter test/ trees hold more — release-flow.sh was run during this planning phase and passes green offline. The drift is not cosmetic: 2.6b routes coverage by asking which executors exist in this environment, and a reader taking the project's own declaration at face value would conclude the integration-covered lane has no executor and route mechanically-checkable criteria to human-uat. That is the false-lane facade 2.6b exists to prevent, produced by the project's own documentation. It sits outside this feature's file set and belongs to the meta lane, so it cannot be fixed here. No eligible candidate covers CLAUDE.md accuracy, so this spawns rather than folds. Resolved autonomously under the operator's explicit instruction in this session to resolve every decision with its recommended option.", "rejected_alternative": "accept — declined because the drift actively misroutes lane assignment, which is a correctness consequence rather than a documentation nicety." } ```
Author
Owner

Linked: this issue is sibling #106 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #106 (recorded by the devwork pipeline).
Author
Owner
{
  "waves": [
    {
      "wave": 1,
      "work_units": [
        {
          "id": "WU-47-3-1",
          "title": "Forge contract: Kind column, normative absence paragraph, grown pipeline_status signature + state enum; tea-cli/gh-cli capability manifests brought into line",
          "files": [
            "plugin/skills/_shared/forge-contract.md",
            "plugin/skills/tea-cli/SKILL.md",
            "plugin/skills/gh-cli/SKILL.md"
          ],
          "acceptance_criteria": [
            {"id": "AC-11", "scope": "the contract's own declaration; the mechanical check is WU-47-3-6"}
          ]
        }
      ]
    },
    {
      "wave": 2,
      "work_units": [
        {
          "id": "WU-47-3-2",
          "title": "Shared dispatch + skill-facing helper: _GATED_PRIMS, EXIT_UNSUPPORTED/EXIT_QUERY, _prim existence+executability classification, --sha validation and stated answers",
          "files": [
            "plugin/skills/_shared/procedures/bin/_lib.sh",
            "plugin/skills/_shared/procedures/bin/release-pipeline-status.sh"
          ],
          "acceptance_criteria": [
            {"id": "AC-9"},
            {"id": "AC-10"},
            {"id": "AC-15"},
            {"id": "AC-17"},
            {"id": "AC-18"}
          ]
        },
        {
          "id": "WU-47-3-3",
          "title": "local-fs adapter: sha-keyed pipeline records, commit/pipeline_id echo, query_failed staging, ref validation, old-fixture compatibility",
          "files": [
            "plugin/skills/local-fs/bin/pipeline_status.sh",
            "plugin/skills/local-fs/SKILL.md"
          ],
          "acceptance_criteria": [
            {"id": "AC-1", "scope": "scriptable-adapter half"},
            {"id": "AC-3", "scope": "staged-record ordering half"},
            {"id": "AC-13", "scope": "staging surface the probe drives"},
            {"id": "AC-17", "scope": "staging a success record with no commit"},
            {"id": "AC-20", "scope": "fixtures without a commit field parse as commit: null"}
          ]
        },
        {
          "id": "WU-47-3-4",
          "title": "glab-cli adapter: sha filter + commit label, rc-checked API call, classified query_failed, GL_JQ_PIPEMAP catch-all removal",
          "files": [
            "plugin/skills/glab-cli/bin/pipeline_status.sh",
            "plugin/skills/glab-cli/bin/_lib.sh",
            "plugin/skills/glab-cli/SKILL.md"
          ],
          "acceptance_criteria": [
            {"id": "AC-1", "scope": "live-forge half"},
            {"id": "AC-3", "scope": "live ordering key order_by=id&sort=desc&per_page=1"},
            {"id": "AC-6"},
            {"id": "AC-7", "scope": "adapter-side query_failed classification"},
            {"id": "AC-16"}
          ]
        },
        {
          "id": "WU-47-3-5",
          "title": "/dev:promote: pinned tag-target sha, pre-tag gate, identity assertion, exhaustive branch table, poll records, escalation content",
          "files": [
            "plugin/skills/promote/SKILL.md"
          ],
          "acceptance_criteria": [
            {"id": "AC-2"},
            {"id": "AC-4", "scope": "monitor wait + poll record half"},
            {"id": "AC-5"},
            {"id": "AC-8", "scope": "run proceeds to deploy_check; PO records the skipped gate"},
            {"id": "AC-12", "scope": "escalation path unchanged, commit stated"},
            {"id": "AC-19"}
          ]
        }
      ]
    },
    {
      "wave": 3,
      "work_units": [
        {
          "id": "WU-47-3-6",
          "title": "Repo lint: contract Kind-column coverage check + _GATED_PRIMS drift check in lint-conventions.sh",
          "files": [
            "scripts/lint-conventions.sh"
          ],
          "acceptance_criteria": [
            {"id": "AC-11", "scope": "mechanical half"},
            {"id": "AC-14"}
          ]
        }
      ]
    },
    {
      "wave": 4,
      "work_units": [
        {
          "id": "WU-47-3-7",
          "title": "Regression probe pipeline-identity.sh driving the real helpers through local-fs and a tea-cli temp project (no network)",
          "files": [
            "plugin/skills/_shared/procedures/test/pipeline-identity.sh"
          ],
          "acceptance_criteria": [
            {"id": "AC-1"},
            {"id": "AC-3"},
            {"id": "AC-4", "scope": "none vs unsupported are different answers with different exit codes"},
            {"id": "AC-6"},
            {"id": "AC-7", "scope": "query_failed never none, never a verdict"},
            {"id": "AC-9"},
            {"id": "AC-10"},
            {"id": "AC-12", "scope": "helper-side failed/canceled with commit stated"},
            {"id": "AC-13"},
            {"id": "AC-15"},
            {"id": "AC-16"},
            {"id": "AC-17"},
            {"id": "AC-18"},
            {"id": "AC-20", "scope": "release-flow.sh still passes unchanged"}
          ]
        }
      ]
    }
  ]
}

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-cli
capabilities: edits are documentation parity with the very paragraph being written, and both are
artifact-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.sh are one
unit because they share the exit-code vocabulary (EXIT_UNSUPPORTED=8 / EXIT_QUERY=9) and the SREQ says
so in terms. local-fs and glab-cli are disjoint file sets. promote/SKILL.md touches nothing the
others do.

Wave 3 — WU-47-3-6 (lint). Not in wave 2: its _GATED_PRIMS drift check reads both the contract's
Kind 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 ACs
are verified by desk-check and by the dogfood /dev:promote run, which is what the SREQ's verification
column 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/*.sh suites, and the three local-fs/test/*.sh suites — 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, plus gh-cli/test/smoke.sh) require live forge credentials or a scratch issue and
are red at baseline for environmental reasons; they are outside the offline gate.

<!-- wu-plan:v1 po=PO-47-3 skill=develop --> ```json { "waves": [ { "wave": 1, "work_units": [ { "id": "WU-47-3-1", "title": "Forge contract: Kind column, normative absence paragraph, grown pipeline_status signature + state enum; tea-cli/gh-cli capability manifests brought into line", "files": [ "plugin/skills/_shared/forge-contract.md", "plugin/skills/tea-cli/SKILL.md", "plugin/skills/gh-cli/SKILL.md" ], "acceptance_criteria": [ {"id": "AC-11", "scope": "the contract's own declaration; the mechanical check is WU-47-3-6"} ] } ] }, { "wave": 2, "work_units": [ { "id": "WU-47-3-2", "title": "Shared dispatch + skill-facing helper: _GATED_PRIMS, EXIT_UNSUPPORTED/EXIT_QUERY, _prim existence+executability classification, --sha validation and stated answers", "files": [ "plugin/skills/_shared/procedures/bin/_lib.sh", "plugin/skills/_shared/procedures/bin/release-pipeline-status.sh" ], "acceptance_criteria": [ {"id": "AC-9"}, {"id": "AC-10"}, {"id": "AC-15"}, {"id": "AC-17"}, {"id": "AC-18"} ] }, { "id": "WU-47-3-3", "title": "local-fs adapter: sha-keyed pipeline records, commit/pipeline_id echo, query_failed staging, ref validation, old-fixture compatibility", "files": [ "plugin/skills/local-fs/bin/pipeline_status.sh", "plugin/skills/local-fs/SKILL.md" ], "acceptance_criteria": [ {"id": "AC-1", "scope": "scriptable-adapter half"}, {"id": "AC-3", "scope": "staged-record ordering half"}, {"id": "AC-13", "scope": "staging surface the probe drives"}, {"id": "AC-17", "scope": "staging a success record with no commit"}, {"id": "AC-20", "scope": "fixtures without a commit field parse as commit: null"} ] }, { "id": "WU-47-3-4", "title": "glab-cli adapter: sha filter + commit label, rc-checked API call, classified query_failed, GL_JQ_PIPEMAP catch-all removal", "files": [ "plugin/skills/glab-cli/bin/pipeline_status.sh", "plugin/skills/glab-cli/bin/_lib.sh", "plugin/skills/glab-cli/SKILL.md" ], "acceptance_criteria": [ {"id": "AC-1", "scope": "live-forge half"}, {"id": "AC-3", "scope": "live ordering key order_by=id&sort=desc&per_page=1"}, {"id": "AC-6"}, {"id": "AC-7", "scope": "adapter-side query_failed classification"}, {"id": "AC-16"} ] }, { "id": "WU-47-3-5", "title": "/dev:promote: pinned tag-target sha, pre-tag gate, identity assertion, exhaustive branch table, poll records, escalation content", "files": [ "plugin/skills/promote/SKILL.md" ], "acceptance_criteria": [ {"id": "AC-2"}, {"id": "AC-4", "scope": "monitor wait + poll record half"}, {"id": "AC-5"}, {"id": "AC-8", "scope": "run proceeds to deploy_check; PO records the skipped gate"}, {"id": "AC-12", "scope": "escalation path unchanged, commit stated"}, {"id": "AC-19"} ] } ] }, { "wave": 3, "work_units": [ { "id": "WU-47-3-6", "title": "Repo lint: contract Kind-column coverage check + _GATED_PRIMS drift check in lint-conventions.sh", "files": [ "scripts/lint-conventions.sh" ], "acceptance_criteria": [ {"id": "AC-11", "scope": "mechanical half"}, {"id": "AC-14"} ] } ] }, { "wave": 4, "work_units": [ { "id": "WU-47-3-7", "title": "Regression probe pipeline-identity.sh driving the real helpers through local-fs and a tea-cli temp project (no network)", "files": [ "plugin/skills/_shared/procedures/test/pipeline-identity.sh" ], "acceptance_criteria": [ {"id": "AC-1"}, {"id": "AC-3"}, {"id": "AC-4", "scope": "none vs unsupported are different answers with different exit codes"}, {"id": "AC-6"}, {"id": "AC-7", "scope": "query_failed never none, never a verdict"}, {"id": "AC-9"}, {"id": "AC-10"}, {"id": "AC-12", "scope": "helper-side failed/canceled with commit stated"}, {"id": "AC-13"}, {"id": "AC-15"}, {"id": "AC-16"}, {"id": "AC-17"}, {"id": "AC-18"}, {"id": "AC-20", "scope": "release-flow.sh still passes unchanged"} ] } ] } ] } ``` ## 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-cli` `capabilities:` edits are documentation parity with the very paragraph being written, and both are artifact-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.sh` are one unit because they share the exit-code vocabulary (`EXIT_UNSUPPORTED=8` / `EXIT_QUERY=9`) and the SREQ says so in terms. `local-fs` and `glab-cli` are disjoint file sets. `promote/SKILL.md` touches nothing the others do. **Wave 3 — WU-47-3-6 (lint).** Not in wave 2: its `_GATED_PRIMS` drift check reads both the contract's Kind 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 ACs are verified by desk-check and by the dogfood `/dev:promote` run, which is what the SREQ's verification column 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/*.sh` suites, and the three `local-fs/test/*.sh` suites — 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`, plus `gh-cli/test/smoke.sh`) require live forge credentials or a scratch issue and are red at baseline for environmental reasons; they are outside the offline gate.
Author
Owner

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

type: discovery
timestamp: 2026-08-25T17:40
where: plugin/skills/_shared/forge-contract.md
tags: [contract, dispatch, doc-only]

Context: Writing the normative paragraph that says a required primitive MUST exist on disk and be
executable, with its absence a broken install.

Pattern: That claim is false as stated for bootstrap_project: no in-tree adapter ships a
bootstrap_project.sh — it is doc-only on all four (local-fs, glab-cli, tea-cli, gh-cli), executed
by the agent from its documented steps. The deferred wiki ops are the same. _prim in
${CLAUDE_PLUGIN_ROOT}/skills/_shared/procedures/bin/_lib.sh only ever dispatches a primitive by path, so a
doc-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_project to
the 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_project is required and
doc-only; pipeline_status is capability-gated and scriptable.

2026-08-25T17:52 DISCOVERY: the contract's own §4 example manifest omitted two required operations

type: discovery
timestamp: 2026-08-25T17:52
where: plugin/skills/_shared/forge-contract.md
tags: [contract, manifest, doc-drift, product-defect]

Context: Rewriting §4's prose to say a required op MUST be declared native or polyfill.

Pattern: §4's example capabilities: block listed neither close_issue nor reopen_issue, though both
were added as required operations in §2.2 (2026-06-19, the /dev:requirements fan-in flow) and all four
adapters 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 is
documentation, it drifts silently, and nothing mechanical catches it. The _GATED_PRIMS drift check
(AC-14, WU-47-3-6) covers the shared-tier copy of the classification; nothing checks an adapter's
capabilities: block against its own bin/ directory. Worth a Finding: a manifest-vs-disk parity check is
cheap (list bin/*.sh, diff against the operations: keys declared non-unsupported) and would have
caught this and the tea-cli/gh-cli omissions 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

type: discovery
timestamp: 2026-08-25T18:05
where: plugin/skills/_shared/forge-contract.md
tags: [contract, lint, machine-readable]

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) also
swallows the new pipeline_status state 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, 34
rows, 20 required, 14 capability-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-facing
operation names (release.pipeline_status), not primitives.

Why it matters: The §2.2-only capability-gated set is exactly eight, and this is what _GATED_PRIMS
must 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

type: complete
variant: development
timestamp: 2026-08-25T18:15
iterations: 1

Hardest part: Deciding where the normative text draws its boundaries — unsupported is a state value
in pipeline_status's answer but only a diagnostic + exit status for the other gated ops, which return no
state field 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:v1 wu=WU-47-3-1 skill=develop --> # 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 ```yaml type: discovery timestamp: 2026-08-25T17:40 where: plugin/skills/_shared/forge-contract.md tags: [contract, dispatch, doc-only] ``` **Context:** Writing the normative paragraph that says a `required` primitive MUST exist on disk and be executable, with its absence a broken install. **Pattern:** That claim is false as stated for `bootstrap_project`: **no** in-tree adapter ships a `bootstrap_project.sh` — it is doc-only on all four (`local-fs`, `glab-cli`, `tea-cli`, `gh-cli`), executed by the agent from its documented steps. The deferred wiki ops are the same. `_prim` in `${CLAUDE_PLUGIN_ROOT}/skills/_shared/procedures/bin/_lib.sh` only ever dispatches a primitive by path, so a doc-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_project` to the 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_project` is `required` and doc-only; `pipeline_status` is `capability-gated` and scriptable. ## 2026-08-25T17:52 DISCOVERY: the contract's own §4 example manifest omitted two required operations ```yaml type: discovery timestamp: 2026-08-25T17:52 where: plugin/skills/_shared/forge-contract.md tags: [contract, manifest, doc-drift, product-defect] ``` **Context:** Rewriting §4's prose to say a `required` op MUST be declared `native` or `polyfill`. **Pattern:** §4's example `capabilities:` block listed neither `close_issue` nor `reopen_issue`, though both were added as required operations in §2.2 (2026-06-19, the `/dev:requirements` fan-in flow) and all four adapters 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 is documentation, it drifts silently, and nothing mechanical catches it. The `_GATED_PRIMS` drift check (AC-14, WU-47-3-6) covers the shared-tier copy of the classification; **nothing** checks an adapter's `capabilities:` block against its own `bin/` directory. Worth a Finding: a manifest-vs-disk parity check is cheap (list `bin/*.sh`, diff against the `operations:` keys declared non-`unsupported`) and would have caught this and the `tea-cli`/`gh-cli` omissions 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 ```yaml type: discovery timestamp: 2026-08-25T18:05 where: plugin/skills/_shared/forge-contract.md tags: [contract, lint, machine-readable] ``` **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`) also swallows the new `pipeline_status` **state 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, 34 rows, 20 `required`, 14 `capability-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-facing operation names (`release.pipeline_status`), not primitives. **Why it matters:** The §2.2-only capability-gated set is exactly eight, and this is what `_GATED_PRIMS` must 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 ```yaml type: complete variant: development timestamp: 2026-08-25T18:15 iterations: 1 ``` **Hardest part:** Deciding where the normative text draws its boundaries — `unsupported` is a *state value* in `pipeline_status`'s answer but only a *diagnostic + exit status* for the other gated ops, which return no `state` field 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.
Author
Owner
{
  "outcome": "complete",
  "summary": "Forge contract declares required vs capability-gated for every operation (Kind column on all three tables, 34 rows), four normative paragraphs (broken-install vs unsupported, absence-on-disk is the declaration, emitting-layer discipline, scriptable vs doc-only), the grown pipeline_status signature and 11-state vocabulary with state authoritative over exit code; tea-cli and gh-cli capabilities blocks declare the six release ops unsupported as documentation parity. Artifact-shape WU; ACs verified by inspection plus the mechanical lint check WU-47-3-6 will add, so no behavioural tests and no Test Writer was spawned.",
  "commit_refs": ["7589341"],
  "journal_ref": "1626",
  "dependencies_satisfied": [],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-47-3-1 skill=develop --> ```json { "outcome": "complete", "summary": "Forge contract declares required vs capability-gated for every operation (Kind column on all three tables, 34 rows), four normative paragraphs (broken-install vs unsupported, absence-on-disk is the declaration, emitting-layer discipline, scriptable vs doc-only), the grown pipeline_status signature and 11-state vocabulary with state authoritative over exit code; tea-cli and gh-cli capabilities blocks declare the six release ops unsupported as documentation parity. Artifact-shape WU; ACs verified by inspection plus the mechanical lint check WU-47-3-6 will add, so no behavioural tests and no Test Writer was spawned.", "commit_refs": ["7589341"], "journal_ref": "1626", "dependencies_satisfied": [], "dependencies_violated": [] } ```
Author
Owner

Dev journal — WU-47-3-2 (shared dispatch + skill-facing pipeline helper)

2026-08-25T17:40 DISCOVERY: _die is an exit, so a helper that must TURN a classification into an answer has to dispatch in a subshell

type: discovery
timestamp: 2026-08-25T17:40
where: plugin/skills/_shared/procedures/bin/release-pipeline-status.sh
tags: [dispatch, exit-codes, set-e]

Context: _prim had to die with exit 8 on an absent capability-gated primitive (so every
other gated-op caller gets a clear 8 instead of a raw 127), while release-pipeline-status.sh
had 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:

rc=0
( _prim pipeline_status "${prim_args[@]}" ) > "$PRIM_OUT" 2> "$PRIM_ERR" || rc=$?

_die's exit then terminates only the subshell. A plain function call would have killed the
whole helper before it could emit anything, and set -e would have masked the difference. This is
also 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 caller
contracts — "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 a set -e landmine

type: discovery
timestamp: 2026-08-25T17:45
where: plugin/skills/_shared/procedures/bin/release-pipeline-status.sh
tags: [set-e, bash, portability]

Context: building the optional --sha argv for the primitive.

Pattern: written as [[ -n "$sha" ]] && prim_args+=(--sha "$sha") on its own line, the whole
statement returns 1 whenever $sha is empty, and set -euo pipefail kills the script on the
no---sha path
— the exact path release-flow.sh exercises. Written as an if block 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-fs adapter accepts --sha

type: discovery
timestamp: 2026-08-25T17:55
where: plugin/skills/local-fs/bin/pipeline_status.sh
tags: [cross-work-unit, sequencing, ac-17]

Context: AC-17 (--sha pinned + an answer with no commitunknown) is exercised through
the local-fs adapter with a staged {state:"success"} record.

Pattern: local-fs/bin/pipeline_status.sh at HEAD parses only --ref; anything else hits its
*) _die "$EXIT_USAGE" "unknown arg: $1" branch. So the dispatch gets rc 2 from the primitive and
correctly propagates it as a usage failure, not an answer — stdout is empty and .state is
"", 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 --sha would hide a stale
adapter and hand back the commit-blind answer this whole feature exists to remove. The forced-
unknown logic is implemented and exercised (verified by hand against a fixture whose primitive
does accept the flag); it goes green the moment WU-47-3-3's local-fs change lands. Reported to the
lead rather than worked around. Out of scope for this unit by the brief (local-fs/bin/ is another
unit's file).

2026-08-25T18:05 DISCOVERY: the dispatch also refuses an adapter-emitted unsupported

type: discovery
timestamp: 2026-08-25T18:05
where: plugin/skills/_shared/procedures/bin/release-pipeline-status.sh
tags: [layer-boundary, fail-safe]

Context: contract §2.2 states unsupported is dispatch-emitted only, and warns that a future
adapter author will "helpfully" emit it from inside a primitive.

Pattern: the helper reclassifies an adapter-emitted state:"unsupported" to unknown, keeps
the forge's word in raw_state, and logs the layer violation. unknown escalates, so the rogue
answer cannot make /dev:promote skip 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 unsupported from inside one is never a
true 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

type: complete
variant: development
timestamp: 2026-08-25T18:10
iterations: 2

Hardest part: getting one _prim classification to serve both "die loudly for every other gated
caller" 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-fs learning --sha was visible in the test
file from the start and would have been worth flagging to the lead in the first minute rather than
after the first full run.

<!-- dev-journal:v1 wu=WU-47-3-2 skill=develop --> # Dev journal — WU-47-3-2 (shared dispatch + skill-facing pipeline helper) ## 2026-08-25T17:40 DISCOVERY: `_die` is an `exit`, so a helper that must TURN a classification into an answer has to dispatch in a subshell ```yaml type: discovery timestamp: 2026-08-25T17:40 where: plugin/skills/_shared/procedures/bin/release-pipeline-status.sh tags: [dispatch, exit-codes, set-e] ``` **Context:** `_prim` had to die with exit 8 on an absent capability-gated primitive (so every *other* gated-op caller gets a clear 8 instead of a raw 127), while `release-pipeline-status.sh` had 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: rc=0 ( _prim pipeline_status "${prim_args[@]}" ) > "$PRIM_OUT" 2> "$PRIM_ERR" || rc=$? `_die`'s `exit` then terminates only the subshell. A plain function call would have killed the whole helper before it could emit anything, and `set -e` would have masked the difference. This is also 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 caller contracts — "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 a `set -e` landmine ```yaml type: discovery timestamp: 2026-08-25T17:45 where: plugin/skills/_shared/procedures/bin/release-pipeline-status.sh tags: [set-e, bash, portability] ``` **Context:** building the optional `--sha` argv for the primitive. **Pattern:** written as `[[ -n "$sha" ]] && prim_args+=(--sha "$sha")` on its own line, the whole statement returns 1 whenever `$sha` is empty, and `set -euo pipefail` kills the script *on the no-`--sha` path* — the exact path `release-flow.sh` exercises. Written as an `if` block 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-fs` adapter accepts `--sha` ```yaml type: discovery timestamp: 2026-08-25T17:55 where: plugin/skills/local-fs/bin/pipeline_status.sh tags: [cross-work-unit, sequencing, ac-17] ``` **Context:** AC-17 (`--sha` pinned + an answer with no `commit` ⇒ `unknown`) is exercised through the `local-fs` adapter with a staged `{state:"success"}` record. **Pattern:** `local-fs/bin/pipeline_status.sh` at HEAD parses only `--ref`; anything else hits its `*) _die "$EXIT_USAGE" "unknown arg: $1"` branch. So the dispatch gets rc 2 from the primitive and correctly propagates it as a **usage failure, not an answer** — stdout is empty and `.state` is `""`, 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 `--sha` would hide a stale adapter **and** hand back the commit-blind answer this whole feature exists to remove. The forced- `unknown` logic is implemented and exercised (verified by hand against a fixture whose primitive does accept the flag); it goes green the moment WU-47-3-3's `local-fs` change lands. Reported to the lead rather than worked around. Out of scope for this unit by the brief (`local-fs/bin/` is another unit's file). ## 2026-08-25T18:05 DISCOVERY: the dispatch also refuses an adapter-emitted `unsupported` ```yaml type: discovery timestamp: 2026-08-25T18:05 where: plugin/skills/_shared/procedures/bin/release-pipeline-status.sh tags: [layer-boundary, fail-safe] ``` **Context:** contract §2.2 states `unsupported` is dispatch-emitted only, and warns that a future adapter author will "helpfully" emit it from inside a primitive. **Pattern:** the helper reclassifies an adapter-emitted `state:"unsupported"` to `unknown`, keeps the forge's word in `raw_state`, and logs the layer violation. `unknown` escalates, so the rogue answer cannot make `/dev:promote` skip 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 `unsupported` from inside one is never a true 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 ```yaml type: complete variant: development timestamp: 2026-08-25T18:10 iterations: 2 ``` **Hardest part:** getting one `_prim` classification to serve both "die loudly for every other gated caller" 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-fs` learning `--sha` was visible in the test file from the start and would have been worth flagging to the lead in the first minute rather than after the first full run.
Author
Owner
{
  "outcome": "complete",
  "summary": "Shared dispatch classifies primitive absence at the point of dispatch (gated+absent -> exit 8 unsupported with no path in the message; gated+present+non-executable -> exit 5 broken install; required+absent-or-non-exec -> exit 5 naming adapter, primitive and search path), reserves EXIT_UNSUPPORTED=8/EXIT_QUERY=9, and mirrors the contract's eight capability-gated primitives in _GATED_PRIMS. The skill-facing helper validates --sha as full 40-hex, dispatches rc-checked to a file (never a pipeline), emits exactly one well-formed JSON answer, and forces state to unknown when a pinned --sha yields no commit. No exit 127 and no 'No such file or directory' reaches a caller. Probe stands at 18/19: AC-17 remains red because local-fs/bin/pipeline_status.sh does not yet parse --sha (it dies 'unknown arg', which the dispatch correctly propagates as a usage failure rather than an answer). That is WU-47-3-3's file; the helper-side forcing logic was hand-verified against a fixture adapter that accepts the flag, and the criterion clears when WU-47-3-3 lands. The Implementer deliberately did not work around it -- retrying without --sha would hide a stale adapter and restore the commit-blind answer this feature removes.",
  "commit_refs": ["661d8a7", "470baa5"],
  "journal_ref": "1692",
  "dependencies_satisfied": ["WU-47-3-1"],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-47-3-2 skill=develop --> ```json { "outcome": "complete", "summary": "Shared dispatch classifies primitive absence at the point of dispatch (gated+absent -> exit 8 unsupported with no path in the message; gated+present+non-executable -> exit 5 broken install; required+absent-or-non-exec -> exit 5 naming adapter, primitive and search path), reserves EXIT_UNSUPPORTED=8/EXIT_QUERY=9, and mirrors the contract's eight capability-gated primitives in _GATED_PRIMS. The skill-facing helper validates --sha as full 40-hex, dispatches rc-checked to a file (never a pipeline), emits exactly one well-formed JSON answer, and forces state to unknown when a pinned --sha yields no commit. No exit 127 and no 'No such file or directory' reaches a caller. Probe stands at 18/19: AC-17 remains red because local-fs/bin/pipeline_status.sh does not yet parse --sha (it dies 'unknown arg', which the dispatch correctly propagates as a usage failure rather than an answer). That is WU-47-3-3's file; the helper-side forcing logic was hand-verified against a fixture adapter that accepts the flag, and the criterion clears when WU-47-3-3 lands. The Implementer deliberately did not work around it -- retrying without --sha would hide a stale adapter and restore the commit-blind answer this feature removes.", "commit_refs": ["661d8a7", "470baa5"], "journal_ref": "1692", "dependencies_satisfied": ["WU-47-3-1"], "dependencies_violated": [] } ```
Author
Owner

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

type: discovery
timestamp: 2026-08-25T20:55
where: plugin/skills/local-fs/bin/pipeline_status.sh
tags: [fixture-format, contract, local-fs, finding]

Context: The SREQ specifies one record object per key ({state, commit, pipeline_id}), but AC-3
requires several pipelines for one (ref, sha) pair and asks for "the most recently created" — which one
object 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 itself
  • type == "array"sort_by(.created_at // "") | last
  • type == "null" → null (reads as state: "none")
  • anything else → error(...), caught by the rc check and answered query_failed (exit 9)

The selected record is bound with as $r and every field read as $r.field, so a missing key and a
null record both answer null naturally — 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.md now documents it (§ "Staging a pipeline record"); if the SREQ is ever
re-read as the spec of record, its local-fs paragraph should be understood as amended by that section.

Two details are load-bearing and easy to get wrong later:

  • created_at, not pipeline_id, not array position. glab-cli uses order_by=id&sort=desc
    because on GitLab id is a proxy for creation time; local-fs can declare created_at outright, so it
    compares the real key. The committed tests stage the correct entry neither first nor last, and neither
    at the max nor the min pipeline_idarray[0], array[-1] and sort-by-id all fail on purpose.
  • sort_by(.created_at // "") puts a created_at-less entry below every entry that has one, and jq's
    sort 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 is
    written down in SKILL.md so it does not get "tidied" away.

2026-08-25T21:10 DISCOVERY: --ref had to be validated in the primitive, because _lib.sh is out of this unit

type: discovery
timestamp: 2026-08-25T21:10
where: plugin/skills/local-fs/bin/pipeline_status.sh
tags: [security, path-traversal, local-fs]

Context: the security lens asked for ref to be validated before it becomes a path component. The
existing encoding (_lfs_ref_key: / and space → __) already neutralizes traversal — ../../etc/x
keys as ..__..__etc__x — so the check adds nothing today.

Pattern: validate anyway, in pipeline_status.sh itself (_lib.sh belongs to no work unit in this
slice). Charset via a variable-held ERE (_REF_OK), then two case arms 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}.json key unambiguous, which the SREQ asserts but nothing enforced.

Bash detail worth keeping: the regex allows a space (_lfs_ref_key maps it, so it has always been
accepted), and a space cannot appear in an unquoted =~ right-hand side — bash reads that operand as a
single 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 N as a script's last statement is a latent exit-1 bug

type: discovery
timestamp: 2026-08-25T21:25
where: plugin/skills/local-fs/bin/pipeline_status.sh
claude_md_gap: true
tags: [bash, set-e, portability-baseline]

Context: 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 false
the 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 — the
same defect is called out by name in release-pipeline-status.sh's own comment about prim_args. The
rule is general ("a bare test && cmd inherits the test's exit status"), but CLAUDE.md's portability
baseline states only the array-append instance. Worth generalizing to: in a set -e helper, never write
a conditional action as a bare test && cmd — use an if block
; the two instances found so far are an
array append and a final exit, and the second one fails on the success path, which is the harder half
to notice. Filed here for the retrospective rather than acted on, since CLAUDE.md is outside this unit.

2026-08-25T21:40 COMPLETE

type: complete
variant: development
timestamp: 2026-08-25T21:40
iterations: 3

Hardest part: deciding not to reclassify a staged state: "unsupported" inside the primitive. The
unit's constraint says the primitive must never emit unsupported, and reclassifying it locally would
satisfy that literally — but it would also make the shared dispatch's own reclassification guard
untestable, since local-fs is the only scriptable surface the layer above can be driven from. The
primitive never chooses unsupported; it echoes a record an operator deliberately staged. Resolved by
echoing 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 | last and 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.

<!-- dev-journal:v1 wu=WU-47-3-3 skill=develop --> # 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 ```yaml type: discovery timestamp: 2026-08-25T20:55 where: plugin/skills/local-fs/bin/pipeline_status.sh tags: [fixture-format, contract, local-fs, finding] ``` **Context:** The SREQ specifies one record object per key (`{state, commit, pipeline_id}`), but AC-3 requires several pipelines for one `(ref, sha)` pair and asks for "the most recently created" — which one object 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 itself - `type == "array"` → `sort_by(.created_at // "") | last` - `type == "null"` → null (reads as `state: "none"`) - anything else → `error(...)`, caught by the rc check and answered `query_failed` (exit 9) The selected record is bound with `as $r` and every field read as `$r.field`, so a **missing** key and a **null** record both answer `null` naturally — 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.md` now documents it (§ "Staging a pipeline record"); if the SREQ is ever re-read as the spec of record, its `local-fs` paragraph should be understood as amended by that section. Two details are load-bearing and easy to get wrong later: - **`created_at`, not `pipeline_id`, not array position.** `glab-cli` uses `order_by=id&sort=desc` because on GitLab id is a *proxy* for creation time; `local-fs` can declare `created_at` outright, so it compares 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 a `created_at`-less entry *below* every entry that has one**, and jq's sort 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 is written down in SKILL.md so it does not get "tidied" away. ## 2026-08-25T21:10 DISCOVERY: `--ref` had to be validated in the primitive, because `_lib.sh` is out of this unit ```yaml type: discovery timestamp: 2026-08-25T21:10 where: plugin/skills/local-fs/bin/pipeline_status.sh tags: [security, path-traversal, local-fs] ``` **Context:** the security lens asked for `ref` to be validated before it becomes a path component. The existing encoding (`_lfs_ref_key`: `/` and space → `__`) *already* neutralizes traversal — `../../etc/x` keys as `..__..__etc__x` — so the check adds nothing today. **Pattern:** validate anyway, in `pipeline_status.sh` itself (`_lib.sh` belongs to no work unit in this slice). Charset via a variable-held ERE (`_REF_OK`), then two `case` arms 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}.json` key unambiguous, which the SREQ asserts but nothing enforced. Bash detail worth keeping: the regex allows a space (`_lfs_ref_key` maps it, so it has always been accepted), and a space cannot appear in an *unquoted* `=~` right-hand side — bash reads that operand as a single 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 N` as a script's last statement is a latent exit-1 bug ```yaml type: discovery timestamp: 2026-08-25T21:25 where: plugin/skills/local-fs/bin/pipeline_status.sh claude_md_gap: true tags: [bash, set-e, portability-baseline] ``` **Context:** 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 **false** the 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* — the same defect is called out by name in `release-pipeline-status.sh`'s own comment about `prim_args`. The rule is general ("a bare `test && cmd` inherits the test's exit status"), but CLAUDE.md's portability baseline states only the array-append instance. Worth generalizing to: **in a `set -e` helper, never write a conditional action as a bare `test && cmd` — use an `if` block**; the two instances found so far are an array append and a final `exit`, and the second one fails on the *success* path, which is the harder half to notice. Filed here for the retrospective rather than acted on, since CLAUDE.md is outside this unit. ## 2026-08-25T21:40 COMPLETE ```yaml type: complete variant: development timestamp: 2026-08-25T21:40 iterations: 3 ``` **Hardest part:** deciding *not* to reclassify a staged `state: "unsupported"` inside the primitive. The unit's constraint says the primitive must never emit `unsupported`, and reclassifying it locally would satisfy that literally — but it would also make the shared dispatch's own reclassification guard untestable, since `local-fs` is the only scriptable surface the layer above can be driven from. The primitive never *chooses* `unsupported`; it echoes a record an operator deliberately staged. Resolved by echoing 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 | last` and 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.
Author
Owner
{
  "outcome": "complete",
  "summary": "local-fs pipeline records are commit-scoped and commit-labelled: --sha reads pipelines/{refkey}@{sha}.json falling back to the ref-level record, a record may be one object (the pre-#47 shape) or a JSON array resolved by max(created_at), and every declared field including commit is echoed verbatim so a wrong-commit answer can be staged deliberately. commit is never derived from the filename; a record missing it answers null at the primitive itself. --ref is validated before it becomes a path component (charset, no '..', no '@') and --sha as full 40-hex; an unreadable record is a stated query_failed (exit 9) whose error names only the store-relative key. SKILL.md documents the staging surface as the normative fixture spec. Probe went 23/35 -> 35/35 green; release-flow.sh still passes on a bare {state:success} ref-level fixture, so backward compatibility is proven rather than assumed. Unblocked AC-17, which had been red since WU-47-3-2.",
  "commit_refs": ["afae374", "81befe4"],
  "journal_ref": "1843",
  "dependencies_satisfied": ["WU-47-3-1", "WU-47-3-2"],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-47-3-3 skill=develop --> ```json { "outcome": "complete", "summary": "local-fs pipeline records are commit-scoped and commit-labelled: --sha reads pipelines/{refkey}@{sha}.json falling back to the ref-level record, a record may be one object (the pre-#47 shape) or a JSON array resolved by max(created_at), and every declared field including commit is echoed verbatim so a wrong-commit answer can be staged deliberately. commit is never derived from the filename; a record missing it answers null at the primitive itself. --ref is validated before it becomes a path component (charset, no '..', no '@') and --sha as full 40-hex; an unreadable record is a stated query_failed (exit 9) whose error names only the store-relative key. SKILL.md documents the staging surface as the normative fixture spec. Probe went 23/35 -> 35/35 green; release-flow.sh still passes on a bare {state:success} ref-level fixture, so backward compatibility is proven rather than assumed. Unblocked AC-17, which had been red since WU-47-3-2.", "commit_refs": ["afae374", "81befe4"], "journal_ref": "1843", "dependencies_satisfied": ["WU-47-3-1", "WU-47-3-2"], "dependencies_violated": [] } ```
Author
Owner

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

type: discovery
timestamp: 2026-08-26T14:40
where: plugin/skills/glab-cli/bin/pipeline_status.sh
tags: [finding, exit-codes, pipefail, forge-contract]

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 answers
with a JSON object ({"message":"401 Unauthorized"}) and glab exit 1. .[0] on an object is a jq
type error, and jq exits 5. Under pipefail a pipeline reports its last stage, so the
primitive's own status became jq's 5 — which is exactly _shared/procedures/bin/_lib.sh's
EXIT_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

type: discovery
timestamp: 2026-08-26T14:45
where: plugin/skills/_shared/procedures/bin/release-pipeline-status.sh
tags: [learning, security, layering, contract]

Context: AC-16 requires that neither the query_failed answer nor the emitted transcript carries a
credential-shaped string or an absolute path. The staged auth failure puts both on glab's stderr, which
is realistic — a real glab auth message quotes the token it tried and the config file it read it from.

Pattern: release-pipeline-status.sh forwards the primitive's stderr to the operator verbatim
(cat "$PRIM_ERR" >&2) on every path, and that is deliberate contract behaviour — the operator needs
the 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 stderr
to a file, extracts only three digits of HTTP status from it (via a body-first lookup, with an
anchored sed on 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 error field, it is about everything the
primitive writes to fd 2.


2026-08-26T14:52 DISCOVERY: removing a catch-all breaks the states that were only right by accident

type: discovery
timestamp: 2026-08-26T14:52
where: plugin/skills/glab-cli/bin/_lib.sh
tags: [learning, jq, tests]

Context: GL_JQ_PIPEMAP's else "pending" was wrong for manual, canceling and an unrecognized
status — but it was also the only thing mapping created, waiting_for_resource, preparing and
scheduled, 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 by
running the map over the whole captured vocabulary (plus null and an invented status) directly, not
only 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 --sha the shared dispatch rewrites none to unknown

type: discovery
timestamp: 2026-08-26T15:05
where: plugin/skills/_shared/procedures/bin/release-pipeline-status.sh
tags: [finding, out-of-unit, ac-4, ac-17]

Context: Desk-checking shapes the WU-47-3-4 suite sections do not stage. With --sha pinned and the
forge 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:

if (( sha_given )); then
  if [[ "$(jq -r '.commit // empty' <<<"$answer")" == "" ]]; then … .state = "unknown"

and the answer becomes {"state":"unknown","raw_state":"none"}. Reproduced through both adapters, so
it is the shared tier, not an adapter:

  • glab-cli, empty list, --sha pinned → {"state":"unknown","raw_state":"none"}
  • local-fs, no staged record, --sha pinned → {"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:promote polls with --sha pinned on every iteration, so this is the normal path, and
the branch table sends unknown to Step 8 (escalate, naming raw_state) while none must keep
waiting
. 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: unknown is
"a verdict arrived with no commit while sha was pinned", and none is not a verdict. The fix is
one condition in the shared helper — exempt none (and arguably query_failed, which also legitimately
carries a null commit) from the force. Reported to the lead; not fixed here.


2026-08-26T15:20 COMPLETE

type: complete
variant: development
timestamp: 2026-08-26T15:20
iterations: 3

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_failed on its behalf, so the passing assertions were testing the
net, not the adapter; the invalid-credential case (which that net does not catch, because jq's 5 is
consumed as EXIT_ADAPTER before 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:v1 wu=WU-47-3-4 skill=develop --> # 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 ```yaml type: discovery timestamp: 2026-08-26T14:40 where: plugin/skills/glab-cli/bin/pipeline_status.sh tags: [finding, exit-codes, pipefail, forge-contract] ``` **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 answers with a JSON **object** (`{"message":"401 Unauthorized"}`) and `glab` exit 1. `.[0]` on an object is a jq **type error**, and jq exits **5**. Under `pipefail` a pipeline reports its **last** stage, so the primitive's own status became jq's 5 — which is exactly `_shared/procedures/bin/_lib.sh`'s `EXIT_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 ```yaml type: discovery timestamp: 2026-08-26T14:45 where: plugin/skills/_shared/procedures/bin/release-pipeline-status.sh tags: [learning, security, layering, contract] ``` **Context:** AC-16 requires that neither the `query_failed` answer nor the **emitted transcript** carries a credential-shaped string or an absolute path. The staged auth failure puts both on `glab`'s stderr, which is realistic — a real `glab` auth message quotes the token it tried and the config file it read it from. **Pattern:** `release-pipeline-status.sh` forwards the primitive's stderr to the operator verbatim (`cat "$PRIM_ERR" >&2`) on **every** path, and that is deliberate contract behaviour — the operator needs the 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 stderr to a file, extracts **only three digits** of HTTP status from it (via a body-first lookup, with an anchored `sed` on 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 `error` **field**, it is about everything the primitive writes to fd 2. --- ## 2026-08-26T14:52 DISCOVERY: removing a catch-all breaks the states that were only right by accident ```yaml type: discovery timestamp: 2026-08-26T14:52 where: plugin/skills/glab-cli/bin/_lib.sh tags: [learning, jq, tests] ``` **Context:** `GL_JQ_PIPEMAP`'s `else "pending"` was wrong for `manual`, `canceling` and an unrecognized status — but it was also the **only** thing mapping `created`, `waiting_for_resource`, `preparing` and `scheduled`, 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 by running the map over the whole captured vocabulary (plus `null` and an invented status) directly, not only 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 `--sha` the shared dispatch rewrites `none` to `unknown` ```yaml type: discovery timestamp: 2026-08-26T15:05 where: plugin/skills/_shared/procedures/bin/release-pipeline-status.sh tags: [finding, out-of-unit, ac-4, ac-17] ``` **Context:** Desk-checking shapes the WU-47-3-4 suite sections do not stage. With `--sha` pinned and the forge 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: ``` if (( sha_given )); then if [[ "$(jq -r '.commit // empty' <<<"$answer")" == "" ]]; then … .state = "unknown" ``` and the answer becomes `{"state":"unknown","raw_state":"none"}`. Reproduced through **both** adapters, so it is the shared tier, not an adapter: - `glab-cli`, empty list, `--sha` pinned → `{"state":"unknown","raw_state":"none"}` - `local-fs`, no staged record, `--sha` pinned → `{"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:promote` polls with `--sha` pinned on every iteration, so this is the *normal* path, and the branch table sends `unknown` to Step 8 (escalate, naming `raw_state`) while `none` must **keep waiting**. 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: `unknown` is "a **verdict** arrived with no `commit` while `sha` was pinned", and `none` is not a verdict. The fix is one condition in the shared helper — exempt `none` (and arguably `query_failed`, which also legitimately carries a null `commit`) from the force. Reported to the lead; not fixed here. --- ## 2026-08-26T15:20 COMPLETE ```yaml type: complete variant: development timestamp: 2026-08-26T15:20 iterations: 3 ``` **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_failed` on its behalf, so the passing assertions were testing the net, not the adapter; the invalid-credential case (which that net does not catch, because jq's 5 is consumed as `EXIT_ADAPTER` before 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.
Author
Owner
{
  "outcome": "complete",
  "summary": "glab-cli scopes the pipeline query by (ref, sha) together with the pinned ordering (order_by=id, sort=desc, per_page=1), validates --sha as full 40-hex, and labels the answer with the commit the returned pipeline belongs to (commit from the entry's own sha, plus pipeline_id, url and raw_state). The glab call is rc-checked with stdout and stderr in separate files -- the `_gl_api | jq '.[0]'` pipeline is gone, which also removes the jq-exit-5 vs EXIT_ADAPTER=5 collision that had been misrouting auth failures as broken installs. Four stated query_failed classes (HTTP status, no HTTP response, non-array body, unreadable array), each a classified error naming status + fixed phrase + adapter; glab's raw stderr is never forwarded, closing the token and skills-root leak AC-16 exists to catch. GL_JQ_PIPEMAP has no catch-all: all twelve captured GitLab statuses map explicitly, null maps to none, anything else maps to unknown with the forge's literal value in raw_state; pr_status shares the map and inherits the fall-through fix while staying head-scoped (commit attribution deliberately deferred). Probe 52/67 -> 67/67 green. Live-forge half of AC-1/AC-3 is verified on a dogfood release per the SREQ's stated risk, not offline; the offline half asserts the adapter asks the right question via a PATH-stubbed glab.",
  "commit_refs": ["5ff5876", "3062f5f"],
  "journal_ref": "1926",
  "dependencies_satisfied": ["WU-47-3-1", "WU-47-3-2"],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-47-3-4 skill=develop --> ```json { "outcome": "complete", "summary": "glab-cli scopes the pipeline query by (ref, sha) together with the pinned ordering (order_by=id, sort=desc, per_page=1), validates --sha as full 40-hex, and labels the answer with the commit the returned pipeline belongs to (commit from the entry's own sha, plus pipeline_id, url and raw_state). The glab call is rc-checked with stdout and stderr in separate files -- the `_gl_api | jq '.[0]'` pipeline is gone, which also removes the jq-exit-5 vs EXIT_ADAPTER=5 collision that had been misrouting auth failures as broken installs. Four stated query_failed classes (HTTP status, no HTTP response, non-array body, unreadable array), each a classified error naming status + fixed phrase + adapter; glab's raw stderr is never forwarded, closing the token and skills-root leak AC-16 exists to catch. GL_JQ_PIPEMAP has no catch-all: all twelve captured GitLab statuses map explicitly, null maps to none, anything else maps to unknown with the forge's literal value in raw_state; pr_status shares the map and inherits the fall-through fix while staying head-scoped (commit attribution deliberately deferred). Probe 52/67 -> 67/67 green. Live-forge half of AC-1/AC-3 is verified on a dogfood release per the SREQ's stated risk, not offline; the offline half asserts the adapter asks the right question via a PATH-stubbed glab.", "commit_refs": ["5ff5876", "3062f5f"], "journal_ref": "1926", "dependencies_satisfied": ["WU-47-3-1", "WU-47-3-2"], "dependencies_violated": [] } ```
Author
Owner

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 commit while sha was pinned". Splitting the
enum by that word:

  • Verdicts (a pipeline exists and the forge has said something about it):
    pending, running, success, failed, canceled, skipped, manual.
    A pipeline that exists has a commit, so a null commit here IS the
    unassertable 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: null is the correct answer, not a
    missing one. Monitor branch is keep waiting, and rewriting it to unknown
    sends it to escalate instead. Exempt.
  • query_failed — "the forge could not be asked". Also legitimately null
    commit, and its monitor branch is retry within the wait's bound, then
    escalate naming the query failure
    — explicitly "never a pipeline verdict".
    Rewriting it to unknown loses the retry AND silently drops the exit status
    from 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 the
    guard, and a primitive-emitted one is already reclassified to unknown above
    the guard. Nothing to change.
  • unknown — not a verdict either, but the guard is a no-op on its state and
    preserves any adapter-set raw_state via //. 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 --sha a
query_failed answer 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 $state to unknown
before 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.sh 85 ok / PASS;
lint-conventions.sh clean; all 9 shared + 3 local-fs suites rc=0; direct
repro of both reported commands now answers none on the pinned call.

<!-- dev-journal:v1 wu=WU-47-3-8 skill=develop --> # 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 `commit` while `sha` was pinned". Splitting the enum by that word: - Verdicts (a pipeline exists and the forge has said something about it): `pending`, `running`, `success`, `failed`, `canceled`, `skipped`, `manual`. A pipeline that exists has a commit, so a null `commit` here IS the unassertable 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: null` is the correct answer, not a missing one. Monitor branch is **keep waiting**, and rewriting it to `unknown` sends it to escalate instead. Exempt. - `query_failed` — "the forge could not be asked". Also legitimately null `commit`, and its monitor branch is **retry within the wait's bound, then escalate naming the query failure** — explicitly "never a pipeline verdict". Rewriting it to `unknown` loses the retry AND silently drops the exit status from 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 the guard, and a primitive-emitted one is already reclassified to `unknown` above the guard. Nothing to change. - `unknown` — not a verdict either, but the guard is a no-op on its `state` and preserves any adapter-set `raw_state` via `//`. 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 `--sha` a `query_failed` answer 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 `$state` to `unknown` before 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.sh` 85 ok / PASS; `lint-conventions.sh` clean; all 9 shared + 3 local-fs suites rc=0; direct repro of both reported commands now answers `none` on the pinned call.
Author
Owner
{
  "outcome": "complete",
  "summary": "Corrective unit added mid-run. The pinned-sha null-commit guard in release-pipeline-status.sh was over-firing on non-verdict states: a legitimate `none` was rewritten to `unknown`, which would have sent /dev:promote to escalation instead of the wait AC-4 requires -- on the normal polling path, since promote pins --sha on every iteration. The guard now excludes `none` and `query_failed`, written as an exclusion rather than a verdict allow-list so a state added to the enum later defaults to guarded (fail-closed) rather than escaping it. The red run surfaced a second half of the same defect: a pinned `query_failed` was also exiting 0 instead of the reserved 9, because the guard rewrote `state` before the final exit-code check read it; the existing AC-7 checks never saw it because they all call unpinned. Eight new assertions cover both adapters (local-fs and the stubbed-glab path) for `none` and `query_failed` under a pinned sha, plus an explicit re-assertion that the AC-17 guard still fires on a commit-less green. Probe 77/85 -> 85/85 green. Single-agent by exception: the defect, its reproduction and the governing contract clause were fully specified up front, leaving nothing for an isolated Test Writer to discover; the tests-first ordering was retained and the assertions were measured red before the fix.",
  "commit_refs": ["2ed5282"],
  "journal_ref": "1929",
  "dependencies_satisfied": ["WU-47-3-1", "WU-47-3-2", "WU-47-3-3", "WU-47-3-4"],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-47-3-8 skill=develop --> ```json { "outcome": "complete", "summary": "Corrective unit added mid-run. The pinned-sha null-commit guard in release-pipeline-status.sh was over-firing on non-verdict states: a legitimate `none` was rewritten to `unknown`, which would have sent /dev:promote to escalation instead of the wait AC-4 requires -- on the normal polling path, since promote pins --sha on every iteration. The guard now excludes `none` and `query_failed`, written as an exclusion rather than a verdict allow-list so a state added to the enum later defaults to guarded (fail-closed) rather than escaping it. The red run surfaced a second half of the same defect: a pinned `query_failed` was also exiting 0 instead of the reserved 9, because the guard rewrote `state` before the final exit-code check read it; the existing AC-7 checks never saw it because they all call unpinned. Eight new assertions cover both adapters (local-fs and the stubbed-glab path) for `none` and `query_failed` under a pinned sha, plus an explicit re-assertion that the AC-17 guard still fires on a commit-less green. Probe 77/85 -> 85/85 green. Single-agent by exception: the defect, its reproduction and the governing contract clause were fully specified up front, leaving nothing for an isolated Test Writer to discover; the tests-first ordering was retained and the assertions were measured red before the fix.", "commit_refs": ["2ed5282"], "journal_ref": "1929", "dependencies_satisfied": ["WU-47-3-1", "WU-47-3-2", "WU-47-3-3", "WU-47-3-4"], "dependencies_violated": [] } ```
Author
Owner

Development Journal — WU-47-3-5 (/dev:promote Steps 4, 5, 8)

2026-08-26T10:20 DISCOVERY: one wait definition, invoked twice — not two branch tables

type: discovery
timestamp: 2026-08-26T10:20
where: plugin/skills/promote/SKILL.md
tags: [learning, skill-text, duplication]

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

type: discovery
timestamp: 2026-08-26T10:35
where: plugin/skills/promote/SKILL.md (Step 4, strategy `tag`)
tags: [finding, ordering, correctness]

Context: Placing the new pre-tag gate inside the tag strategy'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_SHA before the write-back would
assert 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 --target takes $TAG_TARGET_SHA instead of a second git rev-parse "$INTEGRATION_BRANCH". Either
re-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 heredoc

type: discovery
timestamp: 2026-08-26T10:50
where: plugin/skills/promote/SKILL.md (the emitted wait script)
claude_md_gap: false
tags: [learning, portability, heredoc]

Context: The loop is long enough that the baseline requires emitting it as a script file. The
natural shape is cat > … <<'EOF' — quoted, so $state, $rc and $1 survive to run time.

Pattern: A quoted heredoc also stops ${CLAUDE_PLUGIN_ROOT} from expanding at write time, and
the plugin-root variable is not reliably exported into the bash process that later runs the file.
Pass it in instead: the script reads PIPE_HELPER (and its six siblings) from the environment, and
the caller sets them on the bash invocation line, where "${CLAUDE_PLUGIN_ROOT}/…" is quoted
exactly as lint's D12 rule requires.

Why it matters: The alternative — an unquoted heredoc with \$ escaping on every runtime
variable — 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 spaces

type: struggle
timestamp: 2026-08-26T11:10
attempts: 2
resolved: true
tags: [poll-record, format]

Problem: AC-19 asks for one stated one-line format, key=value. The no-answer branch (helper
exit 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 by awk.

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

type: complete
variant: development
timestamp: 2026-08-26T11:25
iterations: 9

Hardest part: Deciding that none must keep the loop alive while unsupported must end it — the
two 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.

<!-- dev-journal:v1 wu=WU-47-3-5 skill=develop --> # Development Journal — WU-47-3-5 (`/dev:promote` Steps 4, 5, 8) ## 2026-08-26T10:20 DISCOVERY: one wait definition, invoked twice — not two branch tables ```yaml type: discovery timestamp: 2026-08-26T10:20 where: plugin/skills/promote/SKILL.md tags: [learning, skill-text, duplication] ``` **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 ```yaml type: discovery timestamp: 2026-08-26T10:35 where: plugin/skills/promote/SKILL.md (Step 4, strategy `tag`) tags: [finding, ordering, correctness] ``` **Context:** Placing the new pre-tag gate inside the `tag` strategy'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_SHA` before the write-back would assert 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 --target` takes `$TAG_TARGET_SHA` instead of a second `git rev-parse "$INTEGRATION_BRANCH"`. Either re-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 heredoc ```yaml type: discovery timestamp: 2026-08-26T10:50 where: plugin/skills/promote/SKILL.md (the emitted wait script) claude_md_gap: false tags: [learning, portability, heredoc] ``` **Context:** The loop is long enough that the baseline requires emitting it as a script file. The natural shape is `cat > … <<'EOF'` — quoted, so `$state`, `$rc` and `$1` survive to run time. **Pattern:** A quoted heredoc also stops `${CLAUDE_PLUGIN_ROOT}` from expanding at *write* time, and the plugin-root variable is not reliably exported into the `bash` process that later runs the file. Pass it in instead: the script reads `PIPE_HELPER` (and its six siblings) from the environment, and the caller sets them on the `bash` invocation line, where `"${CLAUDE_PLUGIN_ROOT}/…"` is quoted exactly as lint's D12 rule requires. **Why it matters:** The alternative — an unquoted heredoc with `\$` escaping on every runtime variable — 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 spaces ```yaml type: struggle timestamp: 2026-08-26T11:10 attempts: 2 resolved: true tags: [poll-record, format] ``` **Problem:** AC-19 asks for **one** stated one-line format, key=value. The `no-answer` branch (helper exit 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 by `awk`. **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 ```yaml type: complete variant: development timestamp: 2026-08-26T11:25 iterations: 9 ``` **Hardest part:** Deciding that `none` must keep the loop alive while `unsupported` must end it — the two 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.
Author
Owner
{
  "outcome": "complete",
  "summary": "/dev:promote pins the tag target, asserts commit identity before tagging, and branches on every pipeline state. The pre-tag gate is new (promote consumed no pipeline answer before tagging, which made AC-2 vacuous) and is sited after the manifest write-back so the commit it asserts is the one that gets tagged. The wait itself is defined once in a new shared `## The pipeline wait` section that both Step 4's gate and Step 5's monitor invoke -- a deliberate deviation from a literal 'Step 5 gains a branch table', taken because two copies of a branch table drift and the repo's one-canonical-source rule applies; lead-approved. All 11 contract states have exactly one case arm plus an explicit off-enum refusal, independently verified by diffing the emitted loop against contract 2.2's enum. `none` is no longer an exit from the loop; `unsupported` breaks to the deploy check with the skip line printed in the transcript at that moment and recorded on the Phase Outcome. Each wait gets its own full ci_timeout pinned at entry, callers branch on the JSON state via the rc-tolerant capture pattern with exits 8/9 named as answers, and one stated poll-record format is used at every non-terminal branch. Escalations carry the pipeline url; the timeout escalation names the commit and the last observed state. Artifact-shape WU verified by desk-check plus execution: the loop was extracted verbatim from the markdown and driven through 13 cases including empty, missing and already-green. No behavioural tests and no Test Writer, since no harness executes skill markdown.",
  "commit_refs": ["ab7f92a"],
  "journal_ref": "2054",
  "dependencies_satisfied": ["WU-47-3-1", "WU-47-3-2", "WU-47-3-3", "WU-47-3-4", "WU-47-3-8"],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-47-3-5 skill=develop --> ```json { "outcome": "complete", "summary": "/dev:promote pins the tag target, asserts commit identity before tagging, and branches on every pipeline state. The pre-tag gate is new (promote consumed no pipeline answer before tagging, which made AC-2 vacuous) and is sited after the manifest write-back so the commit it asserts is the one that gets tagged. The wait itself is defined once in a new shared `## The pipeline wait` section that both Step 4's gate and Step 5's monitor invoke -- a deliberate deviation from a literal 'Step 5 gains a branch table', taken because two copies of a branch table drift and the repo's one-canonical-source rule applies; lead-approved. All 11 contract states have exactly one case arm plus an explicit off-enum refusal, independently verified by diffing the emitted loop against contract 2.2's enum. `none` is no longer an exit from the loop; `unsupported` breaks to the deploy check with the skip line printed in the transcript at that moment and recorded on the Phase Outcome. Each wait gets its own full ci_timeout pinned at entry, callers branch on the JSON state via the rc-tolerant capture pattern with exits 8/9 named as answers, and one stated poll-record format is used at every non-terminal branch. Escalations carry the pipeline url; the timeout escalation names the commit and the last observed state. Artifact-shape WU verified by desk-check plus execution: the loop was extracted verbatim from the markdown and driven through 13 cases including empty, missing and already-green. No behavioural tests and no Test Writer, since no harness executes skill markdown.", "commit_refs": ["ab7f92a"], "journal_ref": "2054", "dependencies_satisfied": ["WU-47-3-1", "WU-47-3-2", "WU-47-3-3", "WU-47-3-4", "WU-47-3-8"], "dependencies_violated": [] } ```
Author
Owner

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

type: discovery
timestamp: 2026-08-26T16:55
where: scripts/lint-conventions.sh
tags: [markdown-parsing, awk, forge-contract]

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 on
the first line that does not start with |, skipping the |---| separator. The section-bounded
alternative (## 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

type: discovery
timestamp: 2026-08-26T17:05
where: scripts/lint-conventions.sh
tags: [drift-check, ac-14]

Context: AC-14 diffs _GATED_PRIMS in _lib.sh against the contract's §2.2 capability-gated set.
Pattern: the expected set is parsed from the contract on every run and diffed with comm; the
eight primitive names appear nowhere in the linter. _GATED_PRIMS is read by grepping the single
top-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 past path:lineno

type: discovery
timestamp: 2026-08-26T17:20
where: scripts/lint-conventions.sh
tags: [house-style]

Context: wanting each violation to name the offending operation.
Pattern: report prints cut -d: -f1-2 of each hit line, so any detail after the line number is
discarded. To name the operation, call report once per offending row with the name interpolated into
the message. The loop must read from a file (< "$bad_kinds"), not a pipe — a piped while runs
in a subshell and the violations counter 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

type: complete
variant: development
timestamp: 2026-08-26T17:40
iterations: 8

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_PRIMS entry (set_status), a removed one (pr_merge), and a renamed table header (both
non-vacuity guards). Every perturbation was restored from a byte-identical backup; git status shows
only scripts/lint-conventions.sh. All 12 shell suites pass; pipeline-identity.sh 85 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.

<!-- dev-journal:v1 wu=WU-47-3-6 skill=develop --> # 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 ```yaml type: discovery timestamp: 2026-08-26T16:55 where: scripts/lint-conventions.sh tags: [markdown-parsing, awk, forge-contract] ``` **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 on the first line that does not start with `|`, skipping the `|---|` separator. The section-bounded alternative (`## 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 ```yaml type: discovery timestamp: 2026-08-26T17:05 where: scripts/lint-conventions.sh tags: [drift-check, ac-14] ``` **Context:** AC-14 diffs `_GATED_PRIMS` in `_lib.sh` against the contract's §2.2 capability-gated set. **Pattern:** the expected set is parsed from the contract on every run and diffed with `comm`; the eight primitive names appear nowhere in the linter. `_GATED_PRIMS` is read by grepping the single top-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 past `path:lineno` ```yaml type: discovery timestamp: 2026-08-26T17:20 where: scripts/lint-conventions.sh tags: [house-style] ``` **Context:** wanting each violation to name the offending operation. **Pattern:** `report` prints `cut -d: -f1-2` of each hit line, so any detail after the line number is discarded. To name the operation, call `report` once per offending row with the name interpolated into the *message*. The loop must read from a **file** (`< "$bad_kinds"`), not a pipe — a piped `while` runs in a subshell and the `violations` counter 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 ```yaml type: complete variant: development timestamp: 2026-08-26T17:40 iterations: 8 ``` **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_PRIMS` entry (`set_status`), a removed one (`pr_merge`), and a renamed table header (both non-vacuity guards). Every perturbation was restored from a byte-identical backup; `git status` shows only `scripts/lint-conventions.sh`. All 12 shell suites pass; `pipeline-identity.sh` 85 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.
Author
Owner
{
  "outcome": "complete",
  "summary": "lint-conventions.sh now fails the gate on contract Kind gaps (AC-11) and _GATED_PRIMS drift (AC-14). One shared awk parser anchors on the `| Operation | Kind |` header row rather than the section, which is what keeps the state-vocabulary table out of the scan; AC-14 additionally bounds on 2.2 so skill-facing names cannot corrupt the primitive set. The expected gated set is parsed from the contract, never typed -- the eight names appear nowhere in the linter, so the check cannot become a third copy of the declaration it exists to keep in sync. _GATED_PRIMS is read by grep+sed rather than by sourcing _lib.sh, and the check asserts there is exactly one such assignment so a second cannot hide. Both checks print examined-counts and treat zero parsed rows as a hard failure. Counts at HEAD: 34 operation rows across 3 tables; 8 gated primitives parsed vs 8 declared. Six drift perturbations were staged and each turned the gate red, including a non-vacuity case where renaming the table headers correctly fired both vacuity guards instead of passing silently; the lead independently reproduced the dropped-entry case and confirmed clean restore. Single-agent by exception: repo-local mechanical tooling with no product surface a behavioural test could drive and no test harness under scripts/, so proving each check fires replaced the Test Writer.",
  "commit_refs": ["da8e89d"],
  "journal_ref": "2073",
  "dependencies_satisfied": ["WU-47-3-1", "WU-47-3-2"],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-47-3-6 skill=develop --> ```json { "outcome": "complete", "summary": "lint-conventions.sh now fails the gate on contract Kind gaps (AC-11) and _GATED_PRIMS drift (AC-14). One shared awk parser anchors on the `| Operation | Kind |` header row rather than the section, which is what keeps the state-vocabulary table out of the scan; AC-14 additionally bounds on 2.2 so skill-facing names cannot corrupt the primitive set. The expected gated set is parsed from the contract, never typed -- the eight names appear nowhere in the linter, so the check cannot become a third copy of the declaration it exists to keep in sync. _GATED_PRIMS is read by grep+sed rather than by sourcing _lib.sh, and the check asserts there is exactly one such assignment so a second cannot hide. Both checks print examined-counts and treat zero parsed rows as a hard failure. Counts at HEAD: 34 operation rows across 3 tables; 8 gated primitives parsed vs 8 declared. Six drift perturbations were staged and each turned the gate red, including a non-vacuity case where renaming the table headers correctly fired both vacuity guards instead of passing silently; the lead independently reproduced the dropped-entry case and confirmed clean restore. Single-agent by exception: repo-local mechanical tooling with no product surface a behavioural test could drive and no test harness under scripts/, so proving each check fires replaced the Test Writer.", "commit_refs": ["da8e89d"], "journal_ref": "2073", "dependencies_satisfied": ["WU-47-3-1", "WU-47-3-2"], "dependencies_violated": [] } ```
Author
Owner

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_state absence is not a portable "the guard did not fire" witness

type: discovery
timestamp: 2026-08-26T14:05
where: plugin/skills/glab-cli/bin/pipeline_status.sh
tags: [forge-contract, raw_state, adapter-divergence, test-design]

Context: Writing the AC-12 assertions, I wanted a second, independent witness that the pinned---sha
null-commit guard in release-pipeline-status.sh had not rewritten a failed/canceled verdict. The
guard sets state = "unknown" and raw_state = <previous state>, so "raw_state is absent" looked
like a clean negative check. It holds on local-fs — that adapter echoes raw_state only when the staged
record declares one — and I wrote it for both adapters.

Pattern: It fails on glab-cli, which emits raw_state on every answer by design
(pipeline_status.sh:141), not only when state is unknown. Contract §2.2 requires raw_state for
unknown and merely permits it elsewhere, so both adapters are conformant and the two shapes are both
correct. The portable claim is the state assertion itself — the guard's output is unknown, so a state
that is still failed is proof it did not fire. On glab-cli the check was replaced with the other half of
the 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

type: discovery
timestamp: 2026-08-26T14:20
where: plugin/skills/_shared/procedures/test/pipeline-identity.sh
tags: [vacuous-assertion, hardening, test-design]

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 jq was being fed an empty
document 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 claim release-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 jq before comparing it to the empty string converts "the
helper 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

type: complete
variant: development
timestamp: 2026-08-26T14:40
iterations: 6

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-cli until the
contract's "MUST be present whenever state is unknown" 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.

<!-- dev-journal:v1 wu=WU-47-3-7 skill=develop --> # 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_state` absence is not a portable "the guard did not fire" witness ```yaml type: discovery timestamp: 2026-08-26T14:05 where: plugin/skills/glab-cli/bin/pipeline_status.sh tags: [forge-contract, raw_state, adapter-divergence, test-design] ``` **Context:** Writing the AC-12 assertions, I wanted a second, independent witness that the pinned-`--sha` null-commit guard in `release-pipeline-status.sh` had not rewritten a `failed`/`canceled` verdict. The guard sets `state = "unknown"` **and** `raw_state = <previous state>`, so "`raw_state` is absent" looked like a clean negative check. It holds on `local-fs` — that adapter echoes `raw_state` only when the staged record declares one — and I wrote it for both adapters. **Pattern:** It fails on `glab-cli`, which emits `raw_state` on **every** answer by design (`pipeline_status.sh:141`), not only when `state` is `unknown`. Contract §2.2 *requires* `raw_state` for `unknown` and merely *permits* it elsewhere, so both adapters are conformant and the two shapes are both correct. The portable claim is the state assertion itself — the guard's output **is** `unknown`, so a state that is still `failed` is proof it did not fire. On `glab-cli` the check was replaced with the other half of the 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 ```yaml type: discovery timestamp: 2026-08-26T14:20 where: plugin/skills/_shared/procedures/test/pipeline-identity.sh tags: [vacuous-assertion, hardening, test-design] ``` **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 `jq` was being fed an empty document 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 claim `release-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 `jq` before comparing it to the empty string converts "the helper 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 ```yaml type: complete variant: development timestamp: 2026-08-26T14:40 iterations: 6 ``` **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-cli` until the contract's "MUST be present whenever `state` is `unknown`" 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.
Author
Owner
{
  "outcome": "complete",
  "summary": "Final integration unit. Added AC-12's helper-side assertions (failed and canceled each survive unchanged, exit 0, naming the pinned commit) across both local-fs and the stubbed-glab path, plus AC-2's helper-side premise, which the SREQ assigns to this probe and which had no section until now. Probe 85 -> 111 checks, all green. Wrote a COVERAGE BOUNDARY block enumerating all twenty criteria and where each is actually verified -- here, the mechanical lint gate, the dogfood run, or desk-check -- and marking every split criterion so a green run is never mistaken for evidence of a live half the probe cannot reach. Hardening pass fixed three real test defects: two assertions that read `jq '.state // empty'` on a branch that emits nothing, making 'said nothing' and 'crashed' the same observation (now compare the raw stdout capture, per the helper's own stated contract), and a cross-adapter assertion of the author's own that wrongly required raw_state to be absent on both adapters. Four perturbations proved the new assertions can fail; the lead independently reproduced the sharpest, a record declaring a wrong commit, which fails exactly the two commit-identity checks while the state checks stay green. Three further observations were reported rather than changed, all in closed units' green sections: three captured exit statuses never asserted, an order dependency across sections (no mutable state leaks), and two fixtures whose labels do not name which code path they exercise.",
  "commit_refs": ["dab5465"],
  "journal_ref": "2076",
  "dependencies_satisfied": ["WU-47-3-1", "WU-47-3-2", "WU-47-3-3", "WU-47-3-4", "WU-47-3-5", "WU-47-3-6", "WU-47-3-8"],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-47-3-7 skill=develop --> ```json { "outcome": "complete", "summary": "Final integration unit. Added AC-12's helper-side assertions (failed and canceled each survive unchanged, exit 0, naming the pinned commit) across both local-fs and the stubbed-glab path, plus AC-2's helper-side premise, which the SREQ assigns to this probe and which had no section until now. Probe 85 -> 111 checks, all green. Wrote a COVERAGE BOUNDARY block enumerating all twenty criteria and where each is actually verified -- here, the mechanical lint gate, the dogfood run, or desk-check -- and marking every split criterion so a green run is never mistaken for evidence of a live half the probe cannot reach. Hardening pass fixed three real test defects: two assertions that read `jq '.state // empty'` on a branch that emits nothing, making 'said nothing' and 'crashed' the same observation (now compare the raw stdout capture, per the helper's own stated contract), and a cross-adapter assertion of the author's own that wrongly required raw_state to be absent on both adapters. Four perturbations proved the new assertions can fail; the lead independently reproduced the sharpest, a record declaring a wrong commit, which fails exactly the two commit-identity checks while the state checks stay green. Three further observations were reported rather than changed, all in closed units' green sections: three captured exit statuses never asserted, an order dependency across sections (no mutable state leaks), and two fixtures whose labels do not name which code path they exercise.", "commit_refs": ["dab5465"], "journal_ref": "2076", "dependencies_satisfied": ["WU-47-3-1", "WU-47-3-2", "WU-47-3-3", "WU-47-3-4", "WU-47-3-5", "WU-47-3-6", "WU-47-3-8"], "dependencies_violated": [] } ```
Author
Owner
{
  "phase": "develop",
  "learnings": [
    {
      "id": "L-1",
      "scope": "project",
      "status": "applied",
      "summary": "A helper whose exit codes are a shared vocabulary must own its rc: `_gl_api | jq '.[0]'` made the primitive exit with jq's 5, colliding with the reserved EXIT_ADAPTER=5, so a correctly-detected auth failure was misrouted as a broken install rather than merely lost. The baseline's rc-never-from-a-pipeline rule was stated only in terms of the false-green failure mode; misrouting is the second one.",
      "evidence": "DISCOVERY, WU-47-3-4 journal — root cause of the AC-7/AC-16 red run",
      "source_ref": "comment:1926",
      "applied_to": "CLAUDE.md"
    },
    {
      "id": "L-2",
      "scope": "project",
      "status": "applied",
      "summary": "`test && cmd` as a script's LAST statement is a latent exit-1 bug under set -e — a false test makes the compound return 1, which becomes the script's exit status, so an ordinary verdict exits non-zero. The baseline forbids the array-append instance by name but states it as an array rule; the general form, and especially the `exit` instance, fails on the success path and is harder to notice.",
      "evidence": "DISCOVERY, WU-47-3-3 journal — hit in the first draft of the local-fs primitive",
      "source_ref": "comment:1843",
      "applied_to": "CLAUDE.md"
    },
    {
      "id": "L-3",
      "scope": "project",
      "status": "applied",
      "summary": "The sanitization boundary for forge-contract 2.2's classified-error rule is the adapter, not the dispatch: the dispatch forwards a primitive's stderr verbatim by design, so a primitive that lets a CLI's raw stderr flow through it has already leaked. The rule governs everything the primitive writes to fd 2, not only the JSON error field.",
      "evidence": "DISCOVERY, WU-47-3-4 journal — the AC-16 token and skills-root leak",
      "source_ref": "comment:1926",
      "applied_to": "CLAUDE.md"
    },
    {
      "id": "L-4",
      "scope": "project",
      "status": "applied",
      "summary": "A quoted heredoc blocks ${CLAUDE_PLUGIN_ROOT} substitution, so a skill that emits a script file referencing a helper must pass the plugin root in as an env var; the unquoted-heredoc alternative needs a backslash on every runtime variable and collides with the lint rule against an escaped ${CLAUDE_PLUGIN_ROOT}.",
      "evidence": "DISCOVERY, WU-47-3-5 journal — emitting promote's pipeline-wait script",
      "source_ref": "comment:2054",
      "applied_to": "CLAUDE.md"
    },
    {
      "id": "L-5",
      "scope": "project",
      "status": "applied",
      "summary": "Where a contract's claim is about the ABSENCE of output, assert on the raw capture rather than a jq parse of it: `jq -r '.state // empty'` on a branch that emits nothing returns empty for any reason at all, including the helper crashing before producing anything, so 'said nothing' and 'crashed' become the same observation. Same principle as rc-from-the-command, applied to the reader side.",
      "evidence": "DISCOVERY, WU-47-3-7 journal — two live vacuous assertions found and fixed in pipeline-identity.sh",
      "source_ref": "comment:2076",
      "applied_to": "CLAUDE.md"
    },
    {
      "id": "L-6",
      "scope": "devwork",
      "status": "unhomed",
      "summary": "/dev:develop's wu-plan is immutable, but a run that discovers a blocking cross-unit defect mid-phase has no stated way to add a corrective work unit. This run added WU-47-3-8 by lead judgement and recorded the deviation in wu-status.md and the outcome; the skill text neither permits nor forbids it. Either the plan schema should admit an amendment record, or the skill should say plainly that a corrective unit is a legitimate mid-run addition.",
      "evidence": "Lead observation, PO-47-3 — the none-to-unknown guard defect found by WU-47-3-4 and fixed by the added WU-47-3-8",
      "source_ref": "comment:1930",
      "applied_to": null
    },
    {
      "id": "L-7",
      "scope": "devwork",
      "status": "unhomed",
      "summary": "A Test Writer that reports which of its own assertions pass INCIDENTALLY (green today for a reason unrelated to the behaviour being built) materially changed this run's outcome: three separate units carried such warnings forward, and in each case the Implementer had to make the assertion pass on purpose rather than inherit an accidental green. /dev:develop's Test Writer prompt does not ask for this; it should.",
      "evidence": "WU-47-3-2, WU-47-3-3 and WU-47-3-4 Test Writer reports; the lead added the instruction ad hoc after the first unit",
      "source_ref": "comment:1692",
      "applied_to": null
    }
  ]
}
<!-- learning:v1 issue=47 skill=develop po=PO-47-3 --> ```json { "phase": "develop", "learnings": [ { "id": "L-1", "scope": "project", "status": "applied", "summary": "A helper whose exit codes are a shared vocabulary must own its rc: `_gl_api | jq '.[0]'` made the primitive exit with jq's 5, colliding with the reserved EXIT_ADAPTER=5, so a correctly-detected auth failure was misrouted as a broken install rather than merely lost. The baseline's rc-never-from-a-pipeline rule was stated only in terms of the false-green failure mode; misrouting is the second one.", "evidence": "DISCOVERY, WU-47-3-4 journal — root cause of the AC-7/AC-16 red run", "source_ref": "comment:1926", "applied_to": "CLAUDE.md" }, { "id": "L-2", "scope": "project", "status": "applied", "summary": "`test && cmd` as a script's LAST statement is a latent exit-1 bug under set -e — a false test makes the compound return 1, which becomes the script's exit status, so an ordinary verdict exits non-zero. The baseline forbids the array-append instance by name but states it as an array rule; the general form, and especially the `exit` instance, fails on the success path and is harder to notice.", "evidence": "DISCOVERY, WU-47-3-3 journal — hit in the first draft of the local-fs primitive", "source_ref": "comment:1843", "applied_to": "CLAUDE.md" }, { "id": "L-3", "scope": "project", "status": "applied", "summary": "The sanitization boundary for forge-contract 2.2's classified-error rule is the adapter, not the dispatch: the dispatch forwards a primitive's stderr verbatim by design, so a primitive that lets a CLI's raw stderr flow through it has already leaked. The rule governs everything the primitive writes to fd 2, not only the JSON error field.", "evidence": "DISCOVERY, WU-47-3-4 journal — the AC-16 token and skills-root leak", "source_ref": "comment:1926", "applied_to": "CLAUDE.md" }, { "id": "L-4", "scope": "project", "status": "applied", "summary": "A quoted heredoc blocks ${CLAUDE_PLUGIN_ROOT} substitution, so a skill that emits a script file referencing a helper must pass the plugin root in as an env var; the unquoted-heredoc alternative needs a backslash on every runtime variable and collides with the lint rule against an escaped ${CLAUDE_PLUGIN_ROOT}.", "evidence": "DISCOVERY, WU-47-3-5 journal — emitting promote's pipeline-wait script", "source_ref": "comment:2054", "applied_to": "CLAUDE.md" }, { "id": "L-5", "scope": "project", "status": "applied", "summary": "Where a contract's claim is about the ABSENCE of output, assert on the raw capture rather than a jq parse of it: `jq -r '.state // empty'` on a branch that emits nothing returns empty for any reason at all, including the helper crashing before producing anything, so 'said nothing' and 'crashed' become the same observation. Same principle as rc-from-the-command, applied to the reader side.", "evidence": "DISCOVERY, WU-47-3-7 journal — two live vacuous assertions found and fixed in pipeline-identity.sh", "source_ref": "comment:2076", "applied_to": "CLAUDE.md" }, { "id": "L-6", "scope": "devwork", "status": "unhomed", "summary": "/dev:develop's wu-plan is immutable, but a run that discovers a blocking cross-unit defect mid-phase has no stated way to add a corrective work unit. This run added WU-47-3-8 by lead judgement and recorded the deviation in wu-status.md and the outcome; the skill text neither permits nor forbids it. Either the plan schema should admit an amendment record, or the skill should say plainly that a corrective unit is a legitimate mid-run addition.", "evidence": "Lead observation, PO-47-3 — the none-to-unknown guard defect found by WU-47-3-4 and fixed by the added WU-47-3-8", "source_ref": "comment:1930", "applied_to": null }, { "id": "L-7", "scope": "devwork", "status": "unhomed", "summary": "A Test Writer that reports which of its own assertions pass INCIDENTALLY (green today for a reason unrelated to the behaviour being built) materially changed this run's outcome: three separate units carried such warnings forward, and in each case the Implementer had to make the assertion pass on purpose rather than inherit an accidental green. /dev:develop's Test Writer prompt does not ask for this; it should.", "evidence": "WU-47-3-2, WU-47-3-3 and WU-47-3-4 Test Writer reports; the lead added the instruction ad hoc after the first unit", "source_ref": "comment:1692", "applied_to": null } ] } ```
Author
Owner
{
  "next_state": "qa",
  "produced": [
    {
      "kind": "wu-plan",
      "ref": "1589",
      "summary": "8 work units in 4 waves (7 planned + 1 corrective added mid-run), executed sequentially on a single shared worktree"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "1627",
      "summary": "WU-47-3-1: forge contract Kind column, normative absence paragraphs, grown pipeline_status signature; tea-cli/gh-cli manifests"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "1693",
      "summary": "WU-47-3-2: shared dispatch classifies primitive absence; --sha validated; one stated JSON answer, never a shell error"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "1844",
      "summary": "WU-47-3-3: local-fs records scoped by commit and echoing the commit they describe"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "1928",
      "summary": "WU-47-3-4: glab-cli query scoped by (ref, sha), rc-checked, classified query failures, no state catch-all"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "2055",
      "summary": "WU-47-3-5: promote pins the tag target, asserts commit identity, branches on every state"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "2074",
      "summary": "WU-47-3-6: lint fails the gate on contract Kind gaps and _GATED_PRIMS drift"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "2077",
      "summary": "WU-47-3-7: AC-12 commit-attributed failure states, coverage boundary, hardening pass"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "1930",
      "summary": "WU-47-3-8 (corrective): non-verdict states exempted from the pinned-sha null-commit guard"
    },
    {
      "kind": "dev-journal",
      "ref": "1626",
      "summary": "WU-47-3-1 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "1692",
      "summary": "WU-47-3-2 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "1843",
      "summary": "WU-47-3-3 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "1926",
      "summary": "WU-47-3-4 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "2054",
      "summary": "WU-47-3-5 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "2073",
      "summary": "WU-47-3-6 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "2076",
      "summary": "WU-47-3-7 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "1929",
      "summary": "WU-47-3-8 development journal"
    },
    {
      "kind": "learning",
      "ref": "comment:2078",
      "summary": "7 learnings (5 applied to CLAUDE.md, 2 unhomed devwork)"
    }
  ],
  "findings": [
    {
      "category": "out-of-scope",
      "severity": "medium",
      "summary": "Nothing mechanically checks an adapter's `capabilities:` manifest against its own bin/ directory",
      "reasoning": "Found while writing the contract's capability declaration. The contract's own §4 example manifest had been omitting `close_issue` and `reopen_issue` since 2026-06-19 — the illustration violated the rule its own section states. That was fixed in-place (two lines, inside the unit's file), but the general gap stands: AC-14's drift check covers only the shared-tier copy of the required-vs-gated classification, not manifest-vs-disk parity. A cheap check (list bin/*.sh, diff against `operations:` keys declared non-`unsupported`) would have caught that omission AND the tea-cli/gh-cli omissions this run fixed by hand. Out of scope here: it is new lint capability against a surface this feature does not otherwise touch.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "small",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": true,
      "id": "F-PO-47-3-1"
    },
    {
      "category": "pre-existing",
      "severity": "medium",
      "summary": "Across the _shared/procedures/bin/* family, a `--flag` passed as the last argument makes `shift 2` fail and the helper dies with a bare exit 1 and no message",
      "reasoning": "Pre-existing, and exactly the 'no shell-level error reaches the operator' class this feature is about — but in every other helper rather than in the pipeline path. Fixed inside the two files this feature owns (`release-pipeline-status.sh` and `local-fs/bin/pipeline_status.sh`) with an explicit arity guard per flag; the same shape remains in most other helpers. Sweeping the whole family is a separate change with its own regression surface.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "small",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": true,
      "id": "F-PO-47-3-2"
    },
    {
      "category": "out-of-scope",
      "severity": "low",
      "summary": "`raw_state` emission diverges between two conformant adapters, and the contract does not say a caller must not read its presence as a signal",
      "reasoning": "local-fs emits `raw_state` only when the record declares one; glab-cli emits it on every answer. Both satisfy contract §2.2, whose rule ('MUST be present whenever `state` is `unknown`') is a floor rather than a ceiling. A caller must therefore read only its VALUE, and only when `state` is `unknown` — never its presence. This surfaced as a real test bug during the hardening pass: an assertion requiring `raw_state` to be absent passed on one adapter and failed on the other, with neither adapter at fault. Nothing in the contract states it out loud, so a future adapter author or test could reasonably read it the other way. A one-line contract clarification, which is meta-lane work rather than this feature's.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "trivial",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": true,
      "id": "F-PO-47-3-3"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "high",
      "summary": "The pinned-sha null-commit guard over-fired on non-verdict states, rewriting a legitimate `none` to `unknown` — found and fixed inside this phase",
      "reasoning": "Introduced by WU-47-3-2's AC-17 guard, found by WU-47-3-4's implementer, independently reproduced by the lead, and fixed by a corrective work unit (WU-47-3-8, commit 2ed5282). It mattered because /dev:promote pins --sha on EVERY poll, so this was the normal path, and promote's table sends `unknown` to escalation while `none` must keep waiting — a release polled in the seconds before the forge creates the tag's pipeline would have escalated instead of waiting, which is AC-4. The red run also exposed a second half: a pinned `query_failed` was exiting 0 instead of the reserved 9, because the guard rewrote `state` before the final exit-code check read it. Recorded rather than dropped so QA knows this path was defective mid-phase and is now covered by eight assertions on both adapters. Developer-decidable: a defect in this feature's own diff, fixed in this feature.",
      "proposed_action": "fix-in-this-feature",
      "fix_cost": "trivial",
      "feature_value": "core",
      "adjacent_to_blocking": true,
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-47-3-4"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "high",
      "summary": "/dev:promote's tag path re-resolved the target commit twice without pinning, leaving a narrower route back to the #47 defect — found and fixed in this phase",
      "reasoning": "`git tag` took no explicit commit and `release-create.sh --target` ran its own `git rev-parse \"$INTEGRATION_BRANCH\"`. Either could pick up a commit pushed between the identity gate and the tag, so the assertion would have passed and the tag still landed on a different commit. Both now take the pinned `$TAG_TARGET_SHA`. Recorded because the identity fix is genuinely incomplete without them, and a reader of the SREQ alone would not know they existed. Developer-decidable: a defect in the surface this feature is changing, fixed in this feature.",
      "proposed_action": "fix-in-this-feature",
      "fix_cost": "trivial",
      "feature_value": "core",
      "adjacent_to_blocking": true,
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-47-3-5"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "medium",
      "summary": "The dispatch now reclassifies an adapter-emitted `unsupported` to `unknown` rather than passing it through — beyond the letter of the SREQ",
      "reasoning": "Contract §2.2 states `unsupported` is dispatch-emitted only and warns that an adapter author will eventually emit it anyway. The implementer made that mechanical rather than advisory: an adapter-emitted `unsupported` becomes `unknown` (forge's word kept in `raw_state`) with a loud log. An adapter that ships a primitive has the capability by construction, so the claim is never true, and accepting it would let a rogue adapter make /dev:promote skip the release gate — the same fail-open shape AC-15 closes. Flagged by its author as beyond brief rather than slipped through, and verified end-to-end by the lead. Developer-decidable: it enforces a contract clause the contract already states, and changes no requirement.",
      "proposed_action": "fix-in-this-feature",
      "fix_cost": "trivial",
      "feature_value": "core",
      "adjacent_to_blocking": true,
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-47-3-6"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "Three exit statuses captured in pipeline-identity.sh's AC-6, AC-1/3-offline and AC-16 sections are never asserted",
      "reasoning": "Each is captured as `rc=0; … || rc=$?` and then unused. Not a false-green risk — the accompanying `state` assertions would fail on a crash — but AC-6 in particular never checks that a stated verdict comes with exit 0. Left alone because they sit in closed units' green sections and the gap is cosmetic; recorded so QA can decide whether to tighten them rather than discovering them as a surprise. Developer-decidable: it changes no acceptance criterion, only the redundancy of an already-covered assertion.",
      "proposed_action": "accept",
      "fix_cost": "trivial",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-47-3-7"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "medium",
      "summary": "Several acceptance criteria are verified only in their offline half; their live-forge and promote-run halves route to the dogfood /dev:promote run",
      "reasoning": "AC-1, AC-3, AC-12, AC-13 and AC-20 are SPLIT — the offline half is asserted in pipeline-identity.sh, the live half is not reachable without a real GitLab project and a real release. AC-2, AC-5, AC-8 and AC-19 have no probe coverage beyond a premise or a stated answer, and are dogfood/desk-check by design. The probe now carries a COVERAGE BOUNDARY block naming, for all twenty criteria, exactly where each is verified, so a green run is never mistaken for full coverage. This split was already dispositioned at plan time — the SREQ records it in Technical Risks and the lane annotations, and D-PO-47-2-12 accepted that seven of the fourteen validation scenarios route to human-uat — so no new decision is raised here. Developer-decidable on that basis; recorded so the QA round inherits the boundary explicitly rather than re-deriving it.",
      "proposed_action": "accept",
      "fix_cost": "substantial",
      "feature_value": "core",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-47-3-8"
    }
  ],
  "pending_decisions": [
    {
      "id": "D-PO-47-3-1",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: no mechanical check exists that an adapter's capabilities: manifest matches its own bin/ directory. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-47-3-1",
      "reasoning": "The contract's own §4 example manifest had been omitting close_issue/reopen_issue since 2026-06-19; that instance was fixed in place, but nothing prevents the next divergence. AC-14 covers only the shared-tier copy of the required-vs-gated classification, not manifest-vs-disk parity. A cheap lint check would have caught this and the tea-cli/gh-cli omissions this run fixed by hand."
    },
    {
      "id": "D-PO-47-3-2",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Pre-existing: across the _shared/procedures/bin/* family, a --flag passed as the last argument dies with a bare exit 1 and no message. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-47-3-2",
      "reasoning": "Exactly the 'no shell-level error reaches the operator' class this feature is about, but in every other helper. Fixed in the two files this feature owns; sweeping the family is a separate change with its own regression surface."
    },
    {
      "id": "D-PO-47-3-3",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: raw_state emission diverges between two conformant adapters and the contract does not say a caller must not read its presence as a signal. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-47-3-3",
      "reasoning": "local-fs emits raw_state only when the record declares one; glab-cli emits it on every answer. Both satisfy §2.2, whose rule is a floor not a ceiling. This surfaced as a real test bug during hardening — an assertion requiring absence passed on one adapter and failed on the other with neither at fault. A one-line contract clarification, which is meta-lane work."
    }
  ],
  "suite": {
    "source": "git",
    "sha": "8de068ebafabe5e9693b6cd0c7c87929d3b852dd",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-47-3 skill=develop --> ```json { "next_state": "qa", "produced": [ { "kind": "wu-plan", "ref": "1589", "summary": "8 work units in 4 waves (7 planned + 1 corrective added mid-run), executed sequentially on a single shared worktree" }, { "kind": "work-unit-outcome", "ref": "1627", "summary": "WU-47-3-1: forge contract Kind column, normative absence paragraphs, grown pipeline_status signature; tea-cli/gh-cli manifests" }, { "kind": "work-unit-outcome", "ref": "1693", "summary": "WU-47-3-2: shared dispatch classifies primitive absence; --sha validated; one stated JSON answer, never a shell error" }, { "kind": "work-unit-outcome", "ref": "1844", "summary": "WU-47-3-3: local-fs records scoped by commit and echoing the commit they describe" }, { "kind": "work-unit-outcome", "ref": "1928", "summary": "WU-47-3-4: glab-cli query scoped by (ref, sha), rc-checked, classified query failures, no state catch-all" }, { "kind": "work-unit-outcome", "ref": "2055", "summary": "WU-47-3-5: promote pins the tag target, asserts commit identity, branches on every state" }, { "kind": "work-unit-outcome", "ref": "2074", "summary": "WU-47-3-6: lint fails the gate on contract Kind gaps and _GATED_PRIMS drift" }, { "kind": "work-unit-outcome", "ref": "2077", "summary": "WU-47-3-7: AC-12 commit-attributed failure states, coverage boundary, hardening pass" }, { "kind": "work-unit-outcome", "ref": "1930", "summary": "WU-47-3-8 (corrective): non-verdict states exempted from the pinned-sha null-commit guard" }, { "kind": "dev-journal", "ref": "1626", "summary": "WU-47-3-1 development journal" }, { "kind": "dev-journal", "ref": "1692", "summary": "WU-47-3-2 development journal" }, { "kind": "dev-journal", "ref": "1843", "summary": "WU-47-3-3 development journal" }, { "kind": "dev-journal", "ref": "1926", "summary": "WU-47-3-4 development journal" }, { "kind": "dev-journal", "ref": "2054", "summary": "WU-47-3-5 development journal" }, { "kind": "dev-journal", "ref": "2073", "summary": "WU-47-3-6 development journal" }, { "kind": "dev-journal", "ref": "2076", "summary": "WU-47-3-7 development journal" }, { "kind": "dev-journal", "ref": "1929", "summary": "WU-47-3-8 development journal" }, { "kind": "learning", "ref": "comment:2078", "summary": "7 learnings (5 applied to CLAUDE.md, 2 unhomed devwork)" } ], "findings": [ { "category": "out-of-scope", "severity": "medium", "summary": "Nothing mechanically checks an adapter's `capabilities:` manifest against its own bin/ directory", "reasoning": "Found while writing the contract's capability declaration. The contract's own §4 example manifest had been omitting `close_issue` and `reopen_issue` since 2026-06-19 — the illustration violated the rule its own section states. That was fixed in-place (two lines, inside the unit's file), but the general gap stands: AC-14's drift check covers only the shared-tier copy of the required-vs-gated classification, not manifest-vs-disk parity. A cheap check (list bin/*.sh, diff against `operations:` keys declared non-`unsupported`) would have caught that omission AND the tea-cli/gh-cli omissions this run fixed by hand. Out of scope here: it is new lint capability against a surface this feature does not otherwise touch.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "small", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": true, "id": "F-PO-47-3-1" }, { "category": "pre-existing", "severity": "medium", "summary": "Across the _shared/procedures/bin/* family, a `--flag` passed as the last argument makes `shift 2` fail and the helper dies with a bare exit 1 and no message", "reasoning": "Pre-existing, and exactly the 'no shell-level error reaches the operator' class this feature is about — but in every other helper rather than in the pipeline path. Fixed inside the two files this feature owns (`release-pipeline-status.sh` and `local-fs/bin/pipeline_status.sh`) with an explicit arity guard per flag; the same shape remains in most other helpers. Sweeping the whole family is a separate change with its own regression surface.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "small", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": true, "id": "F-PO-47-3-2" }, { "category": "out-of-scope", "severity": "low", "summary": "`raw_state` emission diverges between two conformant adapters, and the contract does not say a caller must not read its presence as a signal", "reasoning": "local-fs emits `raw_state` only when the record declares one; glab-cli emits it on every answer. Both satisfy contract §2.2, whose rule ('MUST be present whenever `state` is `unknown`') is a floor rather than a ceiling. A caller must therefore read only its VALUE, and only when `state` is `unknown` — never its presence. This surfaced as a real test bug during the hardening pass: an assertion requiring `raw_state` to be absent passed on one adapter and failed on the other, with neither adapter at fault. Nothing in the contract states it out loud, so a future adapter author or test could reasonably read it the other way. A one-line contract clarification, which is meta-lane work rather than this feature's.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "trivial", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": true, "id": "F-PO-47-3-3" }, { "category": "in-scope-deferrable", "severity": "high", "summary": "The pinned-sha null-commit guard over-fired on non-verdict states, rewriting a legitimate `none` to `unknown` — found and fixed inside this phase", "reasoning": "Introduced by WU-47-3-2's AC-17 guard, found by WU-47-3-4's implementer, independently reproduced by the lead, and fixed by a corrective work unit (WU-47-3-8, commit 2ed5282). It mattered because /dev:promote pins --sha on EVERY poll, so this was the normal path, and promote's table sends `unknown` to escalation while `none` must keep waiting — a release polled in the seconds before the forge creates the tag's pipeline would have escalated instead of waiting, which is AC-4. The red run also exposed a second half: a pinned `query_failed` was exiting 0 instead of the reserved 9, because the guard rewrote `state` before the final exit-code check read it. Recorded rather than dropped so QA knows this path was defective mid-phase and is now covered by eight assertions on both adapters. Developer-decidable: a defect in this feature's own diff, fixed in this feature.", "proposed_action": "fix-in-this-feature", "fix_cost": "trivial", "feature_value": "core", "adjacent_to_blocking": true, "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-47-3-4" }, { "category": "in-scope-deferrable", "severity": "high", "summary": "/dev:promote's tag path re-resolved the target commit twice without pinning, leaving a narrower route back to the #47 defect — found and fixed in this phase", "reasoning": "`git tag` took no explicit commit and `release-create.sh --target` ran its own `git rev-parse \"$INTEGRATION_BRANCH\"`. Either could pick up a commit pushed between the identity gate and the tag, so the assertion would have passed and the tag still landed on a different commit. Both now take the pinned `$TAG_TARGET_SHA`. Recorded because the identity fix is genuinely incomplete without them, and a reader of the SREQ alone would not know they existed. Developer-decidable: a defect in the surface this feature is changing, fixed in this feature.", "proposed_action": "fix-in-this-feature", "fix_cost": "trivial", "feature_value": "core", "adjacent_to_blocking": true, "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-47-3-5" }, { "category": "in-scope-deferrable", "severity": "medium", "summary": "The dispatch now reclassifies an adapter-emitted `unsupported` to `unknown` rather than passing it through — beyond the letter of the SREQ", "reasoning": "Contract §2.2 states `unsupported` is dispatch-emitted only and warns that an adapter author will eventually emit it anyway. The implementer made that mechanical rather than advisory: an adapter-emitted `unsupported` becomes `unknown` (forge's word kept in `raw_state`) with a loud log. An adapter that ships a primitive has the capability by construction, so the claim is never true, and accepting it would let a rogue adapter make /dev:promote skip the release gate — the same fail-open shape AC-15 closes. Flagged by its author as beyond brief rather than slipped through, and verified end-to-end by the lead. Developer-decidable: it enforces a contract clause the contract already states, and changes no requirement.", "proposed_action": "fix-in-this-feature", "fix_cost": "trivial", "feature_value": "core", "adjacent_to_blocking": true, "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-47-3-6" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "Three exit statuses captured in pipeline-identity.sh's AC-6, AC-1/3-offline and AC-16 sections are never asserted", "reasoning": "Each is captured as `rc=0; … || rc=$?` and then unused. Not a false-green risk — the accompanying `state` assertions would fail on a crash — but AC-6 in particular never checks that a stated verdict comes with exit 0. Left alone because they sit in closed units' green sections and the gap is cosmetic; recorded so QA can decide whether to tighten them rather than discovering them as a surprise. Developer-decidable: it changes no acceptance criterion, only the redundancy of an already-covered assertion.", "proposed_action": "accept", "fix_cost": "trivial", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-47-3-7" }, { "category": "in-scope-deferrable", "severity": "medium", "summary": "Several acceptance criteria are verified only in their offline half; their live-forge and promote-run halves route to the dogfood /dev:promote run", "reasoning": "AC-1, AC-3, AC-12, AC-13 and AC-20 are SPLIT — the offline half is asserted in pipeline-identity.sh, the live half is not reachable without a real GitLab project and a real release. AC-2, AC-5, AC-8 and AC-19 have no probe coverage beyond a premise or a stated answer, and are dogfood/desk-check by design. The probe now carries a COVERAGE BOUNDARY block naming, for all twenty criteria, exactly where each is verified, so a green run is never mistaken for full coverage. This split was already dispositioned at plan time — the SREQ records it in Technical Risks and the lane annotations, and D-PO-47-2-12 accepted that seven of the fourteen validation scenarios route to human-uat — so no new decision is raised here. Developer-decidable on that basis; recorded so the QA round inherits the boundary explicitly rather than re-deriving it.", "proposed_action": "accept", "fix_cost": "substantial", "feature_value": "core", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-47-3-8" } ], "pending_decisions": [ { "id": "D-PO-47-3-1", "type": "scope-disposition", "blocking": false, "question": "Out of scope: no mechanical check exists that an adapter's capabilities: manifest matches its own bin/ directory. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-47-3-1", "reasoning": "The contract's own §4 example manifest had been omitting close_issue/reopen_issue since 2026-06-19; that instance was fixed in place, but nothing prevents the next divergence. AC-14 covers only the shared-tier copy of the required-vs-gated classification, not manifest-vs-disk parity. A cheap lint check would have caught this and the tea-cli/gh-cli omissions this run fixed by hand." }, { "id": "D-PO-47-3-2", "type": "scope-disposition", "blocking": false, "question": "Pre-existing: across the _shared/procedures/bin/* family, a --flag passed as the last argument dies with a bare exit 1 and no message. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-47-3-2", "reasoning": "Exactly the 'no shell-level error reaches the operator' class this feature is about, but in every other helper. Fixed in the two files this feature owns; sweeping the family is a separate change with its own regression surface." }, { "id": "D-PO-47-3-3", "type": "scope-disposition", "blocking": false, "question": "Out of scope: raw_state emission diverges between two conformant adapters and the contract does not say a caller must not read its presence as a signal. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-47-3-3", "reasoning": "local-fs emits raw_state only when the record declares one; glab-cli emits it on every answer. Both satisfy §2.2, whose rule is a floor not a ceiling. This surfaced as a real test bug during hardening — an assertion requiring absence passed on one adapter and failed on the other with neither at fault. A one-line contract clarification, which is meta-lane work." } ], "suite": { "source": "git", "sha": "8de068ebafabe5e9693b6cd0c7c87929d3b852dd", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "Recommended disposition. A manifest-vs-disk parity check is cheap (list bin/*.sh, diff against the operations: keys declared non-unsupported) and would have caught both the contract's own §4 example omission (close_issue/reopen_issue, standing since 2026-06-19) and the tea-cli/gh-cli omissions #47 fixed by hand. Resolved autonomously under the operator's standing instruction to resolve PO-47-3 as recommended.",
  "rejected_alternative": "Folding into #105 (lint's piped-gate check scope) or #103 (auditing dispatches beyond existence). Rejected: both are adjacent but neither is the same subject — this is a new check on manifest-vs-disk parity, not an extension of an existing check nor dispatch behaviour. Per the prefer-spawn-when-uncertain rule, a redundant issue is recoverable where a wrong fold compounds."
}
<!-- decision-resolution:v1 ref=D-PO-47-3-1 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "Recommended disposition. A manifest-vs-disk parity check is cheap (list bin/*.sh, diff against the operations: keys declared non-unsupported) and would have caught both the contract's own §4 example omission (close_issue/reopen_issue, standing since 2026-06-19) and the tea-cli/gh-cli omissions #47 fixed by hand. Resolved autonomously under the operator's standing instruction to resolve PO-47-3 as recommended.", "rejected_alternative": "Folding into #105 (lint's piped-gate check scope) or #103 (auditing dispatches beyond existence). Rejected: both are adjacent but neither is the same subject — this is a new check on manifest-vs-disk parity, not an extension of an existing check nor dispatch behaviour. Per the prefer-spawn-when-uncertain rule, a redundant issue is recoverable where a wrong fold compounds." } ```
Author
Owner

Linked: this issue is sibling #355 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #355 (recorded by the devwork pipeline).
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "Recommended disposition. This is exactly the 'no shell-level error reaches the operator' class #47 is about, but living in every other helper rather than the pipeline path. #47 fixed it in the two files it owns with an explicit per-flag arity guard; sweeping the family is a separate change with its own regression surface. Resolved autonomously under the operator's standing instruction to resolve PO-47-3 as recommended.",
  "rejected_alternative": "Folding into #257 (a fix lands where the finding pointed, not everywhere the rule lives), which is genuinely the same meta-pattern. Rejected: this is a concrete, actionable sweep with a named guard and a known file set, and folding it into a meta-issue about sweep discipline risks it never being actioned as code."
}
<!-- decision-resolution:v1 ref=D-PO-47-3-2 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "Recommended disposition. This is exactly the 'no shell-level error reaches the operator' class #47 is about, but living in every other helper rather than the pipeline path. #47 fixed it in the two files it owns with an explicit per-flag arity guard; sweeping the family is a separate change with its own regression surface. Resolved autonomously under the operator's standing instruction to resolve PO-47-3 as recommended.", "rejected_alternative": "Folding into #257 (a fix lands where the finding pointed, not everywhere the rule lives), which is genuinely the same meta-pattern. Rejected: this is a concrete, actionable sweep with a named guard and a known file set, and folding it into a meta-issue about sweep discipline risks it never being actioned as code." } ```
Author
Owner

Linked: this issue is sibling #356 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #356 (recorded by the devwork pipeline).
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "Recommended disposition. local-fs emits raw_state only when the record declares one; glab-cli emits it on every answer. Both satisfy §2.2, whose rule (present whenever state is unknown) is a floor rather than a ceiling — so a caller must read its VALUE, and only when state is unknown, never its presence. This surfaced as a real test bug during #47's hardening pass: an assertion requiring absence passed on one adapter and failed on the other, with neither adapter at fault. A one-line contract clarification, which is meta-lane work rather than feature work. Resolved autonomously under the operator's standing instruction to resolve PO-47-3 as recommended.",
  "rejected_alternative": "Folding into #104 (pr_status.pipeline_state commit attribution). Rejected: same contract section, different defect — that one is a missing capability, this is an under-specified field contract."
}
<!-- decision-resolution:v1 ref=D-PO-47-3-3 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "Recommended disposition. local-fs emits raw_state only when the record declares one; glab-cli emits it on every answer. Both satisfy §2.2, whose rule (present whenever state is unknown) is a floor rather than a ceiling — so a caller must read its VALUE, and only when state is unknown, never its presence. This surfaced as a real test bug during #47's hardening pass: an assertion requiring absence passed on one adapter and failed on the other, with neither adapter at fault. A one-line contract clarification, which is meta-lane work rather than feature work. Resolved autonomously under the operator's standing instruction to resolve PO-47-3 as recommended.", "rejected_alternative": "Folding into #104 (pr_status.pipeline_state commit attribution). Rejected: same contract section, different defect — that one is a missing capability, this is an under-specified field contract." } ```
Author
Owner

Linked: this issue is sibling #357 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #357 (recorded by the devwork pipeline).
Author
Owner
{
  "outcome": "skipped",
  "summary": "e2e not applicable (project declaration). The feature's PREQ-derived validation run is NOT skipped: its integration-covered half executes as plugin/skills/_shared/procedures/test/pipeline-identity.sh and is reported in the code domain + the round's aggregate Phase Outcome; its human-uat half (7 of 14 scenarios, per D-PO-47-2-12) routes to a dogfood /dev:promote release run.",
  "findings": [],
  "not_applicable_reason": "markdown+shell skill suite — no application or browser surface to drive end-to-end; validation is the shell test harness plus dogfooding"
}
<!-- qa-report:v1 issue=47 skill=qa domain=e2e phase=validate --> ```json { "outcome": "skipped", "summary": "e2e not applicable (project declaration). The feature's PREQ-derived validation run is NOT skipped: its integration-covered half executes as plugin/skills/_shared/procedures/test/pipeline-identity.sh and is reported in the code domain + the round's aggregate Phase Outcome; its human-uat half (7 of 14 scenarios, per D-PO-47-2-12) routes to a dogfood /dev:promote release run.", "findings": [], "not_applicable_reason": "markdown+shell skill suite — no application or browser surface to drive end-to-end; validation is the shell test harness plus dogfooding" } ```
Author
Owner
{
  "outcome": "skipped",
  "summary": "a11y not applicable — project declaration in CLAUDE.md qa_domains.not_applicable",
  "findings": [],
  "not_applicable_reason": "no rendered UI — the deliverables are markdown skill text and shell helpers"
}
<!-- qa-report:v1 issue=47 skill=qa domain=a11y phase=validate --> ```json { "outcome": "skipped", "summary": "a11y not applicable — project declaration in CLAUDE.md qa_domains.not_applicable", "findings": [], "not_applicable_reason": "no rendered UI — the deliverables are markdown skill text and shell helpers" } ```
Author
Owner
{
  "outcome": "skipped",
  "summary": "security-browser not applicable — project declaration in CLAUDE.md qa_domains.not_applicable",
  "findings": [],
  "not_applicable_reason": "no browser surface — nothing is served or rendered"
}
<!-- qa-report:v1 issue=47 skill=qa domain=security-browser phase=validate --> ```json { "outcome": "skipped", "summary": "security-browser not applicable — project declaration in CLAUDE.md qa_domains.not_applicable", "findings": [], "not_applicable_reason": "no browser surface — nothing is served or rendered" } ```
Author
Owner
{
  "outcome": "skipped",
  "summary": "api not applicable — resolved by the machine-checkable declaration api_invocation.mode: none (qa-domain-applicability.md resolution order, level 1)",
  "findings": [],
  "not_applicable_reason": "project declares api_invocation: { mode: none } — \"no API — markdown+shell skill suite\". This feature adds no HTTP surface; the glab-cli adapter CALLS the GitLab API but exposes no endpoint of its own, and that outbound call is reviewed under the code and security-api domains rather than driven as an API under test."
}
<!-- qa-report:v1 issue=47 skill=qa domain=api phase=validate --> ```json { "outcome": "skipped", "summary": "api not applicable — resolved by the machine-checkable declaration api_invocation.mode: none (qa-domain-applicability.md resolution order, level 1)", "findings": [], "not_applicable_reason": "project declares api_invocation: { mode: none } — \"no API — markdown+shell skill suite\". This feature adds no HTTP surface; the glab-cli adapter CALLS the GitLab API but exposes no endpoint of its own, and that outbound call is reviewed under the code and security-api domains rather than driven as an API under test." } ```
Author
Owner
{
  "outcome": "skipped",
  "summary": "security-api not applicable as a runtime domain — no endpoint is served. The outbound-call security surface this feature does add (forge-CLI stderr leakage, token exposure, fail-open release gating on an untrusted forge response) is covered statically by the code domain's Static Security Reviewer this round.",
  "findings": [],
  "not_applicable_reason": "project declares api_invocation: { mode: none } — \"no API — markdown+shell skill suite\". There are no authenticated endpoints to drive: the suite consumes forge CLIs, it does not serve an API."
}
<!-- qa-report:v1 issue=47 skill=qa domain=security-api phase=validate --> ```json { "outcome": "skipped", "summary": "security-api not applicable as a runtime domain — no endpoint is served. The outbound-call security surface this feature does add (forge-CLI stderr leakage, token exposure, fail-open release gating on an untrusted forge response) is covered statically by the code domain's Static Security Reviewer this round.", "findings": [], "not_applicable_reason": "project declares api_invocation: { mode: none } — \"no API — markdown+shell skill suite\". There are no authenticated endpoints to drive: the suite consumes forge CLIs, it does not serve an API." } ```
Author
Owner
{
  "outcome": "issues-found",
  "summary": "19 findings (1 High, 7 Medium, 11 Low) from a five-reviewer pass over the 19-file diff at 2220ebd. Six are in-scope-blocking, including a stale-verdict read in promote's own wait invocation that reproduces the #47 defect class one layer above the helper this feature fixed. Dependency Verifier and Spec Checker both returned clean (all 20 ACs satisfied, no traceability gaps, no undeclared drift). 1 candidate rejected as a false positive; below precision floor: 0; carried-forward (already dispositioned): 0 (round 1).",
  "findings": [
    {
      "id": "CR-1",
      "category": "in-scope-blocking",
      "severity": "High",
      "summary": "promote reads .outcome from a fixed /tmp result file without checking the wait script's exit status, so a crashed wait reads the PREVIOUS run's verdict",
      "reasoning": "VERIFIED at promote/SKILL.md:420-429 and 484-491. The caller runs the wait script with `|| rc=$?` and then reads OUTCOME with jq from /tmp/promote-pre-tag.result.json; rc is captured and never read. The wait script writes $PIPE_OUT.result.json only after its loop ends and never truncates it at start. If the wait dies before that write (an unset PIPE_* under `set -u`, a killed session, a crash), jq reads the file left by the previous promote run and a stale `green` gates this run's tag. That is exactly the #47 defect -- a previous release's green answering a question about a newer commit -- reproduced one layer above the helper this feature just fixed. Found independently by the Bug Hunter and the Static Security Reviewer and confirmed by the lead. The fixed paths also collide across the concurrent promote runs this repo's own CLAUDE.md documents. All of these lines are added by this diff. Fix shape: a unique per-run directory, delete or initialise the result file before the wait, and treat a missing or empty result as its own terminal branch."
    },
    {
      "id": "CR-2",
      "category": "in-scope-blocking",
      "severity": "Medium",
      "summary": "The dispatch trusts a primitive-origin exit 8 as a capability boundary, so a spurious 8 makes promote skip the release gate and tag ungated",
      "reasoning": "VERIFIED. _lib.sh ends _prim by exec-ing the primitive, so the primitive's own exit status becomes _prim's. release-pipeline-status.sh's EXIT_UNSUPPORTED case assumes the 8 came from _prim's gated-absent _die and emits state 'unsupported' plus exit 8. The state-level version of this violation IS defended -- an adapter-emitted state 'unsupported' is reclassified to 'unknown' with a loud log, precisely because accepting it would let a rogue adapter skip the gate -- while the rc-level twin is fully trusted. Both in-tree adapters already mirror EXIT_QUERY=9, so a third-party adapter mirroring 8 is the invited pattern rather than a stretch. Cheap fix in the same shape as the existing defence: after rc 8, if the primitive script exists on disk then the 8 cannot be the dispatch's own classification, so reclassify to unknown."
    },
    {
      "id": "CR-3",
      "category": "in-scope-blocking",
      "severity": "Medium",
      "summary": "The primitive's new exit 9 crashes the unchanged local-fs pr_status.sh, which pipes it into jq under `set -euo pipefail`",
      "reasoning": "VERIFIED. local-fs/bin/_lib.sh:13 sets `set -euo pipefail`; local-fs/bin/pr_status.sh:24 pipes pipeline_status.sh into jq and assigns the result. This feature gave pipeline_status exit 9 on a staged query_failed record and on an unreadable record (pipeline_status.sh:142,146), and local-fs/SKILL.md now advertises staging query_failed as how the transient-failure branch is driven. So a documented fixture kills pr_status mid-script with no JSON, which promote's branch-pr phase 1 consumes via release-pr-status.sh. The caller is unchanged code, but this diff is what made the crash reachable -- a regression introduced by this feature, not a pre-existing pattern. It is also a live instance of the repo's own rc-from-the-command-never-a-pipeline rule. Secondary contract gap: pr_status's pipeline_state vocabulary has no query_failed value and local-fs pr_status has no mapping for it."
    },
    {
      "id": "CR-4",
      "category": "in-scope-blocking",
      "severity": "Medium",
      "summary": "The local-fs SKILL.md 'several pipelines for one commit' staging example yields `unknown`, not the `success` it appears to stage",
      "reasoning": "VERIFIED at local-fs/SKILL.md:366-371, added by this diff. The example writes a sha-keyed record whose array entries carry state, pipeline_id and created_at but no `commit` field. /dev:promote pins --sha on every poll, so the dispatch's null-commit verdict guard fires and the answer is state 'unknown' with raw_state 'success', which promote's branch table sends to a blocking escalation. An operator following the shipped documentation to drive the green path gets an escalation instead. Each entry needs a `commit` field set to the same sha, which would also demonstrate the attribution this feature is about."
    },
    {
      "id": "CR-5",
      "category": "in-scope-blocking",
      "severity": "Medium",
      "summary": "The branch-pr release monitor re-derives RELEASED_SHA from the production branch tip instead of pr_merge's returned sha (TOCTOU)",
      "reasoning": "VERIFIED at promote/SKILL.md:508, on lines added by this diff: RELEASED_SHA is taken from `git rev-parse origin/$PRODUCTION_BRANCH` after a fetch. forge-contract.md:70,93 declare pr_merge as (pr) -> {merged, sha}, returning the merge commit's identity from the act itself. Re-deriving from the branch tip races every other writer: a concurrent merge between our merge and our fetch pins THEIR commit, so the wait gates this release on the wrong commit's pipeline (a foreign green shipped as ours) or ends identity-mismatch against our own correct pipeline. This is the same defect the feature deliberately fixed one paragraph earlier for the tag path via TAG_TARGET_SHA (finding F-PO-47-3-5); the branch-pr path was missed. Fix: pin from pr_merge's answer."
    },
    {
      "id": "CR-6",
      "category": "in-scope-blocking",
      "severity": "Medium",
      "summary": "The WU-47-3-8 regression guard for this feature's core defect never asserts its exit status, on either adapter",
      "reasoning": "VERIFIED. pipeline-identity.sh:760 (rc8gu) and :770 (rc8gg) each capture the status of the pinned-sha null-commit guard run and never read it; each variable appears exactly twice in the file, declared then assigned. These are the regression tests for F-PO-47-3-4, the commit-less-green-under-a-pin defect this whole feature exists to remove, so a regression that made the guard return the wrong exit code alongside a correct state string would slip through the one test written to catch it. NOTE FOR THE RECORD: the develop phase accepted this pattern as finding F-PO-47-3-7 on a stated count of three; the real count is eight. The accept was made against an undercount and therefore does not cover these."
    },
    {
      "id": "CR-7",
      "category": "in-scope-deferrable",
      "severity": "Medium",
      "summary": "The GitLab status vocabulary is maintained in three places with nothing tying them together, and the probe never reads the fixture it claims to source from",
      "reasoning": "The captured vocabulary lives in external-contracts/gitlab-pipelines.sample.json, is hand-transcribed into pipeline-identity.sh's AC6_CASES here-string, and is mapped again in glab-cli/bin/_lib.sh's GL_JQ_PIPEMAP. Grepping the probe for external-contracts hits only comments: the fixture is read by a human when authoring the test, never by the test. A re-capture that adds or removes a GitLab status updates the JSON while the probe stays green testing a vocabulary the fixture itself says is stale. A cheap mechanical fix is available -- diff AC6_CASES against GL_JQ_PIPEMAP in lint-conventions.sh, the same shape as the AC-14 _GATED_PRIMS drift check this feature already added."
    },
    {
      "id": "CR-8",
      "category": "in-scope-deferrable",
      "severity": "Low",
      "summary": "Uppercase hex --sha is accepted by all three validators while every downstream compare, filename key and query is case-sensitive",
      "reasoning": "VERIFIED: release-pipeline-status.sh:56, local-fs/bin/pipeline_status.sh:82 and glab-cli/bin/pipeline_status.sh:66 all match a 40-character hex class that includes A-F, while their own error messages say (0-9a-f). Nothing normalises. local-fs keys the record file verbatim, so an uppercase pin misses a lowercase-staged record and falls back to the UNPINNED record -- the quiet pin-drop that sha_given was explicitly written to refuse. GitLab reports sha lowercase, so promote's literal compare would declare identity-mismatch against the correct pipeline. Fix: refuse uppercase, matching the message, or lowercase-normalise at validation."
    },
    {
      "id": "CR-9",
      "category": "in-scope-deferrable",
      "severity": "Low",
      "summary": "The wait script's identity check has no non-verdict exemption, so a keep-waiting state annotated with another commit ends the wait as identity-mismatch",
      "reasoning": "The dispatch's null-commit guard deliberately exempts `none` and `query_failed` as non-verdicts; the wait script's identity block runs before the state branch with no such exemption. A record with state 'none' and a different commit terminates the wait immediately, where the branch table says `none` must keep waiting. This is the converse of CR-10 and worth fixing in the same edit."
    },
    {
      "id": "CR-10",
      "category": "in-scope-deferrable",
      "severity": "Low",
      "summary": "The wait loop's identity check is guarded by a non-empty test on commit, so a `success` with an empty commit reaches outcome=green",
      "reasoning": "VERIFIED at promote/SKILL.md:294-296. Safe today only because release-pipeline-status.sh's null-commit guard rewrites that answer to 'unknown' before the wait sees it -- a single point of enforcement for the tag decision, with no defence in depth in the layer that actually decides. Reachable if a future caller invokes an adapter primitive directly, or if that one guard regresses. One-line fix: under a pinned PIPE_SHA, an empty commit on a verdict state is an escalation, not a green."
    },
    {
      "id": "CR-11",
      "category": "in-scope-deferrable",
      "severity": "Low",
      "summary": "The null-commit guard fires on pending/running, though the contract and the code comment both scope it to 'a verdict'",
      "reasoning": "release-pipeline-status.sh:162 exempts only `none` and `query_failed`, but contract 2.2's unknown row reads 'a VERDICT arrived with no commit while sha was pinned', and pending/running are keep-waiting non-verdicts in the contract's own monitor table. A staged record with state 'running' and no commit -- the pre-#47 shape SKILL.md says still parses -- becomes terminal 'unknown' under a pinned poll instead of a wait. Failing closed is defensible and the guard's comment argues for exactly that, but then the prose should say 'any state except none/query_failed', not 'verdict'. This is a text/behaviour mismatch and the text is normative."
    },
    {
      "id": "CR-12",
      "category": "in-scope-deferrable",
      "severity": "Low",
      "summary": "The probe's check() helper compares with [[ == ]], making the expected side a glob pattern",
      "reasoning": "VERIFIED at pipeline-identity.sh:145. The right operand of [[ == ]] is a pattern, not a literal. No current expected value carries glob metacharacters, but the AC-13 byte-identity assertions pass whole JSON answers as the pattern, so a future fixture whose answer contains an asterisk, question mark or bracket would make non-identical strings compare equal -- a silently-passing test in the file that guards this feature. One-token fix: use [ \"$2\" = \"$3\" ]."
    },
    {
      "id": "CR-13",
      "category": "in-scope-deferrable",
      "severity": "Low",
      "summary": "A partial or stale project-scope adapter copy is misreported as a capability boundary rather than a broken install",
      "reasoning": "_prim classifies against ADAPTER_BIN, resolved to the first candidate directory that exists (project, then bundled, then user). A stale project-scope copy of glab-cli missing pipeline_status.sh shadows the complete bundled adapter, and the new classification reports the gap as EXIT_UNSUPPORTED -- a broken install misread as 'this forge cannot do that', which is precisely the fail-open the gated-non-executable branch (AC-15) was written to prevent. Every release on such a project would be silently ungated behind the reassuring skip line. Lower confidence than the rest: the lead did not trace the resolution order end-to-end."
    },
    {
      "id": "CR-14",
      "category": "in-scope-deferrable",
      "severity": "Low",
      "summary": "local-fs forwards raw jq stderr, which can carry an absolute record path, while glab-cli was changed in this same feature to sanitize all of fd 2",
      "reasoning": "local-fs/bin/pipeline_status.sh:138 forwards the captured jq stderr to fd 2 on an unreadable record. For a valid-JSON-but-wrong-type record, jq emits a diagnostic containing the absolute path of the record file, which the dispatch forwards verbatim to the operator by design. It passes AC-16's letter, which greps for skills-root paths, but contradicts this feature's own recorded learning that the sanitization boundary is the adapter and covers everything the primitive writes to fd 2, not only the JSON -- and it is inconsistent with the sibling adapter changed in the same feature."
    },
    {
      "id": "CR-15",
      "category": "in-scope-deferrable",
      "severity": "Low",
      "summary": "The contract simultaneously makes the `unsupported` monitor branch normative and unreachable",
      "reasoning": "New contract text says /dev:setup refuses to pair a no-release-ops adapter with a project declaring a release: block. Yet the state table's unsupported row, promote's skip-line flow, and AC-8's planned dogfood verification (explicitly on a tea-cli project) all require exactly that pairing to exist at run time. This repo's own CLAUDE.md is the live counterexample: tea-cli plus a release: block. A conformance reader cannot tell whether that configuration is refused or is the canonical unsupported-branch scenario. Needs reconciliation in text -- for example setup warns rather than refuses and promote proceeds via unsupported -- and it decides whether AC-8's dogfood half is runnable at all."
    },
    {
      "id": "CR-16",
      "category": "in-scope-deferrable",
      "severity": "Low",
      "summary": "The new pre-tag gate makes a tag-only-CI project escalate on every release; the SREQ's accepted-risk note says this happens 'once'",
      "reasoning": "SREQ.md:233 accepts that a ref which never gets a pipeline waits out ci_timeout once and escalates. The pre-tag gate applies that wait to the integration branch on EVERY release, so a project whose CI runs only on tags -- a common deploy setup -- burns its full ci_timeout polling `none` and then raises a blocking decision before any tag can be placed, every time. Step 5 carries an 'accepted deliberately' note; the new pre-tag site carries no acknowledgement and no declared opt-out for a branch with legitimately no CI. Requires a product decision: accept the cost, add an opt-out declaration, or scope the pre-tag gate to projects whose integration branch has CI."
    },
    {
      "id": "CR-17",
      "category": "in-scope-deferrable",
      "severity": "Low",
      "summary": "Three further captured-but-unasserted exit statuses in the probe (rc17, rc13b, rc8nu)",
      "reasoning": "pipeline-identity.sh:260, :400 and :697. Same pattern as CR-6 but on non-core paths: AC-17's main assertion, AC-13's idempotent second read (partially mitigated by its byte-identity check), and AC-4's unpinned control. Listed separately from CR-6 so the two instances guarding the core regression can be fixed without waiting on a decision about the rest. Together with CR-6 and the three already accepted as F-PO-47-3-7, the file has eight."
    },
    {
      "id": "CR-18",
      "category": "pre-existing",
      "severity": "Medium",
      "summary": "The fixed-/tmp-path pattern also appears in promote's pre-existing findings/pending/produced scratch files",
      "reasoning": "promote/SKILL.md:578, 625, 632 and 636 use fixed /tmp paths on unchanged lines. Same collision-across-concurrent-runs exposure as CR-1 but without CR-1's stale-verdict consequence, since these are written and immediately consumed within the same step. Recorded so the CR-1 fix can decide whether to sweep them in the same edit or leave them; not proposed as this round's work on its own."
    },
    {
      "id": "CR-19",
      "category": "out-of-scope",
      "severity": "Low",
      "summary": "Rejected candidate, recorded so it is not re-derived next round: the external-contracts fixture is not a verbatim API payload",
      "reasoning": "The test-quality audit flagged that gitlab-pipelines.provenance.json says provenance 'recorded' while gitlab-pipelines.sample.json is a shape skeleton plus prose observations rather than a saved HTTP response body. REJECTED as a false positive by the lead: capture-real-interaction.md Step 3 requires exactly that reduction -- 'the shape, no values' -- to avoid committing sensitive data, and Step 4 prescribes provenance 'recorded' for the resulting artifact. The fixture's list_entry block is the prescribed skeleton. No defect. The genuine concern in the neighbourhood is CR-7, filed separately."
    }
  ]
}
<!-- qa-report:v1 issue=47 skill=qa domain=code phase=validate --> ```json { "outcome": "issues-found", "summary": "19 findings (1 High, 7 Medium, 11 Low) from a five-reviewer pass over the 19-file diff at 2220ebd. Six are in-scope-blocking, including a stale-verdict read in promote's own wait invocation that reproduces the #47 defect class one layer above the helper this feature fixed. Dependency Verifier and Spec Checker both returned clean (all 20 ACs satisfied, no traceability gaps, no undeclared drift). 1 candidate rejected as a false positive; below precision floor: 0; carried-forward (already dispositioned): 0 (round 1).", "findings": [ { "id": "CR-1", "category": "in-scope-blocking", "severity": "High", "summary": "promote reads .outcome from a fixed /tmp result file without checking the wait script's exit status, so a crashed wait reads the PREVIOUS run's verdict", "reasoning": "VERIFIED at promote/SKILL.md:420-429 and 484-491. The caller runs the wait script with `|| rc=$?` and then reads OUTCOME with jq from /tmp/promote-pre-tag.result.json; rc is captured and never read. The wait script writes $PIPE_OUT.result.json only after its loop ends and never truncates it at start. If the wait dies before that write (an unset PIPE_* under `set -u`, a killed session, a crash), jq reads the file left by the previous promote run and a stale `green` gates this run's tag. That is exactly the #47 defect -- a previous release's green answering a question about a newer commit -- reproduced one layer above the helper this feature just fixed. Found independently by the Bug Hunter and the Static Security Reviewer and confirmed by the lead. The fixed paths also collide across the concurrent promote runs this repo's own CLAUDE.md documents. All of these lines are added by this diff. Fix shape: a unique per-run directory, delete or initialise the result file before the wait, and treat a missing or empty result as its own terminal branch." }, { "id": "CR-2", "category": "in-scope-blocking", "severity": "Medium", "summary": "The dispatch trusts a primitive-origin exit 8 as a capability boundary, so a spurious 8 makes promote skip the release gate and tag ungated", "reasoning": "VERIFIED. _lib.sh ends _prim by exec-ing the primitive, so the primitive's own exit status becomes _prim's. release-pipeline-status.sh's EXIT_UNSUPPORTED case assumes the 8 came from _prim's gated-absent _die and emits state 'unsupported' plus exit 8. The state-level version of this violation IS defended -- an adapter-emitted state 'unsupported' is reclassified to 'unknown' with a loud log, precisely because accepting it would let a rogue adapter skip the gate -- while the rc-level twin is fully trusted. Both in-tree adapters already mirror EXIT_QUERY=9, so a third-party adapter mirroring 8 is the invited pattern rather than a stretch. Cheap fix in the same shape as the existing defence: after rc 8, if the primitive script exists on disk then the 8 cannot be the dispatch's own classification, so reclassify to unknown." }, { "id": "CR-3", "category": "in-scope-blocking", "severity": "Medium", "summary": "The primitive's new exit 9 crashes the unchanged local-fs pr_status.sh, which pipes it into jq under `set -euo pipefail`", "reasoning": "VERIFIED. local-fs/bin/_lib.sh:13 sets `set -euo pipefail`; local-fs/bin/pr_status.sh:24 pipes pipeline_status.sh into jq and assigns the result. This feature gave pipeline_status exit 9 on a staged query_failed record and on an unreadable record (pipeline_status.sh:142,146), and local-fs/SKILL.md now advertises staging query_failed as how the transient-failure branch is driven. So a documented fixture kills pr_status mid-script with no JSON, which promote's branch-pr phase 1 consumes via release-pr-status.sh. The caller is unchanged code, but this diff is what made the crash reachable -- a regression introduced by this feature, not a pre-existing pattern. It is also a live instance of the repo's own rc-from-the-command-never-a-pipeline rule. Secondary contract gap: pr_status's pipeline_state vocabulary has no query_failed value and local-fs pr_status has no mapping for it." }, { "id": "CR-4", "category": "in-scope-blocking", "severity": "Medium", "summary": "The local-fs SKILL.md 'several pipelines for one commit' staging example yields `unknown`, not the `success` it appears to stage", "reasoning": "VERIFIED at local-fs/SKILL.md:366-371, added by this diff. The example writes a sha-keyed record whose array entries carry state, pipeline_id and created_at but no `commit` field. /dev:promote pins --sha on every poll, so the dispatch's null-commit verdict guard fires and the answer is state 'unknown' with raw_state 'success', which promote's branch table sends to a blocking escalation. An operator following the shipped documentation to drive the green path gets an escalation instead. Each entry needs a `commit` field set to the same sha, which would also demonstrate the attribution this feature is about." }, { "id": "CR-5", "category": "in-scope-blocking", "severity": "Medium", "summary": "The branch-pr release monitor re-derives RELEASED_SHA from the production branch tip instead of pr_merge's returned sha (TOCTOU)", "reasoning": "VERIFIED at promote/SKILL.md:508, on lines added by this diff: RELEASED_SHA is taken from `git rev-parse origin/$PRODUCTION_BRANCH` after a fetch. forge-contract.md:70,93 declare pr_merge as (pr) -> {merged, sha}, returning the merge commit's identity from the act itself. Re-deriving from the branch tip races every other writer: a concurrent merge between our merge and our fetch pins THEIR commit, so the wait gates this release on the wrong commit's pipeline (a foreign green shipped as ours) or ends identity-mismatch against our own correct pipeline. This is the same defect the feature deliberately fixed one paragraph earlier for the tag path via TAG_TARGET_SHA (finding F-PO-47-3-5); the branch-pr path was missed. Fix: pin from pr_merge's answer." }, { "id": "CR-6", "category": "in-scope-blocking", "severity": "Medium", "summary": "The WU-47-3-8 regression guard for this feature's core defect never asserts its exit status, on either adapter", "reasoning": "VERIFIED. pipeline-identity.sh:760 (rc8gu) and :770 (rc8gg) each capture the status of the pinned-sha null-commit guard run and never read it; each variable appears exactly twice in the file, declared then assigned. These are the regression tests for F-PO-47-3-4, the commit-less-green-under-a-pin defect this whole feature exists to remove, so a regression that made the guard return the wrong exit code alongside a correct state string would slip through the one test written to catch it. NOTE FOR THE RECORD: the develop phase accepted this pattern as finding F-PO-47-3-7 on a stated count of three; the real count is eight. The accept was made against an undercount and therefore does not cover these." }, { "id": "CR-7", "category": "in-scope-deferrable", "severity": "Medium", "summary": "The GitLab status vocabulary is maintained in three places with nothing tying them together, and the probe never reads the fixture it claims to source from", "reasoning": "The captured vocabulary lives in external-contracts/gitlab-pipelines.sample.json, is hand-transcribed into pipeline-identity.sh's AC6_CASES here-string, and is mapped again in glab-cli/bin/_lib.sh's GL_JQ_PIPEMAP. Grepping the probe for external-contracts hits only comments: the fixture is read by a human when authoring the test, never by the test. A re-capture that adds or removes a GitLab status updates the JSON while the probe stays green testing a vocabulary the fixture itself says is stale. A cheap mechanical fix is available -- diff AC6_CASES against GL_JQ_PIPEMAP in lint-conventions.sh, the same shape as the AC-14 _GATED_PRIMS drift check this feature already added." }, { "id": "CR-8", "category": "in-scope-deferrable", "severity": "Low", "summary": "Uppercase hex --sha is accepted by all three validators while every downstream compare, filename key and query is case-sensitive", "reasoning": "VERIFIED: release-pipeline-status.sh:56, local-fs/bin/pipeline_status.sh:82 and glab-cli/bin/pipeline_status.sh:66 all match a 40-character hex class that includes A-F, while their own error messages say (0-9a-f). Nothing normalises. local-fs keys the record file verbatim, so an uppercase pin misses a lowercase-staged record and falls back to the UNPINNED record -- the quiet pin-drop that sha_given was explicitly written to refuse. GitLab reports sha lowercase, so promote's literal compare would declare identity-mismatch against the correct pipeline. Fix: refuse uppercase, matching the message, or lowercase-normalise at validation." }, { "id": "CR-9", "category": "in-scope-deferrable", "severity": "Low", "summary": "The wait script's identity check has no non-verdict exemption, so a keep-waiting state annotated with another commit ends the wait as identity-mismatch", "reasoning": "The dispatch's null-commit guard deliberately exempts `none` and `query_failed` as non-verdicts; the wait script's identity block runs before the state branch with no such exemption. A record with state 'none' and a different commit terminates the wait immediately, where the branch table says `none` must keep waiting. This is the converse of CR-10 and worth fixing in the same edit." }, { "id": "CR-10", "category": "in-scope-deferrable", "severity": "Low", "summary": "The wait loop's identity check is guarded by a non-empty test on commit, so a `success` with an empty commit reaches outcome=green", "reasoning": "VERIFIED at promote/SKILL.md:294-296. Safe today only because release-pipeline-status.sh's null-commit guard rewrites that answer to 'unknown' before the wait sees it -- a single point of enforcement for the tag decision, with no defence in depth in the layer that actually decides. Reachable if a future caller invokes an adapter primitive directly, or if that one guard regresses. One-line fix: under a pinned PIPE_SHA, an empty commit on a verdict state is an escalation, not a green." }, { "id": "CR-11", "category": "in-scope-deferrable", "severity": "Low", "summary": "The null-commit guard fires on pending/running, though the contract and the code comment both scope it to 'a verdict'", "reasoning": "release-pipeline-status.sh:162 exempts only `none` and `query_failed`, but contract 2.2's unknown row reads 'a VERDICT arrived with no commit while sha was pinned', and pending/running are keep-waiting non-verdicts in the contract's own monitor table. A staged record with state 'running' and no commit -- the pre-#47 shape SKILL.md says still parses -- becomes terminal 'unknown' under a pinned poll instead of a wait. Failing closed is defensible and the guard's comment argues for exactly that, but then the prose should say 'any state except none/query_failed', not 'verdict'. This is a text/behaviour mismatch and the text is normative." }, { "id": "CR-12", "category": "in-scope-deferrable", "severity": "Low", "summary": "The probe's check() helper compares with [[ == ]], making the expected side a glob pattern", "reasoning": "VERIFIED at pipeline-identity.sh:145. The right operand of [[ == ]] is a pattern, not a literal. No current expected value carries glob metacharacters, but the AC-13 byte-identity assertions pass whole JSON answers as the pattern, so a future fixture whose answer contains an asterisk, question mark or bracket would make non-identical strings compare equal -- a silently-passing test in the file that guards this feature. One-token fix: use [ \"$2\" = \"$3\" ]." }, { "id": "CR-13", "category": "in-scope-deferrable", "severity": "Low", "summary": "A partial or stale project-scope adapter copy is misreported as a capability boundary rather than a broken install", "reasoning": "_prim classifies against ADAPTER_BIN, resolved to the first candidate directory that exists (project, then bundled, then user). A stale project-scope copy of glab-cli missing pipeline_status.sh shadows the complete bundled adapter, and the new classification reports the gap as EXIT_UNSUPPORTED -- a broken install misread as 'this forge cannot do that', which is precisely the fail-open the gated-non-executable branch (AC-15) was written to prevent. Every release on such a project would be silently ungated behind the reassuring skip line. Lower confidence than the rest: the lead did not trace the resolution order end-to-end." }, { "id": "CR-14", "category": "in-scope-deferrable", "severity": "Low", "summary": "local-fs forwards raw jq stderr, which can carry an absolute record path, while glab-cli was changed in this same feature to sanitize all of fd 2", "reasoning": "local-fs/bin/pipeline_status.sh:138 forwards the captured jq stderr to fd 2 on an unreadable record. For a valid-JSON-but-wrong-type record, jq emits a diagnostic containing the absolute path of the record file, which the dispatch forwards verbatim to the operator by design. It passes AC-16's letter, which greps for skills-root paths, but contradicts this feature's own recorded learning that the sanitization boundary is the adapter and covers everything the primitive writes to fd 2, not only the JSON -- and it is inconsistent with the sibling adapter changed in the same feature." }, { "id": "CR-15", "category": "in-scope-deferrable", "severity": "Low", "summary": "The contract simultaneously makes the `unsupported` monitor branch normative and unreachable", "reasoning": "New contract text says /dev:setup refuses to pair a no-release-ops adapter with a project declaring a release: block. Yet the state table's unsupported row, promote's skip-line flow, and AC-8's planned dogfood verification (explicitly on a tea-cli project) all require exactly that pairing to exist at run time. This repo's own CLAUDE.md is the live counterexample: tea-cli plus a release: block. A conformance reader cannot tell whether that configuration is refused or is the canonical unsupported-branch scenario. Needs reconciliation in text -- for example setup warns rather than refuses and promote proceeds via unsupported -- and it decides whether AC-8's dogfood half is runnable at all." }, { "id": "CR-16", "category": "in-scope-deferrable", "severity": "Low", "summary": "The new pre-tag gate makes a tag-only-CI project escalate on every release; the SREQ's accepted-risk note says this happens 'once'", "reasoning": "SREQ.md:233 accepts that a ref which never gets a pipeline waits out ci_timeout once and escalates. The pre-tag gate applies that wait to the integration branch on EVERY release, so a project whose CI runs only on tags -- a common deploy setup -- burns its full ci_timeout polling `none` and then raises a blocking decision before any tag can be placed, every time. Step 5 carries an 'accepted deliberately' note; the new pre-tag site carries no acknowledgement and no declared opt-out for a branch with legitimately no CI. Requires a product decision: accept the cost, add an opt-out declaration, or scope the pre-tag gate to projects whose integration branch has CI." }, { "id": "CR-17", "category": "in-scope-deferrable", "severity": "Low", "summary": "Three further captured-but-unasserted exit statuses in the probe (rc17, rc13b, rc8nu)", "reasoning": "pipeline-identity.sh:260, :400 and :697. Same pattern as CR-6 but on non-core paths: AC-17's main assertion, AC-13's idempotent second read (partially mitigated by its byte-identity check), and AC-4's unpinned control. Listed separately from CR-6 so the two instances guarding the core regression can be fixed without waiting on a decision about the rest. Together with CR-6 and the three already accepted as F-PO-47-3-7, the file has eight." }, { "id": "CR-18", "category": "pre-existing", "severity": "Medium", "summary": "The fixed-/tmp-path pattern also appears in promote's pre-existing findings/pending/produced scratch files", "reasoning": "promote/SKILL.md:578, 625, 632 and 636 use fixed /tmp paths on unchanged lines. Same collision-across-concurrent-runs exposure as CR-1 but without CR-1's stale-verdict consequence, since these are written and immediately consumed within the same step. Recorded so the CR-1 fix can decide whether to sweep them in the same edit or leave them; not proposed as this round's work on its own." }, { "id": "CR-19", "category": "out-of-scope", "severity": "Low", "summary": "Rejected candidate, recorded so it is not re-derived next round: the external-contracts fixture is not a verbatim API payload", "reasoning": "The test-quality audit flagged that gitlab-pipelines.provenance.json says provenance 'recorded' while gitlab-pipelines.sample.json is a shape skeleton plus prose observations rather than a saved HTTP response body. REJECTED as a false positive by the lead: capture-real-interaction.md Step 3 requires exactly that reduction -- 'the shape, no values' -- to avoid committing sensitive data, and Step 4 prescribes provenance 'recorded' for the resulting artifact. The fixture's list_entry block is the prescribed skeleton. No defect. The genuine concern in the neighbourhood is CR-7, filed separately." } ] } ```
Author
Owner

Linked: this issue is spawned-from #415 (recorded by the devwork pipeline).

Linked: this issue is **spawned-from** #415 (recorded by the devwork pipeline).
Author
Owner

Linked: this issue is spawned-from #416 (recorded by the devwork pipeline).

Linked: this issue is **spawned-from** #416 (recorded by the devwork pipeline).
Author
Owner

Linked: this issue is spawned-from #417 (recorded by the devwork pipeline).

Linked: this issue is **spawned-from** #417 (recorded by the devwork pipeline).
Author
Owner
{
  "outcome": "clean",
  "summary": "19 red assertions written for CR-2, CR-3, CR-4, CR-8 and CR-14, all confirmed failing at e32730f before any fix existed. CR-6, CR-12 and CR-17 were defects in the test file itself and are closed in the same commit. The six markdown-tier findings have no executable lane and are recorded here as desk-check-only rather than left as a silent gap.",
  "findings": [
    {
      "id": "CR-1",
      "category": "in-scope-blocking",
      "severity": "High",
      "summary": "No regression test is possible for CR-1, CR-5, CR-9, CR-10, CR-11-prose, CR-15 and CR-16 — they live in skill markdown, which no harness in this suite executes",
      "reasoning": "Recorded as a coverage note, not a missing test. The test plan's own lane rule already establishes that no harness executes skill markdown, which is why seven of its fourteen scenarios route to human-uat. These fixes are verified by desk-check against the originating scenario plus `bash -n` over the wait script extracted from the fence, and their live half rides the same dogfood /dev:promote run the SREQ already routes AC-2/AC-5/AC-8/AC-19 to. The one partial exception is CR-4: it is a documentation defect, but the thing it documents is executable, so its test EXTRACTS the fence from local-fs/SKILL.md and runs it rather than retyping the example — the doc cannot drift away from the probe."
    }
  ],
  "artifacts": {
    "test_files": [
      "plugin/skills/_shared/procedures/test/pipeline-identity.sh"
    ],
    "test_commit": "e32730f",
    "test_marker": {
      "runner": "custom shell harness (plugin/skills/_shared/procedures/test/*.sh)",
      "write": "a plain check() assertion stating the intended behaviour; the suite exits 1 and prints FAIL until the fix lands",
      "promote": "none — the assertion is already in its final form. This harness has no skip/fixme qualifier and red-markers.md maps no marker for it, so TDD-red is expressed by the suite genuinely failing (confirmed: 19 FAIL lines at e32730f) rather than by a marker to strip. The fix stage must therefore change only non-test files, which is checkable with `git diff --name-only <test_commit>`."
    }
  }
}
<!-- qa-report:v1 issue=47 skill=qa domain=code phase=tests --> ```json { "outcome": "clean", "summary": "19 red assertions written for CR-2, CR-3, CR-4, CR-8 and CR-14, all confirmed failing at e32730f before any fix existed. CR-6, CR-12 and CR-17 were defects in the test file itself and are closed in the same commit. The six markdown-tier findings have no executable lane and are recorded here as desk-check-only rather than left as a silent gap.", "findings": [ { "id": "CR-1", "category": "in-scope-blocking", "severity": "High", "summary": "No regression test is possible for CR-1, CR-5, CR-9, CR-10, CR-11-prose, CR-15 and CR-16 — they live in skill markdown, which no harness in this suite executes", "reasoning": "Recorded as a coverage note, not a missing test. The test plan's own lane rule already establishes that no harness executes skill markdown, which is why seven of its fourteen scenarios route to human-uat. These fixes are verified by desk-check against the originating scenario plus `bash -n` over the wait script extracted from the fence, and their live half rides the same dogfood /dev:promote run the SREQ already routes AC-2/AC-5/AC-8/AC-19 to. The one partial exception is CR-4: it is a documentation defect, but the thing it documents is executable, so its test EXTRACTS the fence from local-fs/SKILL.md and runs it rather than retyping the example — the doc cannot drift away from the probe." } ], "artifacts": { "test_files": [ "plugin/skills/_shared/procedures/test/pipeline-identity.sh" ], "test_commit": "e32730f", "test_marker": { "runner": "custom shell harness (plugin/skills/_shared/procedures/test/*.sh)", "write": "a plain check() assertion stating the intended behaviour; the suite exits 1 and prints FAIL until the fix lands", "promote": "none — the assertion is already in its final form. This harness has no skip/fixme qualifier and red-markers.md maps no marker for it, so TDD-red is expressed by the suite genuinely failing (confirmed: 19 FAIL lines at e32730f) rather than by a marker to strip. The fix stage must therefore change only non-test files, which is checkable with `git diff --name-only <test_commit>`." } } } ```
Author
Owner
{
  "outcome": "clean",
  "summary": "All 14 fix-now findings applied at efd3b6e. The 19 red assertions are green and the file's 141 checks all pass; lint-conventions clean; 13/13 offline suites green; the emitted wait script extracted from its fence and bash -n clean. Assertion-constraint check PASSED — `git diff --name-only e32730f` lists no test file, so the tests that define 'fixed' are byte-identical to the ones written before the fixes existed. CR-7, CR-13 and CR-18 were deferred by operator decision to #415, #416 and #417.",
  "findings": [
    {
      "id": "CR-16",
      "category": "in-scope-deferrable",
      "severity": "Low",
      "summary": "The CR-16 fix adds a new `release.integration_ci` field that /dev:setup does not yet know how to propose",
      "reasoning": "Residue of the fix, recorded rather than dropped. `release.integration_ci: none` is read by /dev:promote's Step 4.3 and documented in promote's `release:` schema block, but /dev:setup's release-block prompt does not offer it, so a project that needs it only discovers it after its first release burns a full ci_timeout and escalates. Not blocking: the default (absent = the gate runs) is the safe one, the escalation message is actionable, and the field is a one-line addition to CLAUDE.md once known. Flagged so the sweep can decide whether it wants a setup prompt in this feature or a follow-up; it is the same class as the delegation-hook gap already filed as #60."
    },
    {
      "id": "CR-3",
      "category": "in-scope-deferrable",
      "severity": "Low",
      "summary": "pr_status's `pipeline_state` vocabulary still has no declared `query_failed` value in the contract, though local-fs now emits it",
      "reasoning": "The CR-3 fix makes local-fs pr_status report `query_failed` in `pipeline_state` instead of dying, which is strictly better than a crash and is what promote's branch-pr poll can already branch on. But forge-contract §2.2's pr_status row does not list `query_failed` among the values that field may take, so the adapter is now slightly ahead of the contract text. Recorded rather than widened here: changing the pr_status contract touches every adapter that implements it and the glab-cli side has no equivalent change in this feature, so it is a contract edit that wants its own review rather than a rider on a fix round."
    }
  ],
  "artifacts": {
    "test_files": [
      "plugin/skills/_shared/procedures/test/pipeline-identity.sh"
    ],
    "test_commit": "e32730f"
  }
}
<!-- qa-report:v1 issue=47 skill=qa domain=code phase=fix --> ```json { "outcome": "clean", "summary": "All 14 fix-now findings applied at efd3b6e. The 19 red assertions are green and the file's 141 checks all pass; lint-conventions clean; 13/13 offline suites green; the emitted wait script extracted from its fence and bash -n clean. Assertion-constraint check PASSED — `git diff --name-only e32730f` lists no test file, so the tests that define 'fixed' are byte-identical to the ones written before the fixes existed. CR-7, CR-13 and CR-18 were deferred by operator decision to #415, #416 and #417.", "findings": [ { "id": "CR-16", "category": "in-scope-deferrable", "severity": "Low", "summary": "The CR-16 fix adds a new `release.integration_ci` field that /dev:setup does not yet know how to propose", "reasoning": "Residue of the fix, recorded rather than dropped. `release.integration_ci: none` is read by /dev:promote's Step 4.3 and documented in promote's `release:` schema block, but /dev:setup's release-block prompt does not offer it, so a project that needs it only discovers it after its first release burns a full ci_timeout and escalates. Not blocking: the default (absent = the gate runs) is the safe one, the escalation message is actionable, and the field is a one-line addition to CLAUDE.md once known. Flagged so the sweep can decide whether it wants a setup prompt in this feature or a follow-up; it is the same class as the delegation-hook gap already filed as #60." }, { "id": "CR-3", "category": "in-scope-deferrable", "severity": "Low", "summary": "pr_status's `pipeline_state` vocabulary still has no declared `query_failed` value in the contract, though local-fs now emits it", "reasoning": "The CR-3 fix makes local-fs pr_status report `query_failed` in `pipeline_state` instead of dying, which is strictly better than a crash and is what promote's branch-pr poll can already branch on. But forge-contract §2.2's pr_status row does not list `query_failed` among the values that field may take, so the adapter is now slightly ahead of the contract text. Recorded rather than widened here: changing the pr_status contract touches every adapter that implements it and the glab-cli side has no equivalent change in this feature, so it is a contract edit that wants its own review rather than a rider on a fix round." } ], "artifacts": { "test_files": [ "plugin/skills/_shared/procedures/test/pipeline-identity.sh" ], "test_commit": "e32730f" } } ```
Author
Owner
{
  "outcome": "clean",
  "summary": "Fresh-context re-validation of the round-1 fixes at efd3b6e, run without any account of what was changed. 12 of 12 re-checked findings confirmed fixed; 2 NEW findings, one Critical, both introduced BY the fixes. Both fixed at 0388527 and the tree is green again: pipeline-identity 153/153, lint clean, 13/13 offline suites. This report supersedes the round-1 validate (comment 2201), whose 19 findings remain readable there as history.",
  "findings": [
    {
      "id": "CR-1",
      "category": "in-scope-blocking",
      "severity": "Critical",
      "summary": "RV-1: the CR-10 fix made every `unsupported` answer unreachable, so promote would refuse to tag on every release for an adapter with no pipeline reporting — reintroducing #55 inside #47's own fix round",
      "reasoning": "FOUND BY THE RE-VALIDATION, REPRODUCED BY THE LEAD, FIXED AT 0388527. CR-10 added an empty-commit guard to promote's wait loop exempting only `none`/`query_failed`. But a dispatch-emitted `unsupported` always carries commit:null — release-pipeline-status.sh's `_answer` synthesizes it that way, and correctly, since there is no pipeline whose commit could be named. So every `unsupported` answer hit the guard, became `unattributable`, and broke out of the loop BEFORE the `unsupported)` branch below it, making the skip line, the proceed-to-deploy_check and the exit-8 mapping all dead code. The live case is this repo: tea-cli ships no pipeline_status primitive and declares a `release:` block — the pairing CR-15 had just confirmed is legitimate — so every /dev:promote run here would have escalated a blocking decision instead of tagging. FIX: the exemption is now the three states that describe NO PIPELINE, and the comment states that as the rule; 'non-verdicts' was the wrong frame and is what made `unsupported` look like it did not belong. Recorded as blocking rather than quietly folded into the fix commit because it is a regression this feature's own fix round produced, and because it is the second time in this feature that a guard written for the commit-blind-green defect has over-fired (the first was F-PO-47-3-4, corrected by WU-47-3-8). That is a pattern worth carrying to the sweep, not a one-off."
    },
    {
      "id": "CR-2",
      "category": "in-scope-blocking",
      "severity": "Medium",
      "summary": "RV-2: promote's branch-pr phase 1 text denied a `query_failed` that this feature's own CR-3 fix had just made reachable",
      "reasoning": "FOUND BY THE RE-VALIDATION, FIXED AT 0388527. The phase-1 prose (added by this feature) said pr_status has 'no unsupported / query_failed value to branch on'. The `unsupported` half holds for both real adapters. The `query_failed` half was made false by CR-3, which sets pipeline_state=query_failed for local-fs whenever the head's pipeline query produces no parseable answer — and local-fs is the adapter the release skills are developed and smoke-tested against. An agent following the prose literally could omit retry handling for a state the shared branch table does cover. The sentence now says query_failed can arise here and is non-terminal."
    },
    {
      "id": "CR-3",
      "category": "in-scope-deferrable",
      "severity": "Medium",
      "summary": "The round-1 tests report was wrong to record promote's wait loop as having no executable lane — and that error is what let RV-1 through",
      "reasoning": "Recorded as a finding against this round's own process rather than against the code, because it is the cause of the Critical above. The tests stage classified all promote/SKILL.md findings as desk-check-only on the grounds that no harness executes skill markdown. That is true of the surrounding prose and false of the wait loop: the loop is written to a file and run with `bash`, so it can be extracted from its fence and driven with a stubbed helper — which is exactly what the re-validation did to find RV-1. Now closed: 13 assertions drive the real extracted loop (the unsupported regression, green/identity-mismatch/unattributable/terminal on verdicts, all three no-pipeline states continuing to wait, off-contract, no-answer), and both fence extractors fail loudly rather than vacuously if their markers move. The general lesson — 'emitted by a skill' is not the same as 'not executable', and the test to reach for is extract-and-drive — is a candidate learning for the sweep to record."
    }
  ],
  "artifacts": {
    "test_files": [
      "plugin/skills/_shared/procedures/test/pipeline-identity.sh"
    ],
    "test_commit": "0388527"
  }
}
<!-- qa-report:v1 issue=47 skill=qa domain=code phase=validate --> ```json { "outcome": "clean", "summary": "Fresh-context re-validation of the round-1 fixes at efd3b6e, run without any account of what was changed. 12 of 12 re-checked findings confirmed fixed; 2 NEW findings, one Critical, both introduced BY the fixes. Both fixed at 0388527 and the tree is green again: pipeline-identity 153/153, lint clean, 13/13 offline suites. This report supersedes the round-1 validate (comment 2201), whose 19 findings remain readable there as history.", "findings": [ { "id": "CR-1", "category": "in-scope-blocking", "severity": "Critical", "summary": "RV-1: the CR-10 fix made every `unsupported` answer unreachable, so promote would refuse to tag on every release for an adapter with no pipeline reporting — reintroducing #55 inside #47's own fix round", "reasoning": "FOUND BY THE RE-VALIDATION, REPRODUCED BY THE LEAD, FIXED AT 0388527. CR-10 added an empty-commit guard to promote's wait loop exempting only `none`/`query_failed`. But a dispatch-emitted `unsupported` always carries commit:null — release-pipeline-status.sh's `_answer` synthesizes it that way, and correctly, since there is no pipeline whose commit could be named. So every `unsupported` answer hit the guard, became `unattributable`, and broke out of the loop BEFORE the `unsupported)` branch below it, making the skip line, the proceed-to-deploy_check and the exit-8 mapping all dead code. The live case is this repo: tea-cli ships no pipeline_status primitive and declares a `release:` block — the pairing CR-15 had just confirmed is legitimate — so every /dev:promote run here would have escalated a blocking decision instead of tagging. FIX: the exemption is now the three states that describe NO PIPELINE, and the comment states that as the rule; 'non-verdicts' was the wrong frame and is what made `unsupported` look like it did not belong. Recorded as blocking rather than quietly folded into the fix commit because it is a regression this feature's own fix round produced, and because it is the second time in this feature that a guard written for the commit-blind-green defect has over-fired (the first was F-PO-47-3-4, corrected by WU-47-3-8). That is a pattern worth carrying to the sweep, not a one-off." }, { "id": "CR-2", "category": "in-scope-blocking", "severity": "Medium", "summary": "RV-2: promote's branch-pr phase 1 text denied a `query_failed` that this feature's own CR-3 fix had just made reachable", "reasoning": "FOUND BY THE RE-VALIDATION, FIXED AT 0388527. The phase-1 prose (added by this feature) said pr_status has 'no unsupported / query_failed value to branch on'. The `unsupported` half holds for both real adapters. The `query_failed` half was made false by CR-3, which sets pipeline_state=query_failed for local-fs whenever the head's pipeline query produces no parseable answer — and local-fs is the adapter the release skills are developed and smoke-tested against. An agent following the prose literally could omit retry handling for a state the shared branch table does cover. The sentence now says query_failed can arise here and is non-terminal." }, { "id": "CR-3", "category": "in-scope-deferrable", "severity": "Medium", "summary": "The round-1 tests report was wrong to record promote's wait loop as having no executable lane — and that error is what let RV-1 through", "reasoning": "Recorded as a finding against this round's own process rather than against the code, because it is the cause of the Critical above. The tests stage classified all promote/SKILL.md findings as desk-check-only on the grounds that no harness executes skill markdown. That is true of the surrounding prose and false of the wait loop: the loop is written to a file and run with `bash`, so it can be extracted from its fence and driven with a stubbed helper — which is exactly what the re-validation did to find RV-1. Now closed: 13 assertions drive the real extracted loop (the unsupported regression, green/identity-mismatch/unattributable/terminal on verdicts, all three no-pipeline states continuing to wait, off-contract, no-answer), and both fence extractors fail loudly rather than vacuously if their markers move. The general lesson — 'emitted by a skill' is not the same as 'not executable', and the test to reach for is extract-and-drive — is a candidate learning for the sweep to record." } ], "artifacts": { "test_files": [ "plugin/skills/_shared/procedures/test/pipeline-identity.sh" ], "test_commit": "0388527" } } ```
Author
Owner
{
  "next_state": "qa",
  "produced": [
    {
      "kind": "qa-report",
      "ref": "2185",
      "summary": "e2e — skipped (project declaration); the PREQ-derived validation run itself is NOT skipped, see below"
    },
    {
      "kind": "qa-report",
      "ref": "2186",
      "summary": "a11y — skipped (project declaration: no rendered UI)"
    },
    {
      "kind": "qa-report",
      "ref": "2187",
      "summary": "security-browser — skipped (project declaration: no browser surface)"
    },
    {
      "kind": "qa-report",
      "ref": "2188",
      "summary": "api — skipped (api_invocation.mode: none, the machine-checkable declaration)"
    },
    {
      "kind": "qa-report",
      "ref": "2189",
      "summary": "security-api — skipped as a runtime domain; the outbound-call security surface was covered statically by the code domain"
    },
    {
      "kind": "qa-report",
      "ref": "2201",
      "summary": "code phase=validate — 19 findings (1 High, 7 Medium, 11 Low) from a five-reviewer pass; superseded by 2276 but retained as the round's finding record"
    },
    {
      "kind": "qa-report",
      "ref": "2272",
      "summary": "code phase=tests — 19 red assertions at e32730f, confirmed failing before any fix existed"
    },
    {
      "kind": "qa-report",
      "ref": "2273",
      "summary": "code phase=fix — 14 findings applied at efd3b6e; assertion-constraint check passed"
    },
    {
      "kind": "qa-report",
      "ref": "2276",
      "summary": "code phase=validate (fresh-context RE-VALIDATION) — 12/12 fixes confirmed, 2 NEW findings introduced by the fixes, 1 Critical; both fixed at 0388527"
    },
    {
      "kind": "code-bundle",
      "ref": "e32730f",
      "summary": "red tests: CR-2, CR-3, CR-4, CR-8, CR-14 + closed CR-6/CR-12/CR-17 in the test file"
    },
    {
      "kind": "code-bundle",
      "ref": "efd3b6e",
      "summary": "round-1 fixes: CR-1..CR-5, CR-8..CR-11, CR-14..CR-16"
    },
    {
      "kind": "code-bundle",
      "ref": "0388527",
      "summary": "re-validation fixes: RV-1 (Critical, self-inflicted), RV-2, plus the extract-and-drive lane for promote's wait loop"
    }
  ],
  "findings": [
    {
      "category": "in-scope-blocking",
      "severity": "critical",
      "summary": "[code RV-1] The round-1 fix for CR-10 made every `unsupported` answer unreachable in promote's wait loop, which would have made this repo's own releases escalate instead of tagging — #55 reintroduced inside #47's fix round",
      "reasoning": "A guard added for the commit-blind-green defect exempted only `none`/`query_failed`, but a dispatch-emitted `unsupported` always carries commit:null by construction, so it hit the guard and became `unattributable` before its own branch could fire. Caught by fresh-context re-validation, which was given the findings but NOT an account of what had been changed — the mechanism working exactly as qa-playbook §4 describes. Fixed at 0388527, and the fence is now under test via extract-and-drive. NOTE THE PATTERN: this is the SECOND time in this feature that a guard written against commit-blind green has over-fired on states that legitimately have no commit (the first was F-PO-47-3-4, corrected by WU-47-3-8 during develop). Two instances of one class is worth carrying into the sweep as a thing to look for, not just two bugs that were fixed.",
      "proposed_action": "fix-in-this-feature",
      "fix_cost": "trivial",
      "feature_value": "core",
      "adjacent_to_blocking": true,
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-47-4-1"
    },
    {
      "category": "in-scope-blocking",
      "severity": "high",
      "summary": "[code CR-1] promote read its wait outcome from a fixed /tmp result file without checking the wait's exit status, so a crashed wait read the PREVIOUS run's verdict",
      "reasoning": "The highest-severity finding of the round-1 validate, and structurally the #47 defect itself one layer above the helper this feature fixed: a previous release's `green` answering a question about a newer commit. Found independently by the Bug Hunter and the Static Security Reviewer. Fixed at efd3b6e with a per-run scratch directory, a delete-before-wait, and a new terminal `no-result` outcome.",
      "proposed_action": "fix-in-this-feature",
      "fix_cost": "small",
      "feature_value": "core",
      "adjacent_to_blocking": true,
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-47-4-2"
    },
    {
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "[code CR-2..CR-5, CR-8..CR-11, CR-14..CR-16, RV-2] Thirteen further defects in this feature's own diff, all fixed this round",
      "reasoning": "A primitive-origin exit 8 trusted as a capability boundary (would let a rogue adapter skip the release gate); the new exit 9 crashing local-fs pr_status.sh under pipefail; the shipped multi-pipeline staging example producing `unknown` rather than the `success` it appeared to stage; branch-pr re-deriving RELEASED_SHA from the branch tip instead of pr_merge's returned sha (the same TOCTOU the feature fixed for tags and missed for branches); uppercase --sha accepted while every downstream key and compare is case-sensitive; the wait loop's identity check lacking a non-verdict exemption and treating an empty commit as licence to go green; a guard comment claiming a scope it did not have; local-fs leaking an absolute path via raw jq stderr; a contract rule that made its own `unsupported` branch unreachable; and a pre-tag gate that would escalate on every release for a tag-only-CI project. Full text and evidence in comment 2201; dispositions applied at efd3b6e and 0388527.",
      "proposed_action": "fix-in-this-feature",
      "fix_cost": "substantial",
      "feature_value": "core",
      "adjacent_to_blocking": true,
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-47-4-3"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "medium",
      "summary": "[code CR-3-residue] The develop phase accepted the captured-but-unasserted-rc pattern (F-PO-47-3-7) on a stated count of three instances; the real count was eight",
      "reasoning": "Recorded because the accept itself was sound but was made against an undercount, so it did not cover the five instances the test-quality audit found — two of which sat on the WU-47-3-8 regression guard for this feature's CORE defect. All eight are now asserted. Worth carrying forward as a process observation rather than a code finding: an `accept` is only as good as the enumeration behind it, and this one was never enumerated mechanically.",
      "proposed_action": "accept",
      "fix_cost": "trivial",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-47-4-4"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-16-residue] /dev:setup does not know how to propose the new `release.integration_ci` field",
      "reasoning": "The field is read by promote and documented in its `release:` schema block, but setup's release-block prompt does not offer it, so a project that needs it discovers it only after its first release burns a full ci_timeout and escalates. Not blocking: the default is the safe one and the escalation is actionable. Same class as the delegation-hook gap already filed as #60.",
      "proposed_action": "accept",
      "fix_cost": "small",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-47-4-5"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code RV-2-residue] forge-contract's pr_status row does not list `query_failed` among the values `pipeline_state` may take, though local-fs now emits it",
      "reasoning": "The CR-3 fix makes local-fs pr_status report `query_failed` rather than dying, which is strictly better and is what promote's poll branches on. But the contract text has not been widened to match, so the adapter is slightly ahead of the contract. Not widened here: changing the pr_status contract touches every adapter that implements it, and glab-cli has no equivalent change in this feature, so it wants its own review rather than a rider on a fix round.",
      "proposed_action": "accept",
      "fix_cost": "trivial",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-47-4-6"
    },
    {
      "category": "out-of-scope",
      "severity": "low",
      "summary": "[code CR-7/CR-13/CR-18] Three findings deferred to sibling issues by operator decision",
      "reasoning": "#415 — no drift check ties the GitLab status vocabulary across the captured fixture, the probe's AC6_CASES and glab-cli's GL_JQ_PIPEMAP; the probe never reads the fixture it claims to source from. #416 — a partial or stale project-scope adapter copy is classified as a capability boundary rather than a broken install, silently ungating releases. #417 — promote's remaining fixed /tmp scratch paths, to be swept onto the per-run convention CR-1 established, after #47 merges. All three linked spawned-from #47.",
      "proposed_action": "defer-to-issue",
      "target": "415, 416, 417",
      "fix_cost": "small",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "defer-to-issue",
      "id": "F-PO-47-4-7"
    },
    {
      "category": "pre-existing",
      "severity": "low",
      "summary": "Four live-forge smoke suites cannot run in this environment and are red at branch base as well as at HEAD",
      "reasoning": "gh-cli, glab-cli and tea-cli `smoke.sh` each require a throwaway scratch issue passed via --issue; glab-cli `release-smoke.sh` needs a real GitLab project and returns HTTP 400 here. Verified red at origin/main before any of this round's work, so they are an environment property, not a regression. Recorded rather than left as a bare skipped count, per validate-workflow Step 3: this is a STRUCTURAL cause and it recurs every round. It is not a coverage gap for #47 specifically — the test plan routes none of its fourteen scenarios to these suites — but it does mean the adapters' own forge round-trips are unverified by any automated lane in this environment.",
      "proposed_action": "accept",
      "fix_cost": "unknown",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-47-4-8"
    }
  ],
  "pending_decisions": [],
  "suite": {
    "source": "git",
    "sha": "038852705979bbadd3da90d7dbc77088bc2546ef",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-47-4 skill=qa --> ```json { "next_state": "qa", "produced": [ { "kind": "qa-report", "ref": "2185", "summary": "e2e — skipped (project declaration); the PREQ-derived validation run itself is NOT skipped, see below" }, { "kind": "qa-report", "ref": "2186", "summary": "a11y — skipped (project declaration: no rendered UI)" }, { "kind": "qa-report", "ref": "2187", "summary": "security-browser — skipped (project declaration: no browser surface)" }, { "kind": "qa-report", "ref": "2188", "summary": "api — skipped (api_invocation.mode: none, the machine-checkable declaration)" }, { "kind": "qa-report", "ref": "2189", "summary": "security-api — skipped as a runtime domain; the outbound-call security surface was covered statically by the code domain" }, { "kind": "qa-report", "ref": "2201", "summary": "code phase=validate — 19 findings (1 High, 7 Medium, 11 Low) from a five-reviewer pass; superseded by 2276 but retained as the round's finding record" }, { "kind": "qa-report", "ref": "2272", "summary": "code phase=tests — 19 red assertions at e32730f, confirmed failing before any fix existed" }, { "kind": "qa-report", "ref": "2273", "summary": "code phase=fix — 14 findings applied at efd3b6e; assertion-constraint check passed" }, { "kind": "qa-report", "ref": "2276", "summary": "code phase=validate (fresh-context RE-VALIDATION) — 12/12 fixes confirmed, 2 NEW findings introduced by the fixes, 1 Critical; both fixed at 0388527" }, { "kind": "code-bundle", "ref": "e32730f", "summary": "red tests: CR-2, CR-3, CR-4, CR-8, CR-14 + closed CR-6/CR-12/CR-17 in the test file" }, { "kind": "code-bundle", "ref": "efd3b6e", "summary": "round-1 fixes: CR-1..CR-5, CR-8..CR-11, CR-14..CR-16" }, { "kind": "code-bundle", "ref": "0388527", "summary": "re-validation fixes: RV-1 (Critical, self-inflicted), RV-2, plus the extract-and-drive lane for promote's wait loop" } ], "findings": [ { "category": "in-scope-blocking", "severity": "critical", "summary": "[code RV-1] The round-1 fix for CR-10 made every `unsupported` answer unreachable in promote's wait loop, which would have made this repo's own releases escalate instead of tagging — #55 reintroduced inside #47's fix round", "reasoning": "A guard added for the commit-blind-green defect exempted only `none`/`query_failed`, but a dispatch-emitted `unsupported` always carries commit:null by construction, so it hit the guard and became `unattributable` before its own branch could fire. Caught by fresh-context re-validation, which was given the findings but NOT an account of what had been changed — the mechanism working exactly as qa-playbook §4 describes. Fixed at 0388527, and the fence is now under test via extract-and-drive. NOTE THE PATTERN: this is the SECOND time in this feature that a guard written against commit-blind green has over-fired on states that legitimately have no commit (the first was F-PO-47-3-4, corrected by WU-47-3-8 during develop). Two instances of one class is worth carrying into the sweep as a thing to look for, not just two bugs that were fixed.", "proposed_action": "fix-in-this-feature", "fix_cost": "trivial", "feature_value": "core", "adjacent_to_blocking": true, "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-47-4-1" }, { "category": "in-scope-blocking", "severity": "high", "summary": "[code CR-1] promote read its wait outcome from a fixed /tmp result file without checking the wait's exit status, so a crashed wait read the PREVIOUS run's verdict", "reasoning": "The highest-severity finding of the round-1 validate, and structurally the #47 defect itself one layer above the helper this feature fixed: a previous release's `green` answering a question about a newer commit. Found independently by the Bug Hunter and the Static Security Reviewer. Fixed at efd3b6e with a per-run scratch directory, a delete-before-wait, and a new terminal `no-result` outcome.", "proposed_action": "fix-in-this-feature", "fix_cost": "small", "feature_value": "core", "adjacent_to_blocking": true, "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-47-4-2" }, { "category": "in-scope-blocking", "severity": "medium", "summary": "[code CR-2..CR-5, CR-8..CR-11, CR-14..CR-16, RV-2] Thirteen further defects in this feature's own diff, all fixed this round", "reasoning": "A primitive-origin exit 8 trusted as a capability boundary (would let a rogue adapter skip the release gate); the new exit 9 crashing local-fs pr_status.sh under pipefail; the shipped multi-pipeline staging example producing `unknown` rather than the `success` it appeared to stage; branch-pr re-deriving RELEASED_SHA from the branch tip instead of pr_merge's returned sha (the same TOCTOU the feature fixed for tags and missed for branches); uppercase --sha accepted while every downstream key and compare is case-sensitive; the wait loop's identity check lacking a non-verdict exemption and treating an empty commit as licence to go green; a guard comment claiming a scope it did not have; local-fs leaking an absolute path via raw jq stderr; a contract rule that made its own `unsupported` branch unreachable; and a pre-tag gate that would escalate on every release for a tag-only-CI project. Full text and evidence in comment 2201; dispositions applied at efd3b6e and 0388527.", "proposed_action": "fix-in-this-feature", "fix_cost": "substantial", "feature_value": "core", "adjacent_to_blocking": true, "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-47-4-3" }, { "category": "in-scope-deferrable", "severity": "medium", "summary": "[code CR-3-residue] The develop phase accepted the captured-but-unasserted-rc pattern (F-PO-47-3-7) on a stated count of three instances; the real count was eight", "reasoning": "Recorded because the accept itself was sound but was made against an undercount, so it did not cover the five instances the test-quality audit found — two of which sat on the WU-47-3-8 regression guard for this feature's CORE defect. All eight are now asserted. Worth carrying forward as a process observation rather than a code finding: an `accept` is only as good as the enumeration behind it, and this one was never enumerated mechanically.", "proposed_action": "accept", "fix_cost": "trivial", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-47-4-4" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-16-residue] /dev:setup does not know how to propose the new `release.integration_ci` field", "reasoning": "The field is read by promote and documented in its `release:` schema block, but setup's release-block prompt does not offer it, so a project that needs it discovers it only after its first release burns a full ci_timeout and escalates. Not blocking: the default is the safe one and the escalation is actionable. Same class as the delegation-hook gap already filed as #60.", "proposed_action": "accept", "fix_cost": "small", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-47-4-5" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code RV-2-residue] forge-contract's pr_status row does not list `query_failed` among the values `pipeline_state` may take, though local-fs now emits it", "reasoning": "The CR-3 fix makes local-fs pr_status report `query_failed` rather than dying, which is strictly better and is what promote's poll branches on. But the contract text has not been widened to match, so the adapter is slightly ahead of the contract. Not widened here: changing the pr_status contract touches every adapter that implements it, and glab-cli has no equivalent change in this feature, so it wants its own review rather than a rider on a fix round.", "proposed_action": "accept", "fix_cost": "trivial", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-47-4-6" }, { "category": "out-of-scope", "severity": "low", "summary": "[code CR-7/CR-13/CR-18] Three findings deferred to sibling issues by operator decision", "reasoning": "#415 — no drift check ties the GitLab status vocabulary across the captured fixture, the probe's AC6_CASES and glab-cli's GL_JQ_PIPEMAP; the probe never reads the fixture it claims to source from. #416 — a partial or stale project-scope adapter copy is classified as a capability boundary rather than a broken install, silently ungating releases. #417 — promote's remaining fixed /tmp scratch paths, to be swept onto the per-run convention CR-1 established, after #47 merges. All three linked spawned-from #47.", "proposed_action": "defer-to-issue", "target": "415, 416, 417", "fix_cost": "small", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "defer-to-issue", "id": "F-PO-47-4-7" }, { "category": "pre-existing", "severity": "low", "summary": "Four live-forge smoke suites cannot run in this environment and are red at branch base as well as at HEAD", "reasoning": "gh-cli, glab-cli and tea-cli `smoke.sh` each require a throwaway scratch issue passed via --issue; glab-cli `release-smoke.sh` needs a real GitLab project and returns HTTP 400 here. Verified red at origin/main before any of this round's work, so they are an environment property, not a regression. Recorded rather than left as a bare skipped count, per validate-workflow Step 3: this is a STRUCTURAL cause and it recurs every round. It is not a coverage gap for #47 specifically — the test plan routes none of its fourteen scenarios to these suites — but it does mean the adapters' own forge round-trips are unverified by any automated lane in this environment.", "proposed_action": "accept", "fix_cost": "unknown", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-47-4-8" } ], "pending_decisions": [], "suite": { "source": "git", "sha": "038852705979bbadd3da90d7dbc77088bc2546ef", "dirty": false } } ```
Author
Owner
{
  "outcome": "clean",
  "summary": "Sweep complete at 749d2fb. Four adversarial rounds on the post-fix tree, each in fresh context: 11 findings, then 6, then 3, then 1 — with severity falling from High to Medium to Low to test-only. The last round found nothing wrong with the product code, having checked the one behavioural change against 20+ inputs in two locales. 21 sweep findings fixed in total. Gates: lint clean; 14/14 offline suites; pipeline-identity 153/153; promote-flow 48/48. Supersedes comment 2276.",
  "findings": [
    {
      "id": "CR-1",
      "category": "in-scope-blocking",
      "severity": "High",
      "summary": "SW-1: the round-1 fixes made /dev:promote's whole branch-pr path die on local-fs — the adapter the contract designates for developing the release skills",
      "reasoning": "FIXED at cabf369, regression-tested. local-fs's pr_merge returned a synthetic `merge-<pr>-<ms>` sha; the CR-5 fix pinned RELEASED_SHA from it and the CR-8 fix refused anything not 40-hex, so every poll died EXIT_USAGE and escalated as 'a usage error or a broken install'. NEITHER FIX WAS WRONG ALONE — the defect lived in their interaction, which is why a per-finding re-check would not have found it and the seam assertion now does. pr_merge mints 40 lowercase hex via printf (no digest tool is in the portability baseline), and the contract now states that a primitive MINTING a sha must mint it in the pinnable form."
    },
    {
      "id": "CR-2",
      "category": "in-scope-blocking",
      "severity": "Medium",
      "summary": "SW-2, SW-3, SW-4, SW-5: four more regressions from the round-1 fixes to promote/SKILL.md",
      "reasoning": "ALL FIXED at cabf369. SW-2: the `integration_ci: none` opt-out skipped the fence that assigned TAG_TARGET_SHA, so the skip path reached `git tag` with an empty pin — the CR-16 fix had recreated the unpinned tag this feature exists to remove. SW-3: the prose above the wait loop still described a two-state exemption after RV-1 made it three, and the sentence that had drifted was the one asserting the two texts 'cannot drift apart'. SW-4/SW-5: $PROMOTE_RUN_DIR does not survive between tool calls and a resume enters at Step 5 where neither it nor the wait script exists — the CR-1 fix had traded a stale-read bug for a may-not-work-at-all bug. Creation is now idempotent, the path is echoed to be carried forward, and the resume entry is stated."
    },
    {
      "id": "CR-3",
      "category": "in-scope-blocking",
      "severity": "Medium",
      "summary": "FN-1: the same commit that wrote the each-fence-has-its-own-shell doctrine added two fences depending on cross-fence variable survival",
      "reasoning": "FIXED at 83e63b6. The skip-path pin set TAG_TARGET_SHA for a later tag fence and the merge fence set RELEASED_SHA for a later wait; neither echoed its value, so a reader following the doctrine literally loses both. The branch-pr case is the sharp one: by then the merge has ALREADY fired the production deploy, so an empty RELEASED_SHA does not fail before anything happened — it strands a live deploy unmonitored behind a false 'pr_merge returned no sha' diagnosis. All three cross-fence values now echo, a table enumerates them, and the failure mode is stated so an operator who hits that message checks the pin before blaming the adapter."
    },
    {
      "id": "CR-4",
      "category": "in-scope-blocking",
      "severity": "Medium",
      "summary": "SW-6, SW-7, SW-8, SW-9, SW-11, FN-2, FN-3, FN-4, CF-1, CF-3: ten further defects in this feature's own new lines",
      "reasoning": "ALL FIXED. A bare `release-merge-pr.sh` call not on PATH; pr_status capturing an rc it never read while its comment claimed rc-based classification, and discarding the only surviving trace of why a query failed; a pin taken without checking the helper's rc or `.merged`, so a refused merge would be waited on as a deploy and escalate as a timeout; a non-POSIX herestring; `unattributable`'s escalation dropping `raw_state`, which is where the forge's real word lives once the helper rewrites a commit-less verdict to `unknown`; an unvalidated `--pr` reaching printf and dying rc 1 with an absolute path on stderr; a stale pre-fix merge_sha handed back as an unusable pin; a contract rule that contradicted local-fs's deliberate verbatim commit echo (rescoped to MINTING vs RELAYING); and two wrong cells in the cross-fence table. CF-1 deserves its own note: the first fix for the printf leak used `^[1-9][0-9]{0,17}$`, and under a UTF-8 locale glibc's range brackets match Arabic-Indic, fullwidth and Bengali digits — so the one-line regex fix left its own hole open. Explicit character lists now; verified rejecting six scripts' digits in both en_GB.UTF-8 and LC_ALL=C."
    },
    {
      "id": "CR-5",
      "category": "in-scope-blocking",
      "severity": "Medium",
      "summary": "CF-2, DL-1: two of the probe's own assertions could not detect the thing they were written to guard",
      "reasoning": "BOTH FIXED and mutation-verified. CF-2: the carry assertions matched a bare mention of each variable, and the surrounding prose happens to name all three — so deleting the table they guard left the probe fully green. They now match the pipe-delimited row shape, and the deletion mutation is red. DL-1: the 'section is bounded' check measured a string that had already been flattened to one line, making `[ 1 -lt 60 ]` unconditionally true; it now measures the unflattened range and reports `unbounded-625` on the anchor-breaking mutation. Recorded as blocking rather than cosmetic because a test that cannot fail is worse than no test: it reports coverage it does not have, and both of these were guarding fixes for Critical/High findings."
    },
    {
      "id": "CR-6",
      "category": "in-scope-deferrable",
      "severity": "Medium",
      "summary": "The sweep's real finding is a pattern, not a list: promote/SKILL.md generated defects at the rate it was patched until it had an executable lane",
      "reasoning": "Recorded deliberately, because the count alone misreads what happened. Round 1 fixed 14 findings in promote/SKILL.md and produced 2 regressions including a Critical; the sweep fixed those and produced 5 more including a High. The mechanism was constant: every fix was unverified prose. The operator's cover-then-fix decision changed it — promote-flow.sh extracts the fences and drives them, and the seam assertion caught a class (two individually-correct fixes interacting) that no per-finding re-check would have. The following round found 1 Medium and no High; the one after, nothing in the product code. THE LESSON, and it is the one worth carrying out of this feature: 'emitted by a skill' is not the same as 'not executable'. A fence written to a file and run with `bash`, and a documented sequence of helper calls, can both be extracted and driven. What genuinely cannot be tested is the prose around them. This was got wrong TWICE in this run — first for the wait loop, then for the orchestration — and each mistake cost a serious regression."
    },
    {
      "id": "CR-7",
      "category": "pre-existing",
      "severity": "Low",
      "summary": "`pr_merge --pr` with no following value dies rc 1 with the helper's absolute path on stderr",
      "reasoning": "Found by the confirming pass, deliberately NOT fixed here. Same leak class as CF-1 but originating in the argument loop's `pr=\"$2\"` under `set -u`, which is unchanged since before this feature — it is an instance of the `--flag`-as-last-argument family already dispositioned defer-to-issue as F-PO-47-3-2, not one of this feature's lines. Recorded so the family sweep inherits a concrete second instance rather than re-deriving it."
    },
    {
      "id": "CR-8",
      "category": "pre-existing",
      "severity": "Low",
      "summary": "`$TAG` and `$PR` also cross fence boundaries into Step 5 and are not in the new cross-fence table",
      "reasoning": "Observed by the delta check while verifying the table's other cells. Both predate this feature, and the table was introduced to solve a problem about the three values this feature added, so widening it was out of scope for a sweep round. Worth doing when promote's state-carrying is next touched — the table is the right place for them and its existence makes the omission visible."
    }
  ],
  "artifacts": {
    "test_files": [
      "plugin/skills/_shared/procedures/test/pipeline-identity.sh",
      "plugin/skills/_shared/procedures/test/promote-flow.sh"
    ],
    "test_commit": "749d2fb",
    "report_path": ".devwork/feature-release-pipeline-status-attributable-answer/test-prerequisites.md"
  }
}
<!-- qa-report:v1 issue=47 skill=qa domain=code phase=validate --> ```json { "outcome": "clean", "summary": "Sweep complete at 749d2fb. Four adversarial rounds on the post-fix tree, each in fresh context: 11 findings, then 6, then 3, then 1 — with severity falling from High to Medium to Low to test-only. The last round found nothing wrong with the product code, having checked the one behavioural change against 20+ inputs in two locales. 21 sweep findings fixed in total. Gates: lint clean; 14/14 offline suites; pipeline-identity 153/153; promote-flow 48/48. Supersedes comment 2276.", "findings": [ { "id": "CR-1", "category": "in-scope-blocking", "severity": "High", "summary": "SW-1: the round-1 fixes made /dev:promote's whole branch-pr path die on local-fs — the adapter the contract designates for developing the release skills", "reasoning": "FIXED at cabf369, regression-tested. local-fs's pr_merge returned a synthetic `merge-<pr>-<ms>` sha; the CR-5 fix pinned RELEASED_SHA from it and the CR-8 fix refused anything not 40-hex, so every poll died EXIT_USAGE and escalated as 'a usage error or a broken install'. NEITHER FIX WAS WRONG ALONE — the defect lived in their interaction, which is why a per-finding re-check would not have found it and the seam assertion now does. pr_merge mints 40 lowercase hex via printf (no digest tool is in the portability baseline), and the contract now states that a primitive MINTING a sha must mint it in the pinnable form." }, { "id": "CR-2", "category": "in-scope-blocking", "severity": "Medium", "summary": "SW-2, SW-3, SW-4, SW-5: four more regressions from the round-1 fixes to promote/SKILL.md", "reasoning": "ALL FIXED at cabf369. SW-2: the `integration_ci: none` opt-out skipped the fence that assigned TAG_TARGET_SHA, so the skip path reached `git tag` with an empty pin — the CR-16 fix had recreated the unpinned tag this feature exists to remove. SW-3: the prose above the wait loop still described a two-state exemption after RV-1 made it three, and the sentence that had drifted was the one asserting the two texts 'cannot drift apart'. SW-4/SW-5: $PROMOTE_RUN_DIR does not survive between tool calls and a resume enters at Step 5 where neither it nor the wait script exists — the CR-1 fix had traded a stale-read bug for a may-not-work-at-all bug. Creation is now idempotent, the path is echoed to be carried forward, and the resume entry is stated." }, { "id": "CR-3", "category": "in-scope-blocking", "severity": "Medium", "summary": "FN-1: the same commit that wrote the each-fence-has-its-own-shell doctrine added two fences depending on cross-fence variable survival", "reasoning": "FIXED at 83e63b6. The skip-path pin set TAG_TARGET_SHA for a later tag fence and the merge fence set RELEASED_SHA for a later wait; neither echoed its value, so a reader following the doctrine literally loses both. The branch-pr case is the sharp one: by then the merge has ALREADY fired the production deploy, so an empty RELEASED_SHA does not fail before anything happened — it strands a live deploy unmonitored behind a false 'pr_merge returned no sha' diagnosis. All three cross-fence values now echo, a table enumerates them, and the failure mode is stated so an operator who hits that message checks the pin before blaming the adapter." }, { "id": "CR-4", "category": "in-scope-blocking", "severity": "Medium", "summary": "SW-6, SW-7, SW-8, SW-9, SW-11, FN-2, FN-3, FN-4, CF-1, CF-3: ten further defects in this feature's own new lines", "reasoning": "ALL FIXED. A bare `release-merge-pr.sh` call not on PATH; pr_status capturing an rc it never read while its comment claimed rc-based classification, and discarding the only surviving trace of why a query failed; a pin taken without checking the helper's rc or `.merged`, so a refused merge would be waited on as a deploy and escalate as a timeout; a non-POSIX herestring; `unattributable`'s escalation dropping `raw_state`, which is where the forge's real word lives once the helper rewrites a commit-less verdict to `unknown`; an unvalidated `--pr` reaching printf and dying rc 1 with an absolute path on stderr; a stale pre-fix merge_sha handed back as an unusable pin; a contract rule that contradicted local-fs's deliberate verbatim commit echo (rescoped to MINTING vs RELAYING); and two wrong cells in the cross-fence table. CF-1 deserves its own note: the first fix for the printf leak used `^[1-9][0-9]{0,17}$`, and under a UTF-8 locale glibc's range brackets match Arabic-Indic, fullwidth and Bengali digits — so the one-line regex fix left its own hole open. Explicit character lists now; verified rejecting six scripts' digits in both en_GB.UTF-8 and LC_ALL=C." }, { "id": "CR-5", "category": "in-scope-blocking", "severity": "Medium", "summary": "CF-2, DL-1: two of the probe's own assertions could not detect the thing they were written to guard", "reasoning": "BOTH FIXED and mutation-verified. CF-2: the carry assertions matched a bare mention of each variable, and the surrounding prose happens to name all three — so deleting the table they guard left the probe fully green. They now match the pipe-delimited row shape, and the deletion mutation is red. DL-1: the 'section is bounded' check measured a string that had already been flattened to one line, making `[ 1 -lt 60 ]` unconditionally true; it now measures the unflattened range and reports `unbounded-625` on the anchor-breaking mutation. Recorded as blocking rather than cosmetic because a test that cannot fail is worse than no test: it reports coverage it does not have, and both of these were guarding fixes for Critical/High findings." }, { "id": "CR-6", "category": "in-scope-deferrable", "severity": "Medium", "summary": "The sweep's real finding is a pattern, not a list: promote/SKILL.md generated defects at the rate it was patched until it had an executable lane", "reasoning": "Recorded deliberately, because the count alone misreads what happened. Round 1 fixed 14 findings in promote/SKILL.md and produced 2 regressions including a Critical; the sweep fixed those and produced 5 more including a High. The mechanism was constant: every fix was unverified prose. The operator's cover-then-fix decision changed it — promote-flow.sh extracts the fences and drives them, and the seam assertion caught a class (two individually-correct fixes interacting) that no per-finding re-check would have. The following round found 1 Medium and no High; the one after, nothing in the product code. THE LESSON, and it is the one worth carrying out of this feature: 'emitted by a skill' is not the same as 'not executable'. A fence written to a file and run with `bash`, and a documented sequence of helper calls, can both be extracted and driven. What genuinely cannot be tested is the prose around them. This was got wrong TWICE in this run — first for the wait loop, then for the orchestration — and each mistake cost a serious regression." }, { "id": "CR-7", "category": "pre-existing", "severity": "Low", "summary": "`pr_merge --pr` with no following value dies rc 1 with the helper's absolute path on stderr", "reasoning": "Found by the confirming pass, deliberately NOT fixed here. Same leak class as CF-1 but originating in the argument loop's `pr=\"$2\"` under `set -u`, which is unchanged since before this feature — it is an instance of the `--flag`-as-last-argument family already dispositioned defer-to-issue as F-PO-47-3-2, not one of this feature's lines. Recorded so the family sweep inherits a concrete second instance rather than re-deriving it." }, { "id": "CR-8", "category": "pre-existing", "severity": "Low", "summary": "`$TAG` and `$PR` also cross fence boundaries into Step 5 and are not in the new cross-fence table", "reasoning": "Observed by the delta check while verifying the table's other cells. Both predate this feature, and the table was introduced to solve a problem about the three values this feature added, so widening it was out of scope for a sweep round. Worth doing when promote's state-carrying is next touched — the table is the right place for them and its existence makes the omission visible." } ], "artifacts": { "test_files": [ "plugin/skills/_shared/procedures/test/pipeline-identity.sh", "plugin/skills/_shared/procedures/test/promote-flow.sh" ], "test_commit": "749d2fb", "report_path": ".devwork/feature-release-pipeline-status-attributable-answer/test-prerequisites.md" } } ```
Author
Owner
{
  "next_state": "integrating",
  "produced": [
    {
      "kind": "qa-report",
      "ref": "2395",
      "summary": "code phase=validate (SWEEP) — 21 sweep findings across four adversarial rounds, all fixed; supersedes 2276"
    },
    {
      "kind": "code-bundle",
      "ref": "cabf369",
      "summary": "sweep round 1: new promote-flow.sh lane (24 assertions) + SW-1..SW-11"
    },
    {
      "kind": "code-bundle",
      "ref": "83e63b6",
      "summary": "sweep round 2: FN-1..FN-6 (promote-flow 24 -> 41)"
    },
    {
      "kind": "code-bundle",
      "ref": "1abf4ad",
      "summary": "sweep round 3: CF-1 unicode digits in a bash range, CF-2/CF-3 (promote-flow 41 -> 48)"
    },
    {
      "kind": "code-bundle",
      "ref": "749d2fb",
      "summary": "sweep round 4: DL-1 — an assertion that could not fail"
    },
    {
      "kind": "test-plan",
      "ref": "local",
      "summary": "UAT prerequisites card at .devwork/feature-release-pipeline-status-attributable-answer/test-prerequisites.md — local reference artifact, not a forge deliverable (.devwork/ is gitignored); its deploy_check claim was verified by running it"
    }
  ],
  "findings": [
    {
      "category": "in-scope-blocking",
      "severity": "high",
      "summary": "[code SW-1] Two individually-correct round-1 fixes interacted to kill /dev:promote's entire branch-pr path on local-fs",
      "reasoning": "The CR-5 pin (take RELEASED_SHA from pr_merge's answer) met the CR-8 refusal (reject any sha that is not 40-hex), and local-fs's pr_merge returned a synthetic non-hex value — so every poll died EXIT_USAGE on the very adapter the contract designates for developing the release skills, reported as 'a usage error or a broken install'. Fixed at cabf369 and covered by a seam assertion that runs against any adapter shipping a pr_merge. Recorded at this severity because of what it says about the review, not only the code: neither fix was wrong on its own, so no per-finding re-check could have found it. It took a probe that asserts the HANDOVER between helpers.",
      "proposed_action": "fix-in-this-feature",
      "fix_cost": "small",
      "feature_value": "core",
      "adjacent_to_blocking": true,
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-47-5-1"
    },
    {
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "[code SW-2..SW-11, FN-1..FN-4, CF-1, CF-3] Twenty further defects across four adversarial rounds, all in this feature's own new lines, all fixed",
      "reasoning": "Includes an opt-out that recreated the unpinned tag this feature exists to remove; prose that had drifted from the loop it described, in the sentence claiming the two could not drift; a scratch-directory fix that traded a stale-read bug for a may-not-work-at-all bug; a doctrine and two fences contradicting each other in one commit; a pin taken without checking whether the merge happened; and a one-line regex fix whose character range matched six scripts' unicode digits under a UTF-8 locale. Full text and per-finding evidence in comment 2395.",
      "proposed_action": "fix-in-this-feature",
      "fix_cost": "substantial",
      "feature_value": "core",
      "adjacent_to_blocking": true,
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-47-5-2"
    },
    {
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "[code CF-2, DL-1] Two of the new probe's own assertions could not detect what they were written to guard",
      "reasoning": "One matched a bare mention of each variable where the surrounding prose already named all three, so deleting the guarded table left the probe green. The other measured a string that had already been flattened to a single line, making its comparison unconditionally true. Both now bind, verified by re-running the exact mutations that had passed. Recorded as blocking rather than cosmetic: a test that cannot fail reports coverage it does not have, and both of these were guarding fixes for Critical and High findings.",
      "proposed_action": "fix-in-this-feature",
      "fix_cost": "trivial",
      "feature_value": "core",
      "adjacent_to_blocking": true,
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-47-5-3"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "medium",
      "summary": "[code CR-6] The transferable finding: 'emitted by a skill' is not the same as 'not executable', and getting that wrong cost this feature two serious regressions",
      "reasoning": "promote/SKILL.md produced defects at roughly the rate it was patched — round 1 fixed 14 and introduced 2 including a Critical; the sweep fixed those and introduced 5 including a High — and the mechanism was constant: every fix was unverified prose. The QA round twice classified promote's findings as having no executable lane, first for the wait loop and then for the orchestration around it, and both times a reviewer disproved it by simply extracting the fence and driving it. Once coverage existed the next round found 1 Medium and the one after found nothing in the product code. A fence written to a file and run with `bash`, and a documented sequence of helper calls, are both testable; only the surrounding prose is not. This belongs in the suite's own guidance, not just in this issue — a candidate for /meta-retrospective.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "small",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": true,
      "id": "F-PO-47-5-4"
    },
    {
      "category": "pre-existing",
      "severity": "low",
      "summary": "[code CR-7, CR-8] Two pre-existing items observed while verifying this round's fixes",
      "reasoning": "`pr_merge --pr` with no following value dies rc 1 with the helper's absolute path on stderr — a second concrete instance of the `--flag`-as-last-argument family already deferred as F-PO-47-3-2, recorded so that sweep inherits it rather than re-deriving it. And `$TAG`/`$PR` also cross fence boundaries in promote but are absent from the new cross-fence table; both predate this feature, and the table's existence is what makes the omission visible. Neither is one of this feature's lines.",
      "proposed_action": "accept",
      "fix_cost": "small",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-47-5-5"
    },
    {
      "category": "pre-existing",
      "severity": "low",
      "summary": "Four live-forge smoke suites remain unrunnable here, so the adapters' real forge round-trips have no automated lane",
      "reasoning": "Unchanged from round 1 (F-PO-47-4-8) and re-confirmed at this HEAD: gh-cli, glab-cli and tea-cli `smoke.sh` need a throwaway scratch issue; glab-cli `release-smoke.sh` needs a real GitLab project and returns HTTP 400. Red at the branch base too, so an environment property rather than a regression. Restated here so the sweep's own record does not imply a green that only covers the offline population, and carried onto the UAT card as a stated gap.",
      "proposed_action": "accept",
      "fix_cost": "unknown",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-47-5-6"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "The adversarial coverage is of 1abf4ad plus one test-only commit; the final bytes were verified mechanically, not adversarially",
      "reasoning": "Stated plainly rather than papered over, because §7's invariant is written over the record. The last adversarial pass reviewed 1abf4ad and returned one Low, test-only finding. Fixing it produced 749d2fb, which changes one assertion in promote-flow.sh and no product code. That commit was verified by re-running the reviewer's own mutation (it now reports `unbounded-625`, matching their measurement) and by the full gate set, but no adversarial agent has read it. Judged proportionate: the delta is a single test assertion, its correctness is demonstrated by the mutation it now catches, and a further pass would review a one-line change. A reviewer who disagrees should re-run the adversarial pass at 749d2fb before merge.",
      "proposed_action": "accept",
      "fix_cost": "trivial",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-47-5-7"
    }
  ],
  "pending_decisions": [
    {
      "id": "D-PO-47-5-1",
      "type": "scope-disposition",
      "blocking": false,
      "question": "The sweep's transferable finding: 'emitted by a skill' is not the same as 'not executable'. This QA round twice classified promote/SKILL.md findings as having no executable lane, and both times a reviewer disproved it by extracting the fence and driving it — each mistake cost a serious regression. Should this go to the suite's own guidance (a /meta-retrospective proposal or a CLAUDE.md learning), or be accepted as recorded here on #47 only?",
      "options": [
        "fix-now",
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-47-5-4",
      "reasoning": "The evidence is unusually strong for a process finding: promote/SKILL.md produced defects at roughly the rate it was patched (round 1 fixed 14 and introduced 2 including a Critical; the sweep fixed those and introduced 5 including a High), and the rate changed only once an executable lane existed — the next round found 1 Medium, the one after found nothing in the product code. The misclassification is also cheap to prevent: the test to reach for is extract-the-fence-and-drive-it, and this repo now has two worked examples of it (pipeline-identity.sh's wait-loop section and promote-flow.sh). It recurred twice within a single feature, which meets the recurrence threshold /meta-retrospective applies. accept = the lesson stays on #47 and the next feature that patches skill markdown re-learns it."
    }
  ],
  "suite": {
    "source": "git",
    "sha": "749d2fbca0940b88a6ce0ae38963cbc0c64caed2",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-47-5 skill=qa --> ```json { "next_state": "integrating", "produced": [ { "kind": "qa-report", "ref": "2395", "summary": "code phase=validate (SWEEP) — 21 sweep findings across four adversarial rounds, all fixed; supersedes 2276" }, { "kind": "code-bundle", "ref": "cabf369", "summary": "sweep round 1: new promote-flow.sh lane (24 assertions) + SW-1..SW-11" }, { "kind": "code-bundle", "ref": "83e63b6", "summary": "sweep round 2: FN-1..FN-6 (promote-flow 24 -> 41)" }, { "kind": "code-bundle", "ref": "1abf4ad", "summary": "sweep round 3: CF-1 unicode digits in a bash range, CF-2/CF-3 (promote-flow 41 -> 48)" }, { "kind": "code-bundle", "ref": "749d2fb", "summary": "sweep round 4: DL-1 — an assertion that could not fail" }, { "kind": "test-plan", "ref": "local", "summary": "UAT prerequisites card at .devwork/feature-release-pipeline-status-attributable-answer/test-prerequisites.md — local reference artifact, not a forge deliverable (.devwork/ is gitignored); its deploy_check claim was verified by running it" } ], "findings": [ { "category": "in-scope-blocking", "severity": "high", "summary": "[code SW-1] Two individually-correct round-1 fixes interacted to kill /dev:promote's entire branch-pr path on local-fs", "reasoning": "The CR-5 pin (take RELEASED_SHA from pr_merge's answer) met the CR-8 refusal (reject any sha that is not 40-hex), and local-fs's pr_merge returned a synthetic non-hex value — so every poll died EXIT_USAGE on the very adapter the contract designates for developing the release skills, reported as 'a usage error or a broken install'. Fixed at cabf369 and covered by a seam assertion that runs against any adapter shipping a pr_merge. Recorded at this severity because of what it says about the review, not only the code: neither fix was wrong on its own, so no per-finding re-check could have found it. It took a probe that asserts the HANDOVER between helpers.", "proposed_action": "fix-in-this-feature", "fix_cost": "small", "feature_value": "core", "adjacent_to_blocking": true, "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-47-5-1" }, { "category": "in-scope-blocking", "severity": "medium", "summary": "[code SW-2..SW-11, FN-1..FN-4, CF-1, CF-3] Twenty further defects across four adversarial rounds, all in this feature's own new lines, all fixed", "reasoning": "Includes an opt-out that recreated the unpinned tag this feature exists to remove; prose that had drifted from the loop it described, in the sentence claiming the two could not drift; a scratch-directory fix that traded a stale-read bug for a may-not-work-at-all bug; a doctrine and two fences contradicting each other in one commit; a pin taken without checking whether the merge happened; and a one-line regex fix whose character range matched six scripts' unicode digits under a UTF-8 locale. Full text and per-finding evidence in comment 2395.", "proposed_action": "fix-in-this-feature", "fix_cost": "substantial", "feature_value": "core", "adjacent_to_blocking": true, "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-47-5-2" }, { "category": "in-scope-blocking", "severity": "medium", "summary": "[code CF-2, DL-1] Two of the new probe's own assertions could not detect what they were written to guard", "reasoning": "One matched a bare mention of each variable where the surrounding prose already named all three, so deleting the guarded table left the probe green. The other measured a string that had already been flattened to a single line, making its comparison unconditionally true. Both now bind, verified by re-running the exact mutations that had passed. Recorded as blocking rather than cosmetic: a test that cannot fail reports coverage it does not have, and both of these were guarding fixes for Critical and High findings.", "proposed_action": "fix-in-this-feature", "fix_cost": "trivial", "feature_value": "core", "adjacent_to_blocking": true, "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-47-5-3" }, { "category": "in-scope-deferrable", "severity": "medium", "summary": "[code CR-6] The transferable finding: 'emitted by a skill' is not the same as 'not executable', and getting that wrong cost this feature two serious regressions", "reasoning": "promote/SKILL.md produced defects at roughly the rate it was patched — round 1 fixed 14 and introduced 2 including a Critical; the sweep fixed those and introduced 5 including a High — and the mechanism was constant: every fix was unverified prose. The QA round twice classified promote's findings as having no executable lane, first for the wait loop and then for the orchestration around it, and both times a reviewer disproved it by simply extracting the fence and driving it. Once coverage existed the next round found 1 Medium and the one after found nothing in the product code. A fence written to a file and run with `bash`, and a documented sequence of helper calls, are both testable; only the surrounding prose is not. This belongs in the suite's own guidance, not just in this issue — a candidate for /meta-retrospective.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "small", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": true, "id": "F-PO-47-5-4" }, { "category": "pre-existing", "severity": "low", "summary": "[code CR-7, CR-8] Two pre-existing items observed while verifying this round's fixes", "reasoning": "`pr_merge --pr` with no following value dies rc 1 with the helper's absolute path on stderr — a second concrete instance of the `--flag`-as-last-argument family already deferred as F-PO-47-3-2, recorded so that sweep inherits it rather than re-deriving it. And `$TAG`/`$PR` also cross fence boundaries in promote but are absent from the new cross-fence table; both predate this feature, and the table's existence is what makes the omission visible. Neither is one of this feature's lines.", "proposed_action": "accept", "fix_cost": "small", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-47-5-5" }, { "category": "pre-existing", "severity": "low", "summary": "Four live-forge smoke suites remain unrunnable here, so the adapters' real forge round-trips have no automated lane", "reasoning": "Unchanged from round 1 (F-PO-47-4-8) and re-confirmed at this HEAD: gh-cli, glab-cli and tea-cli `smoke.sh` need a throwaway scratch issue; glab-cli `release-smoke.sh` needs a real GitLab project and returns HTTP 400. Red at the branch base too, so an environment property rather than a regression. Restated here so the sweep's own record does not imply a green that only covers the offline population, and carried onto the UAT card as a stated gap.", "proposed_action": "accept", "fix_cost": "unknown", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-47-5-6" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "The adversarial coverage is of 1abf4ad plus one test-only commit; the final bytes were verified mechanically, not adversarially", "reasoning": "Stated plainly rather than papered over, because §7's invariant is written over the record. The last adversarial pass reviewed 1abf4ad and returned one Low, test-only finding. Fixing it produced 749d2fb, which changes one assertion in promote-flow.sh and no product code. That commit was verified by re-running the reviewer's own mutation (it now reports `unbounded-625`, matching their measurement) and by the full gate set, but no adversarial agent has read it. Judged proportionate: the delta is a single test assertion, its correctness is demonstrated by the mutation it now catches, and a further pass would review a one-line change. A reviewer who disagrees should re-run the adversarial pass at 749d2fb before merge.", "proposed_action": "accept", "fix_cost": "trivial", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-47-5-7" } ], "pending_decisions": [ { "id": "D-PO-47-5-1", "type": "scope-disposition", "blocking": false, "question": "The sweep's transferable finding: 'emitted by a skill' is not the same as 'not executable'. This QA round twice classified promote/SKILL.md findings as having no executable lane, and both times a reviewer disproved it by extracting the fence and driving it — each mistake cost a serious regression. Should this go to the suite's own guidance (a /meta-retrospective proposal or a CLAUDE.md learning), or be accepted as recorded here on #47 only?", "options": [ "fix-now", "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-47-5-4", "reasoning": "The evidence is unusually strong for a process finding: promote/SKILL.md produced defects at roughly the rate it was patched (round 1 fixed 14 and introduced 2 including a Critical; the sweep fixed those and introduced 5 including a High), and the rate changed only once an executable lane existed — the next round found 1 Medium, the one after found nothing in the product code. The misclassification is also cheap to prevent: the test to reach for is extract-the-fence-and-drive-it, and this repo now has two worked examples of it (pipeline-identity.sh's wait-loop section and promote-flow.sh). It recurred twice within a single feature, which meets the recurrence threshold /meta-retrospective applies. accept = the lesson stays on #47 and the next feature that patches skill markdown re-learns it." } ], "suite": { "source": "git", "sha": "749d2fbca0940b88a6ce0ae38963cbc0c64caed2", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "target_issue_ref": "106",
  "rationale": "Evidence-driven process finding with an unusually clean before/after inside one feature. promote/SKILL.md produced defects at roughly the rate it was patched — QA round 1 fixed 14 findings there and introduced 2 including a Critical; the sweep fixed those and introduced 5 more including a High — and the rate changed only once an executable lane existed: the next round found 1 Medium, and the one after found nothing in the product code. The misclassification recurred TWICE inside this single feature (first for promote's wait loop, then for the orchestration around it), and each time a reviewer disproved it in minutes by extracting the fence and driving it with a stub. That meets the recurrence threshold /meta-retrospective applies, and CLAUDE.md's own routing rule sends evidence-driven evolution of existing skill text to the meta lane rather than to a feature branch. Folded into #106 rather than spawned fresh: #106 was itself spawned from #47 (D-PO-47-2-13) for the same underlying misreading — its scope note already predicts that 'a reader routing coverage from the project declaration alone would conclude the integration-covered lane has no executor here' — and this finding is that prediction coming true with a measured cost. Same defect class, same document (CLAUDE.md's Verification section). What should be written is the GENERAL rule — 'emitted by a skill' is not the same as 'not executable'; a fence written to a file and run with bash, and a documented sequence of helper calls, can both be extracted and driven, and only the surrounding prose cannot — not 'add promote-flow.sh coverage', which this feature already did. Two worked examples now exist in-repo to point at: pipeline-identity.sh's wait-loop section and promote-flow.sh. Resolved on the operator's explicit instruction to resolve as recommended, with the fold target confirmed by them after discovery changed it from a new sibling to #106.",
  "rejected_alternative": "fix-now — write it into CLAUDE.md now, inside this feature. Rejected on two grounds: it would mean editing shared guidance from inside the very feature branch whose QA had just shown I misjudged this exact question twice, and the meta lane is deliberately serialized to one session (CLAUDE.md, Parallel sessions), so a feature session amending shared skill text is the collision that convention exists to prevent. accept — leave the lesson recorded on #47 only. Rejected because the entire cost of this run was that the lesson was not written down anywhere a later feature would read it: the next feature to patch skill markdown would re-learn it the same expensive way, which is exactly what happened here twice within one feature. Also considered and rejected: folding into #10, the standing /meta-retrospective process issue — a concrete finding folded into a standing process description risks being read as description rather than as an actionable item, the same reason an earlier finding this run was not folded into #257."
}
<!-- decision-resolution:v1 ref=D-PO-47-5-1 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "target_issue_ref": "106", "rationale": "Evidence-driven process finding with an unusually clean before/after inside one feature. promote/SKILL.md produced defects at roughly the rate it was patched — QA round 1 fixed 14 findings there and introduced 2 including a Critical; the sweep fixed those and introduced 5 more including a High — and the rate changed only once an executable lane existed: the next round found 1 Medium, and the one after found nothing in the product code. The misclassification recurred TWICE inside this single feature (first for promote's wait loop, then for the orchestration around it), and each time a reviewer disproved it in minutes by extracting the fence and driving it with a stub. That meets the recurrence threshold /meta-retrospective applies, and CLAUDE.md's own routing rule sends evidence-driven evolution of existing skill text to the meta lane rather than to a feature branch. Folded into #106 rather than spawned fresh: #106 was itself spawned from #47 (D-PO-47-2-13) for the same underlying misreading — its scope note already predicts that 'a reader routing coverage from the project declaration alone would conclude the integration-covered lane has no executor here' — and this finding is that prediction coming true with a measured cost. Same defect class, same document (CLAUDE.md's Verification section). What should be written is the GENERAL rule — 'emitted by a skill' is not the same as 'not executable'; a fence written to a file and run with bash, and a documented sequence of helper calls, can both be extracted and driven, and only the surrounding prose cannot — not 'add promote-flow.sh coverage', which this feature already did. Two worked examples now exist in-repo to point at: pipeline-identity.sh's wait-loop section and promote-flow.sh. Resolved on the operator's explicit instruction to resolve as recommended, with the fold target confirmed by them after discovery changed it from a new sibling to #106.", "rejected_alternative": "fix-now — write it into CLAUDE.md now, inside this feature. Rejected on two grounds: it would mean editing shared guidance from inside the very feature branch whose QA had just shown I misjudged this exact question twice, and the meta lane is deliberately serialized to one session (CLAUDE.md, Parallel sessions), so a feature session amending shared skill text is the collision that convention exists to prevent. accept — leave the lesson recorded on #47 only. Rejected because the entire cost of this run was that the lesson was not written down anywhere a later feature would read it: the next feature to patch skill markdown would re-learn it the same expensive way, which is exactly what happened here twice within one feature. Also considered and rejected: folding into #10, the standing /meta-retrospective process issue — a concrete finding folded into a standing process description risks being read as description rather than as an actionable item, the same reason an earlier finding this run was not folded into #257." } ```
Author
Owner

Linked: this issue is sibling #106 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #106 (recorded by the devwork pipeline).
Author
Owner
{
  "integration_branch": "main",
  "feature_branch": "feature/47-release-pipeline-status-attributable-answer",
  "repos": [
    {
      "repo": "devwork-skills",
      "base_sha": "d22f1e31a4e51bfd6bdea660f106b770e4160be3",
      "tip_sha": "4a3e66157dcaeb0fffa59cc9898a53dabe52183e",
      "commit_count": 21,
      "commits": [
        {
          "sha": "4a3e66157dcaeb0fffa59cc9898a53dabe52183e",
          "subject": "test(release-pipeline-status-attributable-answer): [QA-47 sweep round 4] DL-1 — the bounded-range assertion could not fail"
        },
        {
          "sha": "573e7f423dca7982322e023c5384ba00c7ced8b3",
          "subject": "fix(release-pipeline-status-attributable-answer): [QA-47 sweep round 3] CF-1 unicode digits in a bash range; CF-2/CF-3 probe and table"
        },
        {
          "sha": "e9d6b141cf901e58b3dd295dde4dc1e55fc56dfa",
          "subject": "fix(release-pipeline-status-attributable-answer): [QA-47 sweep round 2] FN-1..FN-6 from the final adversarial pass"
        },
        {
          "sha": "2233d4930b20f23a1f85d3efe1011cb40b82a162",
          "subject": "fix(release-pipeline-status-attributable-answer): [QA-47 sweep] cover promote's orchestration, then fix SW-1..SW-11"
        },
        {
          "sha": "34749217a1f25c2ff0a73db8f940937b0a73a512",
          "subject": "fix(release-pipeline-status-attributable-answer): [QA-47 round 1 re-validate] RV-1 unsupported unreachable in the wait loop; RV-2 pr_status query_failed; cover the loop"
        },
        {
          "sha": "56d6846d99aa1d7403102a7b911af94fab316c33",
          "subject": "fix(release-pipeline-status-attributable-answer): [QA-47 round 1] CR-1..CR-5, CR-8..CR-11, CR-14..CR-16"
        },
        {
          "sha": "d66fc4cda6baf4dfb902dc0e21323e6c6d720c89",
          "subject": "test(release-pipeline-status-attributable-answer): [QA-47 round 1] red tests CR-2, CR-3, CR-4, CR-8, CR-14 + close CR-6/CR-12/CR-17"
        },
        {
          "sha": "46046fed2fe7d0bb99241fc76f9a5009fb9f66d8",
          "subject": "docs: capture 5 learnings from /dev:develop on #47 (rc ownership, set -e last-statement, sanitization boundary, heredoc plugin root, absence assertions)"
        },
        {
          "sha": "4813f808c16c5c672c5292306e66d8a10e5f0dd8",
          "subject": "test(release-pipeline-status-attributable-answer): [WU-47-3-7] assert commit-attributed failure states and record the probe's coverage boundary"
        },
        {
          "sha": "3fab426ea35ce61bd69392c0995c50cea530ec57",
          "subject": "feat(release-pipeline-status-attributable-answer): [WU-47-3-6] fail the gate on contract Kind gaps and _GATED_PRIMS drift"
        },
        {
          "sha": "6f53679fecc1ebcc066ce94029e853817e8286f9",
          "subject": "feat(release-pipeline-status-attributable-answer): [WU-47-3-5] pin the tag target, assert commit identity and branch on every pipeline state in promote"
        },
        {
          "sha": "8bcd88709116930f1230140a5ecb53aecdeeac7f",
          "subject": "fix(release-pipeline-status-attributable-answer): [WU-47-3-8] exempt non-verdict states from the pinned-sha null-commit guard"
        },
        {
          "sha": "ba97de15f1817368d74b1d07874ee306aa69c202",
          "subject": "feat(release-pipeline-status-attributable-answer): [WU-47-3-4] scope the glab-cli pipeline query by commit and classify query failures"
        },
        {
          "sha": "b9e9468b923d7f294b049f34afff4348a2a50e1f",
          "subject": "test(release-pipeline-status-attributable-answer): WU-47-3-4 failing tests for acceptance criteria"
        },
        {
          "sha": "3f61ff351898f4c461a6045db69046f9abac777f",
          "subject": "feat(release-pipeline-status-attributable-answer): [WU-47-3-3] scope local-fs pipeline records by commit and echo the commit they describe"
        },
        {
          "sha": "d2dd1e89c9994e3b20e239911e87255ef2649592",
          "subject": "test(release-pipeline-status-attributable-answer): WU-47-3-3 failing tests for acceptance criteria"
        },
        {
          "sha": "bd6ca1b66be93833aa208b09615a4f5d1aaf0d13",
          "subject": "feat(release-pipeline-status-attributable-answer): [WU-47-3-2] classify primitive absence and validate --sha in the shared dispatch"
        },
        {
          "sha": "09d288d988f95530cdf2dba66cd12244ae554d34",
          "subject": "test(release-pipeline-status-attributable-answer): WU-47-3-2 failing tests for acceptance criteria"
        },
        {
          "sha": "56ca5a51b3c84762a4666faf8af72282474f1e35",
          "subject": "feat(release-pipeline-status-attributable-answer): [WU-47-3-1] declare required vs capability-gated primitives in the forge contract"
        },
        {
          "sha": "fdc2b891ed9a0ce504284fc8b23f0baebfb7548e",
          "subject": "docs(release-pipeline-status-attributable-answer): test plan"
        },
        {
          "sha": "0b5815ed93f61e17ffc05f6a4d8b39f910c789e8",
          "subject": "docs(release-pipeline-status-attributable-answer): software requirements"
        }
      ],
      "compare_url": null,
      "pushed": true,
      "remote_url": "ssh://git@forge-devwork/jbr870/devwork-skills.git"
    }
  ]
}
<!-- code-bundle:v1 issue=47 skill=integrate --> ```json { "integration_branch": "main", "feature_branch": "feature/47-release-pipeline-status-attributable-answer", "repos": [ { "repo": "devwork-skills", "base_sha": "d22f1e31a4e51bfd6bdea660f106b770e4160be3", "tip_sha": "4a3e66157dcaeb0fffa59cc9898a53dabe52183e", "commit_count": 21, "commits": [ { "sha": "4a3e66157dcaeb0fffa59cc9898a53dabe52183e", "subject": "test(release-pipeline-status-attributable-answer): [QA-47 sweep round 4] DL-1 — the bounded-range assertion could not fail" }, { "sha": "573e7f423dca7982322e023c5384ba00c7ced8b3", "subject": "fix(release-pipeline-status-attributable-answer): [QA-47 sweep round 3] CF-1 unicode digits in a bash range; CF-2/CF-3 probe and table" }, { "sha": "e9d6b141cf901e58b3dd295dde4dc1e55fc56dfa", "subject": "fix(release-pipeline-status-attributable-answer): [QA-47 sweep round 2] FN-1..FN-6 from the final adversarial pass" }, { "sha": "2233d4930b20f23a1f85d3efe1011cb40b82a162", "subject": "fix(release-pipeline-status-attributable-answer): [QA-47 sweep] cover promote's orchestration, then fix SW-1..SW-11" }, { "sha": "34749217a1f25c2ff0a73db8f940937b0a73a512", "subject": "fix(release-pipeline-status-attributable-answer): [QA-47 round 1 re-validate] RV-1 unsupported unreachable in the wait loop; RV-2 pr_status query_failed; cover the loop" }, { "sha": "56d6846d99aa1d7403102a7b911af94fab316c33", "subject": "fix(release-pipeline-status-attributable-answer): [QA-47 round 1] CR-1..CR-5, CR-8..CR-11, CR-14..CR-16" }, { "sha": "d66fc4cda6baf4dfb902dc0e21323e6c6d720c89", "subject": "test(release-pipeline-status-attributable-answer): [QA-47 round 1] red tests CR-2, CR-3, CR-4, CR-8, CR-14 + close CR-6/CR-12/CR-17" }, { "sha": "46046fed2fe7d0bb99241fc76f9a5009fb9f66d8", "subject": "docs: capture 5 learnings from /dev:develop on #47 (rc ownership, set -e last-statement, sanitization boundary, heredoc plugin root, absence assertions)" }, { "sha": "4813f808c16c5c672c5292306e66d8a10e5f0dd8", "subject": "test(release-pipeline-status-attributable-answer): [WU-47-3-7] assert commit-attributed failure states and record the probe's coverage boundary" }, { "sha": "3fab426ea35ce61bd69392c0995c50cea530ec57", "subject": "feat(release-pipeline-status-attributable-answer): [WU-47-3-6] fail the gate on contract Kind gaps and _GATED_PRIMS drift" }, { "sha": "6f53679fecc1ebcc066ce94029e853817e8286f9", "subject": "feat(release-pipeline-status-attributable-answer): [WU-47-3-5] pin the tag target, assert commit identity and branch on every pipeline state in promote" }, { "sha": "8bcd88709116930f1230140a5ecb53aecdeeac7f", "subject": "fix(release-pipeline-status-attributable-answer): [WU-47-3-8] exempt non-verdict states from the pinned-sha null-commit guard" }, { "sha": "ba97de15f1817368d74b1d07874ee306aa69c202", "subject": "feat(release-pipeline-status-attributable-answer): [WU-47-3-4] scope the glab-cli pipeline query by commit and classify query failures" }, { "sha": "b9e9468b923d7f294b049f34afff4348a2a50e1f", "subject": "test(release-pipeline-status-attributable-answer): WU-47-3-4 failing tests for acceptance criteria" }, { "sha": "3f61ff351898f4c461a6045db69046f9abac777f", "subject": "feat(release-pipeline-status-attributable-answer): [WU-47-3-3] scope local-fs pipeline records by commit and echo the commit they describe" }, { "sha": "d2dd1e89c9994e3b20e239911e87255ef2649592", "subject": "test(release-pipeline-status-attributable-answer): WU-47-3-3 failing tests for acceptance criteria" }, { "sha": "bd6ca1b66be93833aa208b09615a4f5d1aaf0d13", "subject": "feat(release-pipeline-status-attributable-answer): [WU-47-3-2] classify primitive absence and validate --sha in the shared dispatch" }, { "sha": "09d288d988f95530cdf2dba66cd12244ae554d34", "subject": "test(release-pipeline-status-attributable-answer): WU-47-3-2 failing tests for acceptance criteria" }, { "sha": "56ca5a51b3c84762a4666faf8af72282474f1e35", "subject": "feat(release-pipeline-status-attributable-answer): [WU-47-3-1] declare required vs capability-gated primitives in the forge contract" }, { "sha": "fdc2b891ed9a0ce504284fc8b23f0baebfb7548e", "subject": "docs(release-pipeline-status-attributable-answer): test plan" }, { "sha": "0b5815ed93f61e17ffc05f6a4d8b39f910c789e8", "subject": "docs(release-pipeline-status-attributable-answer): software requirements" } ], "compare_url": null, "pushed": true, "remote_url": "ssh://git@forge-devwork/jbr870/devwork-skills.git" } ] } ```
Author
Owner
{
  "next_state": "uat",
  "produced": [
    {
      "kind": "code-bundle",
      "ref": "comment:2462",
      "summary": "Merged 21 commits to main across 1 repo, pushed to origin (d22f1e3..4a3e661, fast-forward)"
    }
  ],
  "findings": [
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "Auto-resolved rebase conflict in scripts/lint-conventions.sh: #43's run-resource-claims checks and #47's Kind-column/_GATED_PRIMS checks both appended at the same insertion point",
      "reasoning": "Both sides added an entirely separate, self-contained check section at the end of the gate; neither modified the other's lines. Resolved by keeping both, in order. Verified beyond syntax before continuing: no function-name collision (`ended_by_offenders` vs `kind_rows`) and no shared top-level variable, and the post-rebase run reports BOTH sides' scan lines — '#43: run-resource-claims contract scan examined 85 markdown files for 6 normative anchors' and '#47: contract Kind-column scan examined 34 operation rows' + '_GATED_PRIMS drift check compared 8 against 8'. A resolution that dropped one side would have left the gate clean and silently weaker, so the scan-line check is the one that actually proves it. Flagged for a post-hoc human sanity check because an auto-resolution is a judgement nothing else reviewed.",
      "proposed_action": "accept",
      "id": "F-PO-47-6-1"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "Auto-resolved rebase conflict in CLAUDE.md: #43's three learnings and #47's five, both appended to the same Learnings list",
      "reasoning": "The same append-vs-append shape as the lint conflict, in a bulleted list. Resolved by keeping both sets in order with a blank line between them. No content from either side was altered or dropped. Low risk by construction — the list is prose with no ordering semantics — but recorded because it is still an unreviewed judgement, and because this exact conflict has now occurred twice on this branch (once at the pre-QA rebase, once here), which suggests the Learnings list is a standing collision point for any two concurrent features.",
      "proposed_action": "accept",
      "id": "F-PO-47-6-2"
    },
    {
      "category": "pre-existing",
      "severity": "medium",
      "summary": "scripts/test-run-resource-claims.sh fails identically on clean origin/main — one scenario depends on a gitignored .devwork/ journal that only #43's own worktree ever had",
      "reasoning": "scenario_cr70_cr57_comment_misstates_journal_spelling reads REAL_JOURNAL = .devwork/feature-qa-intra-run-lane-ownership/dispatch-journal.md and returns 1 when it is absent. Under this project's tea-cli adapter `.devwork/` is GITIGNORED, so that file was never committed and exists only in the worktree that produced it. VERIFIED PRE-EXISTING rather than assumed: origin/main was exported with `git archive` into a scratch repo and the suite run there produced the identical single failure (rc=1, same scenario, fixture_fallback_count=1). So this is not integration breakage from #47 and did not gate the merge. It is a real defect in #43's suite though — the suite cannot pass on a fresh clone, in CI, or for any operator who is not #43, which makes its green meaningless everywhere except one machine. Recorded here rather than fixed because it is another feature's test surface and #47 has no mandate over it; worth its own issue or a /meta-retrospective item.",
      "proposed_action": "defer-to-issue",
      "id": "F-PO-47-6-3"
    }
  ],
  "pending_decisions": [
    {
      "id": "D-PO-47-6-1",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Auto-resolved a rebase conflict in scripts/lint-conventions.sh by keeping both #43's and #47's check sections. Accept the resolution, or track it?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-47-6-1",
      "reasoning": "Both sides appended separate self-contained sections; no function or variable collision, and the post-rebase gate run shows both sides' scan lines, so neither was dropped. Sanity-check the concatenation if you want eyes on it."
    },
    {
      "id": "D-PO-47-6-2",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Auto-resolved a rebase conflict in CLAUDE.md's Learnings list by keeping both #43's three entries and #47's five. Accept the resolution, or track it?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-47-6-2",
      "reasoning": "Append-vs-append in a bulleted list; nothing altered or dropped. Note this list has now collided twice on this one branch, so it is a standing collision point between concurrent features."
    },
    {
      "id": "D-PO-47-6-3",
      "type": "scope-disposition",
      "blocking": false,
      "question": "scripts/test-run-resource-claims.sh is red on main because one scenario reads a gitignored .devwork/ journal only #43's worktree ever had. Spawn a sibling issue, or accept?",
      "options": [
        "fix-now",
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-47-6-3",
      "reasoning": "Verified pre-existing by exporting origin/main with git archive and reproducing the identical single failure there, so it did not gate this merge. But the suite cannot pass on a fresh clone or in CI — its green is only reachable on the machine that produced the journal, which makes it a false signal everywhere else. Belongs to #43's surface, not #47's."
    },
    {
      "id": "D-PO-47-6-4",
      "type": "approval",
      "blocking": true,
      "question": "UAT: does the feature work, and is it what we wanted?",
      "options": [
        "approve",
        "reject"
      ],
      "recommended": "approve",
      "reasoning": "Merged to main and pushed (d22f1e3..4a3e661, 21 commits, fast-forward). NO INTEGRATION DEPLOY EXISTS — this project declares observability.mode: none and has no CI on main, so there is no pipeline to watch and nothing to probe; UAT runs locally per the UAT card at .devwork/feature-release-pipeline-status-attributable-answer/test-prerequisites.md. ONE THING TO KNOW BEFORE WALKING IT: the executing plugin for every session is the MAIN CHECKOUT (~/Coding/devwork-skills, which ~/.claude/skills/dev symlinks to), and it is still at the pre-merge commit d22f1e3 — it advances only by fast-forward, which is an operator action this skill does not take. Until it is pulled, no session is running this code, so a dogfood /dev:promote would exercise the OLD helpers. Pull it first, then walk the card. Highest-value scenario is #8: this repo is tea-cli plus a release: block, so a promote run here exercises the capability-boundary path end to end — the exact path where QA found its worst regression (a guard made every unsupported answer unreachable, which would have made every release of this repo escalate instead of tagging). Expect the 'pipeline gate SKIPPED' line and a placed tag; a blocking decision about an unattributable answer means that regression is back. Six of fourteen validation scenarios need real CI and stay unverified until a dogfood release on a GitLab project — accepted at plan time (D-PO-47-2-12). approve = a second /dev:integrate run posts accepted (queued for release); it does NOT deploy — /dev:promote ships the queue on your cadence. reject = record findings; the UAT return path (qa-playbook.md §9) classifies how far back to re-enter, and since the feature branch is deleted, rework starts from a branch recreated off the current main tip."
    }
  ],
  "suite": {
    "source": "git",
    "sha": "4a3e66157dcaeb0fffa59cc9898a53dabe52183e",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-47-6 skill=integrate --> ```json { "next_state": "uat", "produced": [ { "kind": "code-bundle", "ref": "comment:2462", "summary": "Merged 21 commits to main across 1 repo, pushed to origin (d22f1e3..4a3e661, fast-forward)" } ], "findings": [ { "category": "in-scope-deferrable", "severity": "low", "summary": "Auto-resolved rebase conflict in scripts/lint-conventions.sh: #43's run-resource-claims checks and #47's Kind-column/_GATED_PRIMS checks both appended at the same insertion point", "reasoning": "Both sides added an entirely separate, self-contained check section at the end of the gate; neither modified the other's lines. Resolved by keeping both, in order. Verified beyond syntax before continuing: no function-name collision (`ended_by_offenders` vs `kind_rows`) and no shared top-level variable, and the post-rebase run reports BOTH sides' scan lines — '#43: run-resource-claims contract scan examined 85 markdown files for 6 normative anchors' and '#47: contract Kind-column scan examined 34 operation rows' + '_GATED_PRIMS drift check compared 8 against 8'. A resolution that dropped one side would have left the gate clean and silently weaker, so the scan-line check is the one that actually proves it. Flagged for a post-hoc human sanity check because an auto-resolution is a judgement nothing else reviewed.", "proposed_action": "accept", "id": "F-PO-47-6-1" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "Auto-resolved rebase conflict in CLAUDE.md: #43's three learnings and #47's five, both appended to the same Learnings list", "reasoning": "The same append-vs-append shape as the lint conflict, in a bulleted list. Resolved by keeping both sets in order with a blank line between them. No content from either side was altered or dropped. Low risk by construction — the list is prose with no ordering semantics — but recorded because it is still an unreviewed judgement, and because this exact conflict has now occurred twice on this branch (once at the pre-QA rebase, once here), which suggests the Learnings list is a standing collision point for any two concurrent features.", "proposed_action": "accept", "id": "F-PO-47-6-2" }, { "category": "pre-existing", "severity": "medium", "summary": "scripts/test-run-resource-claims.sh fails identically on clean origin/main — one scenario depends on a gitignored .devwork/ journal that only #43's own worktree ever had", "reasoning": "scenario_cr70_cr57_comment_misstates_journal_spelling reads REAL_JOURNAL = .devwork/feature-qa-intra-run-lane-ownership/dispatch-journal.md and returns 1 when it is absent. Under this project's tea-cli adapter `.devwork/` is GITIGNORED, so that file was never committed and exists only in the worktree that produced it. VERIFIED PRE-EXISTING rather than assumed: origin/main was exported with `git archive` into a scratch repo and the suite run there produced the identical single failure (rc=1, same scenario, fixture_fallback_count=1). So this is not integration breakage from #47 and did not gate the merge. It is a real defect in #43's suite though — the suite cannot pass on a fresh clone, in CI, or for any operator who is not #43, which makes its green meaningless everywhere except one machine. Recorded here rather than fixed because it is another feature's test surface and #47 has no mandate over it; worth its own issue or a /meta-retrospective item.", "proposed_action": "defer-to-issue", "id": "F-PO-47-6-3" } ], "pending_decisions": [ { "id": "D-PO-47-6-1", "type": "scope-disposition", "blocking": false, "question": "Auto-resolved a rebase conflict in scripts/lint-conventions.sh by keeping both #43's and #47's check sections. Accept the resolution, or track it?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-47-6-1", "reasoning": "Both sides appended separate self-contained sections; no function or variable collision, and the post-rebase gate run shows both sides' scan lines, so neither was dropped. Sanity-check the concatenation if you want eyes on it." }, { "id": "D-PO-47-6-2", "type": "scope-disposition", "blocking": false, "question": "Auto-resolved a rebase conflict in CLAUDE.md's Learnings list by keeping both #43's three entries and #47's five. Accept the resolution, or track it?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-47-6-2", "reasoning": "Append-vs-append in a bulleted list; nothing altered or dropped. Note this list has now collided twice on this one branch, so it is a standing collision point between concurrent features." }, { "id": "D-PO-47-6-3", "type": "scope-disposition", "blocking": false, "question": "scripts/test-run-resource-claims.sh is red on main because one scenario reads a gitignored .devwork/ journal only #43's worktree ever had. Spawn a sibling issue, or accept?", "options": [ "fix-now", "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-47-6-3", "reasoning": "Verified pre-existing by exporting origin/main with git archive and reproducing the identical single failure there, so it did not gate this merge. But the suite cannot pass on a fresh clone or in CI — its green is only reachable on the machine that produced the journal, which makes it a false signal everywhere else. Belongs to #43's surface, not #47's." }, { "id": "D-PO-47-6-4", "type": "approval", "blocking": true, "question": "UAT: does the feature work, and is it what we wanted?", "options": [ "approve", "reject" ], "recommended": "approve", "reasoning": "Merged to main and pushed (d22f1e3..4a3e661, 21 commits, fast-forward). NO INTEGRATION DEPLOY EXISTS — this project declares observability.mode: none and has no CI on main, so there is no pipeline to watch and nothing to probe; UAT runs locally per the UAT card at .devwork/feature-release-pipeline-status-attributable-answer/test-prerequisites.md. ONE THING TO KNOW BEFORE WALKING IT: the executing plugin for every session is the MAIN CHECKOUT (~/Coding/devwork-skills, which ~/.claude/skills/dev symlinks to), and it is still at the pre-merge commit d22f1e3 — it advances only by fast-forward, which is an operator action this skill does not take. Until it is pulled, no session is running this code, so a dogfood /dev:promote would exercise the OLD helpers. Pull it first, then walk the card. Highest-value scenario is #8: this repo is tea-cli plus a release: block, so a promote run here exercises the capability-boundary path end to end — the exact path where QA found its worst regression (a guard made every unsupported answer unreachable, which would have made every release of this repo escalate instead of tagging). Expect the 'pipeline gate SKIPPED' line and a placed tag; a blocking decision about an unattributable answer means that regression is back. Six of fourteen validation scenarios need real CI and stay unverified until a dogfood release on a GitLab project — accepted at plan time (D-PO-47-2-12). approve = a second /dev:integrate run posts accepted (queued for release); it does NOT deploy — /dev:promote ships the queue on your cadence. reject = record findings; the UAT return path (qa-playbook.md §9) classifies how far back to re-enter, and since the feature branch is deleted, rework starts from a branch recreated off the current main tip." } ], "suite": { "source": "git", "sha": "4a3e66157dcaeb0fffa59cc9898a53dabe52183e", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "approve",
  "rationale": "Approved by the operator. Recorded as their judgement on the merged result, not as a walkthrough this run observed: the session did not witness a scenario walk, and the main checkout had not been fast-forwarded at the time of approval, so no dogfood /dev:promote could have exercised this code. The project's own uat.url_source names two surfaces — 'review the merged skill text on main and dogfood a run against a real project' — and the first of those is available now on main at 4a3e661. What the approval rests on, evidentially: 21 commits merged fast-forward; 18 of 19 suites green post-rebase with the single red verified identical on clean origin/main by git-archive export; lint clean with both #43's and #47's check sections proven to run; pipeline-identity 153/153 and promote-flow 48/48; and a QA record of 42 findings across six rounds with 38 fixed and the last adversarial pass clean of Critical, High and Medium. The residual exposure is stated rather than closed: six of fourteen validation scenarios need real CI and remain unverified until a dogfood release on a GitLab project (accepted at plan time, D-PO-47-2-12), and the final commit 749d2fb — one test assertion — was verified mechanically rather than adversarially (F-PO-47-5-7).",
  "rejected_alternative": "reject — send the feature back through the UAT return path (qa-playbook.md §9). Not chosen: nothing in the record argues for it. No blocking finding is outstanding, the six sweep rounds converged (11 -> 6 -> 3 -> 1 -> 0 in the product code), and the two known gaps are both stated, accepted at plan time, and not discoverable by a walkthrough on this repo — they need a GitLab project with live CI. Rejecting on those grounds would send the feature backwards without a defect to fix."
}
<!-- decision-resolution:v1 ref=D-PO-47-6-4 --> ```json { "status": "resolved", "chosen_option": "approve", "rationale": "Approved by the operator. Recorded as their judgement on the merged result, not as a walkthrough this run observed: the session did not witness a scenario walk, and the main checkout had not been fast-forwarded at the time of approval, so no dogfood /dev:promote could have exercised this code. The project's own uat.url_source names two surfaces — 'review the merged skill text on main and dogfood a run against a real project' — and the first of those is available now on main at 4a3e661. What the approval rests on, evidentially: 21 commits merged fast-forward; 18 of 19 suites green post-rebase with the single red verified identical on clean origin/main by git-archive export; lint clean with both #43's and #47's check sections proven to run; pipeline-identity 153/153 and promote-flow 48/48; and a QA record of 42 findings across six rounds with 38 fixed and the last adversarial pass clean of Critical, High and Medium. The residual exposure is stated rather than closed: six of fourteen validation scenarios need real CI and remain unverified until a dogfood release on a GitLab project (accepted at plan time, D-PO-47-2-12), and the final commit 749d2fb — one test assertion — was verified mechanically rather than adversarially (F-PO-47-5-7).", "rejected_alternative": "reject — send the feature back through the UAT return path (qa-playbook.md §9). Not chosen: nothing in the record argues for it. No blocking finding is outstanding, the six sweep rounds converged (11 -> 6 -> 3 -> 1 -> 0 in the product code), and the two known gaps are both stated, accepted at plan time, and not discoverable by a walkthrough on this repo — they need a GitLab project with live CI. Rejecting on those grounds would send the feature backwards without a defect to fix." } ```
Author
Owner
{
  "next_state": "accepted",
  "produced": [
    {
      "kind": "code-bundle",
      "ref": "comment:2462",
      "summary": "The code for this issue, merged on the uat run: 21 commits to main (d22f1e3..4a3e661, fast-forward). No new bundle on the acceptance run — this cites the existing one."
    }
  ],
  "findings": [],
  "pending_decisions": [],
  "suite": {
    "source": "git",
    "sha": "4a3e66157dcaeb0fffa59cc9898a53dabe52183e",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-47-7 skill=integrate --> ```json { "next_state": "accepted", "produced": [ { "kind": "code-bundle", "ref": "comment:2462", "summary": "The code for this issue, merged on the uat run: 21 commits to main (d22f1e3..4a3e661, fast-forward). No new bundle on the acceptance run — this cites the existing one." } ], "findings": [], "pending_decisions": [], "suite": { "source": "git", "sha": "4a3e66157dcaeb0fffa59cc9898a53dabe52183e", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted the auto-resolution. The check was not 'it looks fine' — the two sides were verified non-overlapping before concatenation (no function-name collision: ended_by_offenders vs kind_rows; no shared top-level variable), and the post-rebase gate run was then read for BOTH sides' scan lines: #43's 'run-resource-claims contract scan examined 85 markdown files for 6 normative anchors' and #47's 'contract Kind-column scan examined 34 operation rows' plus '_GATED_PRIMS drift check compared 8 against 8'. That last check is the one that matters: a resolution which silently dropped one side would still have passed bash -n AND still printed 'lint-conventions: clean', so syntax and exit status could not have distinguished it. Resolved on the operator's explicit instruction to resolve as recommended.",
  "rejected_alternative": "defer-to-issue — carry the concatenation forward for separate human review. Not chosen: there is nothing left to review that the scan-line evidence has not already settled, and an issue whose content is 'someone should look at a merge that is demonstrably correct' is defer-rot, not tracking."
}
<!-- decision-resolution:v1 ref=D-PO-47-6-1 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted the auto-resolution. The check was not 'it looks fine' — the two sides were verified non-overlapping before concatenation (no function-name collision: ended_by_offenders vs kind_rows; no shared top-level variable), and the post-rebase gate run was then read for BOTH sides' scan lines: #43's 'run-resource-claims contract scan examined 85 markdown files for 6 normative anchors' and #47's 'contract Kind-column scan examined 34 operation rows' plus '_GATED_PRIMS drift check compared 8 against 8'. That last check is the one that matters: a resolution which silently dropped one side would still have passed bash -n AND still printed 'lint-conventions: clean', so syntax and exit status could not have distinguished it. Resolved on the operator's explicit instruction to resolve as recommended.", "rejected_alternative": "defer-to-issue — carry the concatenation forward for separate human review. Not chosen: there is nothing left to review that the scan-line evidence has not already settled, and an issue whose content is 'someone should look at a merge that is demonstrably correct' is defer-rot, not tracking." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted the auto-resolution. Append-vs-append in a bulleted prose list with no ordering semantics: #43's three learnings and #47's five, both kept in full, nothing altered or dropped, one blank line inserted between the sets to match the list's existing spacing. The residual risk is close to zero by construction — the failure mode for this kind of merge is losing an entry, and both sets are present and complete. Recorded observation worth more than the resolution itself: this same conflict fired TWICE on this one branch (the pre-QA rebase and the pre-merge rebase), so CLAUDE.md's Learnings list is a standing collision point for any two concurrent features. Resolved on the operator's explicit instruction to resolve as recommended.",
  "rejected_alternative": "defer-to-issue — track the concatenation. Not chosen for the merge itself, which is verifiably complete. The recurring-collision observation is the part with future value, and it is captured here in the record rather than as an issue, because the fix (if any) is a convention question for /meta-retrospective, not a defect to schedule."
}
<!-- decision-resolution:v1 ref=D-PO-47-6-2 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted the auto-resolution. Append-vs-append in a bulleted prose list with no ordering semantics: #43's three learnings and #47's five, both kept in full, nothing altered or dropped, one blank line inserted between the sets to match the list's existing spacing. The residual risk is close to zero by construction — the failure mode for this kind of merge is losing an entry, and both sets are present and complete. Recorded observation worth more than the resolution itself: this same conflict fired TWICE on this one branch (the pre-QA rebase and the pre-merge rebase), so CLAUDE.md's Learnings list is a standing collision point for any two concurrent features. Resolved on the operator's explicit instruction to resolve as recommended.", "rejected_alternative": "defer-to-issue — track the concatenation. Not chosen for the merge itself, which is verifiably complete. The recurring-collision observation is the part with future value, and it is captured here in the record rather than as an issue, because the fix (if any) is a convention question for /meta-retrospective, not a defect to schedule." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "Deferred to a sibling issue. scenario_cr70_cr57_comment_misstates_journal_spelling in scripts/test-run-resource-claims.sh reads REAL_JOURNAL = .devwork/feature-qa-intra-run-lane-ownership/dispatch-journal.md and returns 1 when absent — and .devwork/ is gitignored under this project's tea-cli adapter, so that file was never committed and exists only in the worktree that produced it. Verified pre-existing rather than assumed: origin/main was exported with git archive into a scratch repo and the suite reproduced the identical single failure there (rc=1, same scenario, fixture_fallback_count=1), which is why it did not gate #47's merge. It is still a real defect on #43's surface: the suite cannot pass on a fresh clone, in CI, or for any operator who is not #43, so its green is a false signal everywhere except one machine — and a suite that is red-by-default trains readers to ignore it, which is the failure that outlives the fixture. Spawned fresh rather than folded: the eligible fold candidates were reviewed and none is the same subject (#105 is lint's piped-gate coverage, #106 is CLAUDE.md's Verification section), and #43's own issue has a frozen scope. Per the prefer-spawn-when-uncertain rule a redundant issue is recoverable where a wrong fold compounds. Resolved on the operator's explicit instruction to resolve as recommended.",
  "rejected_alternative": "accept — leave it red. Not chosen: a permanently-red suite in the default checkout erodes the signal of every other suite beside it, and this one is #43's own gate over its contract, so its failure is exactly where a future regression in that contract would have to show up. fix-now — repair it inside #47. Not chosen: it is another feature's test surface, #47 has no mandate over it, and a cross-feature fix landed from an unrelated branch is how ownership gets lost."
}
<!-- decision-resolution:v1 ref=D-PO-47-6-3 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "Deferred to a sibling issue. scenario_cr70_cr57_comment_misstates_journal_spelling in scripts/test-run-resource-claims.sh reads REAL_JOURNAL = .devwork/feature-qa-intra-run-lane-ownership/dispatch-journal.md and returns 1 when absent — and .devwork/ is gitignored under this project's tea-cli adapter, so that file was never committed and exists only in the worktree that produced it. Verified pre-existing rather than assumed: origin/main was exported with git archive into a scratch repo and the suite reproduced the identical single failure there (rc=1, same scenario, fixture_fallback_count=1), which is why it did not gate #47's merge. It is still a real defect on #43's surface: the suite cannot pass on a fresh clone, in CI, or for any operator who is not #43, so its green is a false signal everywhere except one machine — and a suite that is red-by-default trains readers to ignore it, which is the failure that outlives the fixture. Spawned fresh rather than folded: the eligible fold candidates were reviewed and none is the same subject (#105 is lint's piped-gate coverage, #106 is CLAUDE.md's Verification section), and #43's own issue has a frozen scope. Per the prefer-spawn-when-uncertain rule a redundant issue is recoverable where a wrong fold compounds. Resolved on the operator's explicit instruction to resolve as recommended.", "rejected_alternative": "accept — leave it red. Not chosen: a permanently-red suite in the default checkout erodes the signal of every other suite beside it, and this one is #43's own gate over its contract, so its failure is exactly where a future regression in that contract would have to show up. fix-now — repair it inside #47. Not chosen: it is another feature's test surface, #47 has no mandate over it, and a cross-feature fix landed from an unrelated branch is how ownership gets lost." } ```
Author
Owner

Linked: this issue is sibling #484 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #484 (recorded by the devwork pipeline).
Author
Owner

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's
tea-cli adapter 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:promote run that has not
happened 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-skills sits at
d22f1e3, and main is now 4a3e661. Pull it before walking anything, or the run exercises the
pre-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:

url_source: "no deploy — review the merged skill text on main and dogfood a run against a real project"

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-answer in this worktree
(.claude/worktrees/devwork-47-55-pipeline-status-identity). Note the executing plugin for every
session is the main checkout (~/.claude/skills/dev symlinks to it), so a dogfood run exercises
this branch's code only after it merges — see §4.

2. Authentication

Nothing new. The suite uses credentials you already have:

For Credential State
Gitea tracker (git.wihslon.com, jbr870/devwork-skills) the tea CLI's configured login working — this QA round posted 10 records with it
GitLab (only for the live-CI scenarios, §3) GITLAB_TOKEN from ~/.config/gitlab-token not exercised by this round; the glab-cli smoke suites are red here for this reason

No 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 prepare

# Scenario Where it runs
1 The stale-pipeline false green (the 2026-08-14 case) pipeline-identity.sh
6 States that are neither green, red nor absent pipeline-identity.sh
7 The forge cannot be reached mid-poll pipeline-identity.sh
9 What the operator sees when the capability is absent pipeline-identity.sh
10 A required primitive is missing — a broken install pipeline-identity.sh
11 The contract answers the question for every primitive lint-conventions.sh
13 The originating sequence, replayed pipeline-identity.sh
14 Both kinds of nothing, side by side pipeline-identity.sh

Run them all with:

bash plugin/skills/_shared/procedures/test/pipeline-identity.sh   # 153 checks
bash plugin/skills/_shared/procedures/test/promote-flow.sh        # 48 checks
bash scripts/lint-conventions.sh

Needs you — six scenarios, and what each needs staged

# Scenario What it needs Can you run it here?
2 A wrong-commit answer does not reach the tag a staged local-fs record whose commit ≠ the tag target yes, locally
3 A commit with several pipelines (retry, branch + MR) a real GitLab project with CI no — needs verity or similar
4 A commit whose pipeline has not started yet a forge that can report pipelines, with no pipeline for the commit partly — the answer half is automated; the "monitor keeps polling and says so" half needs a real run
5 Waiting for a pipeline that never appears a short declared ci_timeout yes, with a temporary ci_timeout
8 An adapter that cannot report pipelines at all a tea-cli project with a release: block yes — this repo is exactly that
12 A genuine red and a genuine cancellation still stop the release a real failing pipeline no — needs real CI

Scenario 8 is the one to walk first. This repo declares tea-cli and a release: block, and
tea-cli ships no pipeline_status primitive — so a /dev:promote run here exercises the
capability-boundary path end to end. That path is also where QA found its worst regression (a guard
made every unsupported answer unreachable, which would have made every release of this repo
escalate instead of tagging), so it is the highest-value thing for a human to confirm by eye.

Expect to see, verbatim:

pipeline gate SKIPPED — adapter 'tea-cli' cannot report pipelines (forge contract §2.2,
state `unsupported`). Nothing gated this tag; the required deploy_check in Step 6 is the only

…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:promote dogfood run exercises whatever is on main. So either:

  • merge this branch first (the normal path — /dev:integrate then walk the scenarios), or
  • to test before merging, run the helpers by path from this worktree rather than via the skill.

The declared deploy check is verified working as of this card:

bash scripts/deploy-check.sh
# deploy-check: PASS — dev--v0.2.0 is resolvable on `github` at 15650c0a… and validates on both roots

For Scenario 5 you will need to temporarily lower release.ci_timeout in CLAUDE.md; put it back
afterwards.

5. What changed, if you only want to look at the text

  • plugin/skills/_shared/forge-contract.md — §2.2: the pipeline_status answer shape, required
    vs 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

  • Four live-forge smoke suites cannot run here (gh-cli, glab-cli, tea-cli need a throwaway
    scratch issue; glab-cli release-smoke needs a real GitLab project and returns HTTP 400). They are
    red 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.
  • Scenarios 3 and 12, and the live half of 1 and 13, need real CI and are genuinely unverified
    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:setup does not yet know how to propose the new release.integration_ci field, so a project
    that needs it will discover it only after a release burns a full ci_timeout (recorded as
    F-PO-47-4-5).
## 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's `tea-cli` adapter 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:promote` run that has not happened 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-skills` sits at `d22f1e3`, and `main` is now `4a3e661`. Pull it before walking anything, or the run exercises the pre-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: > **`url_source`:** "no deploy — review the merged skill text on main and dogfood a run against a real project" 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-answer` in this worktree (`.claude/worktrees/devwork-47-55-pipeline-status-identity`). Note the executing plugin for every session is the **main checkout** (`~/.claude/skills/dev` symlinks to it), so a dogfood run exercises this branch's code **only after it merges** — see §4. ## 2. Authentication Nothing new. The suite uses credentials you already have: | For | Credential | State | |---|---|---| | Gitea tracker (`git.wihslon.com`, `jbr870/devwork-skills`) | the `tea` CLI's configured login | working — this QA round posted 10 records with it | | GitLab (only for the live-CI scenarios, §3) | `GITLAB_TOKEN` from `~/.config/gitlab-token` | not exercised by this round; the `glab-cli` smoke suites are red here for this reason | No 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 prepare | # | Scenario | Where it runs | |---|---|---| | 1 | The stale-pipeline false green (the 2026-08-14 case) | `pipeline-identity.sh` | | 6 | States that are neither green, red nor absent | `pipeline-identity.sh` | | 7 | The forge cannot be reached mid-poll | `pipeline-identity.sh` | | 9 | What the operator sees when the capability is absent | `pipeline-identity.sh` | | 10 | A required primitive is missing — a broken install | `pipeline-identity.sh` | | 11 | The contract answers the question for every primitive | `lint-conventions.sh` | | 13 | The originating sequence, replayed | `pipeline-identity.sh` | | 14 | Both kinds of nothing, side by side | `pipeline-identity.sh` | Run them all with: ```bash bash plugin/skills/_shared/procedures/test/pipeline-identity.sh # 153 checks bash plugin/skills/_shared/procedures/test/promote-flow.sh # 48 checks bash scripts/lint-conventions.sh ``` ### Needs you — six scenarios, and what each needs staged | # | Scenario | What it needs | Can you run it here? | |---|---|---|---| | 2 | A wrong-commit answer does not reach the tag | a staged `local-fs` record whose `commit` ≠ the tag target | **yes**, locally | | 3 | A commit with several pipelines (retry, branch + MR) | a **real GitLab** project with CI | **no** — needs *verity* or similar | | 4 | A commit whose pipeline has not started yet | a forge that can report pipelines, with no pipeline for the commit | **partly** — the answer half is automated; the "monitor keeps polling and says so" half needs a real run | | 5 | Waiting for a pipeline that never appears | a short declared `ci_timeout` | **yes**, with a temporary `ci_timeout` | | 8 | An adapter that cannot report pipelines at all | a `tea-cli` project with a `release:` block | **yes — this repo is exactly that** | | 12 | A genuine red and a genuine cancellation still stop the release | a real failing pipeline | **no** — needs real CI | **Scenario 8 is the one to walk first.** This repo declares `tea-cli` *and* a `release:` block, and `tea-cli` ships no `pipeline_status` primitive — so a `/dev:promote` run here exercises the capability-boundary path end to end. That path is also where QA found its worst regression (a guard made every `unsupported` answer unreachable, which would have made *every* release of this repo escalate instead of tagging), so it is the highest-value thing for a human to confirm by eye. Expect to see, verbatim: ``` pipeline gate SKIPPED — adapter 'tea-cli' cannot report pipelines (forge contract §2.2, state `unsupported`). Nothing gated this tag; the required deploy_check in Step 6 is the only ``` …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:promote` dogfood run exercises whatever is on `main`. So either: - merge this branch first (the normal path — `/dev:integrate` then walk the scenarios), or - to test before merging, run the helpers by path from this worktree rather than via the skill. The declared deploy check is **verified working** as of this card: ```bash bash scripts/deploy-check.sh # deploy-check: PASS — dev--v0.2.0 is resolvable on `github` at 15650c0a… and validates on both roots ``` For Scenario 5 you will need to temporarily lower `release.ci_timeout` in CLAUDE.md; put it back afterwards. ## 5. What changed, if you only want to look at the text - `plugin/skills/_shared/forge-contract.md` — §2.2: the `pipeline_status` answer shape, required vs 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 - **Four live-forge smoke suites cannot run here** (`gh-cli`, `glab-cli`, `tea-cli` need a throwaway scratch issue; `glab-cli release-smoke` needs a real GitLab project and returns HTTP 400). They are red 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. - **Scenarios 3 and 12, and the live half of 1 and 13, need real CI** and are genuinely unverified 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:setup` does not yet know how to propose the new `release.integration_ci` field, so a project that needs it will discover it only after a release burns a full `ci_timeout` (recorded as F-PO-47-4-5).
Author
Owner
{
  "tag": "dev--v0.4.0",
  "status": "tag-only",
  "repos": [
    {
      "repo": "devwork-skills",
      "tag": "dev--v0.4.0",
      "target_sha": "42a747446422db23d6aabae63962b8945fd2f932"
    }
  ]
}
<!-- release-link:v1 issue=47 skill=promote tag=dev--v0.4.0 --> ```json { "tag": "dev--v0.4.0", "status": "tag-only", "repos": [ { "repo": "devwork-skills", "tag": "dev--v0.4.0", "target_sha": "42a747446422db23d6aabae63962b8945fd2f932" } ] } ```
Author
Owner
{
  "next_state": "deployed",
  "produced": [
    {
      "kind": "release-link",
      "ref": "comment:2486",
      "summary": "Shipped in dev--v0.4.0 (tag-only release, commit 42a7474) — pipeline gate skipped: adapter `tea-cli` cannot report pipelines (forge contract §2.2, `unsupported`); the deploy_check is the only liveness evidence for this release."
    }
  ],
  "findings": [],
  "pending_decisions": [],
  "suite": {
    "source": "git",
    "sha": "bd2532c494039780d8af631752e0b22bfbb2cdbd",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-47-8 skill=promote --> ```json { "next_state": "deployed", "produced": [ { "kind": "release-link", "ref": "comment:2486", "summary": "Shipped in dev--v0.4.0 (tag-only release, commit 42a7474) — pipeline gate skipped: adapter `tea-cli` cannot report pipelines (forge contract §2.2, `unsupported`); the deploy_check is the only liveness evidence for this release." } ], "findings": [], "pending_decisions": [], "suite": { "source": "git", "sha": "bd2532c494039780d8af631752e0b22bfbb2cdbd", "dirty": false } } ```
Sign in to join this conversation.
No description provided.