devcontainer: refresh for the plugin era — per-container claude state, worktree plugin wiring, parallel-safe compose naming #61

Open
opened 2026-08-25 07:49:36 +00:00 by jbr870 · 69 comments
Owner

PREQ: devcontainer — refresh for the plugin era

Created: 2026-08-25

Problem

The operator wants to run permission-relaxed autonomous Claude Code sessions on this repo's own
issues, several at once, unattended
— and cannot, because the only sandbox available (.devcontainer/,
authored 2026-08-07) predates the plugin migration (#50) and is broken for the current layout.

Users:

  • Primary: the operator, dispatching unattended work on devwork-skills issues and wanting each run
    fenced so a mistake it makes cannot reach the host checkout, the other runs, or the machine.
  • Secondary: an autonomous session itself — it needs the suite it is supposed to execute to actually
    resolve, and it needs to reach the tracker and the forge to leave a record.

Current state: three concrete breakages, none of which have a workaround short of running unattended
work directly on the host (which is what the container exists to avoid):

  1. The dev plugin dangles. The container bind-mounts the host's whole ~/.claude, and
    ~/.claude/skills/dev is a symlink to a host path (/home/jochem/Coding/devwork-skills/plugin)
    that does not exist inside the container — the repo lands at /workspace. No /dev:* skill and no
    helper resolves in a container session. The container cannot run the suite at all.
  2. Parallel containers collide. The compose project name is a fixed literal, so a second container
    started for a second issue clashes with the first.
  3. Session state is shared, not isolated. One mutable host ~/.claude is mounted into the host
    session and every container, so concurrent sessions share and race on the same session, task and
    config state — and there is no per-container place to wire the plugin even if (1) were fixed.

Today the operator therefore runs parallel work as attended host sessions in git worktrees (CLAUDE.md
"Parallel sessions"). That is fine while attended; it is exactly what must not happen unattended.

Proposed Solution

Make .devcontainer/ fit a single purpose — blast-radius isolation for unattended sessions — so that
the operator can bring up, from a terminal, one container per issue, several at a time, each of which is a
working Claude Code session on the suite with its own Claude state and a fenced view of the world.

Four changes carry it. Whether they are reached by repairing the current files or by replacing them is a
design decision, not a requirement:

  • Its own Claude state. Each container gets its own persistent Claude state instead of sharing the
    host's, so sessions do not race, and so the plugin can be wired per container.
  • Working plugin wiring. A container session resolves /dev:* skills and helpers from the checkout
    it is working in, so it executes the suite it is dogfooding, and the run record says which revision
    that was.
  • A real isolation boundary. A container works its own copy of the feature branch and publishes
    code by pushing. Nothing it does — right or wrong — reaches the host checkout, the shared git
    directory, or another container's workspace; and the shared refs everyone depends on are protected on
    the forge so a container cannot rewrite them either.
  • Parallel-safe identity. Container/project naming is derived per issue, so N containers coexist and
    each is addressable and removable on its own.

Scope: Standard — a working, parallel-safe container plus the terminal lifecycle to drive it.
Starting an autonomous run inside the container stays a manual act; bundling run-orchestration into an
infra ticket would drag prompt/permission-mode/failure-reporting decisions in with it.

User Stories

  • As the operator, I want a container session to resolve and run the /dev:* skills, so that unattended
    work on an issue can actually execute the pipeline instead of failing on the first skill call.
  • As the operator, I want each container to hold its own Claude state, so that several unattended runs
    and my own host session do not corrupt each other's sessions and config.
  • As the operator, I want a container's writes fenced to its own workspace and unable to rewrite shared
    refs, so that a permission-relaxed run that goes wrong cannot damage my checkout, my other runs, or the
    branches they depend on.
  • As the operator, I want to bring up, address and tear down one container per issue from a terminal, so
    that I can run several in parallel without VS Code and without editing files to disambiguate them.
  • As an autonomous session, I want to reach the tracker and push my branch, so that my work survives the
    container and is reviewable after it is gone.

Acceptance Criteria

The suite runs in there

  • Given a container brought up for an issue, when a Claude Code session in it invokes a /dev:*
    skill, then the skill loads and its helper scripts execute — no unresolved path, no missing skill.
    This is the definition of a "usable session" wherever the phrase appears below.
  • Given a container session, when it runs the suite's helper tier, then bash (≥ 3.2), jq, git
    and the POSIX utilities named in CLAUDE.md's two-tier baseline table are present and usable, and
    the project's declared forge CLI is installed and authenticated against the declared instance.
    (The capable-date/touch requirement is local-fs-only and does not apply — this project
    declares tea-cli.)
  • Given a container session running a pipeline phase, when that phase posts its record to the
    tracker, then the record's suite-provenance stamp (the mechanism shipped in #57) names the suite
    revision that session executed; and when the checkout it executes from is changed and another
    phase is posted, the stamp changes correspondingly. (Regression check on shipped behaviour — this
    criterion introduces no new mechanism.)

Isolation

  • Given two containers up at the same time, when both run Claude Code sessions, then neither can
    read or write the other's Claude state — its sessions, tasks, history and configuration — and the
    host's own Claude state is unchanged by both. Checked by attempting cross-access from inside one
    container and by inspecting what each container has mounted
    , not by assertion.
  • Given a container working issue N, when a representative set of create/edit/delete operations is
    performed across its workspace, then the host checkout, every host worktree, and every other
    container's workspace compare equal to their prior state.
  • Given a container session that has committed work, when it pushes its branch, then the branch is
    visible on the forge to the host and to a reviewer — and the container's code and commits reach
    the outside world by no other route
    . Verified by enumerating the container's mounts, shared
    volumes and git directories and finding no second path, rather than by asserting a negative.
    (Tracker records are published deliberately and are not "code" for this purpose.)
  • Given the shared refs on the forge, when a container attempts to force-push to or delete the
    integration branch, then the forge refuses — a container holding the host's credentials cannot
    rewrite what other runs and the host depend on.
  • Given a container session, when it performs tracker operations for its issue, then they succeed
    against the project's declared forge instance without an interactive authentication step.
  • Given a container session, when the branch it is pushing has moved on the forge since it was
    copied, then the push is refused and the refusal is visible to the session — it never silently
    overwrites the newer state.

Credentials

  • Given a container started with the operator's Claude credential supplied to it, when a session
    starts, then it is already authenticated — no interactive login step is required.
  • Given a running container, when the session's Claude credential needs refreshing, then it can be
    refreshed in place inside the container and the session continues; and after the container has
    run, the host's own credential file is unchanged.
  • Given a container started with an expired or invalid credential (Claude or forge), when it starts,
    then the failure is reported explicitly and identifiably — it does not hang, and it does not
    present as an unrelated error later in a run.

Lifecycle

The lifecycle has exactly three operations, and the criteria below are what distinguishes them:
create-or-enter (idempotent), stop (state preserved), remove (state destroyed, guarded).

  • Given a host with only a terminal available, when the operator follows the documented lifecycle,
    then a container for a named issue comes up, yields a usable session, and can be stopped and
    removed again — with no VS Code and no GUI step anywhere in the sequence.
  • Given containers brought up for two different issues, when both are running, then neither fails or
    displaces the other on account of naming, and each can be addressed, stopped and removed
    individually without disturbing the other.
  • Given an issue that already has a container, when the operator runs create-or-enter for that same
    issue again, then they are placed in the existing container — a second, rival container for one
    issue is never created.
  • Given a container that was stopped normally, when the operator enters it again, then its Claude
    state and its workspace — including uncommitted changes — are as they were left, and first-time
    setup does not run again.
  • Given a container that exited abnormally (crash, daemon kill, host reboot), when the operator
    enters it again, then they get a usable session with that same state intact, and no leftover state
    from the abnormal exit blocks the entry.
  • Given a container holding commits the forge does not have, when the operator asks to remove it,
    then removal is refused and the unpushed commits are named; removal proceeds only on an explicit
    override.
  • Given a container with nothing unpushed, when the operator removes it, then its workspace and
    Claude state are destroyed, its name is free for re-use, and no other running container is
    affected.
  • Given a host with no audio server running, when a container starts, then it starts cleanly and
    yields a usable session.

Workspace seed

  • Given an issue whose branch already exists on the forge, when a container is created for it, then
    its workspace is that branch at its forge tip; and given an issue with no branch yet, then its
    workspace starts from the integration branch at its forge tip and the feature branch is created in
    the container.
  • Given a freshly created container, when a session starts in it, then that session operates under
    the same Claude configuration a host session would (settings and plugin/skill wiring), while
    session, task and history state start empty.

Out of Scope

  • Launching or supervising the autonomous run itself — no "give it an issue number and walk away"
    script. Which prompt, which permission mode, and how a failed run is surfaced are run-orchestration
    decisions that deserve their own feature.
  • /voice audio plumbing. The host audio socket mount and the audio packages are removed. Unattended
    runs have no one to talk to, and it is the most host-coupled part of the image.
  • VS Code integration. No extension list, no GUI-attach flow, no devcontainer features that only
    matter to an editor UI. The lifecycle is terminal-first because the operator never uses VS Code.
  • Resource isolation (ports, databases, service provisioning). This repo runs no services, so there
    is nothing to allocate or collide over.
  • A parallel_dev: slot recipe. The suite's slot schema cannot currently express a services-free
    project (filed as #62); this feature does not work around that, and does not adopt slots.
  • Hosts other than this Linux dev box, and container hosts other than the local Docker daemon.
  • Changes to shipped skill text (plugin/). .devcontainer/ is repo-local infra. Any suite-level
    gap this work exposes is filed as its own issue rather than fixed here.

Dependencies

  • External systems of record this feature must reach from inside a container:
    • the Gitea tracker at the project's declared forge instance (git.wihslon.com, jbr870/devwork-skills)
      — read and write, via a token supplied to the container;
    • the git remote origin, reached over SSH through a host SSH host-alias
      (ssh://git@forge-devwork/...). The alias is defined in the host's SSH client configuration, so the
      container needs that configuration and not merely the key material — a keys-only mount resolves
      nothing.
  • A forge-side precondition, outside this repo: branch protection on the integration branch
    (no force-push, no deletion). One of the acceptance criteria asserts it, and it is the mechanism that
    makes the isolation claim true for the remote as well as the filesystem.
  • A container runtime and compose tooling on the host.
  • Network access at image build time (the Claude Code CLI and the forge CLI are fetched, not vendored);
    their versions should be pinned so a rebuild is reproducible.
  • The suite's existing run-provenance stamp (#57, shipped) — the record-identifies-the-revision criterion
    relies on it rather than introducing a new mechanism.
  • Related, not blocking: #56 (a dev-machine run executes skill text that can change under it) and #62
    (slot recipe schema). Neither gates this feature.

Timeline

Milestone Date Notes
Requirements complete 2026-08-25
Development complete
QA complete
UAT approved

Notes

  • Constraints:
    • A git worktree's git directory is a pointer into the main checkout's, so a container that
      bind-mounted a host worktree would need the main checkout's git directory too — which would put every
      other worktree and the main checkout inside its blast radius. This is why the isolation boundary is
      stated as "its own copy, published by pushing" rather than "mount the worktree".
    • Consequence of that boundary, accepted deliberately: uncommitted container work is invisible to the
      host
      , and a branch must be pushed before a reviewer can see it. This is also why removal is
      guarded on unpushed commits.
  • Key decisions (operator, 2026-08-25):
    • Isolation: push-only. Chosen over a live shared worktree specifically because the shared-worktree
      option gives up most of the isolation the container exists to provide.
    • Remote-write reach: closed by protecting the shared refs on the forge, rather than by giving
      containers a narrower git credential. Chosen after the review panel showed that host SSH keys inside
      a permission-relaxed container would otherwise let it force-push over another run's branch — which
      would have made the stated problem's "cannot reach the other runs" false.
    • Claude authentication: the host's credential is copied into the container's own state at
      start-up
      , writable, rather than mounted read-only. Chosen after the panel showed a read-only mount
      would fail an unattended run at the unpredictable moment the credential needed refreshing.
    • Container lifetime: long-lived, one per issue, rather than ephemeral one-shot. Chosen so an
      unattended run can be resumed across sittings and — more importantly — so a failed run is still
      there to inspect.
    • Removal: refuses while commits are unpushed, unless overridden. Chosen over auto-pushing, which
      would publish work from a run that may have gone wrong.
    • Retained host plumbing: the host's SSH key material and client configuration, the host git
      identity, and SSH agent forwarding all stay.
    • Accepted risk, recorded explicitly: the operator's Claude credential and the host's SSH keys are
      readable from inside every permission-relaxed container. Forge branch protection closes what those
      keys could otherwise rewrite, and the credential copy is per-container, but neither measure makes
      the secrets unreadable. The container's boundary is therefore real for the filesystem, the
      checkout, and the shared refs
      , and not for the confidentiality of those two credentials. This
      was put to the operator with the exposure spelled out and chosen anyway; it is recorded here so a
      later reader does not mistake it for an oversight.
  • Open questions (deferred to planning, per the operator's note on the issue):
    • Whether a container executes the suite from the checkout it is working on (dogfood-the-edit) or from
      a pinned copy. The acceptance criteria are written to hold either way — they require that skills
      resolve and that the record names the revision executed, not a particular mechanism. Ties into #56.
# PREQ: devcontainer — refresh for the plugin era **Created:** 2026-08-25 ## Problem The operator wants to run **permission-relaxed autonomous Claude Code sessions on this repo's own issues, several at once, unattended** — and cannot, because the only sandbox available (`.devcontainer/`, authored 2026-08-07) predates the plugin migration (#50) and is broken for the current layout. **Users:** - **Primary:** the operator, dispatching unattended work on devwork-skills issues and wanting each run fenced so a mistake it makes cannot reach the host checkout, the other runs, or the machine. - **Secondary:** an autonomous session itself — it needs the suite it is supposed to execute to actually resolve, and it needs to reach the tracker and the forge to leave a record. **Current state:** three concrete breakages, none of which have a workaround short of running unattended work directly on the host (which is what the container exists to avoid): 1. **The `dev` plugin dangles.** The container bind-mounts the host's whole `~/.claude`, and `~/.claude/skills/dev` is a symlink to a *host* path (`/home/jochem/Coding/devwork-skills/plugin`) that does not exist inside the container — the repo lands at `/workspace`. No `/dev:*` skill and no helper resolves in a container session. The container cannot run the suite at all. 2. **Parallel containers collide.** The compose project name is a fixed literal, so a second container started for a second issue clashes with the first. 3. **Session state is shared, not isolated.** One mutable host `~/.claude` is mounted into the host session *and* every container, so concurrent sessions share and race on the same session, task and config state — and there is no per-container place to wire the plugin even if (1) were fixed. Today the operator therefore runs parallel work as attended host sessions in git worktrees (CLAUDE.md "Parallel sessions"). That is fine while attended; it is exactly what must not happen unattended. ## Proposed Solution Make `.devcontainer/` fit a single purpose — **blast-radius isolation for unattended sessions** — so that the operator can bring up, from a terminal, one container per issue, several at a time, each of which is a working Claude Code session on the suite with its own Claude state and a fenced view of the world. Four changes carry it. Whether they are reached by repairing the current files or by replacing them is a design decision, not a requirement: - **Its own Claude state.** Each container gets its own persistent Claude state instead of sharing the host's, so sessions do not race, and so the plugin can be wired per container. - **Working plugin wiring.** A container session resolves `/dev:*` skills and helpers from the checkout it is working in, so it executes the suite it is dogfooding, and the run record says which revision that was. - **A real isolation boundary.** A container works its **own copy** of the feature branch and publishes code by **pushing**. Nothing it does — right or wrong — reaches the host checkout, the shared git directory, or another container's workspace; and the shared refs everyone depends on are protected on the forge so a container cannot rewrite them either. - **Parallel-safe identity.** Container/project naming is derived per issue, so N containers coexist and each is addressable and removable on its own. **Scope:** Standard — a working, parallel-safe container plus the terminal lifecycle to drive it. Starting an autonomous run *inside* the container stays a manual act; bundling run-orchestration into an infra ticket would drag prompt/permission-mode/failure-reporting decisions in with it. ## User Stories - As the operator, I want a container session to resolve and run the `/dev:*` skills, so that unattended work on an issue can actually execute the pipeline instead of failing on the first skill call. - As the operator, I want each container to hold its own Claude state, so that several unattended runs and my own host session do not corrupt each other's sessions and config. - As the operator, I want a container's writes fenced to its own workspace and unable to rewrite shared refs, so that a permission-relaxed run that goes wrong cannot damage my checkout, my other runs, or the branches they depend on. - As the operator, I want to bring up, address and tear down one container per issue from a terminal, so that I can run several in parallel without VS Code and without editing files to disambiguate them. - As an autonomous session, I want to reach the tracker and push my branch, so that my work survives the container and is reviewable after it is gone. ## Acceptance Criteria ### The suite runs in there - [ ] Given a container brought up for an issue, when a Claude Code session in it invokes a `/dev:*` skill, then the skill loads and its helper scripts execute — no unresolved path, no missing skill. **This is the definition of a "usable session" wherever the phrase appears below.** - [ ] Given a container session, when it runs the suite's helper tier, then `bash` (≥ 3.2), `jq`, `git` and the POSIX utilities named in CLAUDE.md's two-tier baseline table are present and usable, and the project's declared forge CLI is installed and authenticated against the declared instance. (The capable-`date`/`touch` requirement is `local-fs`-only and does not apply — this project declares `tea-cli`.) - [ ] Given a container session running a pipeline phase, when that phase posts its record to the tracker, then the record's suite-provenance stamp (the mechanism shipped in #57) names the suite revision that session executed; and when the checkout it executes from is changed and another phase is posted, the stamp changes correspondingly. *(Regression check on shipped behaviour — this criterion introduces no new mechanism.)* ### Isolation - [ ] Given two containers up at the same time, when both run Claude Code sessions, then neither can read or write the other's Claude state — its sessions, tasks, history and configuration — and the host's own Claude state is unchanged by both. **Checked by attempting cross-access from inside one container and by inspecting what each container has mounted**, not by assertion. - [ ] Given a container working issue N, when a representative set of create/edit/delete operations is performed across its workspace, then the host checkout, every host worktree, and every other container's workspace compare equal to their prior state. - [ ] Given a container session that has committed work, when it pushes its branch, then the branch is visible on the forge to the host and to a reviewer — and **the container's code and commits reach the outside world by no other route**. Verified by enumerating the container's mounts, shared volumes and git directories and finding no second path, rather than by asserting a negative. (Tracker records are published deliberately and are not "code" for this purpose.) - [ ] Given the shared refs on the forge, when a container attempts to force-push to or delete the integration branch, then the forge refuses — a container holding the host's credentials cannot rewrite what other runs and the host depend on. - [ ] Given a container session, when it performs tracker operations for its issue, then they succeed against the project's declared forge instance without an interactive authentication step. - [ ] Given a container session, when the branch it is pushing has moved on the forge since it was copied, then the push is refused and the refusal is visible to the session — it never silently overwrites the newer state. ### Credentials - [ ] Given a container started with the operator's Claude credential supplied to it, when a session starts, then it is already authenticated — no interactive login step is required. - [ ] Given a running container, when the session's Claude credential needs refreshing, then it can be refreshed in place inside the container and the session continues; and after the container has run, the host's own credential file is unchanged. - [ ] Given a container started with an expired or invalid credential (Claude or forge), when it starts, then the failure is reported explicitly and identifiably — it does not hang, and it does not present as an unrelated error later in a run. ### Lifecycle The lifecycle has exactly three operations, and the criteria below are what distinguishes them: **create-or-enter** (idempotent), **stop** (state preserved), **remove** (state destroyed, guarded). - [ ] Given a host with only a terminal available, when the operator follows the documented lifecycle, then a container for a named issue comes up, yields a usable session, and can be stopped and removed again — with no VS Code and no GUI step anywhere in the sequence. - [ ] Given containers brought up for two different issues, when both are running, then neither fails or displaces the other on account of naming, and each can be addressed, stopped and removed individually without disturbing the other. - [ ] Given an issue that already has a container, when the operator runs create-or-enter for that same issue again, then they are placed in the existing container — a second, rival container for one issue is never created. - [ ] Given a container that was stopped normally, when the operator enters it again, then its Claude state and its workspace — including uncommitted changes — are as they were left, and first-time setup does not run again. - [ ] Given a container that exited abnormally (crash, daemon kill, host reboot), when the operator enters it again, then they get a usable session with that same state intact, and no leftover state from the abnormal exit blocks the entry. - [ ] Given a container holding commits the forge does not have, when the operator asks to remove it, then removal is refused and the unpushed commits are named; removal proceeds only on an explicit override. - [ ] Given a container with nothing unpushed, when the operator removes it, then its workspace and Claude state are destroyed, its name is free for re-use, and no other running container is affected. - [ ] Given a host with no audio server running, when a container starts, then it starts cleanly and yields a usable session. ### Workspace seed - [ ] Given an issue whose branch already exists on the forge, when a container is created for it, then its workspace is that branch at its forge tip; and given an issue with no branch yet, then its workspace starts from the integration branch at its forge tip and the feature branch is created in the container. - [ ] Given a freshly created container, when a session starts in it, then that session operates under the same Claude configuration a host session would (settings and plugin/skill wiring), while session, task and history state start empty. ## Out of Scope - **Launching or supervising the autonomous run itself** — no "give it an issue number and walk away" script. Which prompt, which permission mode, and how a failed run is surfaced are run-orchestration decisions that deserve their own feature. - **`/voice` audio plumbing.** The host audio socket mount and the audio packages are removed. Unattended runs have no one to talk to, and it is the most host-coupled part of the image. - **VS Code integration.** No extension list, no GUI-attach flow, no devcontainer features that only matter to an editor UI. The lifecycle is terminal-first because the operator never uses VS Code. - **Resource isolation (ports, databases, service provisioning).** This repo runs no services, so there is nothing to allocate or collide over. - **A `parallel_dev:` slot recipe.** The suite's slot schema cannot currently express a services-free project (filed as #62); this feature does not work around that, and does not adopt slots. - **Hosts other than this Linux dev box**, and container hosts other than the local Docker daemon. - **Changes to shipped skill text (`plugin/`).** `.devcontainer/` is repo-local infra. Any suite-level gap this work exposes is filed as its own issue rather than fixed here. ## Dependencies - **External systems of record this feature must reach from inside a container:** - the **Gitea tracker** at the project's declared forge instance (`git.wihslon.com`, `jbr870/devwork-skills`) — read and write, via a token supplied to the container; - the **git remote `origin`**, reached over SSH through a *host SSH host-alias* (`ssh://git@forge-devwork/...`). The alias is defined in the host's SSH client configuration, so the container needs that configuration and not merely the key material — a keys-only mount resolves nothing. - **A forge-side precondition, outside this repo:** branch protection on the integration branch (no force-push, no deletion). One of the acceptance criteria asserts it, and it is the mechanism that makes the isolation claim true for the remote as well as the filesystem. - A container runtime and compose tooling on the host. - Network access at image build time (the Claude Code CLI and the forge CLI are fetched, not vendored); their versions should be pinned so a rebuild is reproducible. - The suite's existing run-provenance stamp (#57, shipped) — the record-identifies-the-revision criterion relies on it rather than introducing a new mechanism. - **Related, not blocking:** #56 (a dev-machine run executes skill text that can change under it) and #62 (slot recipe schema). Neither gates this feature. ## Timeline | Milestone | Date | Notes | |-----------|------|-------| | Requirements complete | 2026-08-25 | | | Development complete | | | | QA complete | | | | UAT approved | | | ## Notes - **Constraints:** - A git worktree's git directory is a *pointer* into the main checkout's, so a container that bind-mounted a host worktree would need the main checkout's git directory too — which would put every other worktree and the main checkout inside its blast radius. This is why the isolation boundary is stated as "its own copy, published by pushing" rather than "mount the worktree". - Consequence of that boundary, accepted deliberately: **uncommitted container work is invisible to the host**, and a branch must be pushed before a reviewer can see it. This is also why removal is guarded on unpushed commits. - **Key decisions (operator, 2026-08-25):** - *Isolation:* push-only. Chosen over a live shared worktree specifically because the shared-worktree option gives up most of the isolation the container exists to provide. - *Remote-write reach:* closed by **protecting the shared refs on the forge**, rather than by giving containers a narrower git credential. Chosen after the review panel showed that host SSH keys inside a permission-relaxed container would otherwise let it force-push over another run's branch — which would have made the stated problem's "cannot reach the other runs" false. - *Claude authentication:* the host's credential is **copied into the container's own state at start-up**, writable, rather than mounted read-only. Chosen after the panel showed a read-only mount would fail an unattended run at the unpredictable moment the credential needed refreshing. - *Container lifetime:* long-lived, one per issue, rather than ephemeral one-shot. Chosen so an unattended run can be resumed across sittings and — more importantly — so a failed run is still there to inspect. - *Removal:* refuses while commits are unpushed, unless overridden. Chosen over auto-pushing, which would publish work from a run that may have gone wrong. - *Retained host plumbing:* the host's SSH key material and client configuration, the host git identity, and SSH agent forwarding all stay. - **Accepted risk, recorded explicitly:** the operator's Claude credential and the host's SSH keys are *readable* from inside every permission-relaxed container. Forge branch protection closes what those keys could otherwise *rewrite*, and the credential copy is per-container, but neither measure makes the secrets unreadable. The container's boundary is therefore real for the **filesystem, the checkout, and the shared refs**, and *not* for **the confidentiality of those two credentials**. This was put to the operator with the exposure spelled out and chosen anyway; it is recorded here so a later reader does not mistake it for an oversight. - **Open questions (deferred to planning, per the operator's note on the issue):** - Whether a container executes the suite from the checkout it is working on (dogfood-the-edit) or from a pinned copy. The acceptance criteria are written to hold either way — they require that skills resolve and that the record names the revision executed, not a particular mechanism. Ties into #56.
Author
Owner

Test Plan: devcontainer-plugin-era-refresh

Validation cases for issue #61, derived from the PREQ alone. No design exists yet, so no scenario
names a file, a container tool, a compose key or a mount path — each says what the operator does and
what they should observe. Where a scenario says "the documented bring-up command", the lifecycle
documentation this feature produces supplies it.

Prerequisites

The state these scenarios need. Concrete commands, paths and credential values belong to the UAT card,
not here.

  • A host that can run containers, with the repository checked out.
  • The operator's Claude credential present on the host in its normal location.
  • Host SSH configuration that can reach the git remote origin by its host-alias, and a working
    forge token for the declared tracker instance.
  • The documented terminal lifecycle for this feature (its create-or-enter, stop and remove
    operations).

Required Test Data

  • Two distinct issues on the tracker to run containers for — call them issue A and issue B.
  • Issue A already has a branch on the forge; issue B has none. (Scenario 24 needs both.)
  • A third issue C whose branch exists on the forge and has a commit the container will not
    have, for the non-fast-forward case.
  • One deliberately invalid credential pair — an expired/garbage Claude credential and a
    garbage forge token — to inject for the failure-path scenarios.
  • A recorded snapshot of the host state before the isolation scenarios: the host checkout, every
    host worktree, and the host's Claude state directory, in a form that can be compared afterwards.

Test Scenarios

Scenario 1: A container session can run the suite

Acceptance criterion: "Given a container brought up for an issue, when a Claude Code session in it
invokes a /dev:* skill, then the skill loads and its helper scripts execute — no unresolved path, no
missing skill."

  1. Bring up a container for issue A using the documented command.
  2. Start a Claude Code session inside it.
  3. Invoke a /dev:* skill that reads from the tracker.
  4. Verify: the skill's instructions load — no "skill not found", no dangling-path or
    no-such-file error naming a host path.
  5. Verify: a helper script the skill runs executes and returns output, rather than failing to be found
    or failing to be executable.

Expected outcome: the session behaves as a suite-capable session. This is the reference definition
of a "usable session" used by later scenarios.

Scenario 2: The helper tier's prerequisites are present at the required versions

Acceptance criterion: "…then bash (≥ 3.2), jq, git and the POSIX utilities named in CLAUDE.md's
two-tier baseline table are present and usable, and the project's declared forge CLI is installed and
authenticated against the declared instance."

  1. In a container session for issue A, ask for the version of each tool named in the project's
    two-tier baseline table.
  2. Verify: each is present, and bash reports at least the required minimum version.
  3. Ask the forge CLI to list its configured logins.
  4. Verify: a login exists for the instance the project declares, and it is usable without being
    prompted for anything.

Expected outcome: nothing the helper tier depends on is missing, and the forge CLI is already
authenticated.

Scenario 3: The run record names the revision that ran, and tracks it

Acceptance criterion: "…the record's suite-provenance stamp names the suite revision that session
executed; and when the checkout it executes from is changed and another phase is posted, the stamp
changes correspondingly."

  1. In a container session for issue A, run a pipeline phase that posts a record to the tracker.
  2. Read the posted record and note the suite revision it names.
  3. Verify: the named revision matches the revision of the checkout the session is executing the suite
    from.
  4. Change that checkout (commit an edit to the suite text inside the container).
  5. Run another phase that posts a record.
  6. Verify: the newly posted record names the changed revision, not the earlier one.

Expected outcome: the stamp is tied to what actually ran, not a value fixed at build time.

Scenario 4: Two containers cannot see each other's Claude state

Acceptance criterion: "Given two containers up at the same time, when both run Claude Code sessions,
then neither can read or write the other's Claude state… and the host's own Claude state is unchanged by
both."

  1. Bring up containers for issue A and issue B, and start a session in each.
  2. In container A, create a distinctive marker inside its Claude state directory.
  3. In container B, look for that marker anywhere in its own Claude state.
  4. Verify: it is not there.
  5. From container B, attempt to reach container A's Claude state directly, and list what container B has
    mounted.
  6. Verify: the attempt fails, and no mount in container B leads to container A's state.
  7. Run a session in each container long enough to write session and task state.
  8. Compare the host's Claude state directory against the snapshot taken beforehand.
  9. Verify: the host's own session, task and history state is unchanged by either container.

Expected outcome: three separate Claude states — two containers and the host — with no path between
them.

Scenario 5: A container's writes do not escape its workspace

Acceptance criterion: "…when a representative set of create/edit/delete operations is performed
across its workspace, then the host checkout, every host worktree, and every other container's workspace
compare equal to their prior state."

  1. With containers for A and B up, in container A: create new files, edit tracked files, and delete
    tracked files across several directories of its workspace, including its repository root.
  2. Compare the host checkout and every host worktree against the snapshot.
  3. Verify: both compare equal — nothing container A did appears in them.
  4. Compare container B's workspace against its own starting state.
  5. Verify: it compares equal.

Expected outcome: the container's write reach stops at its own workspace.

Scenario 6: Pushing is the only route code takes out

Acceptance criterion: "…the branch is visible on the forge… and the container's code and commits
reach the outside world by no other route."

  1. In container A, commit a distinctive change but do not push.
  2. Verify: the change is not visible on the forge, in the host checkout, in any host worktree, or in
    container B.
  3. Enumerate everything container A has mounted, every volume it shares, and every git directory it can
    reach.
  4. Verify: none of them leads to the host checkout, a host worktree, another container's workspace, or a
    shared git directory — the enumerated list contains no second egress path for code.
  5. Push the branch.
  6. Verify: the change is now visible on the forge and can be fetched by the host.

Expected outcome: before the push, nothing; after the push, the branch — and no third possibility.

Scenario 7: The forge refuses to let a container rewrite shared refs

Acceptance criterion: "Given the shared refs on the forge, when a container attempts to force-push to
or delete the integration branch, then the forge refuses."

  1. From inside container A, attempt to force-push a rewritten history over the integration branch.
  2. Verify: the forge rejects it, and the rejection is reported to the session.
  3. Verify: the integration branch on the forge still points at what it pointed at before.
  4. From inside container A, attempt to delete the integration branch on the forge.
  5. Verify: the forge rejects it and the branch still exists.

Expected outcome: the container holds working credentials and still cannot rewrite what other runs
depend on.

Scenario 8: Tracker operations work without an interactive step

Acceptance criterion: "…then they succeed against the project's declared forge instance without an
interactive authentication step."

  1. In a container session for issue A, read the issue, post a comment to it, and read the comment back.
  2. Verify: all three succeed.
  3. Verify: at no point was a login, token, password or confirmation prompt presented.

Expected outcome: the tracker is reachable and pre-authenticated.

Scenario 9: A moved branch causes a refused push, not a silent overwrite

Acceptance criterion: "…when the branch it is pushing has moved on the forge since it was copied,
then the push is refused and the refusal is visible to the session."

  1. Bring up a container for issue C, whose branch exists on the forge.
  2. From the host, push an additional commit to issue C's branch, so the forge is now ahead of the
    container's copy.
  3. In the container, commit a change and push.
  4. Verify: the push is refused, and the refusal is reported in the session — not swallowed.
  5. Verify: the commit the host pushed in step 2 is still on the forge branch.

Expected outcome: the newer state survives; the container is told why its push did not land.

Scenario 10: A session starts already authenticated

Acceptance criterion: "Given a container started with the operator's Claude credential supplied to
it, when a session starts, then it is already authenticated — no interactive login step is required."

  1. Bring up a fresh container for issue A.
  2. Start a Claude Code session and give it a prompt.
  3. Verify: the session answers, with no login prompt, no browser hand-off and no token paste step.

Expected outcome: an unattended start is possible because nothing waits for a human.

Scenario 11: The credential can be refreshed in place, and the host's is untouched

Acceptance criterion: "…then it can be refreshed in place inside the container and the session
continues; and after the container has run, the host's own credential file is unchanged."

  1. Note the host credential file's contents and modification time.
  2. In a running container, cause the session's credential store to be written (refresh it, or otherwise
    trigger a credential write).
  3. Verify: the write succeeds — no read-only or permission-denied failure.
  4. Verify: the session continues working afterwards.
  5. Re-check the host credential file.
  6. Verify: contents and modification time are unchanged.

Expected outcome: refreshing works inside the container and stays inside it.

Scenario 12: Bad credentials fail loudly at the start

Acceptance criterion: "Given a container started with an expired or invalid credential (Claude or
forge), when it starts, then the failure is reported explicitly and identifiably — it does not hang, and
it does not present as an unrelated error later in a run."

  1. Bring up a container supplying the deliberately invalid Claude credential.
  2. Verify: an explicit message identifies the credential as the problem, and it appears at start-up
    rather than partway through work.
  3. Verify: the start does not hang waiting indefinitely.
  4. Repeat with a valid Claude credential and the garbage forge token.
  5. Verify: an explicit message identifies the forge credential as the problem — not, for example, a
    generic "issue not found".

Expected outcome: the operator can tell from the first screen which credential is wrong.

Scenario 13: The whole lifecycle runs from a terminal

Acceptance criterion: "Given a host with only a terminal available, when the operator follows the
documented lifecycle, then a container for a named issue comes up, yields a usable session, and can be
stopped and removed again — with no VS Code and no GUI step anywhere."

  1. From a plain terminal, follow the documented lifecycle end to end for issue B: create-or-enter,
    then a usable session (Scenario 1's check), then stop, then remove.
  2. Verify: every step is a terminal command.
  3. Verify: no step requires an editor, an extension, a GUI attach, or any window.

Expected outcome: the documented sequence is complete and sufficient — nothing has to be discovered.

Scenario 14: Two issues, two containers, no collision

Acceptance criterion: "Given containers brought up for two different issues, when both are running,
then neither fails or displaces the other on account of naming, and each can be addressed, stopped and
removed individually without disturbing the other."

  1. Bring up containers for issue A and issue B.
  2. Verify: both are running at the same time, and bringing up the second did not error, replace, or
    restart the first.
  3. Verify: each can be addressed by a name derived from its issue, and entering one lands in that one.
  4. Stop the container for issue A.
  5. Verify: the container for issue B is still running and its session is unaffected.
  6. Remove the container for issue A.
  7. Verify: issue B's container and workspace are untouched.

Expected outcome: two independent containers, individually addressable.

Scenario 15: Re-entering an issue reuses its container

Acceptance criterion: "Given an issue that already has a container, when the operator runs
create-or-enter for that same issue again, then they are placed in the existing container — a second,
rival container for one issue is never created."

  1. With a container running for issue A, leave a distinctive marker file in its workspace.
  2. Run create-or-enter for issue A a second time.
  3. Verify: the marker file is present — this is the same container, not a new one.
  4. Verify: exactly one container exists for issue A.

Expected outcome: create-or-enter is idempotent per issue.

Scenario 16: State survives a normal stop

Acceptance criterion: "Given a container that was stopped normally, when the operator enters it
again, then its Claude state and its workspace — including uncommitted changes — are as they were left,
and first-time setup does not run again."

  1. In a container for issue A, make an uncommitted edit and run a session so session state accumulates.
  2. Stop the container using the documented stop operation.
  3. Enter it again.
  4. Verify: the uncommitted edit is still there, unchanged.
  5. Verify: the accumulated Claude session state is still there.
  6. Verify: first-time setup did not re-run — no re-initialisation output, and nothing setup created was
    reset.

Expected outcome: stop is not destructive.

Scenario 17: State survives an abnormal exit

Acceptance criterion: "Given a container that exited abnormally (crash, daemon kill, host reboot),
when the operator enters it again, then they get a usable session with that same state intact, and no
leftover state from the abnormal exit blocks the entry."

  1. In a container for issue A, make an uncommitted edit.
  2. Kill the container abruptly rather than stopping it (simulating a crash or a host reboot).
  3. Run create-or-enter for issue A again.
  4. Verify: it comes up — no error about an existing, stale, or conflicting container that the operator
    must clean up by hand.
  5. Verify: the uncommitted edit and the Claude state are intact.
  6. Verify: the session is usable (Scenario 1's check).

Expected outcome: an abnormal exit is recoverable by repeating the ordinary entry command.

Scenario 18: Removal refuses while work is unpushed

Acceptance criterion: "Given a container holding commits the forge does not have, when the operator
asks to remove it, then removal is refused and the unpushed commits are named; removal proceeds only on
an explicit override."

  1. In a container for issue A, commit a change and do not push it.
  2. Ask to remove the container.
  3. Verify: removal is refused.
  4. Verify: the message names the unpushed commits — enough to identify what is at risk.
  5. Verify: the container and its workspace still exist, and the commit is still there.
  6. Push the commit, then ask to remove again.
  7. Verify: removal now proceeds without an override.
  8. Repeat steps 1–2 with a fresh unpushed commit, then remove using the explicit override.
  9. Verify: removal proceeds.

Expected outcome: the one loss this isolation model creates cannot happen by accident, but is not
impossible on purpose.

Scenario 19: Removal is clean and local

Acceptance criterion: "Given a container with nothing unpushed, when the operator removes it, then
its workspace and Claude state are destroyed, its name is free for re-use, and no other running container
is affected."

  1. With containers for A and B running and nothing unpushed in A, remove A.
  2. Verify: A's workspace and Claude state are gone.
  3. Verify: B is still running, its session unaffected, its workspace unchanged.
  4. Create a container for issue A again.
  5. Verify: it is created successfully — the name was released — and it starts from a clean workspace with
    no trace of the previous container's state.

Expected outcome: removal frees everything it held and nothing it did not.

Scenario 20: No audio server, no problem

Acceptance criterion: "Given a host with no audio server running, when a container starts, then it
starts cleanly and yields a usable session."

  1. On a host with no audio server running (or with the audio socket absent), bring up a container.
  2. Verify: it starts without error or warning about audio, sound devices or a missing socket.
  3. Verify: the session is usable (Scenario 1's check).

Expected outcome: the container has no audio dependency left to fail on.

Scenario 21: Workspace seeds from an existing branch

Acceptance criterion: "Given an issue whose branch already exists on the forge, when a container is
created for it, then its workspace is that branch at its forge tip."

  1. Confirm issue A's branch on the forge and note its tip commit.
  2. Create a fresh container for issue A.
  3. Verify: the workspace is on issue A's branch.
  4. Verify: it is at the tip commit noted in step 1.

Expected outcome: the container picks up where the branch left off.

Scenario 22: Workspace seeds when no branch exists yet

Acceptance criterion: "…and given an issue with no branch yet, then its workspace starts from the
integration branch at its forge tip and the feature branch is created in the container."

  1. Confirm issue B has no branch on the forge, and note the integration branch's tip.
  2. Create a fresh container for issue B.
  3. Verify: the workspace is on a feature branch for issue B, not on the integration branch itself.
  4. Verify: that branch starts from the integration branch's tip noted in step 1.
  5. Verify: nothing was pushed as a side effect of creation — the forge still has no branch for issue B
    until the container pushes one.

Expected outcome: a first run needs no branch to be created by hand first.

Scenario 23: A fresh container's session is configured like a host session

Acceptance criterion: "Given a freshly created container, when a session starts in it, then that
session operates under the same Claude configuration a host session would (settings and plugin/skill
wiring), while session, task and history state start empty."

  1. Note the host session's operating configuration — its permission mode and the set of skills available
    to it.
  2. Create a fresh container and start a session.
  3. Verify: the same permission mode is in effect.
  4. Verify: the same suite skills are available.
  5. Verify: the session's own history, session list and task list are empty — nothing carried over from
    the host or from another container.

Expected outcome: configuration is inherited; accumulated state is not.

Scenario 24: Edge case — two containers pushing branches at the same time

Acceptance criteria: the isolation and push criteria, exercised concurrently.

  1. With containers for issues A and B both running, commit work in each.
  2. Push from both, as close to simultaneously as can be arranged.
  3. Verify: both branches land on the forge, each with its own commits.
  4. Verify: neither push altered or removed the other's branch.
  5. Verify: neither container's workspace was affected by the other's push.

Expected outcome: parallel containers are parallel all the way through publication.

Scenario 25: Edge case — the forge is unreachable

Acceptance criteria: the tracker-operations and push criteria, under an unavailable dependency.

  1. Bring up a container for issue A while the forge is reachable.
  2. Make the forge unreachable from the container (block it, or point it at an unreachable address).
  3. Attempt a tracker operation, then attempt a push.
  4. Verify: each fails with a message identifying the forge as unreachable — it does not hang
    indefinitely, and it does not report the issue or branch as missing.
  5. Verify: the container's workspace and Claude state are undamaged, and the session remains usable for
    local work.
  6. Restore reachability and retry.
  7. Verify: both operations now succeed.

Expected outcome: an unavailable forge is a reported, recoverable condition, not a corrupted run.

Scenario 26: Edge case — first run on a host that has never built the container

Acceptance criteria: the lifecycle and prerequisite criteria, from a cold start.

  1. Starting from a host with no image and no container for this project, run the documented
    create-or-enter for issue B.
  2. Verify: it completes, building whatever it needs, without additional manual steps beyond what the
    lifecycle documents.
  3. Verify: the resulting session is usable (Scenario 1's check) and the prerequisite tools are present
    (Scenario 2's checks).

Expected outcome: the documented lifecycle is sufficient from nothing, not just from a warm host.

Traceability

Forward — every acceptance criterion has at least one scenario:

PREQ criterion Scenario(s)
Skill loads, helpers execute ("usable session") 1, 13, 17, 20, 26
Helper-tier prerequisites + forge CLI authenticated 2, 26
Provenance stamp names the executed revision, and tracks it 3
Two containers cannot read/write each other's Claude state; host unchanged 4
Workspace writes do not escape 5, 24
Push is the only route code takes out 6, 24
Forge refuses force-push/delete of shared refs 7
Tracker operations, no interactive step 8, 25
Moved branch → refused push 9
Session starts already authenticated 10
Credential refreshable in place; host file unchanged 11
Bad credentials fail loudly at start 12
Terminal-only lifecycle 13, 26
Two issues, no naming collision, individually addressable 14
Same issue → reattach, never a rival container 15
State survives a normal stop; setup not re-run 16
State survives an abnormal exit 17
Removal refused while unpushed, unless overridden 18
Removal clean, name freed, others unaffected 19
Starts cleanly with no audio server 20
Workspace seeds from an existing branch 21
Workspace seeds from the integration branch when none exists 22
Fresh session inherits configuration, not accumulated state 23

Backward — every scenario traces to a criterion. Scenarios 24–26 are the completeness-lens edge cases
(concurrency, unavailable dependency, first run); each is listed above against the criteria it
exercises rather than introducing new scope.

Notes

  • No lanes are assigned here. Lane assignment is a design fact and belongs to /dev:technical-plan;
    an absent lane means e2e-browser by default, which will need correcting for essentially every
    scenario in this plan — none of them is a browser scenario.
  • Scenario 3 needs a suite-text edit inside the container; it is the one scenario that deliberately
    changes what the session executes.
  • Scenario 17 requires killing a container abruptly. Scenario 25 requires making the forge unreachable
    from inside a container. Both need the environment to be arranged, not just observed.
  • Several scenarios ("compare equal to the snapshot") depend on the before-state capture listed under
    Required Test Data. Capturing it after the fact makes them unrunnable.
<!-- test-plan:v1 issue=61 skill=requirements --> # Test Plan: devcontainer-plugin-era-refresh Validation cases for issue #61, derived from the PREQ alone. No design exists yet, so no scenario names a file, a container tool, a compose key or a mount path — each says *what the operator does* and *what they should observe*. Where a scenario says "the documented bring-up command", the lifecycle documentation this feature produces supplies it. ## Prerequisites The *state* these scenarios need. Concrete commands, paths and credential values belong to the UAT card, not here. - [ ] A host that can run containers, with the repository checked out. - [ ] The operator's Claude credential present on the host in its normal location. - [ ] Host SSH configuration that can reach the git remote `origin` by its host-alias, and a working forge token for the declared tracker instance. - [ ] The documented terminal lifecycle for this feature (its create-or-enter, stop and remove operations). ### Required Test Data - [ ] **Two distinct issues** on the tracker to run containers for — call them issue A and issue B. - [ ] **Issue A already has a branch on the forge**; **issue B has none**. (Scenario 24 needs both.) - [ ] A **third issue C** whose branch exists on the forge *and* has a commit the container will not have, for the non-fast-forward case. - [ ] One **deliberately invalid credential pair** — an expired/garbage Claude credential and a garbage forge token — to inject for the failure-path scenarios. - [ ] A **recorded snapshot of the host state** before the isolation scenarios: the host checkout, every host worktree, and the host's Claude state directory, in a form that can be compared afterwards. ## Test Scenarios ### Scenario 1: A container session can run the suite **Acceptance criterion:** "Given a container brought up for an issue, when a Claude Code session in it invokes a `/dev:*` skill, then the skill loads and its helper scripts execute — no unresolved path, no missing skill." 1. Bring up a container for issue A using the documented command. 2. Start a Claude Code session inside it. 3. Invoke a `/dev:*` skill that reads from the tracker. 4. Verify: the skill's instructions load — no "skill not found", no dangling-path or no-such-file error naming a host path. 5. Verify: a helper script the skill runs executes and returns output, rather than failing to be found or failing to be executable. **Expected outcome:** the session behaves as a suite-capable session. This is the reference definition of a "usable session" used by later scenarios. ### Scenario 2: The helper tier's prerequisites are present at the required versions **Acceptance criterion:** "…then `bash` (≥ 3.2), `jq`, `git` and the POSIX utilities named in CLAUDE.md's two-tier baseline table are present and usable, and the project's declared forge CLI is installed and authenticated against the declared instance." 1. In a container session for issue A, ask for the version of each tool named in the project's two-tier baseline table. 2. Verify: each is present, and `bash` reports at least the required minimum version. 3. Ask the forge CLI to list its configured logins. 4. Verify: a login exists for the instance the project declares, and it is usable without being prompted for anything. **Expected outcome:** nothing the helper tier depends on is missing, and the forge CLI is already authenticated. ### Scenario 3: The run record names the revision that ran, and tracks it **Acceptance criterion:** "…the record's suite-provenance stamp names the suite revision that session executed; and when the checkout it executes from is changed and another phase is posted, the stamp changes correspondingly." 1. In a container session for issue A, run a pipeline phase that posts a record to the tracker. 2. Read the posted record and note the suite revision it names. 3. Verify: the named revision matches the revision of the checkout the session is executing the suite from. 4. Change that checkout (commit an edit to the suite text inside the container). 5. Run another phase that posts a record. 6. Verify: the newly posted record names the changed revision, not the earlier one. **Expected outcome:** the stamp is tied to what actually ran, not a value fixed at build time. ### Scenario 4: Two containers cannot see each other's Claude state **Acceptance criterion:** "Given two containers up at the same time, when both run Claude Code sessions, then neither can read or write the other's Claude state… and the host's own Claude state is unchanged by both." 1. Bring up containers for issue A and issue B, and start a session in each. 2. In container A, create a distinctive marker inside its Claude state directory. 3. In container B, look for that marker anywhere in its own Claude state. 4. Verify: it is not there. 5. From container B, attempt to reach container A's Claude state directly, and list what container B has mounted. 6. Verify: the attempt fails, and no mount in container B leads to container A's state. 7. Run a session in each container long enough to write session and task state. 8. Compare the host's Claude state directory against the snapshot taken beforehand. 9. Verify: the host's own session, task and history state is unchanged by either container. **Expected outcome:** three separate Claude states — two containers and the host — with no path between them. ### Scenario 5: A container's writes do not escape its workspace **Acceptance criterion:** "…when a representative set of create/edit/delete operations is performed across its workspace, then the host checkout, every host worktree, and every other container's workspace compare equal to their prior state." 1. With containers for A and B up, in container A: create new files, edit tracked files, and delete tracked files across several directories of its workspace, including its repository root. 2. Compare the host checkout and every host worktree against the snapshot. 3. Verify: both compare equal — nothing container A did appears in them. 4. Compare container B's workspace against its own starting state. 5. Verify: it compares equal. **Expected outcome:** the container's write reach stops at its own workspace. ### Scenario 6: Pushing is the only route code takes out **Acceptance criterion:** "…the branch is visible on the forge… and the container's code and commits reach the outside world by no other route." 1. In container A, commit a distinctive change but do **not** push. 2. Verify: the change is not visible on the forge, in the host checkout, in any host worktree, or in container B. 3. Enumerate everything container A has mounted, every volume it shares, and every git directory it can reach. 4. Verify: none of them leads to the host checkout, a host worktree, another container's workspace, or a shared git directory — the enumerated list contains no second egress path for code. 5. Push the branch. 6. Verify: the change is now visible on the forge and can be fetched by the host. **Expected outcome:** before the push, nothing; after the push, the branch — and no third possibility. ### Scenario 7: The forge refuses to let a container rewrite shared refs **Acceptance criterion:** "Given the shared refs on the forge, when a container attempts to force-push to or delete the integration branch, then the forge refuses." 1. From inside container A, attempt to force-push a rewritten history over the integration branch. 2. Verify: the forge rejects it, and the rejection is reported to the session. 3. Verify: the integration branch on the forge still points at what it pointed at before. 4. From inside container A, attempt to delete the integration branch on the forge. 5. Verify: the forge rejects it and the branch still exists. **Expected outcome:** the container holds working credentials and still cannot rewrite what other runs depend on. ### Scenario 8: Tracker operations work without an interactive step **Acceptance criterion:** "…then they succeed against the project's declared forge instance without an interactive authentication step." 1. In a container session for issue A, read the issue, post a comment to it, and read the comment back. 2. Verify: all three succeed. 3. Verify: at no point was a login, token, password or confirmation prompt presented. **Expected outcome:** the tracker is reachable and pre-authenticated. ### Scenario 9: A moved branch causes a refused push, not a silent overwrite **Acceptance criterion:** "…when the branch it is pushing has moved on the forge since it was copied, then the push is refused and the refusal is visible to the session." 1. Bring up a container for issue C, whose branch exists on the forge. 2. From the host, push an additional commit to issue C's branch, so the forge is now ahead of the container's copy. 3. In the container, commit a change and push. 4. Verify: the push is refused, and the refusal is reported in the session — not swallowed. 5. Verify: the commit the host pushed in step 2 is still on the forge branch. **Expected outcome:** the newer state survives; the container is told why its push did not land. ### Scenario 10: A session starts already authenticated **Acceptance criterion:** "Given a container started with the operator's Claude credential supplied to it, when a session starts, then it is already authenticated — no interactive login step is required." 1. Bring up a fresh container for issue A. 2. Start a Claude Code session and give it a prompt. 3. Verify: the session answers, with no login prompt, no browser hand-off and no token paste step. **Expected outcome:** an unattended start is possible because nothing waits for a human. ### Scenario 11: The credential can be refreshed in place, and the host's is untouched **Acceptance criterion:** "…then it can be refreshed in place inside the container and the session continues; and after the container has run, the host's own credential file is unchanged." 1. Note the host credential file's contents and modification time. 2. In a running container, cause the session's credential store to be written (refresh it, or otherwise trigger a credential write). 3. Verify: the write succeeds — no read-only or permission-denied failure. 4. Verify: the session continues working afterwards. 5. Re-check the host credential file. 6. Verify: contents and modification time are unchanged. **Expected outcome:** refreshing works inside the container and stays inside it. ### Scenario 12: Bad credentials fail loudly at the start **Acceptance criterion:** "Given a container started with an expired or invalid credential (Claude or forge), when it starts, then the failure is reported explicitly and identifiably — it does not hang, and it does not present as an unrelated error later in a run." 1. Bring up a container supplying the deliberately invalid Claude credential. 2. Verify: an explicit message identifies the credential as the problem, and it appears at start-up rather than partway through work. 3. Verify: the start does not hang waiting indefinitely. 4. Repeat with a valid Claude credential and the garbage forge token. 5. Verify: an explicit message identifies the forge credential as the problem — not, for example, a generic "issue not found". **Expected outcome:** the operator can tell from the first screen which credential is wrong. ### Scenario 13: The whole lifecycle runs from a terminal **Acceptance criterion:** "Given a host with only a terminal available, when the operator follows the documented lifecycle, then a container for a named issue comes up, yields a usable session, and can be stopped and removed again — with no VS Code and no GUI step anywhere." 1. From a plain terminal, follow the documented lifecycle end to end for issue B: create-or-enter, then a usable session (Scenario 1's check), then stop, then remove. 2. Verify: every step is a terminal command. 3. Verify: no step requires an editor, an extension, a GUI attach, or any window. **Expected outcome:** the documented sequence is complete and sufficient — nothing has to be discovered. ### Scenario 14: Two issues, two containers, no collision **Acceptance criterion:** "Given containers brought up for two different issues, when both are running, then neither fails or displaces the other on account of naming, and each can be addressed, stopped and removed individually without disturbing the other." 1. Bring up containers for issue A and issue B. 2. Verify: both are running at the same time, and bringing up the second did not error, replace, or restart the first. 3. Verify: each can be addressed by a name derived from its issue, and entering one lands in that one. 4. Stop the container for issue A. 5. Verify: the container for issue B is still running and its session is unaffected. 6. Remove the container for issue A. 7. Verify: issue B's container and workspace are untouched. **Expected outcome:** two independent containers, individually addressable. ### Scenario 15: Re-entering an issue reuses its container **Acceptance criterion:** "Given an issue that already has a container, when the operator runs create-or-enter for that same issue again, then they are placed in the existing container — a second, rival container for one issue is never created." 1. With a container running for issue A, leave a distinctive marker file in its workspace. 2. Run create-or-enter for issue A a second time. 3. Verify: the marker file is present — this is the same container, not a new one. 4. Verify: exactly one container exists for issue A. **Expected outcome:** create-or-enter is idempotent per issue. ### Scenario 16: State survives a normal stop **Acceptance criterion:** "Given a container that was stopped normally, when the operator enters it again, then its Claude state and its workspace — including uncommitted changes — are as they were left, and first-time setup does not run again." 1. In a container for issue A, make an uncommitted edit and run a session so session state accumulates. 2. Stop the container using the documented stop operation. 3. Enter it again. 4. Verify: the uncommitted edit is still there, unchanged. 5. Verify: the accumulated Claude session state is still there. 6. Verify: first-time setup did not re-run — no re-initialisation output, and nothing setup created was reset. **Expected outcome:** stop is not destructive. ### Scenario 17: State survives an abnormal exit **Acceptance criterion:** "Given a container that exited abnormally (crash, daemon kill, host reboot), when the operator enters it again, then they get a usable session with that same state intact, and no leftover state from the abnormal exit blocks the entry." 1. In a container for issue A, make an uncommitted edit. 2. Kill the container abruptly rather than stopping it (simulating a crash or a host reboot). 3. Run create-or-enter for issue A again. 4. Verify: it comes up — no error about an existing, stale, or conflicting container that the operator must clean up by hand. 5. Verify: the uncommitted edit and the Claude state are intact. 6. Verify: the session is usable (Scenario 1's check). **Expected outcome:** an abnormal exit is recoverable by repeating the ordinary entry command. ### Scenario 18: Removal refuses while work is unpushed **Acceptance criterion:** "Given a container holding commits the forge does not have, when the operator asks to remove it, then removal is refused and the unpushed commits are named; removal proceeds only on an explicit override." 1. In a container for issue A, commit a change and do not push it. 2. Ask to remove the container. 3. Verify: removal is refused. 4. Verify: the message names the unpushed commits — enough to identify what is at risk. 5. Verify: the container and its workspace still exist, and the commit is still there. 6. Push the commit, then ask to remove again. 7. Verify: removal now proceeds without an override. 8. Repeat steps 1–2 with a fresh unpushed commit, then remove using the explicit override. 9. Verify: removal proceeds. **Expected outcome:** the one loss this isolation model creates cannot happen by accident, but is not impossible on purpose. ### Scenario 19: Removal is clean and local **Acceptance criterion:** "Given a container with nothing unpushed, when the operator removes it, then its workspace and Claude state are destroyed, its name is free for re-use, and no other running container is affected." 1. With containers for A and B running and nothing unpushed in A, remove A. 2. Verify: A's workspace and Claude state are gone. 3. Verify: B is still running, its session unaffected, its workspace unchanged. 4. Create a container for issue A again. 5. Verify: it is created successfully — the name was released — and it starts from a clean workspace with no trace of the previous container's state. **Expected outcome:** removal frees everything it held and nothing it did not. ### Scenario 20: No audio server, no problem **Acceptance criterion:** "Given a host with no audio server running, when a container starts, then it starts cleanly and yields a usable session." 1. On a host with no audio server running (or with the audio socket absent), bring up a container. 2. Verify: it starts without error or warning about audio, sound devices or a missing socket. 3. Verify: the session is usable (Scenario 1's check). **Expected outcome:** the container has no audio dependency left to fail on. ### Scenario 21: Workspace seeds from an existing branch **Acceptance criterion:** "Given an issue whose branch already exists on the forge, when a container is created for it, then its workspace is that branch at its forge tip." 1. Confirm issue A's branch on the forge and note its tip commit. 2. Create a fresh container for issue A. 3. Verify: the workspace is on issue A's branch. 4. Verify: it is at the tip commit noted in step 1. **Expected outcome:** the container picks up where the branch left off. ### Scenario 22: Workspace seeds when no branch exists yet **Acceptance criterion:** "…and given an issue with no branch yet, then its workspace starts from the integration branch at its forge tip and the feature branch is created in the container." 1. Confirm issue B has no branch on the forge, and note the integration branch's tip. 2. Create a fresh container for issue B. 3. Verify: the workspace is on a feature branch for issue B, not on the integration branch itself. 4. Verify: that branch starts from the integration branch's tip noted in step 1. 5. Verify: nothing was pushed as a side effect of creation — the forge still has no branch for issue B until the container pushes one. **Expected outcome:** a first run needs no branch to be created by hand first. ### Scenario 23: A fresh container's session is configured like a host session **Acceptance criterion:** "Given a freshly created container, when a session starts in it, then that session operates under the same Claude configuration a host session would (settings and plugin/skill wiring), while session, task and history state start empty." 1. Note the host session's operating configuration — its permission mode and the set of skills available to it. 2. Create a fresh container and start a session. 3. Verify: the same permission mode is in effect. 4. Verify: the same suite skills are available. 5. Verify: the session's own history, session list and task list are empty — nothing carried over from the host or from another container. **Expected outcome:** configuration is inherited; accumulated state is not. ### Scenario 24: Edge case — two containers pushing branches at the same time **Acceptance criteria:** the isolation and push criteria, exercised concurrently. 1. With containers for issues A and B both running, commit work in each. 2. Push from both, as close to simultaneously as can be arranged. 3. Verify: both branches land on the forge, each with its own commits. 4. Verify: neither push altered or removed the other's branch. 5. Verify: neither container's workspace was affected by the other's push. **Expected outcome:** parallel containers are parallel all the way through publication. ### Scenario 25: Edge case — the forge is unreachable **Acceptance criteria:** the tracker-operations and push criteria, under an unavailable dependency. 1. Bring up a container for issue A while the forge is reachable. 2. Make the forge unreachable from the container (block it, or point it at an unreachable address). 3. Attempt a tracker operation, then attempt a push. 4. Verify: each fails with a message identifying the forge as unreachable — it does not hang indefinitely, and it does not report the issue or branch as missing. 5. Verify: the container's workspace and Claude state are undamaged, and the session remains usable for local work. 6. Restore reachability and retry. 7. Verify: both operations now succeed. **Expected outcome:** an unavailable forge is a reported, recoverable condition, not a corrupted run. ### Scenario 26: Edge case — first run on a host that has never built the container **Acceptance criteria:** the lifecycle and prerequisite criteria, from a cold start. 1. Starting from a host with no image and no container for this project, run the documented create-or-enter for issue B. 2. Verify: it completes, building whatever it needs, without additional manual steps beyond what the lifecycle documents. 3. Verify: the resulting session is usable (Scenario 1's check) and the prerequisite tools are present (Scenario 2's checks). **Expected outcome:** the documented lifecycle is sufficient from nothing, not just from a warm host. ## Traceability Forward — every acceptance criterion has at least one scenario: | PREQ criterion | Scenario(s) | |---|---| | Skill loads, helpers execute ("usable session") | 1, 13, 17, 20, 26 | | Helper-tier prerequisites + forge CLI authenticated | 2, 26 | | Provenance stamp names the executed revision, and tracks it | 3 | | Two containers cannot read/write each other's Claude state; host unchanged | 4 | | Workspace writes do not escape | 5, 24 | | Push is the only route code takes out | 6, 24 | | Forge refuses force-push/delete of shared refs | 7 | | Tracker operations, no interactive step | 8, 25 | | Moved branch → refused push | 9 | | Session starts already authenticated | 10 | | Credential refreshable in place; host file unchanged | 11 | | Bad credentials fail loudly at start | 12 | | Terminal-only lifecycle | 13, 26 | | Two issues, no naming collision, individually addressable | 14 | | Same issue → reattach, never a rival container | 15 | | State survives a normal stop; setup not re-run | 16 | | State survives an abnormal exit | 17 | | Removal refused while unpushed, unless overridden | 18 | | Removal clean, name freed, others unaffected | 19 | | Starts cleanly with no audio server | 20 | | Workspace seeds from an existing branch | 21 | | Workspace seeds from the integration branch when none exists | 22 | | Fresh session inherits configuration, not accumulated state | 23 | Backward — every scenario traces to a criterion. Scenarios 24–26 are the completeness-lens edge cases (concurrency, unavailable dependency, first run); each is listed above against the criteria it exercises rather than introducing new scope. ## Notes - **No lanes are assigned here.** Lane assignment is a design fact and belongs to `/dev:technical-plan`; an absent lane means `e2e-browser` by default, which will need correcting for essentially every scenario in this plan — none of them is a browser scenario. - Scenario 3 needs a suite-text edit inside the container; it is the one scenario that deliberately changes what the session executes. - Scenario 17 requires killing a container abruptly. Scenario 25 requires making the forge unreachable from inside a container. Both need the environment to be arranged, not just observed. - Several scenarios ("compare equal to the snapshot") depend on the before-state capture listed under Required Test Data. Capturing it after the fact makes them unrunnable.
Author
Owner
{
  "next_state": "planning",
  "produced": [
    {
      "kind": "preq",
      "ref": "issue-body",
      "summary": "PREQ for devcontainer-plugin-era-refresh — 5 user stories, 22 acceptance criteria, reviewed by a Tier 3 panel"
    },
    {
      "kind": "test-plan",
      "ref": "comment:1312",
      "summary": "26 validation cases covering 22/22 acceptance criteria (23 criterion scenarios + 3 completeness-lens edge cases), PREQ-derived, lanes unassigned"
    }
  ],
  "findings": [
    {
      "category": "out-of-scope",
      "summary": "Launching and supervising the autonomous run itself — no 'give it an issue number and walk away' entry point.",
      "reasoning": "Which prompt, which permission mode, and how a failed run is surfaced are run-orchestration decisions. Bundling them into an infra ticket would settle them by accident rather than deliberately; the operator chose 'container + documented lifecycle' at requirements time.",
      "id": "F-PO-61-1-1"
    },
    {
      "category": "out-of-scope",
      "summary": "The /voice audio plumbing is removed rather than repaired — host audio socket mount, audio packages and the ALSA-to-Pulse routing all go.",
      "reasoning": "Operator decision 2026-08-25. An unattended run has nobody to talk to, and the audio path is the most host-coupled part of the image, so keeping it costs portability for a capability this container's purpose never uses.",
      "id": "F-PO-61-1-2"
    },
    {
      "category": "out-of-scope",
      "summary": "VS Code integration — the extension list, the GUI attach flow, and any editor-only devcontainer features.",
      "reasoning": "The operator never uses VS Code; the lifecycle is terminal-first by requirement. Carrying editor wiring would be dead configuration nobody exercises.",
      "id": "F-PO-61-1-3"
    },
    {
      "category": "out-of-scope",
      "summary": "Resource isolation — port allocation, database provisioning, service lifecycle.",
      "reasoning": "This repo runs no services, so there is nothing for parallel containers to allocate or collide over. Isolation here is about blast radius, not resources.",
      "id": "F-PO-61-1-4"
    },
    {
      "category": "out-of-scope",
      "summary": "Adopting the suite's parallel_dev slot machinery for this container layout.",
      "reasoning": "The slot recipe schema makes port_scheme and database mandatory, so a services-free project cannot declare a valid recipe at all (filed as #62). This feature does not work around that schema gap and does not adopt slots.",
      "id": "F-PO-61-1-5"
    },
    {
      "category": "out-of-scope",
      "summary": "Hosts other than this Linux dev box, and container hosts other than the local Docker daemon.",
      "reasoning": ".devcontainer/ is repo-local infra and explicitly exempt from the stack-agnostic rule (CLAUDE.md 'Fencing'), so it may be openly specific to this host.",
      "id": "F-PO-61-1-6"
    },
    {
      "category": "out-of-scope",
      "summary": "Changes to shipped skill text under plugin/.",
      "reasoning": "Scope boundary set at dispatch. Any suite-level gap this work exposes is filed as its own issue rather than fixed inside an infra feature.",
      "id": "F-PO-61-1-7"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "Whether a container executes the suite from the checkout it is working on (dogfood-the-edit) or from a pinned copy is left to /dev:technical-plan.",
      "reasoning": "Deferred deliberately by the operator's note on the issue. The acceptance criteria were written to hold under either choice — they require that skills resolve and that the run record names the revision executed, not a particular mechanism. Ties into #56.",
      "id": "F-PO-61-1-8"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "Forge-side precondition outside this repo: branch protection on the integration branch (no force-push, no deletion) must be configured on the Gitea instance.",
      "reasoning": "This is the mechanism the operator chose to close the remote-write half of the isolation boundary, and an acceptance criterion asserts it. It is configuration on git.wihslon.com rather than a change in this repository, so it cannot be delivered by a code change alone — it must be done before that criterion can pass.",
      "id": "F-PO-61-1-9"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "The consistency lens noted that no acceptance criterion exercises an actual unattended run, although the problem is framed entirely as unattended operation.",
      "reasoning": "This follows from the scope the operator chose (container + documented lifecycle; launching the run is a separate feature) and was confirmed at requirements time. Recorded so the gap stays visible rather than being mistaken for coverage: this feature delivers the sandbox, not proof that an unattended run completes inside it.",
      "id": "F-PO-61-1-10"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "The simpler-alternative lens argued the three named breakages may be fixable by targeted repair rather than replacement, and that a single-container invocation could remove the compose project-name collision entirely.",
      "reasoning": "Both are design decisions for /dev:technical-plan, not requirements. The PREQ's Proposed Solution was reworded to state the four required changes without mandating a rebuild, so a minimal-repair design remains admissible against the same criteria.",
      "id": "F-PO-61-1-11"
    },
    {
      "category": "pre-existing",
      "summary": "Readiness gap: the workspace is now multi-worktree, so a schema-valid parallel_dev: recipe is conditionally required, and slot-recipe-validate.sh refuses (exit 10) because none is declared.",
      "reasoning": "Not fixable at this project — the recipe schema cannot express a services-free repo (#62), and CLAUDE.md's 2026-08-25 'Parallel sessions' ruling deliberately routes around the slot machinery. Waived for this run and recorded in the readiness report; it will re-report every run until #62 ships.",
      "id": "F-PO-61-1-12"
    }
  ],
  "pending_decisions": [],
  "suite": {
    "source": "git",
    "sha": "07d9ad8b984603b5d5598ee3e4627d68ff0eec55",
    "dirty": true
  }
}
<!-- phase-outcome:v1 id=PO-61-1 skill=requirements --> ```json { "next_state": "planning", "produced": [ { "kind": "preq", "ref": "issue-body", "summary": "PREQ for devcontainer-plugin-era-refresh — 5 user stories, 22 acceptance criteria, reviewed by a Tier 3 panel" }, { "kind": "test-plan", "ref": "comment:1312", "summary": "26 validation cases covering 22/22 acceptance criteria (23 criterion scenarios + 3 completeness-lens edge cases), PREQ-derived, lanes unassigned" } ], "findings": [ { "category": "out-of-scope", "summary": "Launching and supervising the autonomous run itself — no 'give it an issue number and walk away' entry point.", "reasoning": "Which prompt, which permission mode, and how a failed run is surfaced are run-orchestration decisions. Bundling them into an infra ticket would settle them by accident rather than deliberately; the operator chose 'container + documented lifecycle' at requirements time.", "id": "F-PO-61-1-1" }, { "category": "out-of-scope", "summary": "The /voice audio plumbing is removed rather than repaired — host audio socket mount, audio packages and the ALSA-to-Pulse routing all go.", "reasoning": "Operator decision 2026-08-25. An unattended run has nobody to talk to, and the audio path is the most host-coupled part of the image, so keeping it costs portability for a capability this container's purpose never uses.", "id": "F-PO-61-1-2" }, { "category": "out-of-scope", "summary": "VS Code integration — the extension list, the GUI attach flow, and any editor-only devcontainer features.", "reasoning": "The operator never uses VS Code; the lifecycle is terminal-first by requirement. Carrying editor wiring would be dead configuration nobody exercises.", "id": "F-PO-61-1-3" }, { "category": "out-of-scope", "summary": "Resource isolation — port allocation, database provisioning, service lifecycle.", "reasoning": "This repo runs no services, so there is nothing for parallel containers to allocate or collide over. Isolation here is about blast radius, not resources.", "id": "F-PO-61-1-4" }, { "category": "out-of-scope", "summary": "Adopting the suite's parallel_dev slot machinery for this container layout.", "reasoning": "The slot recipe schema makes port_scheme and database mandatory, so a services-free project cannot declare a valid recipe at all (filed as #62). This feature does not work around that schema gap and does not adopt slots.", "id": "F-PO-61-1-5" }, { "category": "out-of-scope", "summary": "Hosts other than this Linux dev box, and container hosts other than the local Docker daemon.", "reasoning": ".devcontainer/ is repo-local infra and explicitly exempt from the stack-agnostic rule (CLAUDE.md 'Fencing'), so it may be openly specific to this host.", "id": "F-PO-61-1-6" }, { "category": "out-of-scope", "summary": "Changes to shipped skill text under plugin/.", "reasoning": "Scope boundary set at dispatch. Any suite-level gap this work exposes is filed as its own issue rather than fixed inside an infra feature.", "id": "F-PO-61-1-7" }, { "category": "in-scope-deferrable", "summary": "Whether a container executes the suite from the checkout it is working on (dogfood-the-edit) or from a pinned copy is left to /dev:technical-plan.", "reasoning": "Deferred deliberately by the operator's note on the issue. The acceptance criteria were written to hold under either choice — they require that skills resolve and that the run record names the revision executed, not a particular mechanism. Ties into #56.", "id": "F-PO-61-1-8" }, { "category": "in-scope-deferrable", "summary": "Forge-side precondition outside this repo: branch protection on the integration branch (no force-push, no deletion) must be configured on the Gitea instance.", "reasoning": "This is the mechanism the operator chose to close the remote-write half of the isolation boundary, and an acceptance criterion asserts it. It is configuration on git.wihslon.com rather than a change in this repository, so it cannot be delivered by a code change alone — it must be done before that criterion can pass.", "id": "F-PO-61-1-9" }, { "category": "in-scope-deferrable", "summary": "The consistency lens noted that no acceptance criterion exercises an actual unattended run, although the problem is framed entirely as unattended operation.", "reasoning": "This follows from the scope the operator chose (container + documented lifecycle; launching the run is a separate feature) and was confirmed at requirements time. Recorded so the gap stays visible rather than being mistaken for coverage: this feature delivers the sandbox, not proof that an unattended run completes inside it.", "id": "F-PO-61-1-10" }, { "category": "in-scope-deferrable", "summary": "The simpler-alternative lens argued the three named breakages may be fixable by targeted repair rather than replacement, and that a single-container invocation could remove the compose project-name collision entirely.", "reasoning": "Both are design decisions for /dev:technical-plan, not requirements. The PREQ's Proposed Solution was reworded to state the four required changes without mandating a rebuild, so a minimal-repair design remains admissible against the same criteria.", "id": "F-PO-61-1-11" }, { "category": "pre-existing", "summary": "Readiness gap: the workspace is now multi-worktree, so a schema-valid parallel_dev: recipe is conditionally required, and slot-recipe-validate.sh refuses (exit 10) because none is declared.", "reasoning": "Not fixable at this project — the recipe schema cannot express a services-free repo (#62), and CLAUDE.md's 2026-08-25 'Parallel sessions' ruling deliberately routes around the slot machinery. Waived for this run and recorded in the readiness report; it will re-report every run until #62 ships.", "id": "F-PO-61-1-12" } ], "pending_decisions": [], "suite": { "source": "git", "sha": "07d9ad8b984603b5d5598ee3e4627d68ff0eec55", "dirty": true } } ```
Author
Owner

Software Requirements: devcontainer-plugin-era-refresh

Context

.devcontainer/ predates the plugin migration and cannot run the suite: it bind-mounts the host's whole
~/.claude (whose skills/dev symlink points at a host path absent in the container), uses a fixed
compose project name, and shares one mutable Claude state across host and every container. The operator
needs one container per issue, several in parallel, each a working Claude Code session whose blast radius
is its own workspace. .devcontainer/ is repo-local infra and exempt from the stack-agnostic rule.

Approaches Considered

Approach A: Repair the existing compose stack in place

Summary: Keep devcontainer.json + docker-compose.yml; parametrize the project name, add a
per-container ~/.claude volume, and fix the plugin symlink.
Pros: Smallest visible diff; keeps a declarative config file; devcontainer.json stays meaningful.
Cons: Parametrizing the compose project name manages the collision class rather than removing it.
Compose earns nothing here — one service, no ports, no networks, no depends_on. And the largest change is
unavoidable anyway: push-only isolation requires replacing the ..:/workspace bind mount with a cloned
private volume, at which point little of the original stack survives. The guard behaviours the PREQ
requires (reattach-not-duplicate, refuse-removal-while-unpushed) cannot live in compose at all.
Effort: Medium

Approach B: A lifecycle script over plain docker — no compose, no devcontainer.json

Summary: .devcontainer/dw.sh owns the whole lifecycle against one container and two named volumes
per issue, all named from the issue number. The workspace is a clone made at create time into a private
volume; the suite is pinned as a separate checkout; credentials are copied in.
Pros: Deletes the project-name collision class instead of parametrizing it. Puts naming, seeding,
credential copying and the guards in the one place they must live anyway. Carries nothing for an editor
the operator does not use. Every acceptance criterion has a single obvious home.
Cons: A hand-rolled lifecycle instead of a declarative file; loses devcontainer-CLI compatibility
(not installed on this host); the script becomes load-bearing shell in a repo whose only gate is bash -n.
Effort: Medium

Approach C: Per-issue compose project plus a thin wrapper

Summary: Keep docker-compose.yml for declarative volumes/env; a wrapper sets
COMPOSE_PROJECT_NAME=dw-<issue> and owns the guards.
Pros: Declarative container config stays reviewable in a familiar format.
Cons: Two mechanisms where one suffices — the wrapper exists regardless (the guards force it), so
compose is additive ceremony: a project and a network per issue for a single service. The seams between
wrapper-owned and compose-owned configuration are where drift will accumulate.
Effort: Medium

Decision

Selected: Approach B — a lifecycle script over plain docker.

Rationale: The guards, the naming, the clone and the credential copy must all live in imperative code
regardless of which approach is chosen; once that is true, compose is carrying a project name that exists
only to collide. Removing it turns breakage #2 from "parametrize carefully" into "cannot occur". The
operator never uses VS Code, so devcontainer.json would be config nothing exercises — and unexercised
config is what rots into a misleading state. The directory keeps its name for familiarity.

Architecture

Component Overview

host
 ├─ .devcontainer/dw.sh              the only supported entry point
 ├─ .devcontainer/Dockerfile         bookworm + zsh + git + jq + tea + claude   (no audio)
 ├─ .devcontainer/container-init.sh  runs INSIDE the container, at create time only
 ├─ .devcontainer/selftest.sh        mechanical verification surface for the ACs
 └─ .devcontainer/README.md          the documented terminal lifecycle

per issue N:
 ├─ container   dw-<N>          (image: devwork-dev:<tag>)
 ├─ volume      dw-<N>-workspace  → /workspace              a CLONE, never a bind mount
 └─ volume      dw-<N>-claude     → /home/vscode/.claude
                                     ├─ .credentials.json      copied in at create
                                     ├─ settings.json          allowlist-seeded from host
                                     ├─ .dw-state.json         init marker + pin record + seed times
                                     ├─ dev-suite/             a GIT CHECKOUT of the integration branch
                                     ├─ skills/dev → dev-suite/plugin
                                     └─ .ssh/, .config/tea/    copied in at create

Everything persistent lives in the two volumes, nothing in the container layer. This is what makes
recreate (below) safe and is why the pin, the credentials and the state marker are all volume-resident.

Data Flow

dw.sh up N — resolves state (see the state machine) and, when creating: resolves the branch for
issue N (an existing feature/N-* on the forge, else a new one from the integration branch tip); creates
both volumes; starts the container; runs container-init.sh via docker exec, which clones the repo into
/workspace at that branch, clones the integration branch into ~/.claude/dev-suite, points
~/.claude/skills/dev at dev-suite/plugin, copies the credentials in, allowlist-seeds settings.json,
probes both credentials, and only then writes .dw-state.json.

Work — a session executes the pinned checkout and edits /workspace. Records go to the tracker;
code leaves only by git push.

dw.sh rm N — inspects the workspace volume for unpushed commits and a dirty working tree;
refuses and names both unless --force; otherwise removes the container and both volumes.

The up state machine

up is the single idempotent verb (enter is an alias). Its four states, and AC-15/AC-16/AC-17 falling
out of them rather than being special cases:

State Detected by Action
absent no container named dw-<N> create volumes, create container, run init, write marker
created-uninit container exists, .dw-state.json absent or status != "ready" init did not complete. Re-run init idempotently; if it fails again, refuse with the named remedy (dw.sh rm <N> --force and retry) — never reattach to a half-built container
stopped container exists, not running, marker ready docker start, re-probe credentials, exec a shell
running container running, marker ready re-probe credentials, exec a shell (this is the AC-15 reattach)

A crash or host reboot lands in stopped, so AC-17 is the same path as AC-16 with no extra machinery.
Two concurrent up N invocations are serialised by a lock file on the host; the loser waits and then
re-evaluates the state machine rather than racing docker create.

External Data Contracts

Boundary / source Operation Real shape (verified) Provenance
Claude credential store (~/.claude/.credentials.json) read key structure OAuth: accessToken, refreshToken, expiresAt, refreshTokenExpiresAt, scopes[], subscriptionTyperewritten in place on refresh recordedexternal-contracts/claude-credential.provenance.json
Docker daemon (unix:///var/run/docker.sock) full container/volume lifecycle UNVERIFIED — access. Operator not in docker group; no rootless socket; no podman. Gated.
git origin over the forge-devwork SSH alias (git.wihslon.com:2222) ls-remote/clone/push from inside a container n/a — git wire protocol UNVERIFIED — access. Host path works; in-container path unprobed. Gated.
Gitea tracker via tea read issue / post comment n/a — the forge adapter owns it UNVERIFIED — access. Currently-wired route reads TEA_TOKEN from a .env that does not exist. Gated.
Gitea branch protection for jbr870/devwork-skills read protection state main has PROTECTED = false (tea branches) UNVERIFIED — verified ABSENT. AC-7 fails as things stand. Gated.
Claude OAuth refresh behaviour does refresh rotate the refresh token? UNVERIFIED — shape. If rotation is single-use, one container's refresh invalidates every sibling's copy. Gated.

Key Decisions

Decision Choice Rationale
Workspace A clone into a private named volume A bind mount of any host directory would be a second egress path for code, which the isolation criterion forbids. A clone makes "push is the only way out" structurally true rather than conventional.
Which suite the session executes A pinned git checkout of the integration branch at ~/.claude/dev-suite/, symlinked from ~/.claude/skills/dev. Not a live symlink to /workspace/plugin, and not a file copy. Two reasons, one of them verified. First: it transplants the model CLAUDE.md already declares for the host — the main checkout is the executing plugin; it advances only by merge — keeping the code a session runs stable while the code it edits churns in /workspace, which contains #56 inside the container where an unattended run has nobody watching. Second: it is the only pin shape that satisfies AC-3. The #57 probe (_suite_provenance) asks is the resolved skills root inside a git work tree? — a git checkout stamps {source:"git", sha, dirty}; a file copy would fall through to the manifest branch and stamp {source:"release", version:"0.2.0"}, reporting the same value for two different commits and failing AC-3 outright.
Where the pin lives Inside the dw-<N>-claude volume, next to the symlink that points at it If the pin lived in the container layer while its symlink lived in a volume, any recreate-preserving-volumes would strand a dangling link. Same volume, same lifetime.
Re-pointing the pin dw.sh repin N (to the integration tip) and repin N --from-workspace (to exercise an in-flight edit) Preserves "run what I just edited" as a deliberate act rather than an ambient hazard — the distinction #56 asks for. --from-workspace also stamps honestly, because /workspace is itself a git work tree. Refuses while a session is running in the container unless --force: swapping skill text under a live run is precisely the hazard the pin exists to contain.
Credentials Copied in at create from read-only mounts, into the claude volume; never mounted writable, never left in the container layer The Claude credential is OAuth and is rewritten on refresh (verified), so a read-only mount fails an unattended run at an unpredictable moment. One rule — credentials are copied in, never mounted writable — covers the Claude credential, the tea token and the SSH key alike, keeps every host credential file unwritable from inside, and surviving in the volume means recreate does not strand them.
Which SSH material is copied Only the forge-devwork host stanza, the key it names, and the matching known_hosts entry — not all of ~/.ssh The operator accepted that the container can read host SSH keys; that acceptance does not oblige us to hand over keys to unrelated hosts. Strictly narrower at no cost.
SSH agent forwarding Opt-in (--ssh-agent), off by default The operator asked to keep it, and it stays available — but forwarding grants use of every key the agent holds, which exceeds the read exposure that was actually accepted, and an unattended container started without a terminal has no agent to forward anyway. Default-off serves the stated purpose; the flag serves the stated preference.
tea authentication Copy the host's tea config in at create The current route (TEA_TOKEN from .env) is broken on this host: no .env exists and the failure is swallowed by || true. The host tea config is the token source that actually exists.
tea token privilege Require a least-privilege token (issue/comment write; no repo-admin), and assert its scope at create AC-7's guarantee is only as strong as the token: a repo-admin token lets a permission-relaxed session disable branch protection through the API and then force-push, which would make the protection theatre.
settings.json seeding An allowlist of keys worth inheriting, not a denylist of keys to strip A denylist silently inherits whatever is added to the host settings next — including apiKeyHelper, hooks and env-embedded secrets. An allowlist fails closed, and closed is the right default when the thing being copied is the operator's configuration.
Unpushed detection Compare against local remote-tracking refs (@{u}..) inside a throwaway container on the workspace volume — no network The throwaway container deliberately mounts no credentials, so it cannot ls-remote. Remote-tracking refs answer the question offline. If they are stale the check errs toward refusing removal, which is the safe direction.
Naming dw-<issue> container, dw-<issue>-workspace / dw-<issue>-claude volumes Derived from the issue, so parallel containers cannot collide and each is individually addressable.
Container user Pinned to uid/gid 1000 Mounted key material must be readable and volume contents must not become root-owned — which matters more if the runtime ends up driven via sudo.
Image rebuild dw.sh recreate N — new container from the current image, both volumes preserved Without it a long-lived container pins a stale image forever, and the only alternative is rm (which destroys the workspace). This verb is the reason everything persistent had to be volume-resident.
Script conventions set -euo pipefail; all diagnostics to stderr; exit 2 = guard refusal, exit 1 = failure A guard is contract. The operator and selftest.sh both need to distinguish "refused, as designed" from "broke".

Technical Risks

Risk Likelihood Impact Mitigation
dw.sh becomes load-bearing shell in a repo whose only gate is bash -n High Medium Keep it small; add .devcontainer/*.sh to lint-conventions.sh's sweep; selftest.sh is the mechanical surface the ACs map to.
Host-key verification from a container with no seeded known_hosts High High Copy the host's matching known_hosts entry in with the key; fail loudly if the host key does not verify — never disable strict host-key checking.
A stale pin silently runs old skill text for a long-lived container's life Medium Medium dw.sh ls reports each container's pinned revision; the #57 stamp names it on every posted record.
Sibling feature branches remain rewritable from a container Medium Medium Not closed. Protection is applied to the integration branch only — a blanket feature/* force-push rule would collide with the rebase /dev:integrate performs. Recorded as a finding and as an explicit narrowing of the isolation claim, not silently absorbed.
Seeded configuration drifts from the host's over a container's lifetime Medium Low Accepted: the seed is a create-time snapshot. recreate re-seeds.
selftest.sh cannot run at all until the Docker access precondition resolves High High Gated by that precondition; see the lane note in the test plan. The build can proceed; verification cannot.

Expert Review

Reviewers

  • Security specialist: three blocking — tea-token scope could disable the very protection AC-7 relies on; protecting the integration branch does not cover sibling feature branches (which is what "cannot reach the other runs" meant); N copies of one OAuth credential may invalidate each other if refresh rotates.
  • Solution architect: two blocking — the pin's storage and its symlink had different lifetimes (dangling link on recreate); AC-3's mechanism was under-specified, and a file-copy pin would have stamped a release version instead of a sha. Endorsed Approach B and the pin decision.
  • Backend developer: three blocking — the dw.shcontainer-init.sh contract and its success marker were undefined, so up would reattach to a half-built container forever; the throwaway unpushed check could not reach the network it needed; AC-12 was not mechanically checkable without a credential-source override.
  • UX expert: two blocking — the rm guard covered unpushed commits but would silently destroy uncommitted work, the likeliest state for an operator returning after days; credential staleness was probed only at create, never on re-entry.

Changes Made

  • Pin is a git checkout of the integration branch, not a copy, and lives in the claude volume beside
    its symlink. (architect ×2 — and the copy variant was verified to break AC-3.)
  • Added the explicit up state machine with a created-uninit state that re-runs init or refuses
    with a named remedy, plus a host lock against concurrent up. (backend)
  • Added dw.sh recreate preserving volumes, and moved all persistent state — credentials, pin,
    marker — into volumes. (architect + backend, converging)
  • rm guard now covers a dirty working tree as well as unpushed commits. (UX)
  • Unpushed detection uses local remote-tracking refs, no network. (backend)
  • Credentials re-probed on every up, not only at create; added a re-copy verb. (UX)
  • tea token must be least-privilege, asserted at create. (security)
  • Only the forge-devwork SSH stanza + its key + known_hosts are copied, not all of ~/.ssh;
    agent forwarding became opt-in. (security)
  • settings.json seeding is an allowlist. (security + architect, converging)
  • Added error/exit-code conventions, dw.sh ls as a state dashboard, repin guarded against a live
    session, and README recovery guidance for a refused push and for an unreachable Docker socket. (UX + backend)
  • Added a --credential-source override so AC-12 is mechanically testable, with a bounded probe timeout. (backend)
  • The Docker precondition now asks for the invocation mode (sudo vs group membership), not merely
    that access exists. (architect + backend, converging)

Noted (not actioned)

  • Extending branch protection to feature/*. It would close sibling-branch rewriting, but collides
    with the rebase /dev:integrate performs on feature branches. Left to the operator as a finding; the
    isolation claim is narrowed in the meantime rather than overstated.
  • Per-container Claude logins instead of a copied credential. The clean answer if refresh rotation
    turns out to be single-use — but the operator explicitly rejected per-container credentials on
    setup-ceremony grounds, so it stays contingent on the rotation precondition rather than pre-empting it.
  • Credential age surfaced in dw.sh ls. Accepted in spirit and folded into the ls dashboard row
    rather than tracked as its own item.

Acceptance Criteria

ID Criterion (from PREQ) Verification approach
AC-1 A /dev:* skill loads and its helpers execute in a container session selftest.sh: run a suite helper through the pinned path; assert exit 0 and real output
AC-2 Helper-tier prerequisites at required versions; forge CLI installed and authenticated selftest.sh: version-probe each tool against the CLAUDE.md baseline; tea login list names the declared instance
AC-3 Provenance stamp names the executed revision, and tracks changes to it selftest.sh: assert the stamp is source:"git" with the pinned sha (not source:"release"); repin --from-workspace; assert the stamp changed to the workspace sha
AC-4 Two containers cannot read/write each other's Claude state; host state unchanged selftest.sh: marker in A, absence-probe from B, cross-access attempt fails, B's mount table contains no path to A; host state hashed before/after
AC-5 Workspace writes do not escape selftest.sh: representative create/edit/delete set, then compare host checkout, every host worktree and container B's workspace against pre-captured hashes
AC-6 Push is the only route code takes out selftest.sh: enumerate the container's mount table and volume list and assert none resolves to a host checkout, worktree or shared git dir; unpushed commit invisible everywhere; visible after push
AC-7 Forge refuses force-push to / deletion of the integration branch selftest.sh attempts both from a container and asserts rejection and that the tip is unchanged; additionally asserts the tea token lacks repo-admin scope. Currently failing — gated by a precondition
AC-8 Tracker operations succeed with no interactive authentication step selftest.sh: read issue, post comment, read back, with stdin closed; assert no prompt
AC-9 A moved branch causes a refused push, not a silent overwrite selftest.sh: advance the branch on the forge, push from the container, assert non-zero exit and unchanged forge tip
AC-10 Session starts already authenticated selftest.sh: non-interactive session prompt answers with no login step
AC-11 Credential refreshable in place; host credential file unchanged selftest.sh: assert the container credential path is writable by the session uid and write to it; host file hash + mtime unchanged
AC-12 Expired/invalid credentials fail explicitly at start, not later selftest.sh: up --credential-source <fixture> with an invalid credential; assert non-zero exit naming the credential within the defined probe timeout
AC-13 Whole lifecycle from a terminal, no GUI step selftest.sh drives up → session → stop → rm via dw.sh; plus a scan asserting no .devcontainer/ file references an editor or GUI step
AC-14 Two issues, no collision, individually addressable and removable selftest.sh: two containers up, distinctly named; stop/remove one, assert the other untouched
AC-15 Same issue → reattach, never a rival container selftest.sh: marker, second up, assert marker present and exactly one container matches
AC-16 State survives a normal stop; setup does not re-run selftest.sh: uncommitted edit + session state, stop, up, assert both intact and init reports already-ready
AC-17 State survives an abnormal exit selftest.sh: docker kill, then up; assert entry succeeds with no manual cleanup and state intact
AC-18 Removal refused while unpushed, unless overridden selftest.sh: unpushed commit → rm exits 2 naming the commits, container survives; also dirty-tree-only → rm exits 2 naming the dirty paths; after push+commit clean, rm succeeds; --force succeeds on unpushed
AC-19 Removal clean and local: volumes gone, name reusable, others unaffected selftest.sh: both volumes absent, sibling container unaffected, re-up yields a clean workspace
AC-20 Starts cleanly with no audio server selftest.sh: start with no audio socket; assert clean start and usable session; plus a scan asserting no audio package or socket mount remains in .devcontainer/
AC-21 Workspace seeds from an existing branch at its forge tip selftest.sh: assert branch name and HEAD match the forge tip recorded before create
AC-22 Workspace seeds from the integration branch when none exists; branch created in-container; nothing pushed as a side effect selftest.sh: assert branch name, merge-base with the integration tip, and that the forge still has no such branch after create
AC-23 Fresh session inherits configuration but not accumulated state selftest.sh: compare effective permission mode and resolved suite skill list against the host's; assert history/sessions/tasks empty

Mechanical rows called out explicitly. AC-6 (no second egress path), AC-13 (no GUI step), AC-20 (no
audio residue) and AC-3 (source:"git", not "release") are scan/assert rows over the real mount
table, the .devcontainer/ sources and the stamp's own shape — not spot checks of behaviour. AC-6 is the
one that fails silently if checked only by "did my commit show up on the host?": a second egress path that
nothing happened to use still exists, so the assertion must be over the enumerated mount list.

Temporary scaffolding: none introduced. docker-compose.yml, devcontainer.json, entrypoint.sh and
post-create.sh are deleted outright, not left as compatibility shims.

Implementation Scope

Areas

Area Files / directories involved Nature of change
Lifecycle entry point .devcontainer/dw.sh new — up/enter, stop, rm, recreate, repin, refresh-creds, ls
In-container create-time setup .devcontainer/container-init.sh new (replaces entrypoint.sh + post-create.sh)
Image .devcontainer/Dockerfile modify — drop audio, pin the Claude CLI and jq, keep zsh/git/tea
Retired stack .devcontainer/docker-compose.yml, devcontainer.json, entrypoint.sh, post-create.sh delete
Mechanical verification .devcontainer/selftest.sh new — the surface nearly every AC maps to
Lint coverage scripts/lint-conventions.sh extend — include .devcontainer/*.sh in the bash -n sweep
Lifecycle documentation .devcontainer/README.md new — the lifecycle AC-13 requires, plus recovery guidance for a refused push and an unreachable Docker socket

File Boundaries

Dockerfile is independent and can be done in parallel with anything. dw.sh and container-init.sh are
a single unit — they share the naming scheme, the volume layout, the state marker and the exit-code
convention; splitting them across parallel workers would invent two conventions. selftest.sh drives
their interface and so must follow them. README.md and the lint extension are independent tail work.

Dependencies & Sequencing

Mixed. Dockerfile, README.md and the lint extension are independent. The core is sequential:
dw.sh + container-init.sh (one unit) → selftest.sh. Nothing can be validated until the Docker
access precondition resolves
— the build can proceed, but every acceptance criterion's verification
needs a runnable container.

Constraints & Non-Goals

Constraints:

  • The suite's POSIX-portability rule does not bind here — .devcontainer/ is repo-local infra and
    explicitly exempt. The scripts still carry #!/usr/bin/env bash and stay within the helper tier's
    bash 3.2 surface, for consistency with the rest of the repo's shell.
  • A gate's output goes to a file and its exit code comes from the command, never a pipeline (CLAUDE.md,
    #49) — selftest.sh must obey this or its own greens are untrustworthy.
  • Host-key checking is never disabled to make SSH work.
  • No host credential file is ever mounted writable, and no credential is left in the container layer.

Non-goals (do NOT build):

  • Launching or supervising the autonomous run inside the container.
  • /voice audio support.
  • VS Code / editor integration.
  • Port, database or service provisioning.
  • A parallel_dev: slot recipe.
  • Hosts other than this Linux dev box, or container hosts other than the local daemon.
  • Any change to shipped skill text under plugin/.
<!-- sreq:v1 issue=61 skill=technical-plan --> # Software Requirements: devcontainer-plugin-era-refresh ## Context `.devcontainer/` predates the plugin migration and cannot run the suite: it bind-mounts the host's whole `~/.claude` (whose `skills/dev` symlink points at a host path absent in the container), uses a fixed compose project name, and shares one mutable Claude state across host and every container. The operator needs one container per issue, several in parallel, each a working Claude Code session whose blast radius is its own workspace. `.devcontainer/` is repo-local infra and exempt from the stack-agnostic rule. ## Approaches Considered ### Approach A: Repair the existing compose stack in place **Summary:** Keep `devcontainer.json` + `docker-compose.yml`; parametrize the project name, add a per-container `~/.claude` volume, and fix the plugin symlink. **Pros:** Smallest visible diff; keeps a declarative config file; `devcontainer.json` stays meaningful. **Cons:** Parametrizing the compose project name *manages* the collision class rather than removing it. Compose earns nothing here — one service, no ports, no networks, no `depends_on`. And the largest change is unavoidable anyway: push-only isolation requires replacing the `..:/workspace` bind mount with a cloned private volume, at which point little of the original stack survives. The guard behaviours the PREQ requires (reattach-not-duplicate, refuse-removal-while-unpushed) cannot live in compose at all. **Effort:** Medium ### Approach B: A lifecycle script over plain `docker` — no compose, no `devcontainer.json` **Summary:** `.devcontainer/dw.sh` owns the whole lifecycle against one container and two named volumes per issue, all named from the issue number. The workspace is a clone made at create time into a private volume; the suite is pinned as a separate checkout; credentials are copied in. **Pros:** Deletes the project-name collision class instead of parametrizing it. Puts naming, seeding, credential copying and the guards in the one place they must live anyway. Carries nothing for an editor the operator does not use. Every acceptance criterion has a single obvious home. **Cons:** A hand-rolled lifecycle instead of a declarative file; loses `devcontainer`-CLI compatibility (not installed on this host); the script becomes load-bearing shell in a repo whose only gate is `bash -n`. **Effort:** Medium ### Approach C: Per-issue compose project plus a thin wrapper **Summary:** Keep `docker-compose.yml` for declarative volumes/env; a wrapper sets `COMPOSE_PROJECT_NAME=dw-<issue>` and owns the guards. **Pros:** Declarative container config stays reviewable in a familiar format. **Cons:** Two mechanisms where one suffices — the wrapper exists regardless (the guards force it), so compose is additive ceremony: a project and a network per issue for a single service. The seams between wrapper-owned and compose-owned configuration are where drift will accumulate. **Effort:** Medium ## Decision **Selected:** Approach B — a lifecycle script over plain `docker`. **Rationale:** The guards, the naming, the clone and the credential copy must all live in imperative code regardless of which approach is chosen; once that is true, compose is carrying a project name that exists only to collide. Removing it turns breakage #2 from "parametrize carefully" into "cannot occur". The operator never uses VS Code, so `devcontainer.json` would be config nothing exercises — and unexercised config is what rots into a misleading state. The directory keeps its name for familiarity. ## Architecture ### Component Overview ``` host ├─ .devcontainer/dw.sh the only supported entry point ├─ .devcontainer/Dockerfile bookworm + zsh + git + jq + tea + claude (no audio) ├─ .devcontainer/container-init.sh runs INSIDE the container, at create time only ├─ .devcontainer/selftest.sh mechanical verification surface for the ACs └─ .devcontainer/README.md the documented terminal lifecycle per issue N: ├─ container dw-<N> (image: devwork-dev:<tag>) ├─ volume dw-<N>-workspace → /workspace a CLONE, never a bind mount └─ volume dw-<N>-claude → /home/vscode/.claude ├─ .credentials.json copied in at create ├─ settings.json allowlist-seeded from host ├─ .dw-state.json init marker + pin record + seed times ├─ dev-suite/ a GIT CHECKOUT of the integration branch ├─ skills/dev → dev-suite/plugin └─ .ssh/, .config/tea/ copied in at create ``` **Everything persistent lives in the two volumes, nothing in the container layer.** This is what makes `recreate` (below) safe and is why the pin, the credentials and the state marker are all volume-resident. ### Data Flow **`dw.sh up N`** — resolves state (see the state machine) and, when creating: resolves the branch for issue N (an existing `feature/N-*` on the forge, else a new one from the integration branch tip); creates both volumes; starts the container; runs `container-init.sh` via `docker exec`, which clones the repo into `/workspace` at that branch, clones the integration branch into `~/.claude/dev-suite`, points `~/.claude/skills/dev` at `dev-suite/plugin`, copies the credentials in, allowlist-seeds `settings.json`, probes both credentials, and only then writes `.dw-state.json`. **Work** — a session executes the *pinned* checkout and edits `/workspace`. Records go to the tracker; code leaves only by `git push`. **`dw.sh rm N`** — inspects the workspace volume for unpushed commits **and** a dirty working tree; refuses and names both unless `--force`; otherwise removes the container and both volumes. ### The `up` state machine `up` is the single idempotent verb (`enter` is an alias). Its four states, and AC-15/AC-16/AC-17 falling out of them rather than being special cases: | State | Detected by | Action | | ----- | ----------- | ------ | | **absent** | no container named `dw-<N>` | create volumes, create container, run init, write marker | | **created-uninit** | container exists, `.dw-state.json` absent or `status != "ready"` | init did not complete. **Re-run init idempotently**; if it fails again, refuse with the named remedy (`dw.sh rm <N> --force` and retry) — never reattach to a half-built container | | **stopped** | container exists, not running, marker `ready` | `docker start`, re-probe credentials, exec a shell | | **running** | container running, marker `ready` | re-probe credentials, exec a shell (this is the AC-15 reattach) | A crash or host reboot lands in **stopped**, so AC-17 is the same path as AC-16 with no extra machinery. Two concurrent `up N` invocations are serialised by a lock file on the host; the loser waits and then re-evaluates the state machine rather than racing `docker create`. ### External Data Contracts | Boundary / source | Operation | Real shape (verified) | Provenance | | ----------------- | --------- | --------------------- | ---------- | | Claude credential store (`~/.claude/.credentials.json`) | read key structure | OAuth: `accessToken`, `refreshToken`, `expiresAt`, `refreshTokenExpiresAt`, `scopes[]`, `subscriptionType` — **rewritten in place on refresh** | `recorded` → `external-contracts/claude-credential.provenance.json` | | Docker daemon (`unix:///var/run/docker.sock`) | full container/volume lifecycle | — | **UNVERIFIED — access.** Operator not in `docker` group; no rootless socket; no podman. Gated. | | git `origin` over the `forge-devwork` SSH alias (git.wihslon.com:2222) | `ls-remote`/`clone`/`push` *from inside a container* | n/a — git wire protocol | **UNVERIFIED — access.** Host path works; in-container path unprobed. Gated. | | Gitea tracker via `tea` | read issue / post comment | n/a — the forge adapter owns it | **UNVERIFIED — access.** Currently-wired route reads `TEA_TOKEN` from a `.env` that does not exist. Gated. | | Gitea branch protection for `jbr870/devwork-skills` | read protection state | **`main` has `PROTECTED = false`** (`tea branches`) | **UNVERIFIED — verified ABSENT.** AC-7 fails as things stand. Gated. | | Claude OAuth refresh behaviour | does refresh rotate the refresh token? | — | **UNVERIFIED — shape.** If rotation is single-use, one container's refresh invalidates every sibling's copy. Gated. | ### Key Decisions | Decision | Choice | Rationale | | -------- | ------ | --------- | | Workspace | A **clone** into a private named volume | A bind mount of any host directory would be a second egress path for code, which the isolation criterion forbids. A clone makes "push is the only way out" structurally true rather than conventional. | | Which suite the session **executes** | A pinned **git checkout** of the **integration branch** at `~/.claude/dev-suite/`, symlinked from `~/.claude/skills/dev`. Not a live symlink to `/workspace/plugin`, and **not a file copy**. | Two reasons, one of them verified. First: it transplants the model CLAUDE.md already declares for the host — *the main checkout is the executing plugin; it advances only by merge* — keeping the code a session **runs** stable while the code it **edits** churns in `/workspace`, which contains #56 inside the container where an unattended run has nobody watching. Second: it is the only pin shape that satisfies AC-3. The #57 probe (`_suite_provenance`) asks *is the resolved skills root inside a git work tree?* — a git checkout stamps `{source:"git", sha, dirty}`; a **file copy** would fall through to the manifest branch and stamp `{source:"release", version:"0.2.0"}`, reporting the same value for two different commits and failing AC-3 outright. | | Where the pin lives | Inside the `dw-<N>-claude` volume, next to the symlink that points at it | If the pin lived in the container layer while its symlink lived in a volume, any `recreate`-preserving-volumes would strand a dangling link. Same volume, same lifetime. | | Re-pointing the pin | `dw.sh repin N` (to the integration tip) and `repin N --from-workspace` (to exercise an in-flight edit) | Preserves "run what I just edited" as a *deliberate act* rather than an ambient hazard — the distinction #56 asks for. `--from-workspace` also stamps honestly, because `/workspace` is itself a git work tree. Refuses while a session is running in the container unless `--force`: swapping skill text under a live run is precisely the hazard the pin exists to contain. | | Credentials | **Copied in at create** from read-only mounts, into the **claude volume**; never mounted writable, never left in the container layer | The Claude credential is OAuth and is rewritten on refresh (verified), so a read-only mount fails an unattended run at an unpredictable moment. One rule — *credentials are copied in, never mounted writable* — covers the Claude credential, the tea token and the SSH key alike, keeps every host credential file unwritable from inside, and surviving in the volume means `recreate` does not strand them. | | Which SSH material is copied | Only the `forge-devwork` host stanza, the key it names, and the matching `known_hosts` entry — **not all of `~/.ssh`** | The operator accepted that the container can read host SSH keys; that acceptance does not oblige us to hand over keys to unrelated hosts. Strictly narrower at no cost. | | SSH agent forwarding | **Opt-in** (`--ssh-agent`), off by default | The operator asked to keep it, and it stays available — but forwarding grants *use* of every key the agent holds, which exceeds the read exposure that was actually accepted, and an unattended container started without a terminal has no agent to forward anyway. Default-off serves the stated purpose; the flag serves the stated preference. | | tea authentication | Copy the host's `tea` config in at create | The current route (`TEA_TOKEN` from `.env`) is broken on this host: no `.env` exists and the failure is swallowed by `\|\| true`. The host tea config is the token source that actually exists. | | tea token privilege | Require a **least-privilege** token (issue/comment write; **no repo-admin**), and assert its scope at create | AC-7's guarantee is only as strong as the token: a repo-admin token lets a permission-relaxed session disable branch protection through the API and *then* force-push, which would make the protection theatre. | | `settings.json` seeding | An **allowlist** of keys worth inheriting, not a denylist of keys to strip | A denylist silently inherits whatever is added to the host settings next — including `apiKeyHelper`, hooks and env-embedded secrets. An allowlist fails closed, and closed is the right default when the thing being copied is the operator's configuration. | | Unpushed detection | Compare against **local remote-tracking refs** (`@{u}..`) inside a throwaway container on the workspace volume — no network | The throwaway container deliberately mounts no credentials, so it cannot `ls-remote`. Remote-tracking refs answer the question offline. If they are stale the check errs toward *refusing* removal, which is the safe direction. | | Naming | `dw-<issue>` container, `dw-<issue>-workspace` / `dw-<issue>-claude` volumes | Derived from the issue, so parallel containers cannot collide and each is individually addressable. | | Container user | Pinned to uid/gid 1000 | Mounted key material must be readable and volume contents must not become root-owned — which matters more if the runtime ends up driven via `sudo`. | | Image rebuild | `dw.sh recreate N` — new container from the current image, **both volumes preserved** | Without it a long-lived container pins a stale image forever, and the only alternative is `rm` (which destroys the workspace). This verb is the reason everything persistent had to be volume-resident. | | Script conventions | `set -euo pipefail`; all diagnostics to stderr; **exit 2 = guard refusal, exit 1 = failure** | A guard is contract. The operator and `selftest.sh` both need to distinguish "refused, as designed" from "broke". | ## Technical Risks | Risk | Likelihood | Impact | Mitigation | | ---- | ---------- | ------ | ---------- | | `dw.sh` becomes load-bearing shell in a repo whose only gate is `bash -n` | High | Medium | Keep it small; add `.devcontainer/*.sh` to `lint-conventions.sh`'s sweep; `selftest.sh` is the mechanical surface the ACs map to. | | Host-key verification from a container with no seeded `known_hosts` | High | High | Copy the host's matching `known_hosts` entry in with the key; fail loudly if the host key does not verify — never disable strict host-key checking. | | A stale pin silently runs old skill text for a long-lived container's life | Medium | Medium | `dw.sh ls` reports each container's pinned revision; the #57 stamp names it on every posted record. | | Sibling **feature** branches remain rewritable from a container | Medium | Medium | Not closed. Protection is applied to the integration branch only — a blanket `feature/*` force-push rule would collide with the rebase `/dev:integrate` performs. Recorded as a finding and as an explicit narrowing of the isolation claim, not silently absorbed. | | Seeded configuration drifts from the host's over a container's lifetime | Medium | Low | Accepted: the seed is a create-time snapshot. `recreate` re-seeds. | | `selftest.sh` cannot run at all until the Docker access precondition resolves | High | High | Gated by that precondition; see the lane note in the test plan. The build can proceed; verification cannot. | ## Expert Review ### Reviewers - **Security specialist:** three blocking — tea-token scope could disable the very protection AC-7 relies on; protecting the integration branch does not cover *sibling* feature branches (which is what "cannot reach the other runs" meant); N copies of one OAuth credential may invalidate each other if refresh rotates. - **Solution architect:** two blocking — the pin's storage and its symlink had different lifetimes (dangling link on recreate); AC-3's mechanism was under-specified, and a file-copy pin would have stamped a release version instead of a sha. Endorsed Approach B and the pin decision. - **Backend developer:** three blocking — the `dw.sh` ↔ `container-init.sh` contract and its success marker were undefined, so `up` would reattach to a half-built container forever; the throwaway unpushed check could not reach the network it needed; AC-12 was not mechanically checkable without a credential-source override. - **UX expert:** two blocking — the `rm` guard covered unpushed commits but would silently destroy *uncommitted* work, the likeliest state for an operator returning after days; credential staleness was probed only at create, never on re-entry. ### Changes Made - Pin is a **git checkout of the integration branch**, not a copy, and lives in the claude volume beside its symlink. (architect ×2 — and the copy variant was verified to break AC-3.) - Added the explicit **`up` state machine** with a `created-uninit` state that re-runs init or refuses with a named remedy, plus a host lock against concurrent `up`. (backend) - Added **`dw.sh recreate`** preserving volumes, and moved *all* persistent state — credentials, pin, marker — into volumes. (architect + backend, converging) - **`rm` guard now covers a dirty working tree as well as unpushed commits.** (UX) - Unpushed detection uses **local remote-tracking refs**, no network. (backend) - **Credentials re-probed on every `up`**, not only at create; added a re-copy verb. (UX) - tea token must be **least-privilege**, asserted at create. (security) - Only the **`forge-devwork` SSH stanza + its key + known_hosts** are copied, not all of `~/.ssh`; agent forwarding became **opt-in**. (security) - `settings.json` seeding is an **allowlist**. (security + architect, converging) - Added **error/exit-code conventions**, `dw.sh ls` as a state dashboard, `repin` guarded against a live session, and README recovery guidance for a refused push and for an unreachable Docker socket. (UX + backend) - Added a `--credential-source` override so AC-12 is mechanically testable, with a bounded probe timeout. (backend) - The Docker precondition now asks for the **invocation mode** (`sudo` vs group membership), not merely that access exists. (architect + backend, converging) ### Noted (not actioned) - **Extending branch protection to `feature/*`.** It would close sibling-branch rewriting, but collides with the rebase `/dev:integrate` performs on feature branches. Left to the operator as a finding; the isolation claim is narrowed in the meantime rather than overstated. - **Per-container Claude logins instead of a copied credential.** The clean answer if refresh rotation turns out to be single-use — but the operator explicitly rejected per-container credentials on setup-ceremony grounds, so it stays contingent on the rotation precondition rather than pre-empting it. - **Credential age surfaced in `dw.sh ls`.** Accepted in spirit and folded into the `ls` dashboard row rather than tracked as its own item. ## Acceptance Criteria | ID | Criterion (from PREQ) | Verification approach | | ---- | ------------------------------------ | -------------------------------------------- | | AC-1 | A `/dev:*` skill loads and its helpers execute in a container session | `selftest.sh`: run a suite helper through the pinned path; assert exit 0 and real output | | AC-2 | Helper-tier prerequisites at required versions; forge CLI installed and authenticated | `selftest.sh`: version-probe each tool against the CLAUDE.md baseline; `tea login list` names the declared instance | | AC-3 | Provenance stamp names the executed revision, and tracks changes to it | `selftest.sh`: assert the stamp is `source:"git"` with the pinned sha (**not** `source:"release"`); `repin --from-workspace`; assert the stamp changed to the workspace sha | | AC-4 | Two containers cannot read/write each other's Claude state; host state unchanged | `selftest.sh`: marker in A, absence-probe from B, cross-access attempt fails, B's mount table contains no path to A; host state hashed before/after | | AC-5 | Workspace writes do not escape | `selftest.sh`: representative create/edit/delete set, then compare host checkout, every host worktree and container B's workspace against pre-captured hashes | | AC-6 | Push is the only route code takes out | `selftest.sh`: **enumerate the container's mount table and volume list** and assert none resolves to a host checkout, worktree or shared git dir; unpushed commit invisible everywhere; visible after push | | AC-7 | Forge refuses force-push to / deletion of the integration branch | `selftest.sh` attempts both from a container and asserts rejection **and** that the tip is unchanged; additionally asserts the tea token lacks repo-admin scope. Currently failing — gated by a precondition | | AC-8 | Tracker operations succeed with no interactive authentication step | `selftest.sh`: read issue, post comment, read back, with stdin closed; assert no prompt | | AC-9 | A moved branch causes a refused push, not a silent overwrite | `selftest.sh`: advance the branch on the forge, push from the container, assert non-zero exit and unchanged forge tip | | AC-10 | Session starts already authenticated | `selftest.sh`: non-interactive session prompt answers with no login step | | AC-11 | Credential refreshable in place; host credential file unchanged | `selftest.sh`: assert the container credential path is writable by the session uid and write to it; host file hash + mtime unchanged | | AC-12 | Expired/invalid credentials fail explicitly at start, not later | `selftest.sh`: `up --credential-source <fixture>` with an invalid credential; assert non-zero exit naming the credential within the defined probe timeout | | AC-13 | Whole lifecycle from a terminal, no GUI step | `selftest.sh` drives up → session → stop → rm via `dw.sh`; plus a scan asserting no `.devcontainer/` file references an editor or GUI step | | AC-14 | Two issues, no collision, individually addressable and removable | `selftest.sh`: two containers up, distinctly named; stop/remove one, assert the other untouched | | AC-15 | Same issue → reattach, never a rival container | `selftest.sh`: marker, second `up`, assert marker present and exactly one container matches | | AC-16 | State survives a normal stop; setup does not re-run | `selftest.sh`: uncommitted edit + session state, stop, `up`, assert both intact and init reports already-ready | | AC-17 | State survives an abnormal exit | `selftest.sh`: `docker kill`, then `up`; assert entry succeeds with no manual cleanup and state intact | | AC-18 | Removal refused while unpushed, unless overridden | `selftest.sh`: unpushed commit → `rm` exits **2** naming the commits, container survives; **also** dirty-tree-only → `rm` exits 2 naming the dirty paths; after push+commit clean, `rm` succeeds; `--force` succeeds on unpushed | | AC-19 | Removal clean and local: volumes gone, name reusable, others unaffected | `selftest.sh`: both volumes absent, sibling container unaffected, re-`up` yields a clean workspace | | AC-20 | Starts cleanly with no audio server | `selftest.sh`: start with no audio socket; assert clean start and usable session; **plus a scan asserting no audio package or socket mount remains in `.devcontainer/`** | | AC-21 | Workspace seeds from an existing branch at its forge tip | `selftest.sh`: assert branch name and HEAD match the forge tip recorded before create | | AC-22 | Workspace seeds from the integration branch when none exists; branch created in-container; nothing pushed as a side effect | `selftest.sh`: assert branch name, merge-base with the integration tip, and that the forge still has no such branch after create | | AC-23 | Fresh session inherits configuration but not accumulated state | `selftest.sh`: compare effective permission mode and resolved suite skill list against the host's; assert history/sessions/tasks empty | **Mechanical rows called out explicitly.** AC-6 (no second egress path), AC-13 (no GUI step), AC-20 (no audio residue) and AC-3 (`source:"git"`, not `"release"`) are **scan/assert** rows over the real mount table, the `.devcontainer/` sources and the stamp's own shape — not spot checks of behaviour. AC-6 is the one that fails silently if checked only by "did my commit show up on the host?": a second egress path that nothing happened to use still exists, so the assertion must be over the enumerated mount list. **Temporary scaffolding:** none introduced. `docker-compose.yml`, `devcontainer.json`, `entrypoint.sh` and `post-create.sh` are deleted outright, not left as compatibility shims. ## Implementation Scope ### Areas | Area | Files / directories involved | Nature of change | | ---- | ---------------------------- | ---------------- | | Lifecycle entry point | `.devcontainer/dw.sh` | new — `up`/`enter`, `stop`, `rm`, `recreate`, `repin`, `refresh-creds`, `ls` | | In-container create-time setup | `.devcontainer/container-init.sh` | new (replaces `entrypoint.sh` + `post-create.sh`) | | Image | `.devcontainer/Dockerfile` | modify — drop audio, pin the Claude CLI and jq, keep zsh/git/tea | | Retired stack | `.devcontainer/docker-compose.yml`, `devcontainer.json`, `entrypoint.sh`, `post-create.sh` | delete | | Mechanical verification | `.devcontainer/selftest.sh` | new — the surface nearly every AC maps to | | Lint coverage | `scripts/lint-conventions.sh` | extend — include `.devcontainer/*.sh` in the `bash -n` sweep | | Lifecycle documentation | `.devcontainer/README.md` | new — the lifecycle AC-13 requires, plus recovery guidance for a refused push and an unreachable Docker socket | ### File Boundaries `Dockerfile` is independent and can be done in parallel with anything. `dw.sh` and `container-init.sh` are a **single unit** — they share the naming scheme, the volume layout, the state marker and the exit-code convention; splitting them across parallel workers would invent two conventions. `selftest.sh` drives their interface and so must follow them. `README.md` and the lint extension are independent tail work. ### Dependencies & Sequencing **Mixed.** `Dockerfile`, `README.md` and the lint extension are independent. The core is sequential: `dw.sh` + `container-init.sh` (one unit) → `selftest.sh`. **Nothing can be validated until the Docker access precondition resolves** — the build can proceed, but every acceptance criterion's verification needs a runnable container. ## Constraints & Non-Goals **Constraints:** - The suite's POSIX-portability rule does **not** bind here — `.devcontainer/` is repo-local infra and explicitly exempt. The scripts still carry `#!/usr/bin/env bash` and stay within the helper tier's bash 3.2 surface, for consistency with the rest of the repo's shell. - A gate's output goes to a file and its exit code comes from the command, never a pipeline (CLAUDE.md, #49) — `selftest.sh` must obey this or its own greens are untrustworthy. - Host-key checking is never disabled to make SSH work. - No host credential file is ever mounted writable, and no credential is left in the container layer. **Non-goals (do NOT build):** - Launching or supervising the autonomous run inside the container. - `/voice` audio support. - VS Code / editor integration. - Port, database or service provisioning. - A `parallel_dev:` slot recipe. - Hosts other than this Linux dev box, or container hosts other than the local daemon. - Any change to shipped skill text under `plugin/`.
Author
Owner

Test Plan: devcontainer-plugin-era-refresh

Validation cases for issue #61, derived from the PREQ alone. No design exists yet, so no scenario
names a file, a container tool, a compose key or a mount path — each says what the operator does and
what they should observe. Where a scenario says "the documented bring-up command", the lifecycle
documentation this feature produces supplies it.

Prerequisites

The state these scenarios need. Concrete commands, paths and credential values belong to the UAT card,
not here.

  • A host that can run containers, with the repository checked out.
  • The operator's Claude credential present on the host in its normal location.
  • Host SSH configuration that can reach the git remote origin by its host-alias, and a working
    forge token for the declared tracker instance.
  • The documented terminal lifecycle for this feature (its create-or-enter, stop and remove
    operations).

Required Test Data

  • Two distinct issues on the tracker to run containers for — call them issue A and issue B.
  • Issue A already has a branch on the forge; issue B has none. (Scenario 24 needs both.)
  • A third issue C whose branch exists on the forge and has a commit the container will not
    have, for the non-fast-forward case.
  • One deliberately invalid credential pair — an expired/garbage Claude credential and a
    garbage forge token — to inject for the failure-path scenarios.
  • A recorded snapshot of the host state before the isolation scenarios: the host checkout, every
    host worktree, and the host's Claude state directory, in a form that can be compared afterwards.

Test Scenarios

Scenario 1: A container session can run the suite

Acceptance criterion: "Given a container brought up for an issue, when a Claude Code session in it
invokes a /dev:* skill, then the skill loads and its helper scripts execute — no unresolved path, no
missing skill."

Lane: integration-covered.devcontainer/selftest.sh

  1. Bring up a container for issue A using the documented command.
  2. Start a Claude Code session inside it.
  3. Invoke a /dev:* skill that reads from the tracker.
  4. Verify: the skill's instructions load — no "skill not found", no dangling-path or
    no-such-file error naming a host path.
  5. Verify: a helper script the skill runs executes and returns output, rather than failing to be found
    or failing to be executable.

Expected outcome: the session behaves as a suite-capable session. This is the reference definition
of a "usable session" used by later scenarios.

Scenario 2: The helper tier's prerequisites are present at the required versions

Acceptance criterion: "…then bash (≥ 3.2), jq, git and the POSIX utilities named in CLAUDE.md's
two-tier baseline table are present and usable, and the project's declared forge CLI is installed and
authenticated against the declared instance."

Lane: integration-covered.devcontainer/selftest.sh

  1. In a container session for issue A, ask for the version of each tool named in the project's
    two-tier baseline table.
  2. Verify: each is present, and bash reports at least the required minimum version.
  3. Ask the forge CLI to list its configured logins.
  4. Verify: a login exists for the instance the project declares, and it is usable without being
    prompted for anything.

Expected outcome: nothing the helper tier depends on is missing, and the forge CLI is already
authenticated.

Scenario 3: The run record names the revision that ran, and tracks it

Acceptance criterion: "…the record's suite-provenance stamp names the suite revision that session
executed; and when the checkout it executes from is changed and another phase is posted, the stamp
changes correspondingly."

Lane: integration-covered.devcontainer/selftest.sh

  1. In a container session for issue A, run a pipeline phase that posts a record to the tracker.
  2. Read the posted record and note the suite revision it names.
  3. Verify: the named revision matches the revision of the checkout the session is executing the suite
    from.
  4. Change that checkout (commit an edit to the suite text inside the container).
  5. Run another phase that posts a record.
  6. Verify: the newly posted record names the changed revision, not the earlier one.

Expected outcome: the stamp is tied to what actually ran, not a value fixed at build time.

Scenario 4: Two containers cannot see each other's Claude state

Acceptance criterion: "Given two containers up at the same time, when both run Claude Code sessions,
then neither can read or write the other's Claude state… and the host's own Claude state is unchanged by
both."

Lane: integration-covered.devcontainer/selftest.sh

  1. Bring up containers for issue A and issue B, and start a session in each.
  2. In container A, create a distinctive marker inside its Claude state directory.
  3. In container B, look for that marker anywhere in its own Claude state.
  4. Verify: it is not there.
  5. From container B, attempt to reach container A's Claude state directly, and list what container B has
    mounted.
  6. Verify: the attempt fails, and no mount in container B leads to container A's state.
  7. Run a session in each container long enough to write session and task state.
  8. Compare the host's Claude state directory against the snapshot taken beforehand.
  9. Verify: the host's own session, task and history state is unchanged by either container.

Expected outcome: three separate Claude states — two containers and the host — with no path between
them.

Scenario 5: A container's writes do not escape its workspace

Acceptance criterion: "…when a representative set of create/edit/delete operations is performed
across its workspace, then the host checkout, every host worktree, and every other container's workspace
compare equal to their prior state."

Lane: integration-covered.devcontainer/selftest.sh

  1. With containers for A and B up, in container A: create new files, edit tracked files, and delete
    tracked files across several directories of its workspace, including its repository root.
  2. Compare the host checkout and every host worktree against the snapshot.
  3. Verify: both compare equal — nothing container A did appears in them.
  4. Compare container B's workspace against its own starting state.
  5. Verify: it compares equal.

Expected outcome: the container's write reach stops at its own workspace.

Scenario 6: Pushing is the only route code takes out

Acceptance criterion: "…the branch is visible on the forge… and the container's code and commits
reach the outside world by no other route."

Lane: integration-covered.devcontainer/selftest.sh

  1. In container A, commit a distinctive change but do not push.
  2. Verify: the change is not visible on the forge, in the host checkout, in any host worktree, or in
    container B.
  3. Enumerate everything container A has mounted, every volume it shares, and every git directory it can
    reach.
  4. Verify: none of them leads to the host checkout, a host worktree, another container's workspace, or a
    shared git directory — the enumerated list contains no second egress path for code.
  5. Push the branch.
  6. Verify: the change is now visible on the forge and can be fetched by the host.

Expected outcome: before the push, nothing; after the push, the branch — and no third possibility.

Scenario 7: The forge refuses to let a container rewrite shared refs

Acceptance criterion: "Given the shared refs on the forge, when a container attempts to force-push to
or delete the integration branch, then the forge refuses."

Lane: config-variant — requires forge-side branch protection on the integration branch to be configured first (see the gating precondition); then covered by .devcontainer/selftest.sh

  1. From inside container A, attempt to force-push a rewritten history over the integration branch.
  2. Verify: the forge rejects it, and the rejection is reported to the session.
  3. Verify: the integration branch on the forge still points at what it pointed at before.
  4. From inside container A, attempt to delete the integration branch on the forge.
  5. Verify: the forge rejects it and the branch still exists.

Expected outcome: the container holds working credentials and still cannot rewrite what other runs
depend on.

Scenario 8: Tracker operations work without an interactive step

Acceptance criterion: "…then they succeed against the project's declared forge instance without an
interactive authentication step."

Lane: integration-covered.devcontainer/selftest.sh

  1. In a container session for issue A, read the issue, post a comment to it, and read the comment back.
  2. Verify: all three succeed.
  3. Verify: at no point was a login, token, password or confirmation prompt presented.

Expected outcome: the tracker is reachable and pre-authenticated.

Scenario 9: A moved branch causes a refused push, not a silent overwrite

Acceptance criterion: "…when the branch it is pushing has moved on the forge since it was copied,
then the push is refused and the refusal is visible to the session."

Lane: integration-covered.devcontainer/selftest.sh

  1. Bring up a container for issue C, whose branch exists on the forge.
  2. From the host, push an additional commit to issue C's branch, so the forge is now ahead of the
    container's copy.
  3. In the container, commit a change and push.
  4. Verify: the push is refused, and the refusal is reported in the session — not swallowed.
  5. Verify: the commit the host pushed in step 2 is still on the forge branch.

Expected outcome: the newer state survives; the container is told why its push did not land.

Scenario 10: A session starts already authenticated

Acceptance criterion: "Given a container started with the operator's Claude credential supplied to
it, when a session starts, then it is already authenticated — no interactive login step is required."

Lane: integration-covered.devcontainer/selftest.sh

  1. Bring up a fresh container for issue A.
  2. Start a Claude Code session and give it a prompt.
  3. Verify: the session answers, with no login prompt, no browser hand-off and no token paste step.

Expected outcome: an unattended start is possible because nothing waits for a human.

Scenario 11: The credential can be refreshed in place, and the host's is untouched

Acceptance criterion: "…then it can be refreshed in place inside the container and the session
continues; and after the container has run, the host's own credential file is unchanged."

Lane: integration-covered.devcontainer/selftest.sh

  1. Note the host credential file's contents and modification time.
  2. In a running container, cause the session's credential store to be written (refresh it, or otherwise
    trigger a credential write).
  3. Verify: the write succeeds — no read-only or permission-denied failure.
  4. Verify: the session continues working afterwards.
  5. Re-check the host credential file.
  6. Verify: contents and modification time are unchanged.

Expected outcome: refreshing works inside the container and stays inside it.

Scenario 12: Bad credentials fail loudly at the start

Acceptance criterion: "Given a container started with an expired or invalid credential (Claude or
forge), when it starts, then the failure is reported explicitly and identifiably — it does not hang, and
it does not present as an unrelated error later in a run."

Lane: integration-covered.devcontainer/selftest.sh

  1. Bring up a container supplying the deliberately invalid Claude credential.
  2. Verify: an explicit message identifies the credential as the problem, and it appears at start-up
    rather than partway through work.
  3. Verify: the start does not hang waiting indefinitely.
  4. Repeat with a valid Claude credential and the garbage forge token.
  5. Verify: an explicit message identifies the forge credential as the problem — not, for example, a
    generic "issue not found".

Expected outcome: the operator can tell from the first screen which credential is wrong.

Scenario 13: The whole lifecycle runs from a terminal

Acceptance criterion: "Given a host with only a terminal available, when the operator follows the
documented lifecycle, then a container for a named issue comes up, yields a usable session, and can be
stopped and removed again — with no VS Code and no GUI step anywhere."

Lane: integration-covered.devcontainer/selftest.sh

  1. From a plain terminal, follow the documented lifecycle end to end for issue B: create-or-enter,
    then a usable session (Scenario 1's check), then stop, then remove.
  2. Verify: every step is a terminal command.
  3. Verify: no step requires an editor, an extension, a GUI attach, or any window.

Expected outcome: the documented sequence is complete and sufficient — nothing has to be discovered.

Scenario 14: Two issues, two containers, no collision

Acceptance criterion: "Given containers brought up for two different issues, when both are running,
then neither fails or displaces the other on account of naming, and each can be addressed, stopped and
removed individually without disturbing the other."

Lane: integration-covered.devcontainer/selftest.sh

  1. Bring up containers for issue A and issue B.
  2. Verify: both are running at the same time, and bringing up the second did not error, replace, or
    restart the first.
  3. Verify: each can be addressed by a name derived from its issue, and entering one lands in that one.
  4. Stop the container for issue A.
  5. Verify: the container for issue B is still running and its session is unaffected.
  6. Remove the container for issue A.
  7. Verify: issue B's container and workspace are untouched.

Expected outcome: two independent containers, individually addressable.

Scenario 15: Re-entering an issue reuses its container

Acceptance criterion: "Given an issue that already has a container, when the operator runs
create-or-enter for that same issue again, then they are placed in the existing container — a second,
rival container for one issue is never created."

Lane: integration-covered.devcontainer/selftest.sh

  1. With a container running for issue A, leave a distinctive marker file in its workspace.
  2. Run create-or-enter for issue A a second time.
  3. Verify: the marker file is present — this is the same container, not a new one.
  4. Verify: exactly one container exists for issue A.

Expected outcome: create-or-enter is idempotent per issue.

Scenario 16: State survives a normal stop

Acceptance criterion: "Given a container that was stopped normally, when the operator enters it
again, then its Claude state and its workspace — including uncommitted changes — are as they were left,
and first-time setup does not run again."

Lane: integration-covered.devcontainer/selftest.sh

  1. In a container for issue A, make an uncommitted edit and run a session so session state accumulates.
  2. Stop the container using the documented stop operation.
  3. Enter it again.
  4. Verify: the uncommitted edit is still there, unchanged.
  5. Verify: the accumulated Claude session state is still there.
  6. Verify: first-time setup did not re-run — no re-initialisation output, and nothing setup created was
    reset.

Expected outcome: stop is not destructive.

Scenario 17: State survives an abnormal exit

Acceptance criterion: "Given a container that exited abnormally (crash, daemon kill, host reboot),
when the operator enters it again, then they get a usable session with that same state intact, and no
leftover state from the abnormal exit blocks the entry."

Lane: integration-covered.devcontainer/selftest.sh

  1. In a container for issue A, make an uncommitted edit.
  2. Kill the container abruptly rather than stopping it (simulating a crash or a host reboot).
  3. Run create-or-enter for issue A again.
  4. Verify: it comes up — no error about an existing, stale, or conflicting container that the operator
    must clean up by hand.
  5. Verify: the uncommitted edit and the Claude state are intact.
  6. Verify: the session is usable (Scenario 1's check).

Expected outcome: an abnormal exit is recoverable by repeating the ordinary entry command.

Scenario 18: Removal refuses while work is unpushed

Acceptance criterion: "Given a container holding commits the forge does not have, when the operator
asks to remove it, then removal is refused and the unpushed commits are named; removal proceeds only on
an explicit override."

Lane: integration-covered.devcontainer/selftest.sh

  1. In a container for issue A, commit a change and do not push it.
  2. Ask to remove the container.
  3. Verify: removal is refused.
  4. Verify: the message names the unpushed commits — enough to identify what is at risk.
  5. Verify: the container and its workspace still exist, and the commit is still there.
  6. Push the commit, then ask to remove again.
  7. Verify: removal now proceeds without an override.
  8. Repeat steps 1–2 with a fresh unpushed commit, then remove using the explicit override.
  9. Verify: removal proceeds.

Expected outcome: the one loss this isolation model creates cannot happen by accident, but is not
impossible on purpose.

Scenario 19: Removal is clean and local

Acceptance criterion: "Given a container with nothing unpushed, when the operator removes it, then
its workspace and Claude state are destroyed, its name is free for re-use, and no other running container
is affected."

Lane: integration-covered.devcontainer/selftest.sh

  1. With containers for A and B running and nothing unpushed in A, remove A.
  2. Verify: A's workspace and Claude state are gone.
  3. Verify: B is still running, its session unaffected, its workspace unchanged.
  4. Create a container for issue A again.
  5. Verify: it is created successfully — the name was released — and it starts from a clean workspace with
    no trace of the previous container's state.

Expected outcome: removal frees everything it held and nothing it did not.

Scenario 20: No audio server, no problem

Acceptance criterion: "Given a host with no audio server running, when a container starts, then it
starts cleanly and yields a usable session."

Lane: integration-covered.devcontainer/selftest.sh

  1. On a host with no audio server running (or with the audio socket absent), bring up a container.
  2. Verify: it starts without error or warning about audio, sound devices or a missing socket.
  3. Verify: the session is usable (Scenario 1's check).

Expected outcome: the container has no audio dependency left to fail on.

Scenario 21: Workspace seeds from an existing branch

Acceptance criterion: "Given an issue whose branch already exists on the forge, when a container is
created for it, then its workspace is that branch at its forge tip."

Lane: integration-covered.devcontainer/selftest.sh

  1. Confirm issue A's branch on the forge and note its tip commit.
  2. Create a fresh container for issue A.
  3. Verify: the workspace is on issue A's branch.
  4. Verify: it is at the tip commit noted in step 1.

Expected outcome: the container picks up where the branch left off.

Scenario 22: Workspace seeds when no branch exists yet

Acceptance criterion: "…and given an issue with no branch yet, then its workspace starts from the
integration branch at its forge tip and the feature branch is created in the container."

Lane: integration-covered.devcontainer/selftest.sh

  1. Confirm issue B has no branch on the forge, and note the integration branch's tip.
  2. Create a fresh container for issue B.
  3. Verify: the workspace is on a feature branch for issue B, not on the integration branch itself.
  4. Verify: that branch starts from the integration branch's tip noted in step 1.
  5. Verify: nothing was pushed as a side effect of creation — the forge still has no branch for issue B
    until the container pushes one.

Expected outcome: a first run needs no branch to be created by hand first.

Scenario 23: A fresh container's session is configured like a host session

Acceptance criterion: "Given a freshly created container, when a session starts in it, then that
session operates under the same Claude configuration a host session would (settings and plugin/skill
wiring), while session, task and history state start empty."

Lane: integration-covered.devcontainer/selftest.sh

  1. Note the host session's operating configuration — its permission mode and the set of skills available
    to it.
  2. Create a fresh container and start a session.
  3. Verify: the same permission mode is in effect.
  4. Verify: the same suite skills are available.
  5. Verify: the session's own history, session list and task list are empty — nothing carried over from
    the host or from another container.

Expected outcome: configuration is inherited; accumulated state is not.

Scenario 24: Edge case — two containers pushing branches at the same time

Acceptance criteria: the isolation and push criteria, exercised concurrently.

Lane: integration-covered.devcontainer/selftest.sh

  1. With containers for issues A and B both running, commit work in each.
  2. Push from both, as close to simultaneously as can be arranged.
  3. Verify: both branches land on the forge, each with its own commits.
  4. Verify: neither push altered or removed the other's branch.
  5. Verify: neither container's workspace was affected by the other's push.

Expected outcome: parallel containers are parallel all the way through publication.

Scenario 25: Edge case — the forge is unreachable

Acceptance criteria: the tracker-operations and push criteria, under an unavailable dependency.

Lane: config-variant — the forge must be made unreachable from the container; needs a dedicated run that owns its own environment (covered by .devcontainer/selftest.sh)

  1. Bring up a container for issue A while the forge is reachable.
  2. Make the forge unreachable from the container (block it, or point it at an unreachable address).
  3. Attempt a tracker operation, then attempt a push.
  4. Verify: each fails with a message identifying the forge as unreachable — it does not hang
    indefinitely, and it does not report the issue or branch as missing.
  5. Verify: the container's workspace and Claude state are undamaged, and the session remains usable for
    local work.
  6. Restore reachability and retry.
  7. Verify: both operations now succeed.

Expected outcome: an unavailable forge is a reported, recoverable condition, not a corrupted run.

Scenario 26: Edge case — first run on a host that has never built the container

Acceptance criteria: the lifecycle and prerequisite criteria, from a cold start.

Lane: integration-covered.devcontainer/selftest.sh

  1. Starting from a host with no image and no container for this project, run the documented
    create-or-enter for issue B.
  2. Verify: it completes, building whatever it needs, without additional manual steps beyond what the
    lifecycle documents.
  3. Verify: the resulting session is usable (Scenario 1's check) and the prerequisite tools are present
    (Scenario 2's checks).

Expected outcome: the documented lifecycle is sufficient from nothing, not just from a warm host.

Traceability

Forward — every acceptance criterion has at least one scenario:

PREQ criterion Scenario(s)
Skill loads, helpers execute ("usable session") 1, 13, 17, 20, 26
Helper-tier prerequisites + forge CLI authenticated 2, 26
Provenance stamp names the executed revision, and tracks it 3
Two containers cannot read/write each other's Claude state; host unchanged 4
Workspace writes do not escape 5, 24
Push is the only route code takes out 6, 24
Forge refuses force-push/delete of shared refs 7
Tracker operations, no interactive step 8, 25
Moved branch → refused push 9
Session starts already authenticated 10
Credential refreshable in place; host file unchanged 11
Bad credentials fail loudly at start 12
Terminal-only lifecycle 13, 26
Two issues, no naming collision, individually addressable 14
Same issue → reattach, never a rival container 15
State survives a normal stop; setup not re-run 16
State survives an abnormal exit 17
Removal refused while unpushed, unless overridden 18
Removal clean, name freed, others unaffected 19
Starts cleanly with no audio server 20
Workspace seeds from an existing branch 21
Workspace seeds from the integration branch when none exists 22
Fresh session inherits configuration, not accumulated state 23

Backward — every scenario traces to a criterion. Scenarios 24–26 are the completeness-lens edge cases
(concurrency, unavailable dependency, first run); each is listed above against the criteria it
exercises rather than introducing new scope.

Notes

  • Lanes assigned by /dev:technical-plan 2.6b (the scenarios themselves are unchanged — they remain
    PREQ-derived). e2e-browser was not assignable at all: the project declares e2e in
    qa_domains.not_applicable ("markdown+shell skill suite — no application or browser surface"), so the
    schema's default lane would have been wrong for all 26. 24 scenarios are integration-covered by
    .devcontainer/selftest.sh — a deliverable of this feature, listed in the SREQ's Implementation Scope,
    so this is a commitment rather than a reference to a suite that will never exist. Scenarios 7 and 25 are
    config-variant: each needs its environment arranged (forge-side branch protection; an unreachable
    forge) and a dedicated run that owns that arrangement.

  • ⚠ Executability: none of these lanes can run in this environment today, and that is gated, not
    assumed.
    selftest.sh needs a runnable container, and the operator's user cannot reach the Docker
    daemon (not in the docker group; no rootless socket; no podman — verified at plan time). The lanes
    above are therefore the honest destination once that gate clears, and the gate is a blocking
    precondition on this phase's Phase Outcome rather than a silent route to a lane that cannot execute.
    Scenario 7 carries a second gate: main currently has PROTECTED = false on the forge, so the
    criterion it validates fails until that is configured.

  • Scenario 3 needs a suite-text edit inside the container; it is the one scenario that deliberately
    changes what the session executes.

  • Scenario 17 requires killing a container abruptly. Scenario 25 requires making the forge unreachable
    from inside a container. Both need the environment to be arranged, not just observed.

  • Several scenarios ("compare equal to the snapshot") depend on the before-state capture listed under
    Required Test Data. Capturing it after the fact makes them unrunnable.

<!-- test-plan:v1 issue=61 skill=technical-plan --> # Test Plan: devcontainer-plugin-era-refresh Validation cases for issue #61, derived from the PREQ alone. No design exists yet, so no scenario names a file, a container tool, a compose key or a mount path — each says *what the operator does* and *what they should observe*. Where a scenario says "the documented bring-up command", the lifecycle documentation this feature produces supplies it. ## Prerequisites The *state* these scenarios need. Concrete commands, paths and credential values belong to the UAT card, not here. - [ ] A host that can run containers, with the repository checked out. - [ ] The operator's Claude credential present on the host in its normal location. - [ ] Host SSH configuration that can reach the git remote `origin` by its host-alias, and a working forge token for the declared tracker instance. - [ ] The documented terminal lifecycle for this feature (its create-or-enter, stop and remove operations). ### Required Test Data - [ ] **Two distinct issues** on the tracker to run containers for — call them issue A and issue B. - [ ] **Issue A already has a branch on the forge**; **issue B has none**. (Scenario 24 needs both.) - [ ] A **third issue C** whose branch exists on the forge *and* has a commit the container will not have, for the non-fast-forward case. - [ ] One **deliberately invalid credential pair** — an expired/garbage Claude credential and a garbage forge token — to inject for the failure-path scenarios. - [ ] A **recorded snapshot of the host state** before the isolation scenarios: the host checkout, every host worktree, and the host's Claude state directory, in a form that can be compared afterwards. ## Test Scenarios ### Scenario 1: A container session can run the suite **Acceptance criterion:** "Given a container brought up for an issue, when a Claude Code session in it invokes a `/dev:*` skill, then the skill loads and its helper scripts execute — no unresolved path, no missing skill." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. Bring up a container for issue A using the documented command. 2. Start a Claude Code session inside it. 3. Invoke a `/dev:*` skill that reads from the tracker. 4. Verify: the skill's instructions load — no "skill not found", no dangling-path or no-such-file error naming a host path. 5. Verify: a helper script the skill runs executes and returns output, rather than failing to be found or failing to be executable. **Expected outcome:** the session behaves as a suite-capable session. This is the reference definition of a "usable session" used by later scenarios. ### Scenario 2: The helper tier's prerequisites are present at the required versions **Acceptance criterion:** "…then `bash` (≥ 3.2), `jq`, `git` and the POSIX utilities named in CLAUDE.md's two-tier baseline table are present and usable, and the project's declared forge CLI is installed and authenticated against the declared instance." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. In a container session for issue A, ask for the version of each tool named in the project's two-tier baseline table. 2. Verify: each is present, and `bash` reports at least the required minimum version. 3. Ask the forge CLI to list its configured logins. 4. Verify: a login exists for the instance the project declares, and it is usable without being prompted for anything. **Expected outcome:** nothing the helper tier depends on is missing, and the forge CLI is already authenticated. ### Scenario 3: The run record names the revision that ran, and tracks it **Acceptance criterion:** "…the record's suite-provenance stamp names the suite revision that session executed; and when the checkout it executes from is changed and another phase is posted, the stamp changes correspondingly." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. In a container session for issue A, run a pipeline phase that posts a record to the tracker. 2. Read the posted record and note the suite revision it names. 3. Verify: the named revision matches the revision of the checkout the session is executing the suite from. 4. Change that checkout (commit an edit to the suite text inside the container). 5. Run another phase that posts a record. 6. Verify: the newly posted record names the changed revision, not the earlier one. **Expected outcome:** the stamp is tied to what actually ran, not a value fixed at build time. ### Scenario 4: Two containers cannot see each other's Claude state **Acceptance criterion:** "Given two containers up at the same time, when both run Claude Code sessions, then neither can read or write the other's Claude state… and the host's own Claude state is unchanged by both." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. Bring up containers for issue A and issue B, and start a session in each. 2. In container A, create a distinctive marker inside its Claude state directory. 3. In container B, look for that marker anywhere in its own Claude state. 4. Verify: it is not there. 5. From container B, attempt to reach container A's Claude state directly, and list what container B has mounted. 6. Verify: the attempt fails, and no mount in container B leads to container A's state. 7. Run a session in each container long enough to write session and task state. 8. Compare the host's Claude state directory against the snapshot taken beforehand. 9. Verify: the host's own session, task and history state is unchanged by either container. **Expected outcome:** three separate Claude states — two containers and the host — with no path between them. ### Scenario 5: A container's writes do not escape its workspace **Acceptance criterion:** "…when a representative set of create/edit/delete operations is performed across its workspace, then the host checkout, every host worktree, and every other container's workspace compare equal to their prior state." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. With containers for A and B up, in container A: create new files, edit tracked files, and delete tracked files across several directories of its workspace, including its repository root. 2. Compare the host checkout and every host worktree against the snapshot. 3. Verify: both compare equal — nothing container A did appears in them. 4. Compare container B's workspace against its own starting state. 5. Verify: it compares equal. **Expected outcome:** the container's write reach stops at its own workspace. ### Scenario 6: Pushing is the only route code takes out **Acceptance criterion:** "…the branch is visible on the forge… and the container's code and commits reach the outside world by no other route." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. In container A, commit a distinctive change but do **not** push. 2. Verify: the change is not visible on the forge, in the host checkout, in any host worktree, or in container B. 3. Enumerate everything container A has mounted, every volume it shares, and every git directory it can reach. 4. Verify: none of them leads to the host checkout, a host worktree, another container's workspace, or a shared git directory — the enumerated list contains no second egress path for code. 5. Push the branch. 6. Verify: the change is now visible on the forge and can be fetched by the host. **Expected outcome:** before the push, nothing; after the push, the branch — and no third possibility. ### Scenario 7: The forge refuses to let a container rewrite shared refs **Acceptance criterion:** "Given the shared refs on the forge, when a container attempts to force-push to or delete the integration branch, then the forge refuses." **Lane:** `config-variant` — requires forge-side branch protection on the integration branch to be configured first (see the gating precondition); then covered by `.devcontainer/selftest.sh` 1. From inside container A, attempt to force-push a rewritten history over the integration branch. 2. Verify: the forge rejects it, and the rejection is reported to the session. 3. Verify: the integration branch on the forge still points at what it pointed at before. 4. From inside container A, attempt to delete the integration branch on the forge. 5. Verify: the forge rejects it and the branch still exists. **Expected outcome:** the container holds working credentials and still cannot rewrite what other runs depend on. ### Scenario 8: Tracker operations work without an interactive step **Acceptance criterion:** "…then they succeed against the project's declared forge instance without an interactive authentication step." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. In a container session for issue A, read the issue, post a comment to it, and read the comment back. 2. Verify: all three succeed. 3. Verify: at no point was a login, token, password or confirmation prompt presented. **Expected outcome:** the tracker is reachable and pre-authenticated. ### Scenario 9: A moved branch causes a refused push, not a silent overwrite **Acceptance criterion:** "…when the branch it is pushing has moved on the forge since it was copied, then the push is refused and the refusal is visible to the session." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. Bring up a container for issue C, whose branch exists on the forge. 2. From the host, push an additional commit to issue C's branch, so the forge is now ahead of the container's copy. 3. In the container, commit a change and push. 4. Verify: the push is refused, and the refusal is reported in the session — not swallowed. 5. Verify: the commit the host pushed in step 2 is still on the forge branch. **Expected outcome:** the newer state survives; the container is told why its push did not land. ### Scenario 10: A session starts already authenticated **Acceptance criterion:** "Given a container started with the operator's Claude credential supplied to it, when a session starts, then it is already authenticated — no interactive login step is required." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. Bring up a fresh container for issue A. 2. Start a Claude Code session and give it a prompt. 3. Verify: the session answers, with no login prompt, no browser hand-off and no token paste step. **Expected outcome:** an unattended start is possible because nothing waits for a human. ### Scenario 11: The credential can be refreshed in place, and the host's is untouched **Acceptance criterion:** "…then it can be refreshed in place inside the container and the session continues; and after the container has run, the host's own credential file is unchanged." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. Note the host credential file's contents and modification time. 2. In a running container, cause the session's credential store to be written (refresh it, or otherwise trigger a credential write). 3. Verify: the write succeeds — no read-only or permission-denied failure. 4. Verify: the session continues working afterwards. 5. Re-check the host credential file. 6. Verify: contents and modification time are unchanged. **Expected outcome:** refreshing works inside the container and stays inside it. ### Scenario 12: Bad credentials fail loudly at the start **Acceptance criterion:** "Given a container started with an expired or invalid credential (Claude or forge), when it starts, then the failure is reported explicitly and identifiably — it does not hang, and it does not present as an unrelated error later in a run." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. Bring up a container supplying the deliberately invalid Claude credential. 2. Verify: an explicit message identifies the credential as the problem, and it appears at start-up rather than partway through work. 3. Verify: the start does not hang waiting indefinitely. 4. Repeat with a valid Claude credential and the garbage forge token. 5. Verify: an explicit message identifies the forge credential as the problem — not, for example, a generic "issue not found". **Expected outcome:** the operator can tell from the first screen which credential is wrong. ### Scenario 13: The whole lifecycle runs from a terminal **Acceptance criterion:** "Given a host with only a terminal available, when the operator follows the documented lifecycle, then a container for a named issue comes up, yields a usable session, and can be stopped and removed again — with no VS Code and no GUI step anywhere." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. From a plain terminal, follow the documented lifecycle end to end for issue B: create-or-enter, then a usable session (Scenario 1's check), then stop, then remove. 2. Verify: every step is a terminal command. 3. Verify: no step requires an editor, an extension, a GUI attach, or any window. **Expected outcome:** the documented sequence is complete and sufficient — nothing has to be discovered. ### Scenario 14: Two issues, two containers, no collision **Acceptance criterion:** "Given containers brought up for two different issues, when both are running, then neither fails or displaces the other on account of naming, and each can be addressed, stopped and removed individually without disturbing the other." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. Bring up containers for issue A and issue B. 2. Verify: both are running at the same time, and bringing up the second did not error, replace, or restart the first. 3. Verify: each can be addressed by a name derived from its issue, and entering one lands in that one. 4. Stop the container for issue A. 5. Verify: the container for issue B is still running and its session is unaffected. 6. Remove the container for issue A. 7. Verify: issue B's container and workspace are untouched. **Expected outcome:** two independent containers, individually addressable. ### Scenario 15: Re-entering an issue reuses its container **Acceptance criterion:** "Given an issue that already has a container, when the operator runs create-or-enter for that same issue again, then they are placed in the existing container — a second, rival container for one issue is never created." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. With a container running for issue A, leave a distinctive marker file in its workspace. 2. Run create-or-enter for issue A a second time. 3. Verify: the marker file is present — this is the same container, not a new one. 4. Verify: exactly one container exists for issue A. **Expected outcome:** create-or-enter is idempotent per issue. ### Scenario 16: State survives a normal stop **Acceptance criterion:** "Given a container that was stopped normally, when the operator enters it again, then its Claude state and its workspace — including uncommitted changes — are as they were left, and first-time setup does not run again." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. In a container for issue A, make an uncommitted edit and run a session so session state accumulates. 2. Stop the container using the documented stop operation. 3. Enter it again. 4. Verify: the uncommitted edit is still there, unchanged. 5. Verify: the accumulated Claude session state is still there. 6. Verify: first-time setup did not re-run — no re-initialisation output, and nothing setup created was reset. **Expected outcome:** stop is not destructive. ### Scenario 17: State survives an abnormal exit **Acceptance criterion:** "Given a container that exited abnormally (crash, daemon kill, host reboot), when the operator enters it again, then they get a usable session with that same state intact, and no leftover state from the abnormal exit blocks the entry." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. In a container for issue A, make an uncommitted edit. 2. Kill the container abruptly rather than stopping it (simulating a crash or a host reboot). 3. Run create-or-enter for issue A again. 4. Verify: it comes up — no error about an existing, stale, or conflicting container that the operator must clean up by hand. 5. Verify: the uncommitted edit and the Claude state are intact. 6. Verify: the session is usable (Scenario 1's check). **Expected outcome:** an abnormal exit is recoverable by repeating the ordinary entry command. ### Scenario 18: Removal refuses while work is unpushed **Acceptance criterion:** "Given a container holding commits the forge does not have, when the operator asks to remove it, then removal is refused and the unpushed commits are named; removal proceeds only on an explicit override." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. In a container for issue A, commit a change and do not push it. 2. Ask to remove the container. 3. Verify: removal is refused. 4. Verify: the message names the unpushed commits — enough to identify what is at risk. 5. Verify: the container and its workspace still exist, and the commit is still there. 6. Push the commit, then ask to remove again. 7. Verify: removal now proceeds without an override. 8. Repeat steps 1–2 with a fresh unpushed commit, then remove using the explicit override. 9. Verify: removal proceeds. **Expected outcome:** the one loss this isolation model creates cannot happen by accident, but is not impossible on purpose. ### Scenario 19: Removal is clean and local **Acceptance criterion:** "Given a container with nothing unpushed, when the operator removes it, then its workspace and Claude state are destroyed, its name is free for re-use, and no other running container is affected." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. With containers for A and B running and nothing unpushed in A, remove A. 2. Verify: A's workspace and Claude state are gone. 3. Verify: B is still running, its session unaffected, its workspace unchanged. 4. Create a container for issue A again. 5. Verify: it is created successfully — the name was released — and it starts from a clean workspace with no trace of the previous container's state. **Expected outcome:** removal frees everything it held and nothing it did not. ### Scenario 20: No audio server, no problem **Acceptance criterion:** "Given a host with no audio server running, when a container starts, then it starts cleanly and yields a usable session." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. On a host with no audio server running (or with the audio socket absent), bring up a container. 2. Verify: it starts without error or warning about audio, sound devices or a missing socket. 3. Verify: the session is usable (Scenario 1's check). **Expected outcome:** the container has no audio dependency left to fail on. ### Scenario 21: Workspace seeds from an existing branch **Acceptance criterion:** "Given an issue whose branch already exists on the forge, when a container is created for it, then its workspace is that branch at its forge tip." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. Confirm issue A's branch on the forge and note its tip commit. 2. Create a fresh container for issue A. 3. Verify: the workspace is on issue A's branch. 4. Verify: it is at the tip commit noted in step 1. **Expected outcome:** the container picks up where the branch left off. ### Scenario 22: Workspace seeds when no branch exists yet **Acceptance criterion:** "…and given an issue with no branch yet, then its workspace starts from the integration branch at its forge tip and the feature branch is created in the container." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. Confirm issue B has no branch on the forge, and note the integration branch's tip. 2. Create a fresh container for issue B. 3. Verify: the workspace is on a feature branch for issue B, not on the integration branch itself. 4. Verify: that branch starts from the integration branch's tip noted in step 1. 5. Verify: nothing was pushed as a side effect of creation — the forge still has no branch for issue B until the container pushes one. **Expected outcome:** a first run needs no branch to be created by hand first. ### Scenario 23: A fresh container's session is configured like a host session **Acceptance criterion:** "Given a freshly created container, when a session starts in it, then that session operates under the same Claude configuration a host session would (settings and plugin/skill wiring), while session, task and history state start empty." **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. Note the host session's operating configuration — its permission mode and the set of skills available to it. 2. Create a fresh container and start a session. 3. Verify: the same permission mode is in effect. 4. Verify: the same suite skills are available. 5. Verify: the session's own history, session list and task list are empty — nothing carried over from the host or from another container. **Expected outcome:** configuration is inherited; accumulated state is not. ### Scenario 24: Edge case — two containers pushing branches at the same time **Acceptance criteria:** the isolation and push criteria, exercised concurrently. **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. With containers for issues A and B both running, commit work in each. 2. Push from both, as close to simultaneously as can be arranged. 3. Verify: both branches land on the forge, each with its own commits. 4. Verify: neither push altered or removed the other's branch. 5. Verify: neither container's workspace was affected by the other's push. **Expected outcome:** parallel containers are parallel all the way through publication. ### Scenario 25: Edge case — the forge is unreachable **Acceptance criteria:** the tracker-operations and push criteria, under an unavailable dependency. **Lane:** `config-variant` — the forge must be made unreachable from the container; needs a dedicated run that owns its own environment (covered by `.devcontainer/selftest.sh`) 1. Bring up a container for issue A while the forge is reachable. 2. Make the forge unreachable from the container (block it, or point it at an unreachable address). 3. Attempt a tracker operation, then attempt a push. 4. Verify: each fails with a message identifying the forge as unreachable — it does not hang indefinitely, and it does not report the issue or branch as missing. 5. Verify: the container's workspace and Claude state are undamaged, and the session remains usable for local work. 6. Restore reachability and retry. 7. Verify: both operations now succeed. **Expected outcome:** an unavailable forge is a reported, recoverable condition, not a corrupted run. ### Scenario 26: Edge case — first run on a host that has never built the container **Acceptance criteria:** the lifecycle and prerequisite criteria, from a cold start. **Lane:** `integration-covered` — `.devcontainer/selftest.sh` 1. Starting from a host with no image and no container for this project, run the documented create-or-enter for issue B. 2. Verify: it completes, building whatever it needs, without additional manual steps beyond what the lifecycle documents. 3. Verify: the resulting session is usable (Scenario 1's check) and the prerequisite tools are present (Scenario 2's checks). **Expected outcome:** the documented lifecycle is sufficient from nothing, not just from a warm host. ## Traceability Forward — every acceptance criterion has at least one scenario: | PREQ criterion | Scenario(s) | |---|---| | Skill loads, helpers execute ("usable session") | 1, 13, 17, 20, 26 | | Helper-tier prerequisites + forge CLI authenticated | 2, 26 | | Provenance stamp names the executed revision, and tracks it | 3 | | Two containers cannot read/write each other's Claude state; host unchanged | 4 | | Workspace writes do not escape | 5, 24 | | Push is the only route code takes out | 6, 24 | | Forge refuses force-push/delete of shared refs | 7 | | Tracker operations, no interactive step | 8, 25 | | Moved branch → refused push | 9 | | Session starts already authenticated | 10 | | Credential refreshable in place; host file unchanged | 11 | | Bad credentials fail loudly at start | 12 | | Terminal-only lifecycle | 13, 26 | | Two issues, no naming collision, individually addressable | 14 | | Same issue → reattach, never a rival container | 15 | | State survives a normal stop; setup not re-run | 16 | | State survives an abnormal exit | 17 | | Removal refused while unpushed, unless overridden | 18 | | Removal clean, name freed, others unaffected | 19 | | Starts cleanly with no audio server | 20 | | Workspace seeds from an existing branch | 21 | | Workspace seeds from the integration branch when none exists | 22 | | Fresh session inherits configuration, not accumulated state | 23 | Backward — every scenario traces to a criterion. Scenarios 24–26 are the completeness-lens edge cases (concurrency, unavailable dependency, first run); each is listed above against the criteria it exercises rather than introducing new scope. ## Notes - **Lanes assigned by `/dev:technical-plan` 2.6b** (the scenarios themselves are unchanged — they remain PREQ-derived). `e2e-browser` was **not assignable at all**: the project declares `e2e` in `qa_domains.not_applicable` ("markdown+shell skill suite — no application or browser surface"), so the schema's default lane would have been wrong for all 26. 24 scenarios are `integration-covered` by `.devcontainer/selftest.sh` — a deliverable of this feature, listed in the SREQ's Implementation Scope, so this is a commitment rather than a reference to a suite that will never exist. Scenarios 7 and 25 are `config-variant`: each needs its environment arranged (forge-side branch protection; an unreachable forge) and a dedicated run that owns that arrangement. - **⚠ Executability: none of these lanes can run in this environment today, and that is gated, not assumed.** `selftest.sh` needs a runnable container, and the operator's user cannot reach the Docker daemon (not in the `docker` group; no rootless socket; no podman — verified at plan time). The lanes above are therefore the honest destination *once that gate clears*, and the gate is a blocking precondition on this phase's Phase Outcome rather than a silent route to a lane that cannot execute. Scenario 7 carries a second gate: `main` currently has `PROTECTED = false` on the forge, so the criterion it validates fails until that is configured. - Scenario 3 needs a suite-text edit inside the container; it is the one scenario that deliberately changes what the session executes. - Scenario 17 requires killing a container abruptly. Scenario 25 requires making the forge unreachable from inside a container. Both need the environment to be arranged, not just observed. - Several scenarios ("compare equal to the snapshot") depend on the before-state capture listed under Required Test Data. Capturing it after the fact makes them unrunnable.
Author
Owner
{
  "next_state": "developing",
  "produced": [
    {
      "kind": "sreq",
      "ref": "comment:1360",
      "summary": "SREQ for devcontainer-plugin-era-refresh (Tier 3)"
    },
    {
      "kind": "test-plan",
      "ref": "comment:1365",
      "summary": "Test plan for devcontainer-plugin-era-refresh — 26 scenarios, lane-annotated (24 integration-covered by .devcontainer/selftest.sh, 2 config-variant; e2e-browser not assignable — the project declares e2e not applicable)"
    }
  ],
  "findings": [
    {
      "category": "out-of-scope",
      "summary": "Launching and supervising the autonomous run inside the container — no 'give it an issue number and walk away' entry point.",
      "reasoning": "Flagged as out of scope in the approved PREQ. The requirements-phase consistency lens noted the consequence and it still stands after design: the problem is framed entirely as unattended operation, but this feature delivers the sandbox, not proof that an unattended run completes inside it. That gap is real and warrants its own feature — the prompt, the permission mode and how a failed run is surfaced are run-orchestration decisions that would otherwise be settled by accident inside an infra ticket.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "id": "F-PO-61-2-1"
    },
    {
      "category": "out-of-scope",
      "summary": "/voice audio plumbing — the host audio socket mount, the audio packages and the ALSA-to-Pulse routing are deleted outright.",
      "reasoning": "Flagged as out of scope in the approved PREQ. An unattended run has nobody to talk to, and this is the most host-coupled part of the image. Deleted rather than shimmed, so there is nothing to follow up.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-61-2-2"
    },
    {
      "category": "out-of-scope",
      "summary": "VS Code / editor integration — devcontainer.json, the extension list and any GUI attach flow are removed.",
      "reasoning": "Flagged as out of scope in the approved PREQ. The operator never uses VS Code and the lifecycle is terminal-first by requirement, so this would be configuration nothing exercises — which is what rots into a misleading state. No follow-up wanted.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-61-2-3"
    },
    {
      "category": "out-of-scope",
      "summary": "Resource isolation — port allocation, database provisioning, service lifecycle.",
      "reasoning": "Flagged as out of scope in the approved PREQ. This repo runs no services, so there is nothing for parallel containers to allocate or collide over; isolation here is about blast radius, not resources.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-61-2-4"
    },
    {
      "category": "out-of-scope",
      "summary": "Adopting the suite's parallel_dev slot machinery for this container layout.",
      "reasoning": "Flagged as out of scope in the approved PREQ. The slot recipe schema makes port_scheme and database mandatory, so a services-free project cannot declare a valid recipe at all — already filed as #62. This feature neither works around that gap nor adopts slots, and the follow-up already has a home.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-61-2-5"
    },
    {
      "category": "out-of-scope",
      "summary": "Hosts other than this Linux dev box, and container hosts other than the local Docker daemon.",
      "reasoning": "Flagged as out of scope in the approved PREQ. .devcontainer/ is repo-local infra and explicitly exempt from the stack-agnostic rule, so being specific to this host is the intended design, not a limitation to remove.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-61-2-6"
    },
    {
      "category": "out-of-scope",
      "summary": "Changes to shipped skill text under plugin/.",
      "reasoning": "Flagged as out of scope in the approved PREQ and reaffirmed at dispatch. Any suite-level gap this work exposes is filed as its own issue rather than fixed inside an infra feature.",
      "proposed_action": "accept",
      "target": null,
      "id": "F-PO-61-2-7"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "Extending forge branch protection to sibling feature/* refs — a container can still rewrite another run's feature branch.",
      "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). The security lens correctly observed that the operator's decision was aimed at 'cannot reach the other runs', and protecting only the integration branch does not cover sibling feature branches, which is exactly what another run depends on. Not actioned because a blanket feature/* force-push rule would collide with the rebase /dev:integrate performs on feature branches, so it needs a deliberate decision rather than a reflex. The isolation claim in the SREQ is narrowed to match reality in the meantime rather than left overstated. Warrants its own follow-up.",
      "proposed_action": "defer-to-issue",
      "id": "F-PO-61-2-8"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "Per-container Claude logins instead of one copied credential.",
      "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). It is the clean answer if OAuth refresh rotation turns out to be single-use, but the operator explicitly rejected per-container credentials at requirements time on setup-ceremony grounds. Rejected as a pre-emptive change on those grounds and made contingent instead: the rotation precondition on this Phase Outcome is what would re-open it. No separate follow-up needed while that gate exists.",
      "proposed_action": "accept",
      "id": "F-PO-61-2-9"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "Surface credential age (and workspace ahead/dirty state) in `dw.sh ls`.",
      "reasoning": "Reviewer suggestion acknowledged and absorbed rather than deferred — accepted into this slice's Implementation Scope as part of the `dw.sh ls` dashboard row, which the UX lens independently asked for as the operator's only cross-container view of work that is invisible until pushed. No follow-up ticket needed.",
      "proposed_action": "accept",
      "id": "F-PO-61-2-10"
    },
    {
      "category": "pre-existing",
      "summary": "The existing post-create.sh `tea login add` has never worked on this host: it reads TEA_TOKEN from a .env that does not exist, and the failure is swallowed by `|| true`.",
      "reasoning": "Discovered while verifying the tracker access path at plan time, and recorded because it is evidence rather than an open item: the container's forge authentication has been silently absent since .devcontainer/ was authored, which is a second reason (beyond the dangling plugin symlink) that a container session could never have completed a pipeline phase. Fixed within this slice — the plan switches the token source to the host's real tea config, copied in at create — so no follow-up is required; the record exists so the silent-failure pattern (`|| true` over a credential setup step) is visible to a later reader.",
      "proposed_action": "accept",
      "id": "F-PO-61-2-11"
    }
  ],
  "pending_decisions": [
    {
      "id": "D-PO-61-2-1",
      "type": "precondition",
      "blocking": true,
      "question": "How should the container lifecycle reach the Docker daemon — add the operator's user to the `docker` group, install rootless Docker, or run every `dw.sh` command under `sudo`?",
      "options": [
        "confirmed",
        "waived"
      ],
      "recommended": "confirmed",
      "reasoning": "Verified at plan time, not assumed: `jochem` is not a member of the `docker` group, there is no rootless socket at /run/user/1000/docker.sock, and podman is not installed — a plain `docker run` fails with 'permission denied while trying to connect to the docker API'. Nothing this feature builds can be exercised until this resolves, so it also blocks every acceptance criterion's verification (all 26 validation scenarios route to `.devcontainer/selftest.sh`, which needs a runnable container). The choice is not merely 'grant access': docker-group membership is effectively root-equivalent for that user, rootless Docker is the better isolation story but new infrastructure, and sudo changes file-ownership assumptions the design's uid-pinning decision depends on. The invocation mode must be decided, not just the access."
    },
    {
      "id": "D-PO-61-2-2",
      "type": "precondition",
      "blocking": true,
      "question": "Configure branch protection on the integration branch (`main`) at git.wihslon.com — no force-push, no deletion — and confirm the tea token copied into containers lacks repo-admin scope?",
      "options": [
        "confirmed",
        "waived"
      ],
      "recommended": "confirmed",
      "reasoning": "Verified ABSENT, not merely unconfirmed: `tea branches --repo jbr870/devwork-skills` reports main with PROTECTED=false. This is the mechanism the operator chose at requirements time to close the container's remote-write reach, so AC-7 fails as things stand and the isolation decision rests on configuration that does not exist. The security review added the second half: a repo-admin-scoped token lets a permission-relaxed session disable that protection through the API and then force-push, which would make the protection theatre. Both halves are forge-side configuration outside this repository and cannot be delivered by a code change."
    },
    {
      "id": "D-PO-61-2-3",
      "type": "precondition",
      "blocking": true,
      "question": "Confirm whether Claude Code's OAuth refresh rotates the refresh token single-use — and therefore whether one credential copied into N parallel containers is viable at all?",
      "options": [
        "confirmed",
        "waived"
      ],
      "recommended": "confirmed",
      "reasoning": "Raised by the security review and not answerable read-only. The plan copies one host credential into every container. If refresh rotates the refresh token single-use, the first container (or the host) to refresh invalidates every sibling's copy mid-run — which would break parallel unattended operation, the entire purpose of the feature. The verified credential shape (accessToken + refreshToken + expiresAt, rewritten in place) establishes that refreshes happen; it does not establish what they do to siblings. If rotation is single-use, the design must fall back to a per-container login — which the operator explicitly rejected on setup-ceremony grounds, so this is a decision to re-open rather than one the plan can make."
    },
    {
      "id": "D-PO-61-2-4",
      "type": "precondition",
      "blocking": true,
      "question": "Confirm git-over-SSH works from inside a container — the `forge-devwork` alias, key permissions under the container's uid, host-key verification with a seeded known_hosts, and egress to port 2222?",
      "options": [
        "confirmed",
        "waived"
      ],
      "recommended": "confirmed",
      "reasoning": "The in-container path is the one this feature depends on and it could not be exercised: the probe container could not start (blocked by the Docker access gap above). The host path working proves the credential exists; it does not prove a container can use it. The specific unknowns are key-file permissions as seen by uid 1000, host-key verification with no seeded known_hosts (the plan copies the host's matching entry and refuses to disable strict checking), and whether port 2222 is reachable from the container network. Push-only isolation means every route work takes out of a container goes through this path."
    },
    {
      "id": "D-PO-61-2-5",
      "type": "precondition",
      "blocking": true,
      "question": "Confirm the tea CLI authenticates against git.wihslon.com from inside a container using the copied host tea config?",
      "options": [
        "confirmed",
        "waived"
      ],
      "recommended": "confirmed",
      "reasoning": "Unverifiable here for the same reason as the SSH path, and the currently-wired route is demonstrably broken rather than merely unproven: the existing post-create.sh runs `tea login add` with TEA_TOKEN read from a `.env` file that does not exist in this repository (only `.env.example`, with an empty value), and the failure is swallowed by `|| true`. The plan switches the source to the host's real tea config at ~/.config/tea/config.yml, copied in at create — but that it works from inside a container remains unproven until the runtime access is resolved."
    },
    {
      "id": "D-PO-61-2-6",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Launching and supervising the autonomous run inside the container — no 'give it an issue number and walk away' entry point.'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-61-2-1",
      "reasoning": "Flagged as out of scope in the approved PREQ. The requirements-phase consistency lens noted the consequence and it still stands after design: the problem is framed entirely as unattended operation, but this feature delivers the sandbox, not proof that an unattended run completes inside it. That gap is real and warrants its own feature — the prompt, the permission mode and how a failed run is surfaced are run-orchestration decisions that would otherwise be settled by accident inside an infra ticket."
    },
    {
      "id": "D-PO-61-2-7",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: '/voice audio plumbing — the host audio socket mount, the audio packages and the ALSA-to-Pulse routing are deleted outright.'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-61-2-2",
      "reasoning": "Flagged as out of scope in the approved PREQ. An unattended run has nobody to talk to, and this is the most host-coupled part of the image. Deleted rather than shimmed, so there is nothing to follow up."
    },
    {
      "id": "D-PO-61-2-8",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'VS Code / editor integration — devcontainer.json, the extension list and any GUI attach flow are removed.'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-61-2-3",
      "reasoning": "Flagged as out of scope in the approved PREQ. The operator never uses VS Code and the lifecycle is terminal-first by requirement, so this would be configuration nothing exercises — which is what rots into a misleading state. No follow-up wanted."
    },
    {
      "id": "D-PO-61-2-9",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Resource isolation — port allocation, database provisioning, service lifecycle.'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-61-2-4",
      "reasoning": "Flagged as out of scope in the approved PREQ. This repo runs no services, so there is nothing for parallel containers to allocate or collide over; isolation here is about blast radius, not resources."
    },
    {
      "id": "D-PO-61-2-10",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Adopting the suite's parallel_dev slot machinery for this container layout.'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-61-2-5",
      "reasoning": "Flagged as out of scope in the approved PREQ. The slot recipe schema makes port_scheme and database mandatory, so a services-free project cannot declare a valid recipe at all — already filed as #62. This feature neither works around that gap nor adopts slots, and the follow-up already has a home."
    },
    {
      "id": "D-PO-61-2-11",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Hosts other than this Linux dev box, and container hosts other than the local Docker daemon.'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-61-2-6",
      "reasoning": "Flagged as out of scope in the approved PREQ. .devcontainer/ is repo-local infra and explicitly exempt from the stack-agnostic rule, so being specific to this host is the intended design, not a limitation to remove."
    },
    {
      "id": "D-PO-61-2-12",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Changes to shipped skill text under plugin/.'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-61-2-7",
      "reasoning": "Flagged as out of scope in the approved PREQ and reaffirmed at dispatch. Any suite-level gap this work exposes is filed as its own issue rather than fixed inside an infra feature."
    },
    {
      "id": "D-PO-61-2-13",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Expert-review note (not actioned): 'Extending forge branch protection to sibling feature/* refs — a container can still rewrite another run's feature branch.'. Spawn a sibling issue, or accept the disposition?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-61-2-8",
      "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). The security lens correctly observed that the operator's decision was aimed at 'cannot reach the other runs', and protecting only the integration branch does not cover sibling feature branches, which is exactly what another run depends on. Not actioned because a blanket feature/* force-push rule would collide with the rebase /dev:integrate performs on feature branches, so it needs a deliberate decision rather than a reflex. The isolation claim in the SREQ is narrowed to match reality in the meantime rather than left overstated. Warrants its own follow-up."
    },
    {
      "id": "D-PO-61-2-14",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Expert-review note (not actioned): 'Per-container Claude logins instead of one copied credential.'. Spawn a sibling issue, or accept the disposition?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-61-2-9",
      "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). It is the clean answer if OAuth refresh rotation turns out to be single-use, but the operator explicitly rejected per-container credentials at requirements time on setup-ceremony grounds. Rejected as a pre-emptive change on those grounds and made contingent instead: the rotation precondition on this Phase Outcome is what would re-open it. No separate follow-up needed while that gate exists."
    },
    {
      "id": "D-PO-61-2-15",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Expert-review note (not actioned): 'Surface credential age (and workspace ahead/dirty state) in `dw.sh ls`.'. Spawn a sibling issue, or accept the disposition?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-61-2-10",
      "reasoning": "Reviewer suggestion acknowledged and absorbed rather than deferred — accepted into this slice's Implementation Scope as part of the `dw.sh ls` dashboard row, which the UX lens independently asked for as the operator's only cross-container view of work that is invisible until pushed. No follow-up ticket needed."
    },
    {
      "id": "D-PO-61-2-16",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Expert-review note (not actioned): 'The existing post-create.sh `tea login add` has never worked on this host: it reads TEA_TOKEN from a .env that does not exist, and the failure is swallowed by `|| true`.'. Spawn a sibling issue, or accept the disposition?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-61-2-11",
      "reasoning": "Discovered while verifying the tracker access path at plan time, and recorded because it is evidence rather than an open item: the container's forge authentication has been silently absent since .devcontainer/ was authored, which is a second reason (beyond the dangling plugin symlink) that a container session could never have completed a pipeline phase. Fixed within this slice — the plan switches the token source to the host's real tea config, copied in at create — so no follow-up is required; the record exists so the silent-failure pattern (`|| true` over a credential setup step) is visible to a later reader."
    }
  ],
  "suite": {
    "source": "git",
    "sha": "cda2366a4ad8a98958f5aa4f35f5ba7c9622c2a8",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-61-2 skill=technical-plan --> ```json { "next_state": "developing", "produced": [ { "kind": "sreq", "ref": "comment:1360", "summary": "SREQ for devcontainer-plugin-era-refresh (Tier 3)" }, { "kind": "test-plan", "ref": "comment:1365", "summary": "Test plan for devcontainer-plugin-era-refresh — 26 scenarios, lane-annotated (24 integration-covered by .devcontainer/selftest.sh, 2 config-variant; e2e-browser not assignable — the project declares e2e not applicable)" } ], "findings": [ { "category": "out-of-scope", "summary": "Launching and supervising the autonomous run inside the container — no 'give it an issue number and walk away' entry point.", "reasoning": "Flagged as out of scope in the approved PREQ. The requirements-phase consistency lens noted the consequence and it still stands after design: the problem is framed entirely as unattended operation, but this feature delivers the sandbox, not proof that an unattended run completes inside it. That gap is real and warrants its own feature — the prompt, the permission mode and how a failed run is surfaced are run-orchestration decisions that would otherwise be settled by accident inside an infra ticket.", "proposed_action": "defer-to-issue", "target": null, "id": "F-PO-61-2-1" }, { "category": "out-of-scope", "summary": "/voice audio plumbing — the host audio socket mount, the audio packages and the ALSA-to-Pulse routing are deleted outright.", "reasoning": "Flagged as out of scope in the approved PREQ. An unattended run has nobody to talk to, and this is the most host-coupled part of the image. Deleted rather than shimmed, so there is nothing to follow up.", "proposed_action": "accept", "target": null, "id": "F-PO-61-2-2" }, { "category": "out-of-scope", "summary": "VS Code / editor integration — devcontainer.json, the extension list and any GUI attach flow are removed.", "reasoning": "Flagged as out of scope in the approved PREQ. The operator never uses VS Code and the lifecycle is terminal-first by requirement, so this would be configuration nothing exercises — which is what rots into a misleading state. No follow-up wanted.", "proposed_action": "accept", "target": null, "id": "F-PO-61-2-3" }, { "category": "out-of-scope", "summary": "Resource isolation — port allocation, database provisioning, service lifecycle.", "reasoning": "Flagged as out of scope in the approved PREQ. This repo runs no services, so there is nothing for parallel containers to allocate or collide over; isolation here is about blast radius, not resources.", "proposed_action": "accept", "target": null, "id": "F-PO-61-2-4" }, { "category": "out-of-scope", "summary": "Adopting the suite's parallel_dev slot machinery for this container layout.", "reasoning": "Flagged as out of scope in the approved PREQ. The slot recipe schema makes port_scheme and database mandatory, so a services-free project cannot declare a valid recipe at all — already filed as #62. This feature neither works around that gap nor adopts slots, and the follow-up already has a home.", "proposed_action": "accept", "target": null, "id": "F-PO-61-2-5" }, { "category": "out-of-scope", "summary": "Hosts other than this Linux dev box, and container hosts other than the local Docker daemon.", "reasoning": "Flagged as out of scope in the approved PREQ. .devcontainer/ is repo-local infra and explicitly exempt from the stack-agnostic rule, so being specific to this host is the intended design, not a limitation to remove.", "proposed_action": "accept", "target": null, "id": "F-PO-61-2-6" }, { "category": "out-of-scope", "summary": "Changes to shipped skill text under plugin/.", "reasoning": "Flagged as out of scope in the approved PREQ and reaffirmed at dispatch. Any suite-level gap this work exposes is filed as its own issue rather than fixed inside an infra feature.", "proposed_action": "accept", "target": null, "id": "F-PO-61-2-7" }, { "category": "in-scope-deferrable", "summary": "Extending forge branch protection to sibling feature/* refs — a container can still rewrite another run's feature branch.", "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). The security lens correctly observed that the operator's decision was aimed at 'cannot reach the other runs', and protecting only the integration branch does not cover sibling feature branches, which is exactly what another run depends on. Not actioned because a blanket feature/* force-push rule would collide with the rebase /dev:integrate performs on feature branches, so it needs a deliberate decision rather than a reflex. The isolation claim in the SREQ is narrowed to match reality in the meantime rather than left overstated. Warrants its own follow-up.", "proposed_action": "defer-to-issue", "id": "F-PO-61-2-8" }, { "category": "in-scope-deferrable", "summary": "Per-container Claude logins instead of one copied credential.", "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). It is the clean answer if OAuth refresh rotation turns out to be single-use, but the operator explicitly rejected per-container credentials at requirements time on setup-ceremony grounds. Rejected as a pre-emptive change on those grounds and made contingent instead: the rotation precondition on this Phase Outcome is what would re-open it. No separate follow-up needed while that gate exists.", "proposed_action": "accept", "id": "F-PO-61-2-9" }, { "category": "in-scope-deferrable", "summary": "Surface credential age (and workspace ahead/dirty state) in `dw.sh ls`.", "reasoning": "Reviewer suggestion acknowledged and absorbed rather than deferred — accepted into this slice's Implementation Scope as part of the `dw.sh ls` dashboard row, which the UX lens independently asked for as the operator's only cross-container view of work that is invisible until pushed. No follow-up ticket needed.", "proposed_action": "accept", "id": "F-PO-61-2-10" }, { "category": "pre-existing", "summary": "The existing post-create.sh `tea login add` has never worked on this host: it reads TEA_TOKEN from a .env that does not exist, and the failure is swallowed by `|| true`.", "reasoning": "Discovered while verifying the tracker access path at plan time, and recorded because it is evidence rather than an open item: the container's forge authentication has been silently absent since .devcontainer/ was authored, which is a second reason (beyond the dangling plugin symlink) that a container session could never have completed a pipeline phase. Fixed within this slice — the plan switches the token source to the host's real tea config, copied in at create — so no follow-up is required; the record exists so the silent-failure pattern (`|| true` over a credential setup step) is visible to a later reader.", "proposed_action": "accept", "id": "F-PO-61-2-11" } ], "pending_decisions": [ { "id": "D-PO-61-2-1", "type": "precondition", "blocking": true, "question": "How should the container lifecycle reach the Docker daemon — add the operator's user to the `docker` group, install rootless Docker, or run every `dw.sh` command under `sudo`?", "options": [ "confirmed", "waived" ], "recommended": "confirmed", "reasoning": "Verified at plan time, not assumed: `jochem` is not a member of the `docker` group, there is no rootless socket at /run/user/1000/docker.sock, and podman is not installed — a plain `docker run` fails with 'permission denied while trying to connect to the docker API'. Nothing this feature builds can be exercised until this resolves, so it also blocks every acceptance criterion's verification (all 26 validation scenarios route to `.devcontainer/selftest.sh`, which needs a runnable container). The choice is not merely 'grant access': docker-group membership is effectively root-equivalent for that user, rootless Docker is the better isolation story but new infrastructure, and sudo changes file-ownership assumptions the design's uid-pinning decision depends on. The invocation mode must be decided, not just the access." }, { "id": "D-PO-61-2-2", "type": "precondition", "blocking": true, "question": "Configure branch protection on the integration branch (`main`) at git.wihslon.com — no force-push, no deletion — and confirm the tea token copied into containers lacks repo-admin scope?", "options": [ "confirmed", "waived" ], "recommended": "confirmed", "reasoning": "Verified ABSENT, not merely unconfirmed: `tea branches --repo jbr870/devwork-skills` reports main with PROTECTED=false. This is the mechanism the operator chose at requirements time to close the container's remote-write reach, so AC-7 fails as things stand and the isolation decision rests on configuration that does not exist. The security review added the second half: a repo-admin-scoped token lets a permission-relaxed session disable that protection through the API and then force-push, which would make the protection theatre. Both halves are forge-side configuration outside this repository and cannot be delivered by a code change." }, { "id": "D-PO-61-2-3", "type": "precondition", "blocking": true, "question": "Confirm whether Claude Code's OAuth refresh rotates the refresh token single-use — and therefore whether one credential copied into N parallel containers is viable at all?", "options": [ "confirmed", "waived" ], "recommended": "confirmed", "reasoning": "Raised by the security review and not answerable read-only. The plan copies one host credential into every container. If refresh rotates the refresh token single-use, the first container (or the host) to refresh invalidates every sibling's copy mid-run — which would break parallel unattended operation, the entire purpose of the feature. The verified credential shape (accessToken + refreshToken + expiresAt, rewritten in place) establishes that refreshes happen; it does not establish what they do to siblings. If rotation is single-use, the design must fall back to a per-container login — which the operator explicitly rejected on setup-ceremony grounds, so this is a decision to re-open rather than one the plan can make." }, { "id": "D-PO-61-2-4", "type": "precondition", "blocking": true, "question": "Confirm git-over-SSH works from inside a container — the `forge-devwork` alias, key permissions under the container's uid, host-key verification with a seeded known_hosts, and egress to port 2222?", "options": [ "confirmed", "waived" ], "recommended": "confirmed", "reasoning": "The in-container path is the one this feature depends on and it could not be exercised: the probe container could not start (blocked by the Docker access gap above). The host path working proves the credential exists; it does not prove a container can use it. The specific unknowns are key-file permissions as seen by uid 1000, host-key verification with no seeded known_hosts (the plan copies the host's matching entry and refuses to disable strict checking), and whether port 2222 is reachable from the container network. Push-only isolation means every route work takes out of a container goes through this path." }, { "id": "D-PO-61-2-5", "type": "precondition", "blocking": true, "question": "Confirm the tea CLI authenticates against git.wihslon.com from inside a container using the copied host tea config?", "options": [ "confirmed", "waived" ], "recommended": "confirmed", "reasoning": "Unverifiable here for the same reason as the SSH path, and the currently-wired route is demonstrably broken rather than merely unproven: the existing post-create.sh runs `tea login add` with TEA_TOKEN read from a `.env` file that does not exist in this repository (only `.env.example`, with an empty value), and the failure is swallowed by `|| true`. The plan switches the source to the host's real tea config at ~/.config/tea/config.yml, copied in at create — but that it works from inside a container remains unproven until the runtime access is resolved." }, { "id": "D-PO-61-2-6", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Launching and supervising the autonomous run inside the container — no 'give it an issue number and walk away' entry point.'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-61-2-1", "reasoning": "Flagged as out of scope in the approved PREQ. The requirements-phase consistency lens noted the consequence and it still stands after design: the problem is framed entirely as unattended operation, but this feature delivers the sandbox, not proof that an unattended run completes inside it. That gap is real and warrants its own feature — the prompt, the permission mode and how a failed run is surfaced are run-orchestration decisions that would otherwise be settled by accident inside an infra ticket." }, { "id": "D-PO-61-2-7", "type": "scope-disposition", "blocking": false, "question": "Out of scope: '/voice audio plumbing — the host audio socket mount, the audio packages and the ALSA-to-Pulse routing are deleted outright.'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-61-2-2", "reasoning": "Flagged as out of scope in the approved PREQ. An unattended run has nobody to talk to, and this is the most host-coupled part of the image. Deleted rather than shimmed, so there is nothing to follow up." }, { "id": "D-PO-61-2-8", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'VS Code / editor integration — devcontainer.json, the extension list and any GUI attach flow are removed.'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-61-2-3", "reasoning": "Flagged as out of scope in the approved PREQ. The operator never uses VS Code and the lifecycle is terminal-first by requirement, so this would be configuration nothing exercises — which is what rots into a misleading state. No follow-up wanted." }, { "id": "D-PO-61-2-9", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Resource isolation — port allocation, database provisioning, service lifecycle.'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-61-2-4", "reasoning": "Flagged as out of scope in the approved PREQ. This repo runs no services, so there is nothing for parallel containers to allocate or collide over; isolation here is about blast radius, not resources." }, { "id": "D-PO-61-2-10", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Adopting the suite's parallel_dev slot machinery for this container layout.'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-61-2-5", "reasoning": "Flagged as out of scope in the approved PREQ. The slot recipe schema makes port_scheme and database mandatory, so a services-free project cannot declare a valid recipe at all — already filed as #62. This feature neither works around that gap nor adopts slots, and the follow-up already has a home." }, { "id": "D-PO-61-2-11", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Hosts other than this Linux dev box, and container hosts other than the local Docker daemon.'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-61-2-6", "reasoning": "Flagged as out of scope in the approved PREQ. .devcontainer/ is repo-local infra and explicitly exempt from the stack-agnostic rule, so being specific to this host is the intended design, not a limitation to remove." }, { "id": "D-PO-61-2-12", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Changes to shipped skill text under plugin/.'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-61-2-7", "reasoning": "Flagged as out of scope in the approved PREQ and reaffirmed at dispatch. Any suite-level gap this work exposes is filed as its own issue rather than fixed inside an infra feature." }, { "id": "D-PO-61-2-13", "type": "scope-disposition", "blocking": false, "question": "Expert-review note (not actioned): 'Extending forge branch protection to sibling feature/* refs — a container can still rewrite another run's feature branch.'. Spawn a sibling issue, or accept the disposition?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-61-2-8", "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). The security lens correctly observed that the operator's decision was aimed at 'cannot reach the other runs', and protecting only the integration branch does not cover sibling feature branches, which is exactly what another run depends on. Not actioned because a blanket feature/* force-push rule would collide with the rebase /dev:integrate performs on feature branches, so it needs a deliberate decision rather than a reflex. The isolation claim in the SREQ is narrowed to match reality in the meantime rather than left overstated. Warrants its own follow-up." }, { "id": "D-PO-61-2-14", "type": "scope-disposition", "blocking": false, "question": "Expert-review note (not actioned): 'Per-container Claude logins instead of one copied credential.'. Spawn a sibling issue, or accept the disposition?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-61-2-9", "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). It is the clean answer if OAuth refresh rotation turns out to be single-use, but the operator explicitly rejected per-container credentials at requirements time on setup-ceremony grounds. Rejected as a pre-emptive change on those grounds and made contingent instead: the rotation precondition on this Phase Outcome is what would re-open it. No separate follow-up needed while that gate exists." }, { "id": "D-PO-61-2-15", "type": "scope-disposition", "blocking": false, "question": "Expert-review note (not actioned): 'Surface credential age (and workspace ahead/dirty state) in `dw.sh ls`.'. Spawn a sibling issue, or accept the disposition?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-61-2-10", "reasoning": "Reviewer suggestion acknowledged and absorbed rather than deferred — accepted into this slice's Implementation Scope as part of the `dw.sh ls` dashboard row, which the UX lens independently asked for as the operator's only cross-container view of work that is invisible until pushed. No follow-up ticket needed." }, { "id": "D-PO-61-2-16", "type": "scope-disposition", "blocking": false, "question": "Expert-review note (not actioned): 'The existing post-create.sh `tea login add` has never worked on this host: it reads TEA_TOKEN from a .env that does not exist, and the failure is swallowed by `|| true`.'. Spawn a sibling issue, or accept the disposition?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-61-2-11", "reasoning": "Discovered while verifying the tracker access path at plan time, and recorded because it is evidence rather than an open item: the container's forge authentication has been silently absent since .devcontainer/ was authored, which is a second reason (beyond the dangling plugin symlink) that a container session could never have completed a pipeline phase. Fixed within this slice — the plan switches the token source to the host's real tea config, copied in at create — so no follow-up is required; the record exists so the silent-failure pattern (`|| true` over a credential setup step) is visible to a later reader." } ], "suite": { "source": "git", "sha": "cda2366a4ad8a98958f5aa4f35f5ba7c9622c2a8", "dirty": false } } ```
Author
Owner
{
  "status": "waived",
  "chosen_option": "waived",
  "rationale": "Proceeding without verifying whether Claude Code's OAuth refresh rotates the refresh token single-use. Grounded in the credential shape actually captured at plan time (external-contracts/claude-credential.provenance.json: accessToken + refreshToken + expiresAt, rewritten in place): that establishes refreshes happen, but says nothing about their effect on sibling copies, and nothing readable on this host can settle it — only a real refresh can. Waived rather than blocking because the failure is loud, not silent (a sibling session's credential simply stops working), recovery is already a single command in the design (dw.sh refresh-creds re-copies from the host), and the first genuine two-container run answers the question definitively at zero extra cost. The risk is also not yet live: it cannot bite until two containers actually run at once, which cannot happen until D-PO-61-2-1 (Docker access) is resolved anyway. Authority: the operator reviewed the five blocking preconditions on 2026-08-25, was given this recommendation with its tripwire, and directed resolution as recommended. TRIPWIRE: revisit BEFORE relying on unattended parallel runs. If rotation proves single-use, the copied-credential design is not viable at N>1 and per-container logins - rejected at requirements time on setup-ceremony grounds - come back on the table.",
  "rejected_alternative": "Confirming it empirically instead, by running two sessions from copies of one credential and forcing or awaiting a refresh to see whether the sibling dies. Turned down on cost-of-timing rather than merit: it needs a real refresh window to elapse, so it would stall the build on an experiment that the first ordinary parallel run performs for free - and, because Docker access (D-PO-61-2-1) is itself unresolved, the experiment cannot even be staged yet. The other alternative in play, pre-emptively switching to per-container logins, was rejected because it overrides an explicit operator decision to avoid a scenario that may not exist."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-3 --> ```json { "status": "waived", "chosen_option": "waived", "rationale": "Proceeding without verifying whether Claude Code's OAuth refresh rotates the refresh token single-use. Grounded in the credential shape actually captured at plan time (external-contracts/claude-credential.provenance.json: accessToken + refreshToken + expiresAt, rewritten in place): that establishes refreshes happen, but says nothing about their effect on sibling copies, and nothing readable on this host can settle it — only a real refresh can. Waived rather than blocking because the failure is loud, not silent (a sibling session's credential simply stops working), recovery is already a single command in the design (dw.sh refresh-creds re-copies from the host), and the first genuine two-container run answers the question definitively at zero extra cost. The risk is also not yet live: it cannot bite until two containers actually run at once, which cannot happen until D-PO-61-2-1 (Docker access) is resolved anyway. Authority: the operator reviewed the five blocking preconditions on 2026-08-25, was given this recommendation with its tripwire, and directed resolution as recommended. TRIPWIRE: revisit BEFORE relying on unattended parallel runs. If rotation proves single-use, the copied-credential design is not viable at N>1 and per-container logins - rejected at requirements time on setup-ceremony grounds - come back on the table.", "rejected_alternative": "Confirming it empirically instead, by running two sessions from copies of one credential and forcing or awaiting a refresh to see whether the sibling dies. Turned down on cost-of-timing rather than merit: it needs a real refresh window to elapse, so it would stall the build on an experiment that the first ordinary parallel run performs for free - and, because Docker access (D-PO-61-2-1) is itself unresolved, the experiment cannot even be staged yet. The other alternative in play, pre-emptively switching to per-container logins, was rejected because it overrides an explicit operator decision to avoid a scenario that may not exist." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "waived",
  "rationale": "Proceeding without verifying whether Claude Code's OAuth refresh rotates the refresh token single-use. Grounded in the credential shape actually captured at plan time (external-contracts/claude-credential.provenance.json: accessToken + refreshToken + expiresAt, rewritten in place): that establishes refreshes happen, but says nothing about their effect on sibling copies, and nothing readable on this host can settle it - only a real refresh can. Waived rather than blocking because the failure is loud, not silent (a sibling session's credential simply stops working), recovery is already a single command in the design (dw.sh refresh-creds re-copies from the host), and the first genuine two-container run answers the question definitively at zero extra cost. The risk is also not yet live: it cannot bite until two containers actually run at once, which cannot happen until D-PO-61-2-1 (Docker access) is resolved anyway. Authority: the operator reviewed the five blocking preconditions on 2026-08-25, was given this recommendation together with its tripwire, and directed resolution as recommended. TRIPWIRE: revisit BEFORE relying on unattended parallel runs. If rotation proves single-use, the copied-credential design is not viable at N>1 and per-container logins - rejected at requirements time on setup-ceremony grounds - come back on the table. (Supersedes comment 1370, which carried an invalid status value of 'waived'; status is a three-value enum and the waiver belongs in chosen_option. Content of the decision is unchanged.)",
  "rejected_alternative": "Confirming it empirically instead, by running two sessions from copies of one credential and forcing or awaiting a refresh to see whether the sibling dies. Turned down on timing rather than merit: it needs a real refresh window to elapse, so it would stall the build on an experiment the first ordinary parallel run performs for free - and because Docker access (D-PO-61-2-1) is itself unresolved, the experiment cannot even be staged yet. The other alternative in play, pre-emptively switching to per-container logins, was rejected because it overrides an explicit operator decision in order to avoid a problem that may not exist."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-3 --> ```json { "status": "resolved", "chosen_option": "waived", "rationale": "Proceeding without verifying whether Claude Code's OAuth refresh rotates the refresh token single-use. Grounded in the credential shape actually captured at plan time (external-contracts/claude-credential.provenance.json: accessToken + refreshToken + expiresAt, rewritten in place): that establishes refreshes happen, but says nothing about their effect on sibling copies, and nothing readable on this host can settle it - only a real refresh can. Waived rather than blocking because the failure is loud, not silent (a sibling session's credential simply stops working), recovery is already a single command in the design (dw.sh refresh-creds re-copies from the host), and the first genuine two-container run answers the question definitively at zero extra cost. The risk is also not yet live: it cannot bite until two containers actually run at once, which cannot happen until D-PO-61-2-1 (Docker access) is resolved anyway. Authority: the operator reviewed the five blocking preconditions on 2026-08-25, was given this recommendation together with its tripwire, and directed resolution as recommended. TRIPWIRE: revisit BEFORE relying on unattended parallel runs. If rotation proves single-use, the copied-credential design is not viable at N>1 and per-container logins - rejected at requirements time on setup-ceremony grounds - come back on the table. (Supersedes comment 1370, which carried an invalid status value of 'waived'; status is a three-value enum and the waiver belongs in chosen_option. Content of the decision is unchanged.)", "rejected_alternative": "Confirming it empirically instead, by running two sessions from copies of one credential and forcing or awaiting a refresh to see whether the sibling dies. Turned down on timing rather than merit: it needs a real refresh window to elapse, so it would stall the build on an experiment the first ordinary parallel run performs for free - and because Docker access (D-PO-61-2-1) is itself unresolved, the experiment cannot even be staged yet. The other alternative in play, pre-emptively switching to per-container logins, was rejected because it overrides an explicit operator decision in order to avoid a problem that may not exist." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "confirmed",
  "rationale": "Container runtime access is now verified by doing, not asserted. Root cause of the original gap was misdiagnosed at first and is worth recording: the operator's initial 'usermod -aG docker jochem' was run on a DIFFERENT MACHINE (Jochems-Forge) from the one this feature targets (jochems-server, which holds the repo, the worktree, the executing plugin and this session). The tell was that 'getent group docker' returned 'docker:x:973:jochem' in the operator's shell and 'docker:x:983:' here - a different GID, not a stale copy of the same group, which rules out a sandbox artifact and means two hosts. Re-running usermod on jochems-server gives 'docker:x:983:jochem', and 'sg docker -c docker version' now returns server 29.7.2 passwordlessly. INVOCATION MODE decided, which the precondition explicitly asked for and not merely 'access exists': docker-group membership on jochems-server, NOT sudo and NOT rootless. Rationale - the operator already holds sudo on this single-user box, so group membership grants no privilege they lacked; sudo-per-command was rejected because an unattended run cannot answer a password prompt, which would defeat the feature's purpose; rootless Docker is the stronger isolation story and would be the choice on a shared host, but its main benefit (a container escape lands as uid 1000 rather than root) is second-order here because the design never mounts the Docker socket into the container. Note for the build: a process acquires supplementary groups only at start, so THIS session must still wrap docker calls in 'sg docker -c'; dw.sh gets the group ambiently after the operator's next fresh login. Authority: operator performed the change and directed resolution as recommended, 2026-08-25.",
  "rejected_alternative": "Rootless Docker, and sudo-per-command. Rootless was turned down on cost-versus-benefit rather than merit - it is new infrastructure to install and maintain, and the escape-containment benefit it adds is redundant with the design's own boundary (no socket is ever mounted into the container). Sudo-per-command was turned down on a hard functional ground rather than taste: an unattended container start cannot satisfy a password prompt, so it would break the single use case the feature exists for, and a NOPASSWD rule to work around that would grant broader standing privilege than the group does."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-1 --> ```json { "status": "resolved", "chosen_option": "confirmed", "rationale": "Container runtime access is now verified by doing, not asserted. Root cause of the original gap was misdiagnosed at first and is worth recording: the operator's initial 'usermod -aG docker jochem' was run on a DIFFERENT MACHINE (Jochems-Forge) from the one this feature targets (jochems-server, which holds the repo, the worktree, the executing plugin and this session). The tell was that 'getent group docker' returned 'docker:x:973:jochem' in the operator's shell and 'docker:x:983:' here - a different GID, not a stale copy of the same group, which rules out a sandbox artifact and means two hosts. Re-running usermod on jochems-server gives 'docker:x:983:jochem', and 'sg docker -c docker version' now returns server 29.7.2 passwordlessly. INVOCATION MODE decided, which the precondition explicitly asked for and not merely 'access exists': docker-group membership on jochems-server, NOT sudo and NOT rootless. Rationale - the operator already holds sudo on this single-user box, so group membership grants no privilege they lacked; sudo-per-command was rejected because an unattended run cannot answer a password prompt, which would defeat the feature's purpose; rootless Docker is the stronger isolation story and would be the choice on a shared host, but its main benefit (a container escape lands as uid 1000 rather than root) is second-order here because the design never mounts the Docker socket into the container. Note for the build: a process acquires supplementary groups only at start, so THIS session must still wrap docker calls in 'sg docker -c'; dw.sh gets the group ambiently after the operator's next fresh login. Authority: operator performed the change and directed resolution as recommended, 2026-08-25.", "rejected_alternative": "Rootless Docker, and sudo-per-command. Rootless was turned down on cost-versus-benefit rather than merit - it is new infrastructure to install and maintain, and the escape-containment benefit it adds is redundant with the design's own boundary (no socket is ever mounted into the container). Sudo-per-command was turned down on a hard functional ground rather than taste: an unattended container start cannot satisfy a password prompt, so it would break the single use case the feature exists for, and a NOPASSWD rule to work around that would grant broader standing privilege than the group does." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "confirmed",
  "rationale": "Both halves of this gate are now configured and verified on the forge. Branch protection: 'tea branches --repo jbr870/devwork-skills' previously reported main with PROTECTED=false - the mechanism chosen at requirements time to close the container's remote-write reach simply did not exist, so AC-7 could not have passed. It now reports main PROTECTED=true with USER-CAN-PUSH=true. Both halves of that reading matter: protection blocks force-push and deletion (what the isolation decision was actually for), while push stays enabled so /dev:integrate's fast-forward merge to main still works - this project declares uat.open_pr false and merges directly, so a 'require pull request' configuration would have broken its own pipeline. The operator enabled push WITHOUT a whitelist, which is the better shape here: an empty whitelist with push enabled permits normal pushes for anyone holding write access while still refusing history rewrites, whereas a whitelist would be narrower than a single-user repo needs and would add ongoing maintenance. Token scope: a separate least-privilege token (issue/comment write, read repository, no repo-admin) is to be the one copied into containers, closing the API-bypass the security review identified - an admin-scoped token would let a permission-relaxed session disable the protection through the API and then force-push, making the protection decorative. RESIDUAL, recorded rather than hidden: with push enabled, a container can still APPEND commits to main; it cannot rewrite or delete history. That is consistent with the decision actually taken (force-push and deletion were the named threats) but it is not the same as 'a container cannot touch main'. Sibling feature/* refs remain rewritable and are carried separately as finding F-PO-61-2-8. Authority: operator configured the forge and directed resolution as recommended, 2026-08-25.",
  "rejected_alternative": "Requiring pull requests on main, and using a push whitelist. Require-PR was rejected because /dev:integrate fast-forward-merges directly to main on this project (uat.open_pr: false), so it would have broken the pipeline it was meant to protect - and the operator's first configuration attempt did exactly that, leaving USER-CAN-PUSH=false until corrected. A push whitelist was considered and dropped as narrower than a single-user repo requires, with no security gain over blocking force-push and deletion, at the cost of a list to keep current."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-2 --> ```json { "status": "resolved", "chosen_option": "confirmed", "rationale": "Both halves of this gate are now configured and verified on the forge. Branch protection: 'tea branches --repo jbr870/devwork-skills' previously reported main with PROTECTED=false - the mechanism chosen at requirements time to close the container's remote-write reach simply did not exist, so AC-7 could not have passed. It now reports main PROTECTED=true with USER-CAN-PUSH=true. Both halves of that reading matter: protection blocks force-push and deletion (what the isolation decision was actually for), while push stays enabled so /dev:integrate's fast-forward merge to main still works - this project declares uat.open_pr false and merges directly, so a 'require pull request' configuration would have broken its own pipeline. The operator enabled push WITHOUT a whitelist, which is the better shape here: an empty whitelist with push enabled permits normal pushes for anyone holding write access while still refusing history rewrites, whereas a whitelist would be narrower than a single-user repo needs and would add ongoing maintenance. Token scope: a separate least-privilege token (issue/comment write, read repository, no repo-admin) is to be the one copied into containers, closing the API-bypass the security review identified - an admin-scoped token would let a permission-relaxed session disable the protection through the API and then force-push, making the protection decorative. RESIDUAL, recorded rather than hidden: with push enabled, a container can still APPEND commits to main; it cannot rewrite or delete history. That is consistent with the decision actually taken (force-push and deletion were the named threats) but it is not the same as 'a container cannot touch main'. Sibling feature/* refs remain rewritable and are carried separately as finding F-PO-61-2-8. Authority: operator configured the forge and directed resolution as recommended, 2026-08-25.", "rejected_alternative": "Requiring pull requests on main, and using a push whitelist. Require-PR was rejected because /dev:integrate fast-forward-merges directly to main on this project (uat.open_pr: false), so it would have broken the pipeline it was meant to protect - and the operator's first configuration attempt did exactly that, leaving USER-CAN-PUSH=false until corrected. A push whitelist was considered and dropped as narrower than a single-user repo requires, with no security gain over blocking force-push and deletion, at the cost of a list to keep current." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "confirmed",
  "rationale": "Verified by executing the real operation, not by reasoning about it. A throwaway container (uid 1000, the host's SSH material mounted read-only, no other credentials) ran 'git ls-remote ssh://git@forge-devwork/jbr870/devwork-skills.git HEAD' and returned '2ceacfe8490eb7efb2258c1d62981eea06acb97b HEAD' at exit 0. That single call settles every unknown the precondition named, each of which could independently have broken push-only isolation: the forge-devwork host alias resolved from inside the container (so the SSH client configuration is being read, not just the key); the private key was readable and usable under the container's uid; host-key verification SUCCEEDED against git.wihslon.com without strict checking being disabled; and egress to port 2222 works from the container network. This is the path every route work takes out of a container depends on, so proving it was worth more than assuming it. SCOPE LIMIT, stated so the record is not read as broader than the evidence: the probe mounted the whole of ~/.ssh, whereas the SREQ narrows this to copying only the forge-devwork stanza, the key it names and the matching known_hosts entry. The risky properties above are proven; that the narrowed subset is sufficient is a build-time detail which AC-6 and selftest.sh assert. Authority: probe run by this session after D-PO-61-2-1 cleared, 2026-08-25.",
  "rejected_alternative": "Waiving it and letting /dev:develop discover any breakage. Turned down because the probe cost roughly two minutes once Docker access existed, and because a failure here would not surface as a clean error during the build - it would surface as an unattended run that cannot publish its work, which is the most expensive place to find it."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-4 --> ```json { "status": "resolved", "chosen_option": "confirmed", "rationale": "Verified by executing the real operation, not by reasoning about it. A throwaway container (uid 1000, the host's SSH material mounted read-only, no other credentials) ran 'git ls-remote ssh://git@forge-devwork/jbr870/devwork-skills.git HEAD' and returned '2ceacfe8490eb7efb2258c1d62981eea06acb97b HEAD' at exit 0. That single call settles every unknown the precondition named, each of which could independently have broken push-only isolation: the forge-devwork host alias resolved from inside the container (so the SSH client configuration is being read, not just the key); the private key was readable and usable under the container's uid; host-key verification SUCCEEDED against git.wihslon.com without strict checking being disabled; and egress to port 2222 works from the container network. This is the path every route work takes out of a container depends on, so proving it was worth more than assuming it. SCOPE LIMIT, stated so the record is not read as broader than the evidence: the probe mounted the whole of ~/.ssh, whereas the SREQ narrows this to copying only the forge-devwork stanza, the key it names and the matching known_hosts entry. The risky properties above are proven; that the narrowed subset is sufficient is a build-time detail which AC-6 and selftest.sh assert. Authority: probe run by this session after D-PO-61-2-1 cleared, 2026-08-25.", "rejected_alternative": "Waiving it and letting /dev:develop discover any breakage. Turned down because the probe cost roughly two minutes once Docker access existed, and because a failure here would not surface as a clean error during the build - it would surface as an unattended run that cannot publish its work, which is the most expensive place to find it." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "confirmed",
  "rationale": "Verified by executing the real operation. A throwaway container with the host's tea binary and tea configuration mounted read-only ran 'tea login list' and then read issue #61 from the tracker: the login for the declared instance (wihslon / git.wihslon.com / jbr870) was present and usable, and the issue body came back over the network, at exit 0 with no interactive prompt. This confirms the SREQ's decision to change the token source. The route the existing container wiring used has never worked on this host and was not merely unproven: post-create.sh runs 'tea login add' with TEA_TOKEN read from a .env file that does not exist in this repository (only .env.example, with an empty value), and the failure is swallowed by '|| true'. So a container session has had no forge authentication at all since .devcontainer/ was authored - a second, independent reason (beyond the dangling plugin symlink) that it could never have completed a pipeline phase. Recorded as finding F-PO-61-2-11 and fixed within this slice. NOTE for the build: the probe validated the host tea config as the token source. Per D-PO-61-2-2 the token copied into containers should be the new least-privilege one rather than the operator's admin-capable host token, so the container's tea configuration must carry that token, not a verbatim copy of the host's. Authority: probe run by this session after D-PO-61-2-1 cleared, 2026-08-25.",
  "rejected_alternative": "Keeping the TEA_TOKEN/.env injection route and simply documenting that a .env must be created. Rejected because it preserves a design whose failure mode is silent - the existing '|| true' swallowed a credential-setup failure for the entire life of the current .devcontainer/, which is precisely how the breakage went unnoticed. Reading the token source that actually exists on the host removes the setup step rather than documenting it."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-5 --> ```json { "status": "resolved", "chosen_option": "confirmed", "rationale": "Verified by executing the real operation. A throwaway container with the host's tea binary and tea configuration mounted read-only ran 'tea login list' and then read issue #61 from the tracker: the login for the declared instance (wihslon / git.wihslon.com / jbr870) was present and usable, and the issue body came back over the network, at exit 0 with no interactive prompt. This confirms the SREQ's decision to change the token source. The route the existing container wiring used has never worked on this host and was not merely unproven: post-create.sh runs 'tea login add' with TEA_TOKEN read from a .env file that does not exist in this repository (only .env.example, with an empty value), and the failure is swallowed by '|| true'. So a container session has had no forge authentication at all since .devcontainer/ was authored - a second, independent reason (beyond the dangling plugin symlink) that it could never have completed a pipeline phase. Recorded as finding F-PO-61-2-11 and fixed within this slice. NOTE for the build: the probe validated the host tea config as the token source. Per D-PO-61-2-2 the token copied into containers should be the new least-privilege one rather than the operator's admin-capable host token, so the container's tea configuration must carry that token, not a verbatim copy of the host's. Authority: probe run by this session after D-PO-61-2-1 cleared, 2026-08-25.", "rejected_alternative": "Keeping the TEA_TOKEN/.env injection route and simply documenting that a .env must be created. Rejected because it preserves a design whose failure mode is silent - the existing '|| true' swallowed a credential-setup failure for the entire life of the current .devcontainer/, which is precisely how the breakage went unnoticed. Reading the token source that actually exists on the host removes the setup step rather than documenting it." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted out of scope with NO follow-up issue, overriding this phase's defer-to-issue recommendation. The operator states the autonomous-run launcher will be covered by a different route than a sibling issue on this tracker. The gap itself is not disputed and is recorded on finding F-PO-61-2-1: #61 delivers the sandbox but not the thing that drives it unattended, so nothing in this feature demonstrates a full run completing inside a container - which is what the container was built for. What accept changes is only the bookkeeping: no sibling issue is spawned, so this finding plus this resolution on #61 are the entire durable trail. Flagged to the operator before posting so the choice was made knowing the trail ends here rather than in a backlog item. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding.",
  "rejected_alternative": "Spawning a sibling issue, which was this phase's recommendation and would have given the launcher work a tracked home with its own PREQ. Turned down by the operator in favour of covering it another way; not turned down on the merits of the gap, which stands as recorded."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-6 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted out of scope with NO follow-up issue, overriding this phase's defer-to-issue recommendation. The operator states the autonomous-run launcher will be covered by a different route than a sibling issue on this tracker. The gap itself is not disputed and is recorded on finding F-PO-61-2-1: #61 delivers the sandbox but not the thing that drives it unattended, so nothing in this feature demonstrates a full run completing inside a container - which is what the container was built for. What accept changes is only the bookkeeping: no sibling issue is spawned, so this finding plus this resolution on #61 are the entire durable trail. Flagged to the operator before posting so the choice was made knowing the trail ends here rather than in a backlog item. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding.", "rejected_alternative": "Spawning a sibling issue, which was this phase's recommendation and would have given the launcher work a tracked home with its own PREQ. Turned down by the operator in favour of covering it another way; not turned down on the merits of the gap, which stands as recorded." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted with NO follow-up issue, overriding this phase's defer-to-issue recommendation. The operator will cover the sibling-branch question by another route. The exposure is real and stays on the record in finding F-PO-61-2-8 and in the SREQ's Technical Risks table: main is now protected against force-push and deletion, but every feature/* ref is unprotected, and each container holds the host's SSH keys - so one container can force-push over another container's branch and destroy its work. That is precisely the 'a mistake cannot reach the other runs' promise, and it is not yet fully met. It was not fixed inside this slice because the obvious fix (a blanket feature/* force-push rule) would collide with the rebase-then-force-push that /dev:integrate performs, so it needs a deliberate choice - protect selectively, change how integrate pushes, or accept the exposure - rather than a reflex. Current risk is low (single operator; a container only pushes its own branch) and rises when several unattended containers run concurrently, which is the feature's destination. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding.",
  "rejected_alternative": "Spawning a sibling issue to decide between selective protection, changing integrate's push strategy, and accepting the exposure. Turned down by the operator in favour of covering it another way. The narrower alternative of protecting feature/* immediately was already rejected at plan time because it would break /dev:integrate."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-13 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted with NO follow-up issue, overriding this phase's defer-to-issue recommendation. The operator will cover the sibling-branch question by another route. The exposure is real and stays on the record in finding F-PO-61-2-8 and in the SREQ's Technical Risks table: main is now protected against force-push and deletion, but every feature/* ref is unprotected, and each container holds the host's SSH keys - so one container can force-push over another container's branch and destroy its work. That is precisely the 'a mistake cannot reach the other runs' promise, and it is not yet fully met. It was not fixed inside this slice because the obvious fix (a blanket feature/* force-push rule) would collide with the rebase-then-force-push that /dev:integrate performs, so it needs a deliberate choice - protect selectively, change how integrate pushes, or accept the exposure - rather than a reflex. Current risk is low (single operator; a container only pushes its own branch) and rises when several unattended containers run concurrently, which is the feature's destination. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding.", "rejected_alternative": "Spawning a sibling issue to decide between selective protection, changing integrate's push strategy, and accepting the exposure. Turned down by the operator in favour of covering it another way. The narrower alternative of protecting feature/* immediately was already rejected at plan time because it would break /dev:integrate." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted as recommended. The /voice audio plumbing is deleted outright rather than shimmed or flagged, so there is no residue to follow up: an unattended run has nobody to talk to, and the audio path was the most host-coupled part of the image. AC-20 asserts mechanically that no audio package or socket mount survives in .devcontainer/, which is a stronger guarantee than a tracking ticket would give. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-7 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted as recommended. The /voice audio plumbing is deleted outright rather than shimmed or flagged, so there is no residue to follow up: an unattended run has nobody to talk to, and the audio path was the most host-coupled part of the image. AC-20 asserts mechanically that no audio package or socket mount survives in .devcontainer/, which is a stronger guarantee than a tracking ticket would give. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted as recommended. VS Code integration is removed, not deferred: the operator does not use VS Code and the lifecycle is terminal-first by requirement, so devcontainer.json and the extension list would be configuration nothing exercises - which is what rots into a misleading state. AC-13 asserts no .devcontainer/ file references an editor or GUI step. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-8 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted as recommended. VS Code integration is removed, not deferred: the operator does not use VS Code and the lifecycle is terminal-first by requirement, so devcontainer.json and the extension list would be configuration nothing exercises - which is what rots into a misleading state. AC-13 asserts no .devcontainer/ file references an editor or GUI step. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted as recommended. Resource isolation (ports, databases, service lifecycle) has nothing to attach to: this repo runs no services, so parallel containers have nothing to allocate or collide over. Isolation here is about blast radius, not resources, and a follow-up issue would describe work that has no trigger. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-9 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted as recommended. Resource isolation (ports, databases, service lifecycle) has nothing to attach to: this repo runs no services, so parallel containers have nothing to allocate or collide over. Isolation here is about blast radius, not resources, and a follow-up issue would describe work that has no trigger. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted as recommended, and specifically because the follow-up already exists elsewhere. The slot recipe schema makes port_scheme and database mandatory, so a services-free project cannot declare a valid parallel_dev recipe at all - filed as suite issue #62, whose live case is this very repository. Spawning a sibling here would duplicate #62 rather than add anything. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-10 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted as recommended, and specifically because the follow-up already exists elsewhere. The slot recipe schema makes port_scheme and database mandatory, so a services-free project cannot declare a valid parallel_dev recipe at all - filed as suite issue #62, whose live case is this very repository. Spawning a sibling here would duplicate #62 rather than add anything. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted as recommended. Being specific to this Linux host and the local Docker daemon is the intended design rather than a limitation to remove: .devcontainer/ is repo-local infra and explicitly exempt from the suite's stack-agnostic rule (CLAUDE.md, Fencing). A portability follow-up would contradict that exemption. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-11 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted as recommended. Being specific to this Linux host and the local Docker daemon is the intended design rather than a limitation to remove: .devcontainer/ is repo-local infra and explicitly exempt from the suite's stack-agnostic rule (CLAUDE.md, Fencing). A portability follow-up would contradict that exemption. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted as recommended. The no-changes-to-plugin/ boundary was set at dispatch and reaffirmed throughout; suite-level gaps found during this work are filed as their own issues rather than fixed inside an infra feature, which is what happened in practice (#62 already exists, and the plan-time findings are recorded on this issue). Nothing is left needing a ticket. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-12 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted as recommended. The no-changes-to-plugin/ boundary was set at dispatch and reaffirmed throughout; suite-level gaps found during this work are filed as their own issues rather than fixed inside an infra feature, which is what happened in practice (#62 already exists, and the plan-time findings are recorded on this issue). Nothing is left needing a ticket. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted as recommended. Per-container Claude logins remain the clean answer IF OAuth refresh rotation turns out to be single-use, but that is exactly what precondition D-PO-61-2-3 gates, and its waiver carries an explicit tripwire to revisit before relying on unattended parallel runs. A separate issue would duplicate that tripwire while the gate is the thing that would actually fire. The operator also rejected per-container credentials at requirements time on setup-ceremony grounds, so pre-empting it would override a standing decision to avoid a problem that may not exist. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-14 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted as recommended. Per-container Claude logins remain the clean answer IF OAuth refresh rotation turns out to be single-use, but that is exactly what precondition D-PO-61-2-3 gates, and its waiver carries an explicit tripwire to revisit before relying on unattended parallel runs. A separate issue would duplicate that tripwire while the gate is the thing that would actually fire. The operator also rejected per-container credentials at requirements time on setup-ceremony grounds, so pre-empting it would override a standing decision to avoid a problem that may not exist. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted as recommended, because it was absorbed rather than deferred. Surfacing credential age and workspace ahead/dirty state is already in this slice's Implementation Scope as part of the dw.sh ls dashboard - the UX and security lenses asked for the same row for different reasons (the operator's only cross-container view of work that is invisible until pushed, and credential hygiene on stopped containers). There is nothing outstanding to track. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-15 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted as recommended, because it was absorbed rather than deferred. Surfacing credential age and workspace ahead/dirty state is already in this slice's Implementation Scope as part of the dw.sh ls dashboard - the UX and security lenses asked for the same row for different reasons (the operator's only cross-container view of work that is invisible until pushed, and credential hygiene on stopped containers). There is nothing outstanding to track. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Accepted as recommended, because it is fixed inside this slice. The finding records a pre-existing latent defect discovered while verifying the tracker access path: post-create.sh has always run 'tea login add' with TEA_TOKEN read from a .env that does not exist, with the failure swallowed by '|| true', so a container session has had no forge authentication since .devcontainer/ was authored. The plan replaces that route with the host's real tea config, and D-PO-61-2-5's probe verified the replacement works from inside a container. The finding is kept as a record so the silent-failure pattern stays visible to a later reader, not because work remains. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding."
}
<!-- decision-resolution:v1 ref=D-PO-61-2-16 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Accepted as recommended, because it is fixed inside this slice. The finding records a pre-existing latent defect discovered while verifying the tracker access path: post-create.sh has always run 'tea login add' with TEA_TOKEN read from a .env that does not exist, with the failure swallowed by '|| true', so a container session has had no forge authentication since .devcontainer/ was authored. The plan replaces that route with the host's real tea config, and D-PO-61-2-5's probe verified the replacement works from inside a container. The finding is kept as a record so the silent-failure pattern stays visible to a later reader, not because work remains. Authority: operator resolved this disposition directly on 2026-08-25 after reviewing the finding." } ```
Author
Owner
{
  "waves": [
    {
      "wave": 1,
      "work_units": [
        {
          "id": "WU-61-3-1",
          "title": "Image refresh and retirement of the compose/VS Code stack",
          "files": [
            ".devcontainer/Dockerfile",
            ".devcontainer/docker-compose.yml",
            ".devcontainer/devcontainer.json",
            ".devcontainer/entrypoint.sh",
            ".devcontainer/post-create.sh"
          ],
          "acceptance_criteria": [
            {"id": "AC-2", "scope": "image-side tool presence and versions"},
            {"id": "AC-20", "scope": "no audio package, ALSA config or socket mount remains in .devcontainer/"}
          ]
        }
      ]
    },
    {
      "wave": 2,
      "work_units": [
        {
          "id": "WU-61-3-2",
          "title": "Lifecycle core: dw.sh + container-init.sh, with selftest.sh as the AC surface",
          "files": [
            ".devcontainer/dw.sh",
            ".devcontainer/container-init.sh",
            ".devcontainer/selftest.sh"
          ],
          "acceptance_criteria": [
            {"id": "AC-1"},
            {"id": "AC-2", "scope": "runtime forge-CLI authentication against the declared instance"},
            {"id": "AC-3"},
            {"id": "AC-4"},
            {"id": "AC-5"},
            {"id": "AC-6"},
            {"id": "AC-7"},
            {"id": "AC-8"},
            {"id": "AC-9"},
            {"id": "AC-10"},
            {"id": "AC-11"},
            {"id": "AC-12"},
            {"id": "AC-13", "scope": "the lifecycle verbs themselves and the no-editor/no-GUI scan"},
            {"id": "AC-14"},
            {"id": "AC-15"},
            {"id": "AC-16"},
            {"id": "AC-17"},
            {"id": "AC-18"},
            {"id": "AC-19"},
            {"id": "AC-20", "scope": "clean start on a host with no audio server"},
            {"id": "AC-21"},
            {"id": "AC-22"},
            {"id": "AC-23"}
          ]
        }
      ]
    },
    {
      "wave": 3,
      "work_units": [
        {
          "id": "WU-61-3-3",
          "title": "Lifecycle documentation and lint coverage for .devcontainer/*.sh",
          "files": [
            ".devcontainer/README.md",
            "scripts/lint-conventions.sh",
            "scripts/test-lint-conventions.sh"
          ],
          "acceptance_criteria": [
            {"id": "AC-13", "scope": "the documented terminal lifecycle the criterion requires the operator to follow"}
          ]
        }
      ]
    }
  ]
}

Decomposition rationale

Three units, three waves, executed sequentially — this project is a single shared git worktree
(the suite's parallel_dev: slot machinery is deliberately unused here, F-PO-61-2-5 / #62), so
within-wave parallelism is unavailable and the waves express ordering only.

WU-61-3-1 (wave 1) — image and retirement. The SREQ's File Boundaries note says Dockerfile is
independent of everything else, and the four retired files (docker-compose.yml, devcontainer.json,
entrypoint.sh, post-create.sh) are deleted outright rather than shimmed, so their removal belongs
with the image change that makes them obsolete. This is an artifact-shape unit — its criteria are
"which packages are in the image" and "no audio residue remains in .devcontainer/", both settled by
inspection and a build, not by behavioural tests — so it runs Implementer-only under the 3.2
artifact-shape exception.

WU-61-3-2 (wave 2) — the lifecycle core. dw.sh and container-init.sh are one unit by the SREQ's
explicit instruction: they share the naming scheme, the volume layout, the state marker and the
exit-code convention, and splitting them would invent two conventions. This unit carries the full
2-phase TDD discipline, and the split is unusually clean here because the SREQ already names the test
artifact: the Test Writer owns .devcontainer/selftest.sh, written from the acceptance criteria
alone, and the Implementer owns dw.sh + container-init.sh. The AC table's "Verification
approach" column is literally a specification for selftest.sh, so the Test Writer has a defined
scope without ever seeing the Architecture section.

WU-61-3-3 (wave 3) — documentation and lint coverage. Both are independent tail work in the SREQ's
sequencing, and both are trivially small on their own, so they are combined per the sizing guidance.
README.md supplies the documented lifecycle AC-13 requires the operator to follow; the
lint-conventions.sh extension brings .devcontainer/*.sh into the repo's only mechanical gate,
which is the mitigation recorded against the "load-bearing shell behind only bash -n" risk. It runs
last so the lint sweep and the README both describe files that exist.

No file is shared between units. AC-2, AC-13 and AC-20 are each split across two units, and every
split carries a scope annotation naming which slice this unit covers.

Operator decisions taken at dispatch

Two build-blocking questions were settled with the operator before any teammate was spawned; both are
recorded here because they constrain what the Implementers may assume.

  1. The container's tea token. D-PO-61-2-2 requires containers to carry a least-privilege token and
    dw.sh up to assert that scope at create, but the host holds only the admin-capable wihslon
    login. The operator will mint a scoped token (issue/comment write, repository read, no
    repo-admin). dw.sh therefore reads the container token from a dedicated source
    ($DW_TEA_TOKEN, else ~/.config/dw/tea-token), asserts by probing an admin-only endpoint that it
    lacks repo-admin, and refuses with exit 2 naming the remedy otherwise. It never falls back to
    the host tea config's token, and ships no override flag — the guard is the point.

  2. selftest.sh's forge blast radius. It creates and cleans up its own scratch objects: its
    own dw-selftest/* branches and its own scratch tracker issues, deleted / closed at the end of the
    run. It does not depend on pre-created fixture issues, and it must leave the forge as it found it
    apart from closed scratch issues. Its force-push and branch-deletion attempts against main are
    expected to be refused (AC-7) — the assertion is that they fail and that main's tip is
    unchanged.

<!-- wu-plan:v1 po=PO-61-3 skill=develop --> ```json { "waves": [ { "wave": 1, "work_units": [ { "id": "WU-61-3-1", "title": "Image refresh and retirement of the compose/VS Code stack", "files": [ ".devcontainer/Dockerfile", ".devcontainer/docker-compose.yml", ".devcontainer/devcontainer.json", ".devcontainer/entrypoint.sh", ".devcontainer/post-create.sh" ], "acceptance_criteria": [ {"id": "AC-2", "scope": "image-side tool presence and versions"}, {"id": "AC-20", "scope": "no audio package, ALSA config or socket mount remains in .devcontainer/"} ] } ] }, { "wave": 2, "work_units": [ { "id": "WU-61-3-2", "title": "Lifecycle core: dw.sh + container-init.sh, with selftest.sh as the AC surface", "files": [ ".devcontainer/dw.sh", ".devcontainer/container-init.sh", ".devcontainer/selftest.sh" ], "acceptance_criteria": [ {"id": "AC-1"}, {"id": "AC-2", "scope": "runtime forge-CLI authentication against the declared instance"}, {"id": "AC-3"}, {"id": "AC-4"}, {"id": "AC-5"}, {"id": "AC-6"}, {"id": "AC-7"}, {"id": "AC-8"}, {"id": "AC-9"}, {"id": "AC-10"}, {"id": "AC-11"}, {"id": "AC-12"}, {"id": "AC-13", "scope": "the lifecycle verbs themselves and the no-editor/no-GUI scan"}, {"id": "AC-14"}, {"id": "AC-15"}, {"id": "AC-16"}, {"id": "AC-17"}, {"id": "AC-18"}, {"id": "AC-19"}, {"id": "AC-20", "scope": "clean start on a host with no audio server"}, {"id": "AC-21"}, {"id": "AC-22"}, {"id": "AC-23"} ] } ] }, { "wave": 3, "work_units": [ { "id": "WU-61-3-3", "title": "Lifecycle documentation and lint coverage for .devcontainer/*.sh", "files": [ ".devcontainer/README.md", "scripts/lint-conventions.sh", "scripts/test-lint-conventions.sh" ], "acceptance_criteria": [ {"id": "AC-13", "scope": "the documented terminal lifecycle the criterion requires the operator to follow"} ] } ] } ] } ``` ## Decomposition rationale Three units, three waves, **executed sequentially** — this project is a single shared git worktree (the suite's `parallel_dev:` slot machinery is deliberately unused here, F-PO-61-2-5 / #62), so within-wave parallelism is unavailable and the waves express ordering only. **WU-61-3-1 (wave 1) — image and retirement.** The SREQ's File Boundaries note says `Dockerfile` is independent of everything else, and the four retired files (`docker-compose.yml`, `devcontainer.json`, `entrypoint.sh`, `post-create.sh`) are deleted outright rather than shimmed, so their removal belongs with the image change that makes them obsolete. This is an **artifact-shape** unit — its criteria are "which packages are in the image" and "no audio residue remains in `.devcontainer/`", both settled by inspection and a build, not by behavioural tests — so it runs **Implementer-only** under the 3.2 artifact-shape exception. **WU-61-3-2 (wave 2) — the lifecycle core.** `dw.sh` and `container-init.sh` are one unit by the SREQ's explicit instruction: they share the naming scheme, the volume layout, the state marker and the exit-code convention, and splitting them would invent two conventions. This unit carries the full 2-phase TDD discipline, and the split is unusually clean here because the SREQ already names the test artifact: the **Test Writer owns `.devcontainer/selftest.sh`**, written from the acceptance criteria alone, and the **Implementer owns `dw.sh` + `container-init.sh`**. The AC table's "Verification approach" column is literally a specification for `selftest.sh`, so the Test Writer has a defined scope without ever seeing the Architecture section. **WU-61-3-3 (wave 3) — documentation and lint coverage.** Both are independent tail work in the SREQ's sequencing, and both are trivially small on their own, so they are combined per the sizing guidance. `README.md` supplies the documented lifecycle AC-13 requires the operator to follow; the `lint-conventions.sh` extension brings `.devcontainer/*.sh` into the repo's only mechanical gate, which is the mitigation recorded against the "load-bearing shell behind only `bash -n`" risk. It runs last so the lint sweep and the README both describe files that exist. **No file is shared between units.** AC-2, AC-13 and AC-20 are each split across two units, and every split carries a `scope` annotation naming which slice this unit covers. ## Operator decisions taken at dispatch Two build-blocking questions were settled with the operator before any teammate was spawned; both are recorded here because they constrain what the Implementers may assume. 1. **The container's tea token.** D-PO-61-2-2 requires containers to carry a least-privilege token and `dw.sh up` to assert that scope at create, but the host holds only the admin-capable `wihslon` login. The operator will **mint a scoped token** (issue/comment write, repository read, no repo-admin). `dw.sh` therefore reads the container token from a dedicated source (`$DW_TEA_TOKEN`, else `~/.config/dw/tea-token`), asserts by probing an admin-only endpoint that it lacks repo-admin, and **refuses with exit 2 naming the remedy** otherwise. It never falls back to the host tea config's token, and ships **no** override flag — the guard is the point. 2. **`selftest.sh`'s forge blast radius.** It **creates and cleans up its own scratch objects**: its own `dw-selftest/*` branches and its own scratch tracker issues, deleted / closed at the end of the run. It does not depend on pre-created fixture issues, and it must leave the forge as it found it apart from closed scratch issues. Its force-push and branch-deletion attempts against `main` are expected to be **refused** (AC-7) — the assertion is that they fail and that `main`'s tip is unchanged.
Author
Owner

Development journal — WU-61-3-1 (image refresh, compose/IDE stack retirement)

2026-08-25T15:40 DISCOVERY: install.sh | bash cannot pin the Claude CLI in this image

type: discovery
timestamp: 2026-08-25T15:40
where: .devcontainer/Dockerfile
tags: [pinning, claude-cli, volumes, reproducibility]

Context: The SREQ's Implementation Scope asks to "pin the Claude CLI and jq". The old image ran
curl -fsSL https://claude.ai/install.sh | bash, which installs whatever the service serves that day.

Pattern: Read the installer before assuming it can pin. It does take a target
(install.sh [stable|latest|VERSION]), but it is still the wrong mechanism here, for three reasons that
only show up once you read it:

  1. It always downloads the latest bootstrap binary first ("which has the most up-to-date installer")
    and only then runs claude install <VERSION> — so the build is not reproducible from the version
    argument alone.
  2. It installs under $HOME, and in this design /home/vscode/.claude is a named volume at runtime.
    Anything the installer wrote there during the build would be shadowed the moment the volume mounts.
  3. A per-user install is writable by the session, so the pin would not actually hold.

What the installer really does, stripped of the launcher setup, is: fetch
https://downloads.claude.ai/claude-code-releases/<version>/<platform>/claude, verify it against
.platforms["<platform>"].checksum from that version's manifest.json, and chmod +x. Doing exactly
that in the Dockerfile — to /usr/local/bin/claude, root-owned, with the version and the sha256 as
build ARGs — pins the CLI properly. The standalone binary needs no claude install: it reports
2.1.231 (Claude Code) straight away. DISABLE_AUTOUPDATER=1 keeps the pin from being quietly retired.

Why it matters: the same three-part reasoning applies to any tool installed by a
convenience installer into an image whose $HOME is a volume — the install silently evaporates at
runtime, and nobody notices until the tool is missing mid-session.

2026-08-25T15:55 DISCOVERY: create the volume mount points in the image, owned by the container user

type: discovery
timestamp: 2026-08-25T15:55
where: .devcontainer/Dockerfile
tags: [volumes, ownership, uid-1000]

Context: The design mounts two named volumes per issue — dw-<N>-workspace at /workspace and
dw-<N>-claude at /home/vscode/.claude.

Pattern: Docker seeds a fresh named volume from whatever is at that path in the image, ownership
included
. If the path does not exist in the image, the volume comes up owned by root:root, and a
container running as uid 1000 cannot write to it. Creating both directories in the image with
install -d -o vscode -g vscode is what makes the volumes come up owned by uid 1000 — verified:
drwxr-xr-x 2 vscode vscode /workspace and the same for ~/.claude.

Why it matters: this is an image-side property that only fails on the consumer side
(container-init.sh failing to write), so it would be debugged in the wrong file. Recorded here so the
lifecycle unit does not have to chase it.

2026-08-25T16:08 STRUGGLE: the residue scan trips on the comments announcing the removal

type: struggle
timestamp: 2026-08-25T16:08
attempts: 2
resolved: true
tags: [ac-20, ac-13, scanning]

Problem: AC-20 and AC-13 are scan assertions: no file under .devcontainer/ may reference an
audio package/config/socket or an IDE-integration step.
Expected: After deleting the compose stack and the audio layer, the scan is clean.
Actual: The audio scan still matched two lines — both of them my own comments saying there is no
audio stack
("no audio stack (/voice is a non-goal)", and the same in the image description label).
Solution: Removed the negative mentions rather than the pattern. The Dockerfile now describes what it
is ("terminal-only: it carries nothing for an IDE and nothing for a sound card"), so a strict
token scan needs no allowlist and no comment-stripping pass.
Key insight: A mechanical "absence" assertion cannot tell a use site from a comment denying one.
Whoever writes the assertion pays for every explanatory mention, so the file that must pass the scan
should not name the thing at all — not even to disclaim it. Note for selftest.sh: the container user is
named vscode (the base image's user, and the path the SREQ itself specifies), so the AC-13 scan must
match IDE-integration tokens (devcontainer.json, dockerComposeFile, customizations,
extensions, anthropic.claude-code, postCreateCommand, .vscode) rather than the bare string
vscode.

2026-08-25T16:20 COMPLETE

type: complete
variant: development
timestamp: 2026-08-25T16:20
iterations: 6

Hardest part: Working out that the Claude installer's VERSION argument does not actually give a
reproducible build, which took reading the installer end to end rather than trusting its usage line.
If I did this again: I would write the AC-20/AC-13 residue scan before writing the Dockerfile
comments — the scan is the spec for how the file may talk about what it removed, and I wrote the file
first and then had to reword it.

<!-- dev-journal:v1 wu=WU-61-3-1 skill=develop --> # Development journal — WU-61-3-1 (image refresh, compose/IDE stack retirement) ## 2026-08-25T15:40 DISCOVERY: `install.sh | bash` cannot pin the Claude CLI in this image ```yaml type: discovery timestamp: 2026-08-25T15:40 where: .devcontainer/Dockerfile tags: [pinning, claude-cli, volumes, reproducibility] ``` **Context:** The SREQ's Implementation Scope asks to "pin the Claude CLI and jq". The old image ran `curl -fsSL https://claude.ai/install.sh | bash`, which installs whatever the service serves that day. **Pattern:** Read the installer before assuming it can pin. It *does* take a target (`install.sh [stable|latest|VERSION]`), but it is still the wrong mechanism here, for three reasons that only show up once you read it: 1. It always downloads the **latest** bootstrap binary first ("which has the most up-to-date installer") and only then runs `claude install <VERSION>` — so the build is not reproducible from the version argument alone. 2. It installs under `$HOME`, and in this design `/home/vscode/.claude` is a **named volume** at runtime. Anything the installer wrote there during the build would be shadowed the moment the volume mounts. 3. A per-user install is writable by the session, so the pin would not actually hold. What the installer really does, stripped of the launcher setup, is: fetch `https://downloads.claude.ai/claude-code-releases/<version>/<platform>/claude`, verify it against `.platforms["<platform>"].checksum` from that version's `manifest.json`, and `chmod +x`. Doing exactly that in the Dockerfile — to `/usr/local/bin/claude`, root-owned, with the version *and* the sha256 as build ARGs — pins the CLI properly. The standalone binary needs no `claude install`: it reports `2.1.231 (Claude Code)` straight away. `DISABLE_AUTOUPDATER=1` keeps the pin from being quietly retired. **Why it matters:** the same three-part reasoning applies to any tool installed by a convenience installer into an image whose `$HOME` is a volume — the install silently evaporates at runtime, and nobody notices until the tool is missing mid-session. ## 2026-08-25T15:55 DISCOVERY: create the volume mount points in the image, owned by the container user ```yaml type: discovery timestamp: 2026-08-25T15:55 where: .devcontainer/Dockerfile tags: [volumes, ownership, uid-1000] ``` **Context:** The design mounts two named volumes per issue — `dw-<N>-workspace` at `/workspace` and `dw-<N>-claude` at `/home/vscode/.claude`. **Pattern:** Docker seeds a *fresh* named volume from whatever is at that path in the image, **ownership included**. If the path does not exist in the image, the volume comes up owned by `root:root`, and a container running as uid 1000 cannot write to it. Creating both directories in the image with `install -d -o vscode -g vscode` is what makes the volumes come up owned by uid 1000 — verified: `drwxr-xr-x 2 vscode vscode /workspace` and the same for `~/.claude`. **Why it matters:** this is an image-side property that only fails on the *consumer* side (`container-init.sh` failing to write), so it would be debugged in the wrong file. Recorded here so the lifecycle unit does not have to chase it. ## 2026-08-25T16:08 STRUGGLE: the residue scan trips on the comments announcing the removal ```yaml type: struggle timestamp: 2026-08-25T16:08 attempts: 2 resolved: true tags: [ac-20, ac-13, scanning] ``` **Problem:** AC-20 and AC-13 are **scan** assertions: no file under `.devcontainer/` may reference an audio package/config/socket or an IDE-integration step. **Expected:** After deleting the compose stack and the audio layer, the scan is clean. **Actual:** The audio scan still matched two lines — both of them *my own comments saying there is no audio stack* ("no audio stack (/voice is a non-goal)", and the same in the image description label). **Solution:** Removed the negative mentions rather than the pattern. The Dockerfile now describes what it *is* ("terminal-only: it carries nothing for an IDE and nothing for a sound card"), so a strict token scan needs no allowlist and no comment-stripping pass. **Key insight:** A mechanical "absence" assertion cannot tell a use site from a comment denying one. Whoever writes the assertion pays for every explanatory mention, so the file that must pass the scan should not name the thing at all — not even to disclaim it. Note for `selftest.sh`: the container user is *named* `vscode` (the base image's user, and the path the SREQ itself specifies), so the AC-13 scan must match IDE-**integration** tokens (`devcontainer.json`, `dockerComposeFile`, `customizations`, `extensions`, `anthropic.claude-code`, `postCreateCommand`, `.vscode`) rather than the bare string `vscode`. ## 2026-08-25T16:20 COMPLETE ```yaml type: complete variant: development timestamp: 2026-08-25T16:20 iterations: 6 ``` **Hardest part:** Working out that the Claude installer's `VERSION` argument does not actually give a reproducible build, which took reading the installer end to end rather than trusting its usage line. **If I did this again:** I would write the AC-20/AC-13 residue scan *before* writing the Dockerfile comments — the scan is the spec for how the file may talk about what it removed, and I wrote the file first and then had to reword it.
Author
Owner
{
  "outcome": "complete",
  "summary": "Refreshed .devcontainer/Dockerfile to the terminal-only, checksum-pinned image the new lifecycle needs and deleted the retired compose/VS Code stack outright (docker-compose.yml, devcontainer.json, entrypoint.sh, post-create.sh).",
  "commit_refs": ["0c5b90a"],
  "journal_ref": "1472",
  "dependencies_satisfied": [],
  "dependencies_violated": []
}

Artifact-shape work unit — its acceptance criteria are "which tools are in the image" and "no audio or
IDE residue remains in .devcontainer/", both settled by a build plus a mechanical scan rather than by
behavioural assertions. Per /dev:develop §3.2 it therefore ran Implementer-only, with no Test
Writer
; the ACs are verified by AC-MECH inspection and a real docker build.

Verified, with the gate's verdict taken from the command's own exit code:

  • docker build -t devwork-dev:wu1 .devcontainer → exit 0. Image left on the box for the next unit.
  • Observed inside the built image: bash 5.2.15, jq 1.8.1, git 2.55.0, tea 0.15.1, zsh 5.9 (the container
    user's login shell), claude 2.1.231, openssh-client 9.2p1. POSIX baseline utilities all present.
  • Container user is uid 1000 / gid 1000 — asserted at build time (the build fails if a future base image
    changes it) and confirmed by id in a running container.
  • Runtime shape: Entrypoint: null, Cmd: ["sleep","infinity"], User: vscode, WorkingDir: /workspace. container-init.sh is deliberately not baked into the image.
  • Residue scans over .devcontainer/ both return rc 1 (no match): the audio pattern
    (alsa|asound|pulseaudio|arecord|pactl|sox|/voice|…) and the IDE/GUI pattern
    (devcontainer.json|dockerComposeFile|customizations|extensions|postCreateCommand|.vscode|DISPLAY=|…).
  • bash scripts/lint-conventions.sh → exit 0, "lint-conventions: clean".

Two build decisions worth carrying forward (both recorded as DISCOVERY entries in the journal):

  1. The Claude CLI is pinned by fetching the release binary directly, not via install.sh. The
    installer accepts a version, but always downloads the latest bootstrap binary before pinning (so the
    build is not reproducible from the ARG alone) and installs under $HOME — which is a named volume
    here, so an image-layer install would be shadowed at mount time. The Dockerfile instead fetches
    downloads.claude.ai/claude-code-releases/${CLAUDE_VERSION}/linux-x64/claude, verifies the sha256
    from that version's official manifest, installs it root-owned at /usr/local/bin/claude, and sets
    DISABLE_AUTOUPDATER=1 so the pin holds against a session that could otherwise update out of it. jq
    and tea are pinned the same way. Bumping any of the three means bumping version and checksum
    together
    — a version bumped alone fails the build loudly.
  2. /workspace and /home/vscode/.claude are created in the image, owned by the container user.
    Docker seeds a fresh named volume from the image path it mounts over, ownership included; without
    this, dw-<N>-workspace and dw-<N>-claude come up root-owned and container-init.sh cannot write
    to them. The lifecycle unit gets correct ownership for free and needs no chown.

Recorded for the units that follow: the image writes
/usr/local/share/devwork/image-versions.json ({"claude","jq","tea","user","uid","gid"}) plus
dev.devwork.{claude,jq,tea}-version labels, so selftest.sh and dw.sh ls can assert against a
machine-readable record instead of parsing --version output. Also: the AC-13 scan must match IDE
integration tokens, never the bare string vscode — the container user is named vscode and the
SREQ itself specifies /home/vscode/.claude, so every unit's files will contain it legitimately.

AC coverage. AC-2's image-side slice (presence + versions) and AC-20's residue slice are fully
covered. AC-13's scan slice is clean as of this unit but must be re-run once dw.sh,
container-init.sh, selftest.sh and README.md land. The authentication half of AC-2 and the runtime
half of AC-20 belong to WU-61-3-2.

Pin choice, decided at the lead: CLAUDE_VERSION=2.1.231 tracks the stable channel pointer
(latest was 2.1.245 at build time). stable is the right default for a box that runs unattended.

<!-- work-unit-outcome:v1 id=WU-61-3-1 skill=develop --> ```json { "outcome": "complete", "summary": "Refreshed .devcontainer/Dockerfile to the terminal-only, checksum-pinned image the new lifecycle needs and deleted the retired compose/VS Code stack outright (docker-compose.yml, devcontainer.json, entrypoint.sh, post-create.sh).", "commit_refs": ["0c5b90a"], "journal_ref": "1472", "dependencies_satisfied": [], "dependencies_violated": [] } ``` Artifact-shape work unit — its acceptance criteria are "which tools are in the image" and "no audio or IDE residue remains in `.devcontainer/`", both settled by a build plus a mechanical scan rather than by behavioural assertions. Per `/dev:develop` §3.2 it therefore ran **Implementer-only, with no Test Writer**; the ACs are verified by AC-MECH inspection and a real `docker build`. **Verified, with the gate's verdict taken from the command's own exit code:** - `docker build -t devwork-dev:wu1 .devcontainer` → exit 0. Image left on the box for the next unit. - Observed inside the built image: bash 5.2.15, jq 1.8.1, git 2.55.0, tea 0.15.1, zsh 5.9 (the container user's login shell), claude 2.1.231, openssh-client 9.2p1. POSIX baseline utilities all present. - Container user is uid 1000 / gid 1000 — asserted at build time (the build fails if a future base image changes it) and confirmed by `id` in a running container. - Runtime shape: `Entrypoint: null`, `Cmd: ["sleep","infinity"]`, `User: vscode`, `WorkingDir: /workspace`. `container-init.sh` is deliberately **not** baked into the image. - Residue scans over `.devcontainer/` both return rc 1 (no match): the audio pattern (`alsa|asound|pulseaudio|arecord|pactl|sox|/voice|…`) and the IDE/GUI pattern (`devcontainer.json|dockerComposeFile|customizations|extensions|postCreateCommand|.vscode|DISPLAY=|…`). - `bash scripts/lint-conventions.sh` → exit 0, "lint-conventions: clean". **Two build decisions worth carrying forward** (both recorded as DISCOVERY entries in the journal): 1. **The Claude CLI is pinned by fetching the release binary directly, not via `install.sh`.** The installer accepts a version, but always downloads the *latest* bootstrap binary before pinning (so the build is not reproducible from the ARG alone) and installs under `$HOME` — which is a named volume here, so an image-layer install would be shadowed at mount time. The Dockerfile instead fetches `downloads.claude.ai/claude-code-releases/${CLAUDE_VERSION}/linux-x64/claude`, verifies the sha256 from that version's official manifest, installs it root-owned at `/usr/local/bin/claude`, and sets `DISABLE_AUTOUPDATER=1` so the pin holds against a session that could otherwise update out of it. jq and tea are pinned the same way. **Bumping any of the three means bumping version and checksum together** — a version bumped alone fails the build loudly. 2. **`/workspace` and `/home/vscode/.claude` are created in the image, owned by the container user.** Docker seeds a fresh named volume from the image path it mounts over, ownership included; without this, `dw-<N>-workspace` and `dw-<N>-claude` come up root-owned and `container-init.sh` cannot write to them. The lifecycle unit gets correct ownership for free and needs no `chown`. **Recorded for the units that follow:** the image writes `/usr/local/share/devwork/image-versions.json` (`{"claude","jq","tea","user","uid","gid"}`) plus `dev.devwork.{claude,jq,tea}-version` labels, so `selftest.sh` and `dw.sh ls` can assert against a machine-readable record instead of parsing `--version` output. Also: the AC-13 scan must match IDE **integration** tokens, never the bare string `vscode` — the container user is *named* `vscode` and the SREQ itself specifies `/home/vscode/.claude`, so every unit's files will contain it legitimately. **AC coverage.** AC-2's image-side slice (presence + versions) and AC-20's residue slice are fully covered. AC-13's scan slice is clean as of this unit but must be **re-run** once `dw.sh`, `container-init.sh`, `selftest.sh` and `README.md` land. The authentication half of AC-2 and the runtime half of AC-20 belong to WU-61-3-2. **Pin choice, decided at the lead:** `CLAUDE_VERSION=2.1.231` tracks the `stable` channel pointer (`latest` was 2.1.245 at build time). `stable` is the right default for a box that runs unattended.
Author
Owner

Development journal — WU-61-3-2 (dw.sh + container-init.sh)

2026-08-25T16:50 DISCOVERY: ssh -G <alias> beats parsing the ssh_config stanza

type: discovery
timestamp: 2026-08-25T16:50
where: .devcontainer/dw.sh
tags: [ssh, portability, seeding]

Context: The SREQ says to copy "the forge-devwork SSH stanza, the key it names, and the
matching known_hosts entry" into the container.
Pattern: Don't read ~/.ssh/config. ssh -G <alias> prints the effective configuration
(hostname, port, user, identityfile) after all of ssh's own include/match resolution, so the
container gets a synthesized stanza that is exactly equivalent and strictly narrower than the literal
text — no Included file, no unrelated Match block, no sibling host.
Why it matters: The known_hosts lookup key has to be derived from the same resolution
([host]:port when the port is not 22, bare host otherwise), or the copied entry silently fails to
match and the container falls back to asking about an unknown host. Deriving both from one ssh -G
call keeps them consistent by construction.

2026-08-25T16:58 STRUGGLE: ${var#~/} tilde-expands the pattern

type: struggle
timestamp: 2026-08-25T16:58
attempts: 1
resolved: true
tags: [bash, quoting]

Problem: ssh -G reports the identity file as ~/.ssh/<key>; that had to become an absolute path.
Expected: case "$f" in "~/"*) f="$HOME/${f#~/}" ;; esac strips the prefix.
Actual: /home/jochem/~/.ssh/<key> — "not readable".
Solution: f="$HOME${f#\~}".
Key insight: Bash performs tilde expansion on the pattern half of ${var#pattern}, so the ~/
there had already become /home/jochem/ and matched nothing. The case pattern is quoted and
therefore literal, which is exactly why the case matched while the strip inside it did not — the two
halves of the same idiom disagree about what ~ means. Escape it in the parameter expansion.

2026-08-25T17:05 DISCOVERY: probe the Claude credential in a throwaway config dir

type: discovery
timestamp: 2026-08-25T17:05
where: .devcontainer/container-init.sh
tags: [credentials, acceptance-criteria]

Context: The credential has to be probed by using it (a token that parses fine but is no longer
accepted is the whole failure mode), but AC-23 requires a fresh container to carry no accumulated
session state — and claude -p writes a transcript under projects/ for every run.
Pattern: Copy the credential into $(mktemp -d), run the probe under
CLAUDE_CONFIG_DIR=<that dir>, delete the directory. The credential under test is byte-identical, and
every artefact the probe creates dies with the temp dir.
Why it matters: Without this the probe would have to run in ~/.claude and then selectively delete
what it created — a cleanup that has to stay in step with whatever the CLI writes next. This version
has nothing to keep in step with. Measured: an invalid credential is refused in 2s, well inside the
90s bound AC-12 allows.

2026-08-25T17:20 DISCOVERY: tea api prefixes advisory NOTE lines to its output

type: discovery
timestamp: 2026-08-25T17:20
where: .devcontainer/container-init.sh
tags: [tea, forge, parsing]

Context: The forge-token probe read tea api /user and asked jq for .login.
Pattern: When no configured login matches the repository exactly, tea writes
NOTE: no login matched this repository, falling back to login '<name>' in non-interactive mode.
ahead of the JSON body. Read the body from its first { (sed -n '/^{/,$p') rather than assuming it
starts at byte zero.
Why it matters: This compounds the already-recorded fact that tea api exits 0 even on an
HTTP-level error — so the probe is judged on content, and the content is not where you expect it.
Anything in this repo that feeds tea api output to jq is exposed to the same thing.

2026-08-25T17:35 DISCOVERY: a ready-marker short-circuit skipped recreate's re-seed

type: discovery
timestamp: 2026-08-25T17:35
where: .devcontainer/container-init.sh
tags: [idempotence, recreate]

Context: recreate is defined as "new container, both volumes preserved, configuration re-seeded".
Pattern: Because the volumes are ready, is_ready returns true and the whole volume-resident
block — which is where seed_settings originally lived — never runs. The re-seed therefore has to sit
with the container-layer setup that runs unconditionally, not with the once-per-volume-pair work.
Why it matters: The general shape: when a verb's entire purpose is "redo part of setup", that part
cannot live behind the marker that says setup is done. Caught by exercising recreate rather than by
reading the code — the code reads correctly either way.

2026-08-25T17:40 DISCOVERY (PRODUCT PROBLEM): branch_protections cannot serve as an admin-scope probe

type: discovery
timestamp: 2026-08-25T17:40
where: .devcontainer/dw.sh
tags: [product-problem, sreq, acceptance-criteria, forge, security]

This is a problem in the product/spec, not a learning about the codebase — route it to the findings
pipeline, not to CLAUDE.md.

Context: The SREQ says to assert the container's token lacks repo-admin scope by probing an
admin-only endpoint, and AC-7 pins that endpoint to GET /repos/{o}/{r}/branch_protections.
Pattern: That endpoint is not admin-scope-gated at all. Gitea gates it on the caller's owner
role over the repository
plus the plain repository token scope — and jbr870 owns the repo, so the
role half is always satisfied. Measured on the real least-privilege token (admin: No access): the
GET is reachable, and the POST creates branch-protection rules. Meanwhile /admin/users is
correctly refused with token does not have at least one of required scope(s): [read:admin].
Why it matters: Two things fall out, and they point in opposite directions.

  1. For the guard: a probe pointed at branch_protections refuses every token that could actually
    work and passes none — it does not discriminate. The probe must be an endpoint gated by the
    admin scope. dw.sh now names it in one constant, ADMIN_PROBE_PATH, because the shape of a
    least-privilege token is expected to keep narrowing.
  2. For the threat model: D-PO-61-2-2's mitigation was "a repo-admin token would let a session
    disable protection and then force-push". Removing admin scope does not close that — a
    non-admin scoped token can create and alter protection rules just as well. The isolation claim
    AC-7 rests on is narrower than the SREQ assumed.

Corrected from my own earlier reading. I had concluded the fix was to strip repository scope
entirely, and that this might collide with AC-1/AC-8 needing a repo lookup. The first half was the
wrong lever; the second half turned out to be a non-issue — measured after the token landed,
issue-list.sh through the pinned suite returns the real open-issue array on the scoped token.

2026-08-25T17:45 DISCOVERY (PRODUCT PROBLEM): the selftest's token preflight is all-or-nothing

type: discovery
timestamp: 2026-08-25T17:45
where: .devcontainer/selftest.sh
tags: [product-problem, test-harness]

This is a problem in the product, not a learning about the codebase.

Context: preflight() treats a missing container forge token as fail=1, which aborts the entire
run before any criterion executes.
Pattern: Only AC-7 actually asserts anything about the token's scope; the rest merely need a
working one, and several (AC-13's static scan, AC-5, AC-6, AC-15..AC-19, AC-22) would still be
meaningful with a token that is present but over-scoped.
Why it matters: As written, one missing precondition costs all 23 verdicts instead of one. A
per-criterion SKIP for the token-dependent rows would have let this work unit report real evidence for
most of the suite while the credential question was still open.

2026-08-25T17:25 STRUGGLE: I got the branch-protection probe backwards, and it propagated

type: struggle
timestamp: 2026-08-25T17:25
attempts: 2
resolved: true
tags: [forge, security, guard, measurement]

Problem: Choosing the endpoint the token-privilege guard probes.
Expected: branch_protections would answer only for an over-privileged token.
Actual: Measured against the first token minted (which carried repository scope), it answered
200 — so I concluded it "does not discriminate" and replaced it with /admin/users.
Attempts:

  1. branch_protections only — refused the first token. Correct outcome, but I read the reason wrong.
  2. /admin/users only — passes a repository-scoped token, which can still create protection rules.
    That is the escalation the guard exists to close, so this was strictly weaker.
    Solution: Probe both, require both refused. Measured against the correctly-scoped token:
    branch_protections → 403 [read:repository].
    Key insight: I generalized "this endpoint answered my one token" into "this endpoint cannot
    discriminate", from a sample of one token whose scope I had not isolated. The scope check runs
    before the owner-role check, so the endpoint discriminates precisely on repository scope — which
    is the thing worth discriminating on. Worse, I wrote the wrong conclusion into a code comment, and the
    Test Writer cited that comment as the authority for deleting the same probe from AC-7. A confident
    comment is a load-bearing claim: state the measurement, not the inference from it.

2026-08-25T17:30 DISCOVERY (PRODUCT PROBLEMS): four harness defects the first full run exposed

type: discovery
timestamp: 2026-08-25T17:30
where: .devcontainer/selftest.sh
tags: [product-problem, test-harness, acceptance-criteria]

These are problems in the product, not learnings about the codebase. First full run: 19 PASS,
4 FAIL — and all four failures are in the harness, not in dw.sh/container-init.sh.

  1. AC-19 contradicts AC-18. AC-19 writes an untracked file into /workspace and then calls rm
    expecting exit 0 — while AC-18(b) writes an untracked file into /workspace and requires rm to
    exit 2 naming it. The same act, two opposite required outcomes. No implementation can satisfy both.
    AC-19 needs --force, or a marker outside the work tree.
  2. AC-7's force-push is a no-op. The container's branch is cut from main's tip with zero
    commits, so push --force HEAD:main proposes no ref update, prints Everything up-to-date, and
    exits 0 without the protection rule ever being consulted — which the test reads as "the force-push
    succeeded". It needs a divergent commit first, the way AC-9 makes one. (AC-9 passes, which is the
    evidence that the forge side is fine.)
  3. AC-23 compares two sorts under different collations. Host and container hold the identical 15
    skill directories, but the host's locale collates _shared between setup and simplify-report
    while the container's C locale puts it first. Sort both sides under LC_ALL=C, or compare sets.
  4. AC-5 hashes a directory that contains every sibling session's worktree. .claude/worktrees/
    lives under the main checkout and held four worktrees during the run; dirhash walks in with
    find, so a peer session writing a file registers as "the host checkout changed". On a box where
    parallel sessions are the documented working model this cannot be stable. AC-6 — which enumerates
    the container's actual mount table — passes, and is the assertion that carries the real property.
<!-- dev-journal:v1 wu=WU-61-3-2 skill=develop --> # Development journal — WU-61-3-2 (dw.sh + container-init.sh) ## 2026-08-25T16:50 DISCOVERY: `ssh -G <alias>` beats parsing the ssh_config stanza ```yaml type: discovery timestamp: 2026-08-25T16:50 where: .devcontainer/dw.sh tags: [ssh, portability, seeding] ``` **Context:** The SREQ says to copy "the `forge-devwork` SSH stanza, the key it names, and the matching `known_hosts` entry" into the container. **Pattern:** Don't read `~/.ssh/config`. `ssh -G <alias>` prints the *effective* configuration (`hostname`, `port`, `user`, `identityfile`) after all of ssh's own include/match resolution, so the container gets a synthesized stanza that is exactly equivalent and strictly narrower than the literal text — no `Include`d file, no unrelated `Match` block, no sibling host. **Why it matters:** The known_hosts lookup key has to be derived from the same resolution (`[host]:port` when the port is not 22, bare host otherwise), or the copied entry silently fails to match and the container falls back to asking about an unknown host. Deriving both from one `ssh -G` call keeps them consistent by construction. ## 2026-08-25T16:58 STRUGGLE: `${var#~/}` tilde-expands the *pattern* ```yaml type: struggle timestamp: 2026-08-25T16:58 attempts: 1 resolved: true tags: [bash, quoting] ``` **Problem:** `ssh -G` reports the identity file as `~/.ssh/<key>`; that had to become an absolute path. **Expected:** `case "$f" in "~/"*) f="$HOME/${f#~/}" ;; esac` strips the prefix. **Actual:** `/home/jochem/~/.ssh/<key>` — "not readable". **Solution:** `f="$HOME${f#\~}"`. **Key insight:** Bash performs tilde expansion on the *pattern* half of `${var#pattern}`, so the `~/` there had already become `/home/jochem/` and matched nothing. The `case` pattern is quoted and therefore literal, which is exactly why the `case` matched while the strip inside it did not — the two halves of the same idiom disagree about what `~` means. Escape it in the parameter expansion. ## 2026-08-25T17:05 DISCOVERY: probe the Claude credential in a throwaway config dir ```yaml type: discovery timestamp: 2026-08-25T17:05 where: .devcontainer/container-init.sh tags: [credentials, acceptance-criteria] ``` **Context:** The credential has to be probed by *using* it (a token that parses fine but is no longer accepted is the whole failure mode), but AC-23 requires a fresh container to carry no accumulated session state — and `claude -p` writes a transcript under `projects/` for every run. **Pattern:** Copy the credential into `$(mktemp -d)`, run the probe under `CLAUDE_CONFIG_DIR=<that dir>`, delete the directory. The credential under test is byte-identical, and every artefact the probe creates dies with the temp dir. **Why it matters:** Without this the probe would have to run in `~/.claude` and then selectively delete what it created — a cleanup that has to stay in step with whatever the CLI writes next. This version has nothing to keep in step with. Measured: an invalid credential is refused in **2s**, well inside the 90s bound AC-12 allows. ## 2026-08-25T17:20 DISCOVERY: `tea api` prefixes advisory NOTE lines to its output ```yaml type: discovery timestamp: 2026-08-25T17:20 where: .devcontainer/container-init.sh tags: [tea, forge, parsing] ``` **Context:** The forge-token probe read `tea api /user` and asked jq for `.login`. **Pattern:** When no configured login matches the repository exactly, `tea` writes `NOTE: no login matched this repository, falling back to login '<name>' in non-interactive mode.` *ahead of* the JSON body. Read the body from its first `{` (`sed -n '/^{/,$p'`) rather than assuming it starts at byte zero. **Why it matters:** This compounds the already-recorded fact that `tea api` exits 0 even on an HTTP-level error — so the probe is judged on content, and the content is not where you expect it. Anything in this repo that feeds `tea api` output to jq is exposed to the same thing. ## 2026-08-25T17:35 DISCOVERY: a ready-marker short-circuit skipped `recreate`'s re-seed ```yaml type: discovery timestamp: 2026-08-25T17:35 where: .devcontainer/container-init.sh tags: [idempotence, recreate] ``` **Context:** `recreate` is defined as "new container, both volumes preserved, configuration re-seeded". **Pattern:** Because the volumes *are* ready, `is_ready` returns true and the whole volume-resident block — which is where `seed_settings` originally lived — never runs. The re-seed therefore has to sit with the container-layer setup that runs unconditionally, not with the once-per-volume-pair work. **Why it matters:** The general shape: when a verb's entire purpose is "redo part of setup", that part cannot live behind the marker that says setup is done. Caught by exercising `recreate` rather than by reading the code — the code reads correctly either way. ## 2026-08-25T17:40 DISCOVERY (PRODUCT PROBLEM): `branch_protections` cannot serve as an admin-scope probe ```yaml type: discovery timestamp: 2026-08-25T17:40 where: .devcontainer/dw.sh tags: [product-problem, sreq, acceptance-criteria, forge, security] ``` **This is a problem in the product/spec, not a learning about the codebase** — route it to the findings pipeline, not to CLAUDE.md. **Context:** The SREQ says to assert the container's token lacks repo-admin scope by probing an admin-only endpoint, and AC-7 pins that endpoint to `GET /repos/{o}/{r}/branch_protections`. **Pattern:** That endpoint is not admin-*scope*-gated at all. Gitea gates it on the caller's **owner role over the repository** plus the plain `repository` token scope — and jbr870 owns the repo, so the role half is always satisfied. Measured on the real least-privilege token (`admin: No access`): the GET is reachable, **and the POST creates branch-protection rules**. Meanwhile `/admin/users` is correctly refused with `token does not have at least one of required scope(s): [read:admin]`. **Why it matters:** Two things fall out, and they point in opposite directions. 1. *For the guard:* a probe pointed at `branch_protections` refuses every token that could actually work and passes none — it does not discriminate. The probe must be an endpoint gated by the **admin** scope. `dw.sh` now names it in one constant, `ADMIN_PROBE_PATH`, because the shape of a least-privilege token is expected to keep narrowing. 2. *For the threat model:* D-PO-61-2-2's mitigation was "a repo-admin token would let a session disable protection and then force-push". Removing admin scope does **not** close that — a non-admin scoped token can create and alter protection rules just as well. The isolation claim AC-7 rests on is narrower than the SREQ assumed. **Corrected from my own earlier reading.** I had concluded the fix was to strip *repository* scope entirely, and that this might collide with AC-1/AC-8 needing a repo lookup. The first half was the wrong lever; the second half turned out to be a non-issue — measured after the token landed, `issue-list.sh` through the pinned suite returns the real open-issue array on the scoped token. ## 2026-08-25T17:45 DISCOVERY (PRODUCT PROBLEM): the selftest's token preflight is all-or-nothing ```yaml type: discovery timestamp: 2026-08-25T17:45 where: .devcontainer/selftest.sh tags: [product-problem, test-harness] ``` **This is a problem in the product, not a learning about the codebase.** **Context:** `preflight()` treats a missing container forge token as `fail=1`, which aborts the entire run before any criterion executes. **Pattern:** Only AC-7 actually asserts anything about the token's *scope*; the rest merely need a working one, and several (AC-13's static scan, AC-5, AC-6, AC-15..AC-19, AC-22) would still be meaningful with a token that is present but over-scoped. **Why it matters:** As written, one missing precondition costs all 23 verdicts instead of one. A per-criterion SKIP for the token-dependent rows would have let this work unit report real evidence for most of the suite while the credential question was still open. ## 2026-08-25T17:25 STRUGGLE: I got the branch-protection probe backwards, and it propagated ```yaml type: struggle timestamp: 2026-08-25T17:25 attempts: 2 resolved: true tags: [forge, security, guard, measurement] ``` **Problem:** Choosing the endpoint the token-privilege guard probes. **Expected:** `branch_protections` would answer only for an over-privileged token. **Actual:** Measured against the *first* token minted (which carried `repository` scope), it answered 200 — so I concluded it "does not discriminate" and replaced it with `/admin/users`. **Attempts:** 1. `branch_protections` only — refused the first token. Correct outcome, but I read the reason wrong. 2. `/admin/users` only — passes a `repository`-scoped token, which can still *create* protection rules. That is the escalation the guard exists to close, so this was strictly weaker. **Solution:** Probe **both**, require both refused. Measured against the correctly-scoped token: `branch_protections` → 403 `[read:repository]`. **Key insight:** I generalized "this endpoint answered my one token" into "this endpoint cannot discriminate", from a sample of one token whose scope I had not isolated. The scope check runs *before* the owner-role check, so the endpoint discriminates precisely on `repository` scope — which is the thing worth discriminating on. Worse, I wrote the wrong conclusion into a code comment, and the Test Writer cited that comment as the authority for deleting the same probe from AC-7. **A confident comment is a load-bearing claim: state the measurement, not the inference from it.** ## 2026-08-25T17:30 DISCOVERY (PRODUCT PROBLEMS): four harness defects the first full run exposed ```yaml type: discovery timestamp: 2026-08-25T17:30 where: .devcontainer/selftest.sh tags: [product-problem, test-harness, acceptance-criteria] ``` **These are problems in the product, not learnings about the codebase.** First full run: 19 PASS, 4 FAIL — and all four failures are in the harness, not in `dw.sh`/`container-init.sh`. 1. **AC-19 contradicts AC-18.** AC-19 writes an untracked file into `/workspace` and then calls `rm` expecting exit 0 — while AC-18(b) writes an untracked file into `/workspace` and requires `rm` to exit 2 naming it. The same act, two opposite required outcomes. No implementation can satisfy both. AC-19 needs `--force`, or a marker outside the work tree. 2. **AC-7's force-push is a no-op.** The container's branch is cut from `main`'s tip with zero commits, so `push --force HEAD:main` proposes no ref update, prints `Everything up-to-date`, and exits 0 without the protection rule ever being consulted — which the test reads as "the force-push succeeded". It needs a divergent commit first, the way AC-9 makes one. (AC-9 passes, which is the evidence that the forge side is fine.) 3. **AC-23 compares two sorts under different collations.** Host and container hold the identical 15 skill directories, but the host's locale collates `_shared` between `setup` and `simplify-report` while the container's C locale puts it first. Sort both sides under `LC_ALL=C`, or compare sets. 4. **AC-5 hashes a directory that contains every sibling session's worktree.** `.claude/worktrees/` lives under the main checkout and held four worktrees during the run; `dirhash` walks in with `find`, so a peer session writing a file registers as "the host checkout changed". On a box where parallel sessions are the documented working model this cannot be stable. AC-6 — which enumerates the container's actual mount table — passes, and is the assertion that carries the real property.
Author
Owner
{
  "outcome": "complete",
  "summary": "Built the .devcontainer/dw.sh lifecycle (up/enter, stop, rm, recreate, repin, refresh-creds, ls) and container-init.sh, with .devcontainer/selftest.sh as the acceptance surface; final sweep is 23/23 PASS against real containers and the live forge.",
  "commit_refs": ["027c645", "0b9ca3d", "b26744b", "ec4ea36", "2582e9f", "76b7f61", "e082d91", "2623c25", "5f1e03e"],
  "journal_ref": "1810",
  "dependencies_satisfied": ["WU-61-3-1"],
  "dependencies_violated": []
}

Full 2-phase TDD. The Test Writer owned .devcontainer/selftest.sh, written from the acceptance
criteria alone with no sight of the SREQ's Architecture or Implementation Scope; the Implementer owned
dw.sh + container-init.sh. Depends on WU-61-3-1, whose image supplies the pinned toolchain, the
uid-1000 assertion and the pre-owned volume mount points these containers are built from.

Final result: 23 of 23 criteria PASS (run 6), verified against real containers and the live forge —
not by inspection. bash -n clean on both files, lint-conventions.sh exit 0, --clean leaves
nothing, zero containers and volumes at rest, forge back to its two resting branches.

commit_refs includes e082d91, which belongs to this unit but carries no [WU-61-3-2] marker in its
subject, so the marker-grep recipe missed it. Added by hand after reading the branch log.

What it took: six sweeps, and where the defects actually lived

Run Result Where the fault was
1–2 blocked container token over-scoped; guard refused, correctly
3 void executed stale code — harness commits landed mid-flight and bash kept reading the replaced inode; verdicts discarded rather than reported
4 22/23 AC-7 only — and it pushed a commit to main on its failure path
5 16/23 AC-7 payload collision + six criteria lost to a mid-run Claude credential rotation
6 23/23

Every defect found across runs 3–6 was in the test harness or the environment, not in dw.sh or
container-init.sh.
The implementation was substantially right from its first commit; what took five
further sweeps was making the tests capable of telling the truth about it.

AC-7 passed for the right reason, and the evidence says so

The criterion that was wrong three separate ways finally demonstrates its whole property end to end. The
non-descendant invariant is asserted before the forge is touched, and both operations are refused by
the protection rule by name:

remote: Forgejo: branch main is protected from force push
 ! [remote rejected] HEAD -> main (pre-receive hook declined)
remote: Forgejo: branch main is the default branch and cannot be deleted
 ! [remote rejected] main (pre-receive hook declined)

with the container's token refused at both escalation paths in the same log —
branch_protections[read:repository], /admin/users[read:admin].

The credential evidence is a known unknown, deliberately recorded as one

Run 6 sampled ~/.claude/.credentials.json every 5s for the full sweep — 240 samples, one distinct
reading
: mtime and expiresAt unchanged throughout. No rotation occurred.

This is weaker than it looks and must not be read as reassurance. The access token was ~7h50m from
expiry for the entire run, so no container ever needed to refresh — run 6 avoided the rotation path
rather than exercising it. It establishes that ~28 containers holding copies of a valid credential
work concurrently, which was never in doubt. It says nothing about N copies live when the token expires,
which is precisely run 5's failure and precisely what D-PO-61-2-3 waived. See the Finding on the
terminal Phase Outcome.

Carried forward, not fixed here

  • selftest.sh's up-to-date backstop greps the cumulative log, which already holds dw.sh up's
    clone output. It did not fire in run 6 — the string is absent from every run-6 log — but it stays
    latent for a git version that phrases a fetch differently. Fix is to capture the push's own output to
    its own file. Left as a known false-red (never a false green), so it fails safe.
  • sweep_branches_for_issues() fetches /branches?limit=50 — a silent cap. Two branches at rest today,
    so enormous headroom; recorded because "no silent caps" is a repo principle.
<!-- work-unit-outcome:v1 id=WU-61-3-2 skill=develop --> ```json { "outcome": "complete", "summary": "Built the .devcontainer/dw.sh lifecycle (up/enter, stop, rm, recreate, repin, refresh-creds, ls) and container-init.sh, with .devcontainer/selftest.sh as the acceptance surface; final sweep is 23/23 PASS against real containers and the live forge.", "commit_refs": ["027c645", "0b9ca3d", "b26744b", "ec4ea36", "2582e9f", "76b7f61", "e082d91", "2623c25", "5f1e03e"], "journal_ref": "1810", "dependencies_satisfied": ["WU-61-3-1"], "dependencies_violated": [] } ``` Full 2-phase TDD. The Test Writer owned `.devcontainer/selftest.sh`, written from the acceptance criteria alone with no sight of the SREQ's Architecture or Implementation Scope; the Implementer owned `dw.sh` + `container-init.sh`. Depends on **WU-61-3-1**, whose image supplies the pinned toolchain, the uid-1000 assertion and the pre-owned volume mount points these containers are built from. **Final result: 23 of 23 criteria PASS** (run 6), verified against real containers and the live forge — not by inspection. `bash -n` clean on both files, `lint-conventions.sh` exit 0, `--clean` leaves nothing, zero containers and volumes at rest, forge back to its two resting branches. `commit_refs` includes `e082d91`, which belongs to this unit but carries no `[WU-61-3-2]` marker in its subject, so the marker-grep recipe missed it. Added by hand after reading the branch log. ### What it took: six sweeps, and where the defects actually lived | Run | Result | Where the fault was | |---|---|---| | 1–2 | blocked | container token over-scoped; guard refused, correctly | | 3 | void | executed **stale code** — harness commits landed mid-flight and bash kept reading the replaced inode; verdicts discarded rather than reported | | 4 | 22/23 | AC-7 only — and it **pushed a commit to `main`** on its failure path | | 5 | 16/23 | AC-7 payload collision + six criteria lost to a mid-run Claude credential rotation | | 6 | **23/23** | — | **Every defect found across runs 3–6 was in the test harness or the environment, not in `dw.sh` or `container-init.sh`.** The implementation was substantially right from its first commit; what took five further sweeps was making the tests capable of telling the truth about it. ### AC-7 passed for the right reason, and the evidence says so The criterion that was wrong three separate ways finally demonstrates its whole property end to end. The non-descendant invariant is asserted **before** the forge is touched, and both operations are refused by the protection rule by name: ``` remote: Forgejo: branch main is protected from force push ! [remote rejected] HEAD -> main (pre-receive hook declined) remote: Forgejo: branch main is the default branch and cannot be deleted ! [remote rejected] main (pre-receive hook declined) ``` with the container's token refused at both escalation paths in the same log — `branch_protections` → `[read:repository]`, `/admin/users` → `[read:admin]`. ### The credential evidence is a known unknown, deliberately recorded as one Run 6 sampled `~/.claude/.credentials.json` every 5s for the full sweep — 240 samples, **one distinct reading**: mtime and `expiresAt` unchanged throughout. No rotation occurred. **This is weaker than it looks and must not be read as reassurance.** The access token was ~7h50m from expiry for the entire run, so no container ever *needed* to refresh — run 6 avoided the rotation path rather than exercising it. It establishes that ~28 containers holding copies of a *valid* credential work concurrently, which was never in doubt. It says nothing about N copies live when the token expires, which is precisely run 5's failure and precisely what `D-PO-61-2-3` waived. See the Finding on the terminal Phase Outcome. ### Carried forward, not fixed here - `selftest.sh`'s `up-to-date` backstop greps the **cumulative** log, which already holds `dw.sh up`'s clone output. It did not fire in run 6 — the string is absent from every run-6 log — but it stays latent for a git version that phrases a fetch differently. Fix is to capture the push's own output to its own file. Left as a known false-**red** (never a false green), so it fails safe. - `sweep_branches_for_issues()` fetches `/branches?limit=50` — a silent cap. Two branches at rest today, so enormous headroom; recorded because "no silent caps" is a repo principle.
Author
Owner

Development Journal — WU-61-3-3

Scope: .devcontainer/README.md (new), scripts/lint-conventions.sh (extend to .devcontainer/*.sh),
scripts/test-lint-conventions.sh (coverage for the extension).

2026-08-25T21:15 DISCOVERY: the helper-tier comment exemption is what makes .devcontainer/*.sh addable at all

type: discovery
timestamp: 2026-08-25T21:15
where: scripts/lint-conventions.sh
tags: [lint, helper-tier, bash-3.2]

Context: before widening helper_files to .devcontainer/*.sh, I checked whether the three scripts
would trip the bash-3.2 ceiling rules.

Pattern: they do produce raw matches — dw.sh:21 and selftest.sh:12 each name
mapfile/readarray, declare -A, local -n, ${var,,} in a header comment declaring that the file avoids
them. helper_hits() strips pure-comment lines (grep -Ev '^[0-9]+:[[:space:]]*#'), so all four hits
are filtered and the population lands clean.

Why it matters: this is the mirror image of the trap AC-13's static scan sets in .devcontainer/.
There, a mechanical absence check has no comment exemption, so a file may not even name the thing it
disclaims. Here, the comment exemption exists precisely so a header can name the constructs it avoids.
Two scans in the same feature with opposite rules about comments — worth keeping straight.

2026-08-25T21:40 DISCOVERY: which existing checks genuinely apply to .devcontainer/ — and why two of them do not

type: discovery
timestamp: 2026-08-25T21:40
where: scripts/lint-conventions.sh
tags: [lint, scope, false-positives]

Context: the SREQ's Technical Risks table asks for .devcontainer/*.sh in the lint sweep. The open
question was how much more of the linter to point at it.

Pattern: helper tier (bash -n + the four bash-3.2 rules) — added. Route-through and a script-tier
gate-output rule — deliberately not.

Why it matters: the argument against the gate-output rule is concrete, not stylistic. Its existing
implementation is a fence-tier regex over package-manager and test-runner gate verbs
(pnpm|npm|…|pytest|vitest|jest|tsc) — none of which occur anywhere in .devcontainer/, so pointing it
at these scripts would add a pass that can never fire. Writing a docker-flavoured variant instead would
fire falsely on correct code: dw.sh:235 pipes docker images … | sort | head -n1 | cut -f1 and
dw.sh:857 pipes docker ps -a … | sort -V. Both are data queries, not gates, and both are right.
The rule the risk table actually cares about is already obeyed by construction in these files
(cmd > "$OUT" 2>&1; rc=$? throughout) and is enforced for the ACs by selftest.sh's own verdict rule.

2026-08-25T22:05 DISCOVERY: the AC-13 scan reads README.md, and it reads it whole

type: discovery
timestamp: 2026-08-25T22:05
where: .devcontainer/selftest.sh:917
tags: [ac-13, documentation]

Context: writing .devcontainer/README.md against a scan that has no allowlist and no
comment-stripping.

Pattern: the scan loops for f in "$HERE"/* over every regular file except selftest.sh itself, and
grep -qF for eight literal tokens. A README is therefore held to exactly the same rule as a config
file: it may not name the tokens, even in a sentence saying they are absent. The document is written
positively throughout — it describes the terminal lifecycle that exists, and never mentions what does not.

Why it matters: the natural instinct when documenting infrastructure is to explain what it
deliberately omits. Here that instinct fails the gate.

<!-- dev-journal:v1 wu=WU-61-3-3 skill=develop --> # Development Journal — WU-61-3-3 Scope: `.devcontainer/README.md` (new), `scripts/lint-conventions.sh` (extend to `.devcontainer/*.sh`), `scripts/test-lint-conventions.sh` (coverage for the extension). ## 2026-08-25T21:15 DISCOVERY: the helper-tier comment exemption is what makes `.devcontainer/*.sh` addable at all ```yaml type: discovery timestamp: 2026-08-25T21:15 where: scripts/lint-conventions.sh tags: [lint, helper-tier, bash-3.2] ``` **Context:** before widening `helper_files` to `.devcontainer/*.sh`, I checked whether the three scripts would trip the bash-3.2 ceiling rules. **Pattern:** they do produce raw matches — `dw.sh:21` and `selftest.sh:12` each name `mapfile/readarray, declare -A, local -n, ${var,,}` in a header comment declaring that the file avoids them. `helper_hits()` strips pure-comment lines (`grep -Ev '^[0-9]+:[[:space:]]*#'`), so all four hits are filtered and the population lands clean. **Why it matters:** this is the mirror image of the trap AC-13's static scan sets in `.devcontainer/`. There, a mechanical absence check has *no* comment exemption, so a file may not even name the thing it disclaims. Here, the comment exemption exists precisely so a header can name the constructs it avoids. Two scans in the same feature with opposite rules about comments — worth keeping straight. ## 2026-08-25T21:40 DISCOVERY: which existing checks genuinely apply to `.devcontainer/` — and why two of them do not ```yaml type: discovery timestamp: 2026-08-25T21:40 where: scripts/lint-conventions.sh tags: [lint, scope, false-positives] ``` **Context:** the SREQ's Technical Risks table asks for `.devcontainer/*.sh` in the lint sweep. The open question was how much more of the linter to point at it. **Pattern:** helper tier (bash -n + the four bash-3.2 rules) — added. Route-through and a script-tier gate-output rule — deliberately not. **Why it matters:** the argument against the gate-output rule is concrete, not stylistic. Its existing implementation is a fence-tier regex over package-manager and test-runner gate verbs (`pnpm|npm|…|pytest|vitest|jest|tsc`) — none of which occur anywhere in `.devcontainer/`, so pointing it at these scripts would add a pass that can never fire. Writing a *docker-flavoured* variant instead would fire **falsely** on correct code: `dw.sh:235` pipes `docker images … | sort | head -n1 | cut -f1` and `dw.sh:857` pipes `docker ps -a … | sort -V`. Both are data queries, not gates, and both are right. The rule the risk table actually cares about is already obeyed by construction in these files (`cmd > "$OUT" 2>&1; rc=$?` throughout) and is enforced for the ACs by `selftest.sh`'s own verdict rule. ## 2026-08-25T22:05 DISCOVERY: the AC-13 scan reads README.md, and it reads it whole ```yaml type: discovery timestamp: 2026-08-25T22:05 where: .devcontainer/selftest.sh:917 tags: [ac-13, documentation] ``` **Context:** writing `.devcontainer/README.md` against a scan that has no allowlist and no comment-stripping. **Pattern:** the scan loops `for f in "$HERE"/*` over every regular file except `selftest.sh` itself, and `grep -qF` for eight literal tokens. A README is therefore held to exactly the same rule as a config file: it may not *name* the tokens, even in a sentence saying they are absent. The document is written positively throughout — it describes the terminal lifecycle that exists, and never mentions what does not. **Why it matters:** the natural instinct when documenting infrastructure is to explain what it deliberately omits. Here that instinct fails the gate.
Author
Owner
{
  "outcome": "complete",
  "summary": "Wrote .devcontainer/README.md — the documented terminal lifecycle AC-13 requires — and brought .devcontainer/*.sh into scripts/lint-conventions.sh's helper tier with its own non-vacuity guard and two new test-harness scenarios.",
  "commit_refs": ["339f114"],
  "journal_ref": "1835",
  "dependencies_satisfied": ["WU-61-3-1", "WU-61-3-2"],
  "dependencies_violated": []
}

Artifact-shape work unit — documentation plus a mechanical gate extension, verified by running the gate
and by scanning the artifact rather than by behavioural tests. Per /dev:develop §3.2 it ran
Implementer-only, with no Test Writer. It depends on both prior units: it documents the lifecycle
WU-61-3-2 built and lints the shell that unit and WU-61-3-1 produced, so it could only be written
once those files were final.

Verified by the lead, not taken on report:

  • bash scripts/lint-conventions.shexit 0, "lint-conventions: clean", and it now prints
    helper-tier scan examined 120 scripts / of those, 3 are .devcontainer/*.sh.
  • bash scripts/test-lint-conventions.shPASS, including the two new scenarios
    devcontainer_sh_in_helper_tier and devcontainer_population_non_vacuity.
  • The AC-13 IDE/GUI token scan and the AC-20 audio scan over .devcontainer/ both return no match
    outside selftest.sh itself, which the real scan excludes. The README passes the scan that has no
    allowlist and no comment exemption.

Non-vacuity was proved two ways, which is the standard this repo's own linter sets for itself:
permanently in the harness (a planted mapfile in a synthetic .devcontainer/bad.sh must be named at
its line, while a comment naming the same construct must not be — mirroring dw.sh's own header), and
ad hoc against the real files in a scratch tree outside the worktree, where a planted declare -A and a
dangling if were each caught and then removed. Nothing in the worktree was ever left violating.

What was deliberately not extended, with reasons — recorded because a lint rule that can never fire
is worse than no rule, since it reads as coverage:

  1. The gate-output rule. It governs these scripts, but its implementation is a regex over
    package-manager and test-runner verbs (pnpm|npm|yarn|pytest|vitest|jest|tsc), none of which occur
    in .devcontainer/ — pointing it there adds a pass that can never fire. A docker-flavoured variant
    would fire falsely on correct code: dw.sh pipes docker images … | sort | head and
    docker ps -a … | sort -V, which are data queries, not gates. The rule is obeyed by construction
    throughout these files (cmd > "$OUT" 2>&1; rc=$?).
  2. Tier-1 POSIX-glue and the stack-agnostic tier-3 scans. .devcontainer/ is exempt by explicit
    SREQ constraint; these scripts carry their own bash shebang and do not ship.
  3. The route-through / typed-record scan. It governs code that reads the forge ledger; these scripts
    drive Docker and never touch it. Flagged by the unit as a judgment call open to being overruled.

One necessary adjustment: scripts/test-lint-conventions.sh is now exempt from the construct scans
(still bash -n'd). To prove a ceiling rule fires, the harness must write a fixture containing the very
construct that rule hunts, and a quoted heredoc bound for another file is indistinguishable from a use
site to a line scan. Same reasoning as the pre-existing $SELF exclusion. test-plugin-gates.sh stays
fully checked.

Three defects noticed in dw.sh and reported rather than edited (that file belongs to a closed work
unit) — all carried as Findings on the terminal Phase Outcome:

  1. usage() omits recreate's --ssh-agent flag, which cmd_recreate does parse — so dw.sh --help
    and the README now disagree.
  2. cmd_ls mixes streams: header and data rows to stdout, the trailing legend to stderr. The table is
    the one output a human is likely to pipe.
  3. cmd_ls's last three columns are populated only for running containers — a stopped one reports -
    for branch, pin and credential age. Not a defect, but it is the state an operator returning after
    days is most likely to see, so the README documents it explicitly rather than letting the dashboard
    read as broken.
<!-- work-unit-outcome:v1 id=WU-61-3-3 skill=develop --> ```json { "outcome": "complete", "summary": "Wrote .devcontainer/README.md — the documented terminal lifecycle AC-13 requires — and brought .devcontainer/*.sh into scripts/lint-conventions.sh's helper tier with its own non-vacuity guard and two new test-harness scenarios.", "commit_refs": ["339f114"], "journal_ref": "1835", "dependencies_satisfied": ["WU-61-3-1", "WU-61-3-2"], "dependencies_violated": [] } ``` Artifact-shape work unit — documentation plus a mechanical gate extension, verified by running the gate and by scanning the artifact rather than by behavioural tests. Per `/dev:develop` §3.2 it ran **Implementer-only, with no Test Writer**. It depends on both prior units: it documents the lifecycle **WU-61-3-2** built and lints the shell that unit and **WU-61-3-1** produced, so it could only be written once those files were final. **Verified by the lead, not taken on report:** - `bash scripts/lint-conventions.sh` → **exit 0**, "lint-conventions: clean", and it now prints `helper-tier scan examined 120 scripts` / `of those, 3 are .devcontainer/*.sh`. - `bash scripts/test-lint-conventions.sh` → **PASS**, including the two new scenarios `devcontainer_sh_in_helper_tier` and `devcontainer_population_non_vacuity`. - The AC-13 IDE/GUI token scan and the AC-20 audio scan over `.devcontainer/` both return **no match** outside `selftest.sh` itself, which the real scan excludes. The README passes the scan that has no allowlist and no comment exemption. **Non-vacuity was proved two ways**, which is the standard this repo's own linter sets for itself: permanently in the harness (a planted `mapfile` in a synthetic `.devcontainer/bad.sh` must be named at its line, while a *comment* naming the same construct must not be — mirroring `dw.sh`'s own header), and ad hoc against the real files in a scratch tree outside the worktree, where a planted `declare -A` and a dangling `if` were each caught and then removed. Nothing in the worktree was ever left violating. **What was deliberately not extended, with reasons** — recorded because a lint rule that can never fire is worse than no rule, since it reads as coverage: 1. **The gate-output rule.** It governs these scripts, but its *implementation* is a regex over package-manager and test-runner verbs (`pnpm|npm|yarn|pytest|vitest|jest|tsc`), none of which occur in `.devcontainer/` — pointing it there adds a pass that can never fire. A docker-flavoured variant would fire **falsely on correct code**: `dw.sh` pipes `docker images … | sort | head` and `docker ps -a … | sort -V`, which are data queries, not gates. The rule is obeyed by construction throughout these files (`cmd > "$OUT" 2>&1; rc=$?`). 2. **Tier-1 POSIX-glue and the stack-agnostic tier-3 scans.** `.devcontainer/` is exempt by explicit SREQ constraint; these scripts carry their own bash shebang and do not ship. 3. **The route-through / typed-record scan.** It governs code that reads the forge ledger; these scripts drive Docker and never touch it. Flagged by the unit as a judgment call open to being overruled. One necessary adjustment: `scripts/test-lint-conventions.sh` is now exempt from the *construct* scans (still `bash -n`'d). To prove a ceiling rule fires, the harness must write a fixture containing the very construct that rule hunts, and a quoted heredoc bound for another file is indistinguishable from a use site to a line scan. Same reasoning as the pre-existing `$SELF` exclusion. `test-plugin-gates.sh` stays fully checked. **Three defects noticed in `dw.sh` and reported rather than edited** (that file belongs to a closed work unit) — all carried as Findings on the terminal Phase Outcome: 1. `usage()` omits `recreate`'s `--ssh-agent` flag, which `cmd_recreate` does parse — so `dw.sh --help` and the README now disagree. 2. `cmd_ls` mixes streams: header and data rows to stdout, the trailing legend to stderr. The table is the one output a human is likely to pipe. 3. `cmd_ls`'s last three columns are populated only for *running* containers — a stopped one reports `-` for branch, pin and credential age. Not a defect, but it is the state an operator returning after days is most likely to see, so the README documents it explicitly rather than letting the dashboard read as broken.
Author
Owner
{
  "phase": "develop",
  "learnings": [
    {
      "id": "L-1",
      "scope": "stack:docker",
      "status": "applied",
      "summary": "A fresh named volume is seeded from the image path it is mounted over, ownership included — create every mount point in the image owned by the container user, or the volume comes up root-owned and the create-time setup script cannot write to it.",
      "evidence": "DISCOVERY (WU-61-3-1): 'create the volume mount points in the image, owned by the container user' — the failure surfaces on the consumer side, so it gets debugged in the wrong file.",
      "source_ref": "comment:1472",
      "applied_to": ".claude/skills/stack-docker/SKILL.md"
    },
    {
      "id": "L-2",
      "scope": "stack:docker",
      "status": "applied",
      "summary": "A convenience installer that installs under $HOME evaporates when $HOME is a named volume, and usually cannot pin even when it takes a version argument — fetch the versioned artifact and verify its published checksum instead, install it root-owned outside $HOME, and disable autoupdate or the pin is retired behind your back.",
      "evidence": "DISCOVERY (WU-61-3-1): 'install.sh | bash cannot pin the Claude CLI in this image' — the installer fetches the latest bootstrap binary before pinning, and installs under a path that is a volume at runtime.",
      "source_ref": "comment:1472",
      "applied_to": ".claude/skills/stack-docker/SKILL.md"
    },
    {
      "id": "L-3",
      "scope": "project",
      "status": "applied",
      "summary": "This repo's two mechanical scanners have opposite rules about comments: lint-conventions.sh strips pure-comment lines before matching, while selftest.sh's AC-13/AC-20 absence scans have no comment exemption — so a file there may not name a token even to say it is absent.",
      "evidence": "DISCOVERY (WU-61-3-3) 'the helper-tier comment exemption is what makes .devcontainer/*.sh addable at all', plus STRUGGLE (WU-61-3-1) where the first Dockerfile draft failed the audio scan on its own comments disclaiming an audio stack.",
      "source_ref": "comment:1835",
      "applied_to": "CLAUDE.md"
    },
    {
      "id": "L-4",
      "scope": "project",
      "status": "applied",
      "summary": "Never edit a script while a run of it is in flight — git replaces files rather than rewriting them, so a running bash keeps reading the old inode and silently executes the previous version to completion. Whole-tree hashes of a shared checkout are also never stable on a box where parallel sessions are the working model.",
      "evidence": "Run 3 of selftest.sh executed entirely pre-fix code after harness commits landed mid-flight, reproducing stale 19/23 results that looked real; and AC-5 failed naming this run's own worktree because a peer session was committing into it while the hash walked it.",
      "source_ref": "comment:1810",
      "applied_to": "CLAUDE.md"
    },
    {
      "id": "L-5",
      "scope": "devwork",
      "status": "unhomed",
      "summary": "`tea api` exits 0 on HTTP-level errors (403/404) — it only fails non-zero on client-side errors such as an unknown login name — and prefixes advisory `NOTE:` lines to stdout when no login matches the repository exactly, so the JSON body does not start at byte zero. Every helper piping `tea api` into jq is exposed on both counts.",
      "evidence": "Found by the WU-61-3-2 Test Writer while building the harness (every pass/fail judgment had to be rewritten to inspect parsed JSON rather than exit status), and confirmed live in this run's own `tea issues ls` output.",
      "source_ref": "comment:1810",
      "applied_to": null
    },
    {
      "id": "L-6",
      "scope": "devwork",
      "status": "unhomed",
      "summary": "Gitea gates branch-protection endpoints on the `repository` token scope and checks that scope BEFORE the caller's owner-role, so `GET/POST /repos/{o}/{r}/branch_protections` is the direct behavioural discriminator for 'can this token reach branch protection' — and a token with plain `repository` scope and no admin of any kind can CREATE protection rules, which the `admin` scope check does not catch.",
      "evidence": "Measured across two token mints: a repository-scoped, non-admin token created a real protection rule on a throwaway glob; the same probe returns `[read:repository]` refused once repository scope is removed. Relevant to the forge contract's least-privilege guidance for tea-cli.",
      "source_ref": "comment:1810",
      "applied_to": null
    }
  ]
}
<!-- learning:v1 issue=61 skill=develop po=PO-61-3 --> ```json { "phase": "develop", "learnings": [ { "id": "L-1", "scope": "stack:docker", "status": "applied", "summary": "A fresh named volume is seeded from the image path it is mounted over, ownership included — create every mount point in the image owned by the container user, or the volume comes up root-owned and the create-time setup script cannot write to it.", "evidence": "DISCOVERY (WU-61-3-1): 'create the volume mount points in the image, owned by the container user' — the failure surfaces on the consumer side, so it gets debugged in the wrong file.", "source_ref": "comment:1472", "applied_to": ".claude/skills/stack-docker/SKILL.md" }, { "id": "L-2", "scope": "stack:docker", "status": "applied", "summary": "A convenience installer that installs under $HOME evaporates when $HOME is a named volume, and usually cannot pin even when it takes a version argument — fetch the versioned artifact and verify its published checksum instead, install it root-owned outside $HOME, and disable autoupdate or the pin is retired behind your back.", "evidence": "DISCOVERY (WU-61-3-1): 'install.sh | bash cannot pin the Claude CLI in this image' — the installer fetches the latest bootstrap binary before pinning, and installs under a path that is a volume at runtime.", "source_ref": "comment:1472", "applied_to": ".claude/skills/stack-docker/SKILL.md" }, { "id": "L-3", "scope": "project", "status": "applied", "summary": "This repo's two mechanical scanners have opposite rules about comments: lint-conventions.sh strips pure-comment lines before matching, while selftest.sh's AC-13/AC-20 absence scans have no comment exemption — so a file there may not name a token even to say it is absent.", "evidence": "DISCOVERY (WU-61-3-3) 'the helper-tier comment exemption is what makes .devcontainer/*.sh addable at all', plus STRUGGLE (WU-61-3-1) where the first Dockerfile draft failed the audio scan on its own comments disclaiming an audio stack.", "source_ref": "comment:1835", "applied_to": "CLAUDE.md" }, { "id": "L-4", "scope": "project", "status": "applied", "summary": "Never edit a script while a run of it is in flight — git replaces files rather than rewriting them, so a running bash keeps reading the old inode and silently executes the previous version to completion. Whole-tree hashes of a shared checkout are also never stable on a box where parallel sessions are the working model.", "evidence": "Run 3 of selftest.sh executed entirely pre-fix code after harness commits landed mid-flight, reproducing stale 19/23 results that looked real; and AC-5 failed naming this run's own worktree because a peer session was committing into it while the hash walked it.", "source_ref": "comment:1810", "applied_to": "CLAUDE.md" }, { "id": "L-5", "scope": "devwork", "status": "unhomed", "summary": "`tea api` exits 0 on HTTP-level errors (403/404) — it only fails non-zero on client-side errors such as an unknown login name — and prefixes advisory `NOTE:` lines to stdout when no login matches the repository exactly, so the JSON body does not start at byte zero. Every helper piping `tea api` into jq is exposed on both counts.", "evidence": "Found by the WU-61-3-2 Test Writer while building the harness (every pass/fail judgment had to be rewritten to inspect parsed JSON rather than exit status), and confirmed live in this run's own `tea issues ls` output.", "source_ref": "comment:1810", "applied_to": null }, { "id": "L-6", "scope": "devwork", "status": "unhomed", "summary": "Gitea gates branch-protection endpoints on the `repository` token scope and checks that scope BEFORE the caller's owner-role, so `GET/POST /repos/{o}/{r}/branch_protections` is the direct behavioural discriminator for 'can this token reach branch protection' — and a token with plain `repository` scope and no admin of any kind can CREATE protection rules, which the `admin` scope check does not catch.", "evidence": "Measured across two token mints: a repository-scoped, non-admin token created a real protection rule on a throwaway glob; the same probe returns `[read:repository]` refused once repository scope is removed. Relevant to the forge contract's least-privilege guidance for tea-cli.", "source_ref": "comment:1810", "applied_to": null } ] } ```
Author
Owner
{
  "next_state": "qa",
  "produced": [
    {
      "kind": "wu-plan",
      "ref": "1457",
      "summary": "3 work units in 3 waves, executed sequentially on a single shared worktree"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "1476",
      "summary": "WU-61-3-1: image refresh and retirement of the compose/VS Code stack"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "1811",
      "summary": "WU-61-3-2: dw.sh lifecycle + container-init.sh, with selftest.sh as the AC surface — 23/23 PASS"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "1836",
      "summary": "WU-61-3-3: .devcontainer/README.md and .devcontainer/*.sh brought into lint-conventions.sh"
    },
    {
      "kind": "dev-journal",
      "ref": "1472",
      "summary": "WU-61-3-1 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "1810",
      "summary": "WU-61-3-2 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "1835",
      "summary": "WU-61-3-3 development journal"
    },
    {
      "kind": "learning",
      "ref": "comment:1838",
      "summary": "6 learnings (4 applied — 2 to a new stack-docker skill, 2 to CLAUDE.md; 2 unhomed)"
    }
  ],
  "findings": [
    {
      "category": "in-scope-deferrable",
      "severity": "high",
      "summary": "D-PO-61-2-3's waived question is now live evidence, not a hypothesis: containers holding copies of one Claude credential lost authentication mid-run while the host's kept working — and the clean re-run did NOT exercise the rotation path.",
      "reasoning": "Run 5 lost seven criteria to `Failed to authenticate: OAuth session expired and could not be refreshed` inside containers, while the host credential remained valid. Run 6 then passed 23/23 with 240 credential samples showing no rotation at all — but the access token was ~7h50m from expiry throughout, so no container ever NEEDED to refresh. Run 6 therefore avoided the rotation path rather than clearing it. What is established: ~28 containers holding copies of a VALID credential work concurrently, which was never in doubt. What is not established: what happens when the token expires with N copies live — precisely run 5's scenario and precisely what the waiver deferred. The waiver's own tripwire reads: revisit BEFORE relying on unattended parallel runs. Since unattended parallel operation is the feature's stated purpose, this is the gap between the feature shipping and the feature delivering.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "substantial",
      "feature_value": "core",
      "adjacent_to_blocking": false,
      "requires_product_decision": true,
      "id": "F-PO-61-3-1"
    },
    {
      "category": "pre-existing",
      "severity": "high",
      "summary": "`tea api` exits 0 on HTTP-level errors and prefixes advisory `NOTE:` lines to stdout — every helper in the suite that pipes it into jq is exposed on both counts.",
      "reasoning": "Two independent defects in the forge CLI this project's adapter is built on, both found while building this feature's harness and neither specific to it. (a) `tea api` returns exit 0 on 403/404 — it fails non-zero only on client-side errors such as an unknown login name, so anything judging success by rc gets a false green. (b) It prefixes `NOTE: no login matched this repository, falling back to login '<x>'` to stdout, so the JSON body does not start at byte zero and a naive `| jq` fails. Both were confirmed live in this run. This is the same class as the repo's own rc-checked-stdout rule (#33) and gate-output rule (#49), and it reaches `_shared/procedures/bin/` and the tea-cli adapter generally — outside this feature's scope, but it silently undermines every typed-record read on this forge.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "substantial",
      "feature_value": "none",
      "adjacent_to_blocking": false,
      "requires_product_decision": true,
      "id": "F-PO-61-3-2"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "medium",
      "summary": "D-PO-61-2-2 was resolved on a threat model that is factually wrong: a token with plain `repository` scope and NO admin of any kind can create and alter branch-protection rules.",
      "reasoning": "The decision's stated mitigation was that 'a repo-ADMIN-scoped token lets a permission-relaxed session disable protection through the API and then force-push'. Measured twice: a repository-scoped, non-admin token created a real protection rule on a throwaway glob (removed immediately; main's own rule untouched). Gitea gates those routes on the `repository` scope plus the caller's owner-role, and checks the scope FIRST — so the escalation was live under the very token the decision blessed. Closed within this slice by narrowing the container token to read:user + read:issue + write:issue with no repository scope, verified refused on both `branch_protections` and `/admin/users`, and dw.sh now probes both paths. Recorded because the decision record on this issue still states the wrong model and a later reader would re-derive it.",
      "proposed_action": "accept",
      "fix_cost": "trivial",
      "feature_value": "core",
      "adjacent_to_blocking": true,
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-61-3-3"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "The SREQ's Key Decisions table states two mutually exclusive tea-authentication decisions; only one was implemented.",
      "reasoning": "'tea authentication — copy the host's tea config in at create' and 'tea token privilege — require a least-privilege token, assert scope at create' cannot both hold. Implemented the second, per D-PO-61-2-5's resolution, which already ruled this way (host-config route = the mechanism, scoped token = the value). No code consequence; the SREQ text should lose the first row so the contradiction is not rediscovered.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "trivial",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "defer-to-issue",
      "id": "F-PO-61-3-4"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "selftest.sh's `up-to-date` no-op guard greps the cumulative scenario log, which already contains `dw.sh up`'s clone output — a latent false-red for a git version that phrases a fetch differently.",
      "reasoning": "The string is absent from every run-6 log, so it did not fire and the guard is now only a backstop behind AC-7's `merge-base --is-ancestor` check. It fails SAFE — a false red, never a false green — so it does not threaten a verdict's trustworthiness. Fix is to capture the push's own output to its own file rather than grep the accumulated one. Left for a later pass rather than spending another 20-minute sweep to re-verify a cosmetic change.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "trivial",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "defer-to-issue",
      "id": "F-PO-61-3-5"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "selftest.sh's `sweep_branches_for_issues()` fetches `/branches?limit=50` and filters in memory — a silent cap on cleanup.",
      "reasoning": "Two branches at rest today and the only per-run accumulator is one AC-18 branch, so the headroom is enormous. Recorded rather than fixed because 'no silent caps' is a stated repo principle and the failure mode — cleanup going quietly incomplete on the operator's real forge — is exactly the kind that compounds unnoticed.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "trivial",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "defer-to-issue",
      "id": "F-PO-61-3-6"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "`dw.sh usage()` omits `recreate`'s `--ssh-agent` flag, which `cmd_recreate` does parse — so `dw.sh --help` and the new README disagree.",
      "reasoning": "Found by WU-61-3-3 while documenting the lifecycle from the code rather than from the plan, and reported rather than edited because dw.sh belongs to a closed work unit. One-line fix in usage(). Reported by the unit that could not fix it, which is the behaviour the file-boundary rule is meant to produce.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "trivial",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "defer-to-issue",
      "id": "F-PO-61-3-7"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "`dw.sh ls` mixes streams — header and data rows to stdout, the trailing legend to stderr — and its last three columns are populated only for running containers.",
      "reasoning": "The table is the one output a human is likely to pipe, and the split means a filter silently keeps or drops the legend depending on redirection. The empty columns are not a defect (they come from .dw-state.json read via exec, which needs a running container) but a stopped container is the state an operator returning after days is most likely to see, so the README documents it explicitly rather than letting the dashboard read as broken.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "trivial",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "defer-to-issue",
      "id": "F-PO-61-3-8"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "medium",
      "summary": "The isolation guarantee is narrower than 'a container cannot reach the other runs': sibling feature/* refs remain rewritable over SSH by any container holding the key.",
      "reasoning": "Restated from F-PO-61-2-8, which already carries this and already has a home, because this slice's token narrowing must NOT be read as having addressed it. Branch protection covers the integration branch only; the token work closed the unprotect-then-force-push escalation against main and nothing else. AC-7 verifies what it says it verifies and no more.",
      "proposed_action": "accept",
      "fix_cost": "substantial",
      "feature_value": "core",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-61-3-9"
    }
  ],
  "pending_decisions": [
    {
      "type": "scope-disposition",
      "blocking": false,
      "question": "The OAuth credential-copy question D-PO-61-2-3 waived is now backed by a real failure (run 5) and a re-run that did NOT clear it (run 6 never needed a refresh). Unattended parallel operation is this feature's stated purpose. Spawn a sibling issue to settle rotation behaviour deliberately, or accept the risk as it stands?",
      "options": [
        "defer-to-issue",
        "accept",
        "fix-now"
      ],
      "recommended": "defer-to-issue",
      "reasoning": "This is the waiver's own tripwire firing. Run 5 lost seven criteria to containers losing authentication while the host credential kept working; run 6 passed 23/23 but sampled the credential 240 times and saw no rotation at all, because the token was ~7h50m from expiry throughout — it avoided the path rather than exercising it. Settling it needs a deliberate experiment: a credential close to expiry with several containers live, observing whether one container's refresh invalidates its siblings. That is a bounded piece of work with a clear result, and it is not this feature's build. If rotation proves single-use, the copied-credential design is not viable at N>1 and per-container logins — rejected at requirements time on setup-ceremony grounds — come back on the table, which is a requirements conversation rather than a fix. `accept` is defensible for attended use, where the failure is loud and `dw.sh refresh-creds <N>` recovers it in one command; it is not defensible for the unattended runs the feature exists to enable.",
      "id": "D-PO-61-3-1"
    },
    {
      "type": "scope-disposition",
      "blocking": false,
      "question": "`tea api` exits 0 on HTTP errors and prefixes advisory NOTE: lines to stdout, exposing every suite helper that pipes it into jq. Out of scope for #61. Spawn a sibling issue against component:adapters, or accept?",
      "options": [
        "defer-to-issue",
        "accept",
        "fix-now"
      ],
      "recommended": "defer-to-issue",
      "reasoning": "Two independent defects in the forge CLI the tea-cli adapter is built on, both confirmed live in this run and neither introduced by this feature. They silently undermine typed-record reads across `_shared/procedures/bin/` — a false green from an rc check, and a jq parse failure from a body that does not start at byte zero. This is precisely the class the repo already legislated against twice (#33's rc-checked-stdout rule, #49's gate-output rule), so the principle is settled and only the audit-and-fix work remains. It belongs to component:adapters and wants its own slice; folding it into a devcontainer feature would bury a suite-wide correctness issue inside an infra ticket. `accept` would leave a known false-green surface in the code path that writes every phase record.",
      "id": "D-PO-61-3-2"
    }
  ],
  "suite": {
    "source": "git",
    "sha": "2ceacfe8490eb7efb2258c1d62981eea06acb97b",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-61-3 skill=develop --> ```json { "next_state": "qa", "produced": [ { "kind": "wu-plan", "ref": "1457", "summary": "3 work units in 3 waves, executed sequentially on a single shared worktree" }, { "kind": "work-unit-outcome", "ref": "1476", "summary": "WU-61-3-1: image refresh and retirement of the compose/VS Code stack" }, { "kind": "work-unit-outcome", "ref": "1811", "summary": "WU-61-3-2: dw.sh lifecycle + container-init.sh, with selftest.sh as the AC surface — 23/23 PASS" }, { "kind": "work-unit-outcome", "ref": "1836", "summary": "WU-61-3-3: .devcontainer/README.md and .devcontainer/*.sh brought into lint-conventions.sh" }, { "kind": "dev-journal", "ref": "1472", "summary": "WU-61-3-1 development journal" }, { "kind": "dev-journal", "ref": "1810", "summary": "WU-61-3-2 development journal" }, { "kind": "dev-journal", "ref": "1835", "summary": "WU-61-3-3 development journal" }, { "kind": "learning", "ref": "comment:1838", "summary": "6 learnings (4 applied — 2 to a new stack-docker skill, 2 to CLAUDE.md; 2 unhomed)" } ], "findings": [ { "category": "in-scope-deferrable", "severity": "high", "summary": "D-PO-61-2-3's waived question is now live evidence, not a hypothesis: containers holding copies of one Claude credential lost authentication mid-run while the host's kept working — and the clean re-run did NOT exercise the rotation path.", "reasoning": "Run 5 lost seven criteria to `Failed to authenticate: OAuth session expired and could not be refreshed` inside containers, while the host credential remained valid. Run 6 then passed 23/23 with 240 credential samples showing no rotation at all — but the access token was ~7h50m from expiry throughout, so no container ever NEEDED to refresh. Run 6 therefore avoided the rotation path rather than clearing it. What is established: ~28 containers holding copies of a VALID credential work concurrently, which was never in doubt. What is not established: what happens when the token expires with N copies live — precisely run 5's scenario and precisely what the waiver deferred. The waiver's own tripwire reads: revisit BEFORE relying on unattended parallel runs. Since unattended parallel operation is the feature's stated purpose, this is the gap between the feature shipping and the feature delivering.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "substantial", "feature_value": "core", "adjacent_to_blocking": false, "requires_product_decision": true, "id": "F-PO-61-3-1" }, { "category": "pre-existing", "severity": "high", "summary": "`tea api` exits 0 on HTTP-level errors and prefixes advisory `NOTE:` lines to stdout — every helper in the suite that pipes it into jq is exposed on both counts.", "reasoning": "Two independent defects in the forge CLI this project's adapter is built on, both found while building this feature's harness and neither specific to it. (a) `tea api` returns exit 0 on 403/404 — it fails non-zero only on client-side errors such as an unknown login name, so anything judging success by rc gets a false green. (b) It prefixes `NOTE: no login matched this repository, falling back to login '<x>'` to stdout, so the JSON body does not start at byte zero and a naive `| jq` fails. Both were confirmed live in this run. This is the same class as the repo's own rc-checked-stdout rule (#33) and gate-output rule (#49), and it reaches `_shared/procedures/bin/` and the tea-cli adapter generally — outside this feature's scope, but it silently undermines every typed-record read on this forge.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "substantial", "feature_value": "none", "adjacent_to_blocking": false, "requires_product_decision": true, "id": "F-PO-61-3-2" }, { "category": "in-scope-deferrable", "severity": "medium", "summary": "D-PO-61-2-2 was resolved on a threat model that is factually wrong: a token with plain `repository` scope and NO admin of any kind can create and alter branch-protection rules.", "reasoning": "The decision's stated mitigation was that 'a repo-ADMIN-scoped token lets a permission-relaxed session disable protection through the API and then force-push'. Measured twice: a repository-scoped, non-admin token created a real protection rule on a throwaway glob (removed immediately; main's own rule untouched). Gitea gates those routes on the `repository` scope plus the caller's owner-role, and checks the scope FIRST — so the escalation was live under the very token the decision blessed. Closed within this slice by narrowing the container token to read:user + read:issue + write:issue with no repository scope, verified refused on both `branch_protections` and `/admin/users`, and dw.sh now probes both paths. Recorded because the decision record on this issue still states the wrong model and a later reader would re-derive it.", "proposed_action": "accept", "fix_cost": "trivial", "feature_value": "core", "adjacent_to_blocking": true, "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-61-3-3" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "The SREQ's Key Decisions table states two mutually exclusive tea-authentication decisions; only one was implemented.", "reasoning": "'tea authentication — copy the host's tea config in at create' and 'tea token privilege — require a least-privilege token, assert scope at create' cannot both hold. Implemented the second, per D-PO-61-2-5's resolution, which already ruled this way (host-config route = the mechanism, scoped token = the value). No code consequence; the SREQ text should lose the first row so the contradiction is not rediscovered.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "trivial", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "defer-to-issue", "id": "F-PO-61-3-4" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "selftest.sh's `up-to-date` no-op guard greps the cumulative scenario log, which already contains `dw.sh up`'s clone output — a latent false-red for a git version that phrases a fetch differently.", "reasoning": "The string is absent from every run-6 log, so it did not fire and the guard is now only a backstop behind AC-7's `merge-base --is-ancestor` check. It fails SAFE — a false red, never a false green — so it does not threaten a verdict's trustworthiness. Fix is to capture the push's own output to its own file rather than grep the accumulated one. Left for a later pass rather than spending another 20-minute sweep to re-verify a cosmetic change.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "trivial", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "defer-to-issue", "id": "F-PO-61-3-5" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "selftest.sh's `sweep_branches_for_issues()` fetches `/branches?limit=50` and filters in memory — a silent cap on cleanup.", "reasoning": "Two branches at rest today and the only per-run accumulator is one AC-18 branch, so the headroom is enormous. Recorded rather than fixed because 'no silent caps' is a stated repo principle and the failure mode — cleanup going quietly incomplete on the operator's real forge — is exactly the kind that compounds unnoticed.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "trivial", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "defer-to-issue", "id": "F-PO-61-3-6" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "`dw.sh usage()` omits `recreate`'s `--ssh-agent` flag, which `cmd_recreate` does parse — so `dw.sh --help` and the new README disagree.", "reasoning": "Found by WU-61-3-3 while documenting the lifecycle from the code rather than from the plan, and reported rather than edited because dw.sh belongs to a closed work unit. One-line fix in usage(). Reported by the unit that could not fix it, which is the behaviour the file-boundary rule is meant to produce.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "trivial", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "defer-to-issue", "id": "F-PO-61-3-7" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "`dw.sh ls` mixes streams — header and data rows to stdout, the trailing legend to stderr — and its last three columns are populated only for running containers.", "reasoning": "The table is the one output a human is likely to pipe, and the split means a filter silently keeps or drops the legend depending on redirection. The empty columns are not a defect (they come from .dw-state.json read via exec, which needs a running container) but a stopped container is the state an operator returning after days is most likely to see, so the README documents it explicitly rather than letting the dashboard read as broken.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "trivial", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "defer-to-issue", "id": "F-PO-61-3-8" }, { "category": "in-scope-deferrable", "severity": "medium", "summary": "The isolation guarantee is narrower than 'a container cannot reach the other runs': sibling feature/* refs remain rewritable over SSH by any container holding the key.", "reasoning": "Restated from F-PO-61-2-8, which already carries this and already has a home, because this slice's token narrowing must NOT be read as having addressed it. Branch protection covers the integration branch only; the token work closed the unprotect-then-force-push escalation against main and nothing else. AC-7 verifies what it says it verifies and no more.", "proposed_action": "accept", "fix_cost": "substantial", "feature_value": "core", "adjacent_to_blocking": false, "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-61-3-9" } ], "pending_decisions": [ { "type": "scope-disposition", "blocking": false, "question": "The OAuth credential-copy question D-PO-61-2-3 waived is now backed by a real failure (run 5) and a re-run that did NOT clear it (run 6 never needed a refresh). Unattended parallel operation is this feature's stated purpose. Spawn a sibling issue to settle rotation behaviour deliberately, or accept the risk as it stands?", "options": [ "defer-to-issue", "accept", "fix-now" ], "recommended": "defer-to-issue", "reasoning": "This is the waiver's own tripwire firing. Run 5 lost seven criteria to containers losing authentication while the host credential kept working; run 6 passed 23/23 but sampled the credential 240 times and saw no rotation at all, because the token was ~7h50m from expiry throughout — it avoided the path rather than exercising it. Settling it needs a deliberate experiment: a credential close to expiry with several containers live, observing whether one container's refresh invalidates its siblings. That is a bounded piece of work with a clear result, and it is not this feature's build. If rotation proves single-use, the copied-credential design is not viable at N>1 and per-container logins — rejected at requirements time on setup-ceremony grounds — come back on the table, which is a requirements conversation rather than a fix. `accept` is defensible for attended use, where the failure is loud and `dw.sh refresh-creds <N>` recovers it in one command; it is not defensible for the unattended runs the feature exists to enable.", "id": "D-PO-61-3-1" }, { "type": "scope-disposition", "blocking": false, "question": "`tea api` exits 0 on HTTP errors and prefixes advisory NOTE: lines to stdout, exposing every suite helper that pipes it into jq. Out of scope for #61. Spawn a sibling issue against component:adapters, or accept?", "options": [ "defer-to-issue", "accept", "fix-now" ], "recommended": "defer-to-issue", "reasoning": "Two independent defects in the forge CLI the tea-cli adapter is built on, both confirmed live in this run and neither introduced by this feature. They silently undermine typed-record reads across `_shared/procedures/bin/` — a false green from an rc check, and a jq parse failure from a body that does not start at byte zero. This is precisely the class the repo already legislated against twice (#33's rc-checked-stdout rule, #49's gate-output rule), so the principle is settled and only the audit-and-fix work remains. It belongs to component:adapters and wants its own slice; folding it into a devcontainer feature would bury a suite-wide correctness issue inside an infra ticket. `accept` would leave a known false-green surface in the code path that writes every phase record.", "id": "D-PO-61-3-2" } ], "suite": { "source": "git", "sha": "2ceacfe8490eb7efb2258c1d62981eea06acb97b", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "Operator-directed at the QA gate (2026-08-26): resolve with the recommended option. Grounded in this run's own evidence rather than a preference. Run 5 lost seven criteria to `Failed to authenticate: OAuth session expired and could not be refreshed` inside containers while the host credential stayed valid. Run 6's 23/23 pass did NOT clear it: 240 credential samples showed no rotation at all, because the access token sat ~7h50m from expiry throughout, so no container ever needed to refresh. Run 6 avoided the rotation path rather than exercising it. Settling the question needs a deliberate experiment (a near-expiry credential with several containers live, observing whether one container's refresh invalidates its siblings) - bounded work with a clear result, but not this feature's build. If rotation proves single-use, the copied-credential design is not viable at N>1 and per-container logins (rejected at requirements time on setup-ceremony grounds) return to the table, which is a requirements conversation rather than a fix. This is the tripwire D-PO-61-2-3's waiver wrote for itself: revisit BEFORE relying on unattended parallel runs.",
  "rejected_alternative": "`accept` was genuinely in play and is defensible for ATTENDED use: the failure is loud and `dw.sh refresh-creds <N>` recovers it in one command. Rejected because unattended parallel operation is this feature's stated purpose, and under that mode the same failure is silent until the run is found dead. `fix-now` was rejected because the only known fix is per-container logins - a requirements-level reversal the operator already ruled on, not something to settle inside an infra slice."
}
<!-- decision-resolution:v1 ref=D-PO-61-3-1 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "Operator-directed at the QA gate (2026-08-26): resolve with the recommended option. Grounded in this run's own evidence rather than a preference. Run 5 lost seven criteria to `Failed to authenticate: OAuth session expired and could not be refreshed` inside containers while the host credential stayed valid. Run 6's 23/23 pass did NOT clear it: 240 credential samples showed no rotation at all, because the access token sat ~7h50m from expiry throughout, so no container ever needed to refresh. Run 6 avoided the rotation path rather than exercising it. Settling the question needs a deliberate experiment (a near-expiry credential with several containers live, observing whether one container's refresh invalidates its siblings) - bounded work with a clear result, but not this feature's build. If rotation proves single-use, the copied-credential design is not viable at N>1 and per-container logins (rejected at requirements time on setup-ceremony grounds) return to the table, which is a requirements conversation rather than a fix. This is the tripwire D-PO-61-2-3's waiver wrote for itself: revisit BEFORE relying on unattended parallel runs.", "rejected_alternative": "`accept` was genuinely in play and is defensible for ATTENDED use: the failure is loud and `dw.sh refresh-creds <N>` recovers it in one command. Rejected because unattended parallel operation is this feature's stated purpose, and under that mode the same failure is silent until the run is found dead. `fix-now` was rejected because the only known fix is per-container logins - a requirements-level reversal the operator already ruled on, not something to settle inside an infra slice." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "Operator-directed at the QA gate (2026-08-26): resolve with the recommended option. Both defects were confirmed live in this run and neither was introduced by #61: (a) `tea api` returns exit 0 on HTTP 403/404 - it fails non-zero only on client-side errors such as an unknown login name, so any helper judging success by rc gets a false green; (b) it prefixes `NOTE: no login matched this repository, falling back to login <x>` to stdout, so the JSON body does not start at byte zero and a naive `| jq` fails. The blast radius is `_shared/procedures/bin/` and the tea-cli adapter generally - the same code path that writes every typed phase record - so this is a suite-wide correctness issue, not devcontainer infra. The repo has already legislated the principle twice (#33 rc-checked-stdout, #49 gate-output), so only the audit-and-fix labour remains, and it belongs to component:adapters as its own slice. Folding it into a devcontainer feature would bury it.",
  "rejected_alternative": "`accept` was considered and rejected: it would knowingly leave a false-green surface in the code that records what every phase did, which is the failure mode the repo has twice written rules against. `fix-now` was rejected on scope, not on merit - auditing every `tea api` call site across the shared helper tier and the adapter is substantial work with its own review surface, and doing it inside #61 would make an infra slice the de facto owner of the forge adapter contract."
}
<!-- decision-resolution:v1 ref=D-PO-61-3-2 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "Operator-directed at the QA gate (2026-08-26): resolve with the recommended option. Both defects were confirmed live in this run and neither was introduced by #61: (a) `tea api` returns exit 0 on HTTP 403/404 - it fails non-zero only on client-side errors such as an unknown login name, so any helper judging success by rc gets a false green; (b) it prefixes `NOTE: no login matched this repository, falling back to login <x>` to stdout, so the JSON body does not start at byte zero and a naive `| jq` fails. The blast radius is `_shared/procedures/bin/` and the tea-cli adapter generally - the same code path that writes every typed phase record - so this is a suite-wide correctness issue, not devcontainer infra. The repo has already legislated the principle twice (#33 rc-checked-stdout, #49 gate-output), so only the audit-and-fix labour remains, and it belongs to component:adapters as its own slice. Folding it into a devcontainer feature would bury it.", "rejected_alternative": "`accept` was considered and rejected: it would knowingly leave a false-green surface in the code that records what every phase did, which is the failure mode the repo has twice written rules against. `fix-now` was rejected on scope, not on merit - auditing every `tea api` call site across the shared helper tier and the adapter is substantial work with its own review surface, and doing it inside #61 would make an infra slice the de facto owner of the forge adapter contract." } ```
Author
Owner

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

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

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

Linked: this issue is **sibling** #260 (recorded by the devwork pipeline).
Author
Owner
{
  "outcome": "skipped",
  "summary": "Skipped: no rendered UI in this project or this feature — the deliverables are shell scripts, a Dockerfile and markdown.",
  "findings": [],
  "not_applicable_reason": "no rendered UI — the deliverables are markdown skill text and shell helpers"
}
<!-- qa-report:v1 issue=61 skill=qa domain=a11y phase=validate --> ```json { "outcome": "skipped", "summary": "Skipped: no rendered UI in this project or this feature — the deliverables are shell scripts, a Dockerfile and markdown.", "findings": [], "not_applicable_reason": "no rendered UI — the deliverables are markdown skill text and shell helpers" } ```
Author
Owner
{
  "outcome": "skipped",
  "summary": "Skipped as a browser domain per the project declaration. The feature's end-to-end coverage is the shell harness .devcontainer/selftest.sh (AC-1..AC-23), executed as this round's validation run and reported on the round's Phase Outcome.",
  "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=61 skill=qa domain=e2e phase=validate --> ```json { "outcome": "skipped", "summary": "Skipped as a browser domain per the project declaration. The feature's end-to-end coverage is the shell harness .devcontainer/selftest.sh (AC-1..AC-23), executed as this round's validation run and reported on the round's Phase Outcome.", "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": "Skipped: this feature serves and renders nothing — it builds a container image and a lifecycle script.",
  "findings": [],
  "not_applicable_reason": "no browser surface — nothing is served or rendered"
}
<!-- qa-report:v1 issue=61 skill=qa domain=security-browser phase=validate --> ```json { "outcome": "skipped", "summary": "Skipped: this feature serves and renders nothing — it builds a container image and a lifecycle script.", "findings": [], "not_applicable_reason": "no browser surface — nothing is served or rendered" } ```
Author
Owner
{
  "outcome": "skipped",
  "summary": "Skipped on the project's machine-checkable declaration api_invocation.mode: none. This feature adds no HTTP surface; its only network callers are the git and tea CLIs against the existing forge.",
  "findings": [],
  "not_applicable_reason": "project declares api_invocation: { mode: none } — no API — markdown+shell skill suite"
}
<!-- qa-report:v1 issue=61 skill=qa domain=api phase=validate --> ```json { "outcome": "skipped", "summary": "Skipped on the project's machine-checkable declaration api_invocation.mode: none. This feature adds no HTTP surface; its only network callers are the git and tea CLIs against the existing forge.", "findings": [], "not_applicable_reason": "project declares api_invocation: { mode: none } — no API — markdown+shell skill suite" } ```
Author
Owner
{
  "outcome": "skipped",
  "summary": "Skipped on the project's machine-checkable declaration api_invocation.mode: none. The feature's credential and token handling is reviewed under the code domain's Static Security Reviewer role instead, since there is no API surface to exercise.",
  "findings": [],
  "not_applicable_reason": "project declares api_invocation: { mode: none } — no API — markdown+shell skill suite"
}
<!-- qa-report:v1 issue=61 skill=qa domain=security-api phase=validate --> ```json { "outcome": "skipped", "summary": "Skipped on the project's machine-checkable declaration api_invocation.mode: none. The feature's credential and token handling is reviewed under the code domain's Static Security Reviewer role instead, since there is no API surface to exercise.", "findings": [], "not_applicable_reason": "project declares api_invocation: { mode: none } — no API — markdown+shell skill suite" } ```
Author
Owner
{
  "outcome": "issues-found",
  "summary": "34 findings from four roles over the .devcontainer diff (dw.sh, container-init.sh, selftest.sh, Dockerfile). 18 blocking. The load-bearing result is that 8 of them are defects in selftest.sh itself - the harness that grades all 23 acceptance criteria - so this round does NOT treat a selftest green as evidence until they are fixed.",
  "findings": [
    {
      "id": "CR-1",
      "category": "in-scope-blocking",
      "severity": "high",
      "summary": "refresh-creds replaces the forge-token file but never re-registers it with tea, and its probe validates the OLD login — the verb's forge-token half is inert and its verdict misleading.",
      "reasoning": "The only consumer of $DW_DIR/forge-token is setup_forge_login (container-init.sh:119-121), which the DW_PROBE_ONLY path never calls; probe_forge_token goes through tea's registered login, not the file. README ('Re-copies ... the forge token in ... and re-probes both') documents behavior the code does not have.  [reported by bug-hunter (fable, adversarial) as CR-1; file .devcontainer/dw.sh:826]",
      "failure_scenario": "Operator rotates the forge token (old revoked, new written to ~/.config/dw/tea-token) and runs 'dw.sh refresh-creds N'. dw.sh pushes the new token to ~/.claude/.dw/forge-token, then runs container-init with DW_PROBE_ONLY=1, which skips setup_forge_login (container-init.sh:303-309) and probes via 'tea api /user' (container-init.sh:92) — tea uses its STORED login, i.e. the revoked token -> probe fails -> exit 1 'refreshed credentials did not probe clean' even though the new token is good. Converse: old token still valid but expiring — probe passes, dw.sh prints 'refreshed the credentials inside dw-N', but tea keeps using the old token until the next full 'up', so an unattended run dies mid-flight at exactly the moment refresh-creds exists to prevent.",
      "file": ".devcontainer/dw.sh",
      "line": 826,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-1"
    },
    {
      "id": "CR-2",
      "category": "in-scope-blocking",
      "severity": "high",
      "summary": "AC-20's required static scan for audio packages/socket mounts in .devcontainer/ is not implemented anywhere in the diff, despite selftest.sh's own comment and the WU-61-3-1 commit message claiming it exists and passes.",
      "reasoning": "SREQ's Acceptance Criteria table for AC-20 explicitly lists two verification approaches: 'assert clean start and usable session; plus a scan asserting no audio package or socket mount remains in .devcontainer/'. Only the first exists. Traced the claimed second half back through git history (3d2acf6's commit message says '.devcontainer/ scans clean for audio and IDE-integration tokens' but describes a manual check, not committed code) and through df6584a (the original failing-test commit for the harness, which already lacked it) -- it was never written, only asserted to exist.  [reported by spec-checker (sonnet) as CR-1; file .devcontainer/selftest.sh:1136]",
      "failure_scenario": "A future change reintroduces an audio package or a pulse socket mount into .devcontainer/ (e.g. someone restores /voice support) -- no mechanical gate catches it, because ac20() only checks the runtime environment (env -u PULSE_SERVER -u XDG_RUNTIME_DIR), never the file contents.",
      "file": ".devcontainer/selftest.sh",
      "line": 1136,
      "source_role": "spec-checker (sonnet)",
      "source_id": "CR-1"
    },
    {
      "id": "CR-3",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "seed_ssh_material's 'no IdentityFile' guard is dead code: ssh -G always emits the default identity list, so a stanza without IdentityFile silently copies ~/.ssh/id_rsa — a potentially unrelated private key — into the container.",
      "reasoning": "ssh -G's default-identity emission is unconditional (verified empirically this session with a nonexistent alias). The check must distinguish an explicitly configured IdentityFile from the default list.  [reported by bug-hunter (fable, adversarial) as CR-5; file .devcontainer/dw.sh:330]",
      "failure_scenario": "Operator's Host forge-devwork stanza omits IdentityFile (relies on agent/defaults). 'ssh -G forge-devwork' then prints the default chain (verified on this host: id_rsa, id_ecdsa, id_ed25519, ...); ssh_setting takes the first line, so idfile=~/.ssh/id_rsa — never empty, so the die at line 332 cannot fire. If that personal key exists it is pushed into the container (line 355); if it is also registered on the forge, everything works silently on a key with broader reach — the exact over-exposure the 'only the key for THIS forge' design promises to prevent. The hostname guard at line 331 is dead the same way (ssh -G echoes the alias as hostname).",
      "file": ".devcontainer/dw.sh",
      "line": 330,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-5"
    },
    {
      "id": "CR-4",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "AC-5's peer-workspace before/after comparison has no rc or non-empty check on the dexec calls that compute the two hashes being compared to each other.",
      "reasoning": "This is the one place in the file where two independently-computed values are compared only to each other rather than to a known non-empty expected constant (contrast with AC-21/AC-22, which compare dexec output to a concrete expected branch/sha and would correctly fail on an empty result). It fits section-6 item 6: 'a scenario whose setup silently no-ops, so it asserts on a state it never created.'  [reported by test-quality-audit (sonnet) as CR-2; file .devcontainer/selftest.sh:559]",
      "failure_scenario": "If 'dexec \"$peer\" ...' fails identically both times (e.g. a regression breaks /workspace access specifically for a second/peer container while the primary container A still works), peer_ws_before and peer_ws_after would both come back as empty strings, and the equality check would pass -- the scenario would report no violation despite never having actually hashed the peer's workspace either time.",
      "file": ".devcontainer/selftest.sh",
      "line": 559,
      "source_role": "test-quality-audit (sonnet)",
      "source_id": "CR-2"
    },
    {
      "id": "CR-5",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "AC-11 never verifies that the in-container credential was actually refreshed; it only checks that dw.sh refresh-creds exits 0 and that the host credential file's hash/mtime are unchanged.",
      "reasoning": "Matches this repo's own SREQ.md AC-11 row, which also only specifies the writability + host-unchanged checks -- so this traces to how the criterion was scoped, not solely to the test author's choice. Still worth flagging per the audit's 'single-character change' method: no change to the container-side refresh behavior itself would break this test.  [reported by test-quality-audit (sonnet) as CR-3; file .devcontainer/selftest.sh:854]",
      "failure_scenario": "A refresh-creds implementation that is a complete no-op (does nothing to the container's credential file, just returns 0) passes this scenario, because the only assertions are: (1) the container path is writable, (2) refresh-creds exits 0, (3) the host file didn't change. The AC's own title, 'credential refreshable in place,' is half-untested.",
      "file": ".devcontainer/selftest.sh",
      "line": 854,
      "source_role": "test-quality-audit (sonnet)",
      "source_id": "CR-3"
    },
    {
      "id": "CR-6",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "The token least-privilege guard fails OPEN on response-shape drift: 'refused' is inferred from 'body is not a JSON array', so a forge upgrade that changes either probe endpoint's 200 body to an object envelope green-lights over-scoped tokens.",
      "reasoning": "The guard's dangerous direction should fail closed: distinguish HTTP 403/401 (refused) from HTTP 200 with any parseable body (reachable), rather than keying acceptance on one specific success shape measured on today's forge.  [reported by bug-hunter (fable, adversarial) as CR-7; file .devcontainer/dw.sh:286]",
      "failure_scenario": "A future Gitea/Forgejo version paginates /repos/{o}/{r}/branch_protections or /admin/users as {\"data\":[...],\"total\":N}. probe_reachable's 'jq type==array' fails -> guard concludes 'token could not reach it' -> a repository- or admin-scoped token passes assert_token_least_privilege on every up/recreate/refresh, silently reopening the unprotect-then-force-push escalation. selftest ac7 mirrors the same shape test (~lines 795-805), so the harness goes blind at the same moment.",
      "file": ".devcontainer/dw.sh",
      "line": 286,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-7"
    },
    {
      "id": "CR-7",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "up/rm adopt ANY container named dw-<N> without checking the dev.devwork.managed-by label — credentials get seeded into, or removal destroys, a container dw.sh does not own.",
      "reasoning": "create_container sets dev.devwork.managed-by=dw.sh (line 475) precisely so managed containers are identifiable, but no lifecycle verb checks it before adopting or destroying by name. One label inspect before adoption closes it.  [reported by bug-hunter (fable, adversarial) as CR-2; file .devcontainer/dw.sh:587]",
      "failure_scenario": "Host has an unrelated container named dw-7 (short generic name; any devcontainer-derived image has a 'vscode' user). 'dw.sh up 7' finds state=running, takes the reattach path, and push_file copies the Claude OAuth credential, the forge token and the SSH private key INTO that foreign container, then execs init in it. 'dw.sh rm 7' similarly 'docker rm -f's it and deletes any volumes named dw-7-*. cmd_ls filters by the label (line 857) but up/rm/stop/repin/refresh-creds never verify it.",
      "file": ".devcontainer/dw.sh",
      "line": 587,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-2"
    },
    {
      "id": "CR-8",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "The rm unpushed-work guard ('git log --branches --not --remotes') is blind to detached-HEAD commits and stashes — rm destroys them with exit 0.",
      "reasoning": "--branches enumerates refs/heads/* only; detached HEAD and refs/stash are excluded by construction. The stated design intent is 'err toward refusing'; this errs toward deleting. Add HEAD/--reflog/refs/stash to the walk or refuse on detached HEAD.  [reported by bug-hunter (fable, adversarial) as CR-3; file .devcontainer/dw.sh:650]",
      "failure_scenario": "Session inside dw-N does 'git checkout <sha>', commits an experiment (or stashes work), then returns to the branch with a clean tree. inspect_workspace: porcelain empty, and the detached commits / refs/stash are reachable from no refs/heads/* ref, so '--branches --not --remotes' prints nothing -> both lists empty -> 'dw.sh rm N' proceeds and deletes the only copy, despite the guard existing exactly to refuse this.",
      "file": ".devcontainer/dw.sh",
      "line": 650,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-3"
    },
    {
      "id": "CR-9",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "PID 1 is 'sleep infinity' with no init: it ignores SIGTERM, so every 'dw.sh stop' hangs 10s and ends in SIGKILL — 'normal stop' is mechanically identical to the 'abnormal exit' AC-17 simulates.",
      "reasoning": "Harmless to state today (volumes carry everything, AC-17 proves kill-safety), but --init/tini or an exec-form trap loop makes stop immediate and genuinely graceful.  [reported by bug-hunter (fable, adversarial) as CR-11; file .devcontainer/Dockerfile:155]",
      "failure_scenario": "'dw.sh stop N' -> docker sends SIGTERM to PID 1; sleep as PID 1 has no handler and default-signal immunity, ignores it; docker waits the 10s grace period, then SIGKILLs. Every stop takes 10+ seconds, and any future entrypoint logic assuming a graceful-shutdown window would silently never get one.",
      "file": ".devcontainer/Dockerfile",
      "line": 155,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-11"
    },
    {
      "id": "CR-10",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "AC-22's preexisting-branch sanity check is vacuous: contains(\"-\"+$n+\"-\") can never match feature/<N>-slug, where N is preceded by '/', not '-'.",
      "reasoning": "A belt-and-braces check that cannot catch the exact case it was written for; startswith(\"feature/\"+$n+\"-\") is the predicate dw.sh itself matches on.  [reported by bug-hunter (fable, adversarial) as CR-12; file .devcontainer/selftest.sh:1190]",
      "failure_scenario": "A branch feature/123-anything already exists for scratch issue 123 (leftover from a crashed run). The guard computes contains(\"-123-\") against 'feature/123-anything' -> no match ('/123-') -> preexisting=0 -> the scenario proceeds on a contaminated precondition and a later assertion fails with a misleading reason (or dw.sh's two-branch refusal fires and reads as a dw.sh bug).",
      "file": ".devcontainer/selftest.sh",
      "line": 1190,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-12"
    },
    {
      "id": "CR-11",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "AC-23's permission-mode assertion passes vacuously when neither side declares a mode, and the host side hardcodes $HOME/.claude while the rest of the harness honors CLAUDE_CONFIG_DIR.",
      "reasoning": "Vacuity in the direction a harness must not have (a broken seeder passes), plus inconsistency with HOST_CLAUDE_CREDENTIALS (selftest.sh:48), which does honor CLAUDE_CONFIG_DIR.  [reported by bug-hunter (fable, adversarial) as CR-13; file .devcontainer/selftest.sh:1218]",
      "failure_scenario": "(a) Host has no settings.json/defaultMode: host_mode='' — and if dw.sh's seeding broke entirely and wrote nothing, ct_mode='' too; '' = '' -> PASS with the inheritance mechanism completely absent. (b) Operator sets CLAUDE_CONFIG_DIR=/custom: dw.sh seeds from /custom/settings.json (dw.sh:77,377) but ac23 reads $HOME/.claude/settings.json -> modes differ -> false FAIL.",
      "file": ".devcontainer/selftest.sh",
      "line": 1218,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-13"
    },
    {
      "id": "CR-12",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "AC-6 never checks the rc of its marker commit; if the commit or dexec fails, marker_sha is the main tip (present in host .git -> spurious VIOLATION) or empty ('grep -rl \"\"' matches every file -> guaranteed spurious VIOLATION).",
      "reasoning": "Fail-safe direction (false FAIL, not false PASS), but a harness that can fabricate an 'escape' finding costs a debugging cycle exactly where trust matters most. Check both rcs and non-emptiness before grepping.  [reported by bug-hunter (fable, adversarial) as CR-14; file .devcontainer/selftest.sh:653]",
      "failure_scenario": "A container hiccup makes 'git rev-parse HEAD' via dexec return empty -> grep -rl '' \"$REPO_ROOT\" matches everything -> AC-6 reports 'unpushed commit visible under REPO_ROOT' — a fabricated isolation violation. Alternatively the unchecked commit (line 651) fails, HEAD is origin/main's tip, whose sha legitimately appears in the host checkout's packed-refs/reflogs -> same spurious violation.",
      "file": ".devcontainer/selftest.sh",
      "line": 653,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-14"
    },
    {
      "id": "CR-13",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "The harness's SKIP verdict (return 2) is dead code — no scenario ever returns 2, and preflight's promise that token-less criteria will 'fail or SKIP' can only ever produce FAIL.",
      "reasoning": "Scenario functions return only 0/1; mapping dw.sh's exit-2 guard refusals on 'up' to SKIP would make the documented contract real.  [reported by bug-hunter (fable, adversarial) as CR-22; file .devcontainer/selftest.sh:380]",
      "failure_scenario": "Run without a container tea token: preflight WARNs and promises per-criterion 'fail or SKIP ... each with dw.sh's own reason'; every criterion then FAILs at 'dw.sh up exited 2', indistinguishable in results.tsv from genuine regressions — 23 red rows for one missing precondition, defeating the per-criterion attribution the soft gate was argued on.",
      "file": ".devcontainer/selftest.sh",
      "line": 380,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-22"
    },
    {
      "id": "CR-14",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "push_file() creates the destination file with the shell's default umask and only chmod's it to the intended restrictive mode afterward, leaving a brief but real window where credential material is not yet at 0600.",
      "reasoning": "real gap introduced by this feature's own new code, in the direct credential-handling path named in the task brief; not pre-existing.  [reported by static-security-reviewer (sonnet) as CR-1; file .devcontainer/dw.sh:193]",
      "failure_scenario": "push_file() is dw.sh's ONLY mechanism for copying the Claude OAuth credential (line 502/839), the forge token (via push_string -> push_file, line 503/840), and the SSH private key (line 355) into a container. Its implementation is: sh -c 'mkdir -p ... && cat > \"$DW_DEST\" && chmod \"$DW_MODE\" \"$DW_DEST\"'. The 'cat >' redirection creates $DW_DEST at whatever mode the container's default umask yields (typically 0022 -> 0644, world-readable) and chmod to 0600 only runs as a separate, later step in the same command chain. Any other process already running inside that container namespace (a background job the container's own Claude session started, a leftover process from a previous 'up', or a second exec racing this one) can read the OAuth accessToken/refreshToken, the forge token, or the SSH private key during that window. container-init.sh:65 demonstrates the atomic, race-free alternative already in use elsewhere in this same feature: install -m 0600 SRC DST sets the mode at creation with no window at all.",
      "file": ".devcontainer/dw.sh",
      "line": 193,
      "source_role": "static-security-reviewer (sonnet)",
      "source_id": "CR-1"
    },
    {
      "id": "CR-15",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "The rm-guard's throwaway inspection container is claimed unable to 'reach the network' (comment + README line 135), but it runs on the default bridge with full egress — only credentials are absent.",
      "reasoning": "docker run at line 641 passes no --network; '--network none' would make the stated property true for one flag.  [reported by bug-hunter (fable, adversarial) as CR-18; file .devcontainer/dw.sh:641]",
      "failure_scenario": "None today (the inspection script only reads local git state), but the documented isolation property is false as stated: any future edit that makes the inspection script network-touching would work fine and inherit the overclaim, and the README's safety narrative leans on it.",
      "file": ".devcontainer/dw.sh",
      "line": 641,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-18"
    },
    {
      "id": "CR-16",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "seed_host_material's header claims a re-entered container 'never runs on staler material than the host has', but the settings seed is only APPLIED when reseed=1 or the file is missing — host settings drift never reaches an existing container via 'up'.",
      "reasoning": "Behavior is arguably right (a session may own its settings); the in-file comment is what a future editor will trust, and it is wrong. Doc-level fix.  [reported by bug-hunter (fable, adversarial) as CR-25; file .devcontainer/dw.sh:495]",
      "failure_scenario": "Operator changes host permission rules, runs 'dw.sh up N' on the existing container trusting the comment: the fresh seed lands at ~/.claude/.dw/settings-seed.json but seed_settings (container-init.sh:216-222) leaves the live settings.json untouched (DW_RESEED=0 on reattach). Only credentials/token are actually fresh; settings require 'recreate' — which the README says, contradicting this header.",
      "file": ".devcontainer/dw.sh",
      "line": 495,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-25"
    },
    {
      "id": "CR-17",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "ac2()'s jq>=1.7 version-floor check cites 'the CLAUDE.md floor' in its comment, but CLAUDE.md documents no such floor (readiness-check.md only checks jq presence, not version); the actual source is preq.original.md's incidental mention of 'jq 1.7.1'.",
      "reasoning": "Grepped CLAUDE.md and plugin/skills/_shared/procedures/readiness-check.md for any jq version requirement -- none exists, only a presence check (command -v jq). The only place '1.7' appears in this feature's own paper trail is .devwork/feature-devcontainer-plugin-era-refresh/preq.original.md:33 ('jq 1.7.1'), which is a fact about the OLD devcontainer setup, not a stated project-wide floor. Not a functional defect since the pinned jq (1.8.1, Dockerfile:26) trivially clears any real floor.  [reported by spec-checker (sonnet) as CR-2; file .devcontainer/selftest.sh:415]",
      "failure_scenario": "A future reader trusts the comment, goes looking in CLAUDE.md for a documented jq version requirement to reconcile against, and finds nothing -- wasted investigation, or a future change to jq's pinned version relies on a floor that isn't actually a suite-wide contract.",
      "file": ".devcontainer/selftest.sh",
      "line": 415,
      "source_role": "spec-checker (sonnet)",
      "source_id": "CR-2"
    },
    {
      "id": "CR-18",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "AC-5's peer-workspace before/after digest hashes only the FILE LIST (find | sort | sha256sum), never file content — a content-only modification of the peer workspace cannot fail the assertion (distinct from the already-known missing rc guard on the same line).",
      "reasoning": "This is the only check on the peer's workspace in AC-5, and it cannot fail for a whole class of violation even when both dexecs succeed. Reusing the dirhash pattern inside the container fixes both this and the known vacuity.  [reported by bug-hunter (fable, adversarial) as CR-6; file .devcontainer/selftest.sh:559]",
      "failure_scenario": "If isolation broke such that container A could rewrite the CONTENTS of existing files in peer B's /workspace (no adds/deletes), peer_ws_before and peer_ws_after are still identical — the pipeline hashes the sorted list of file NAMES, not bytes (contrast dirhash(), which hashes relpath+content). The scenario PASSES while the property it reports ('container B's OWN workspace changed') is violated.",
      "file": ".devcontainer/selftest.sh",
      "line": 559,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-6"
    },
    {
      "id": "CR-19",
      "category": "in-scope-deferrable",
      "severity": "medium",
      "summary": "A partial clone (SIGKILL/host reboot mid-clone) leaves /workspace/.git present; setup_workspace early-returns on '-d .git' and write_state marks the container ready on a corrupt workspace.",
      "reasoning": "git cleans up its own failures but cannot after SIGKILL/power loss, and volumes persist exactly those states. A cheap 'git -C /workspace rev-parse --verify HEAD' before early-returning fails loudly instead.  [reported by bug-hunter (fable, adversarial) as CR-4; file .devcontainer/container-init.sh:176]",
      "failure_scenario": "First 'up N': host reboots mid 'git clone' into /workspace; the volume keeps a partial .git. Next 'up N': setup_workspace sees [ -d /workspace/.git ] and returns 0 with no integrity check; is_ready's check is also just '-d .git' (line 289); write_state records status ready with branch '-' (rev-parse failure swallowed at line 246). Every subsequent up reattaches to a broken workspace and nothing ever re-clones — manual surgery required.",
      "file": ".devcontainer/container-init.sh",
      "line": 176,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-4"
    },
    {
      "id": "CR-20",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "The forge token is passed as a plaintext command-line argument to curl (dw.sh) and to 'tea login add' (container-init.sh), where it is visible via the process table for the life of that process.",
      "reasoning": "low practical severity given the token's intended recipient already holds it through the sanctioned path (the file at $TEA_TOKEN_FILE / $CLAUDE_HOME/.dw/forge-token); genuinely fixable (curl supports --header @file / a netrc-style approach, tea supports STDIN or a config file) but not gating given the narrow, single-tenant threat model this container design otherwise assumes.  [reported by static-security-reviewer (sonnet) as CR-2; file .devcontainer/dw.sh:280]",
      "failure_scenario": "dw.sh:280, dw.sh:295 (probe_reachable/assert_token_least_privilege) and dw.sh:445 (resolve_branch) all build curl ... -H \"Authorization: token $tok\" ... -- the token appears verbatim in that curl process's argv on the HOST for as long as the request is in flight, readable by any other local user via ps -eo args or /proc/<pid>/cmdline. container-init.sh:125 does the same thing inside the container with tea login add --token \"$tok\". This is the classic CWE-214 secrets-on-the-command-line pattern. Impact is bounded here because the intended recipient (the operator on the host; the container's own Claude/tea session) already has legitimate access to the same token via the token file / forge-token volume file, so this does not disclose the secret to a new principal in the common case -- it only widens the window and the audience (any other local host user, any other process already running in that same container) beyond what's necessary.",
      "file": ".devcontainer/dw.sh",
      "line": 280,
      "source_role": "static-security-reviewer (sonnet)",
      "source_id": "CR-2"
    },
    {
      "id": "CR-21",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "cmd_rm takes no per-issue lock, so rm can interleave with a concurrent 'up N' — removing the container/volumes mid-create, or destroying a commit made between inspection and rm -f (TOCTOU).",
      "reasoning": "cmd_up and cmd_recreate call acquire_lock; cmd_rm never does. Both windows are narrow but the fix is the existing acquire_lock call.  [reported by bug-hunter (fable, adversarial) as CR-8; file .devcontainer/dw.sh:655]",
      "failure_scenario": "(a) 'dw.sh rm N' runs while an unattended 'up N' is mid-clone: inspect_workspace reports '== no-repo ==' (clone not started in the volume yet) -> rm proceeds, deletes container and volumes out from under the in-flight up, which dies mid-seed with a confusing docker error. (b) A running session commits in the seconds between inspect_workspace's clean verdict and 'docker rm -f' -> the commit is destroyed despite the guard.",
      "file": ".devcontainer/dw.sh",
      "line": 655,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-8"
    },
    {
      "id": "CR-22",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "acquire_lock's stale-lock clearing has a TOCTOU: two waiters can both observe the dead pid, and the slower one's rm -rf deletes the lock the faster one just re-acquired — yielding two concurrent holders.",
      "reasoning": "Check-then-remove is not atomic. Removing only the pid file and retrying mkdir, or re-verifying ownership after rm, narrows it; likelihood low, mechanism textbook.  [reported by bug-hunter (fable, adversarial) as CR-9; file .devcontainer/dw.sh:129]",
      "failure_scenario": "Holder dies. Waiters B and C both read the stale pid and pass kill -0. B: rm -rf, mkdir, writes pid, proceeds. C (decision already made in the same loop iteration): rm -rf removes B's fresh lock dir, loops, mkdir succeeds -> B and C both run 'up N' concurrently — exactly the docker-create race the lock exists to serialize.",
      "file": ".devcontainer/dw.sh",
      "line": 129,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-9"
    },
    {
      "id": "CR-23",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "require_issue accepts leading zeros, so 'up 07' and 'up 7' create rival containers (dw-07 vs dw-7) and eventually two branches for the same logical issue.",
      "reasoning": "One-line fix: normalize with $((10#$n)) or reject leading zeros. Mechanically certain; needs an operator typo or padding script to trigger.  [reported by bug-hunter (fable, adversarial) as CR-10; file .devcontainer/dw.sh:153]",
      "failure_scenario": "A script zero-pads issue numbers and runs 'dw.sh up 07'. '07' passes the [!0-9] filter -> container dw-07, volumes dw-07-*; branch resolution ls-remotes 'refs/heads/feature/07-*', finds nothing (the real branch is feature/7-...), and cuts a NEW branch feature/07-<slug> from main. Two containers and two divergent branches for one issue — the one-issue-one-container invariant silently broken.",
      "file": ".devcontainer/dw.sh",
      "line": 153,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-10"
    },
    {
      "id": "CR-24",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-4/AC-11 assert the host credential file's hash (and mtime) unchanged across multi-minute scenarios — a legitimate OAuth refresh by any host-side claude session mid-scenario is reported as a violation.",
      "reasoning": "The assertion conflates 'a container wrote the host file' with 'the host file changed for any reason'. At minimum the failure message should name the benign cause; ac11 (line 863) shares the pattern via mtime.  [reported by bug-hunter (fable, adversarial) as CR-15; file .devcontainer/selftest.sh:499]",
      "failure_scenario": "While ac4 runs (two full 'up's, several minutes), the operator's own host claude session refreshes its OAuth token, rewriting ~/.claude/.credentials.json. host_hash_before != host_hash_after -> AC-4 FAILs claiming 'host Claude credential file changed during this scenario', implicating container isolation for a change no container made. On a box running parallel Claude sessions by design, this is a live flake.",
      "file": ".devcontainer/selftest.sh",
      "line": 499,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-15"
    },
    {
      "id": "CR-25",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "--ssh-agent is silently ignored whenever the container already exists — 'up N --ssh-agent' on a running/stopped container forwards nothing and says nothing.",
      "reasoning": "Code-path certain: the state-nonempty branch never touches $agent. A warn naming recreate is the minimal fix.  [reported by bug-hunter (fable, adversarial) as CR-16; file .devcontainer/dw.sh:561]",
      "failure_scenario": "Operator needs agent-held keys for a one-off operation and runs 'dw.sh up N --ssh-agent' on existing dw-N. The flag is only consulted in create_container (line 481), which the reattach path never calls; the session enters with no agent socket, ssh fails, and nothing hints the flag was a no-op or that 'recreate' is the fix.",
      "file": ".devcontainer/dw.sh",
      "line": 561,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-16"
    },
    {
      "id": "CR-26",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "'exec docker exec -it ...' on the attach path replaces the shell, so the EXIT trap never runs and $TMPROOT (probe bodies, ls-remote output, issue.json, known_hosts copy) is left behind on every interactive up.",
      "reasoning": "rm -rf \"$TMPROOT\" immediately before the exec (release_lock is already done there) closes it. Mechanically certain; impact is litter, not leakage.  [reported by bug-hunter (fable, adversarial) as CR-17; file .devcontainer/dw.sh:621]",
      "failure_scenario": "Each attended 'dw.sh up N' leaves one /tmp/dw.XXXXXX directory permanently (cleanup() only runs on non-exec exits). No secret persists — push.tmp holding the token is rm'd inline at line 202 — but forge API bodies accumulate in /tmp indefinitely.",
      "file": ".devcontainer/dw.sh",
      "line": 621,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-17"
    },
    {
      "id": "CR-27",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "setup_forge_login deletes the existing tea login before attempting the add — if the add fails, the container is left with NO forge login, strictly worse than before the up.",
      "reasoning": "Add-then-swap or tolerating add-over-existing removes the regression window; the next successful up repairs it, hence low.  [reported by bug-hunter (fable, adversarial) as CR-19; file .devcontainer/container-init.sh:124]",
      "failure_scenario": "Reattach 'up' during a brief forge outage: 'tea login delete devwork' succeeds, 'tea login add' fails against the unreachable forge -> die. The container, which had a working login a second earlier, now has none; any in-container helper run before the next successful up fails on auth. Also a one-command window on every up where a concurrently running in-container session's tea calls fail.",
      "file": ".devcontainer/container-init.sh",
      "line": 124,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-19"
    },
    {
      "id": "CR-28",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "resolve_branch's host-side 'git ls-remote' has no timeout and (on the create path) runs while the per-issue lock is held — a hung forge connection blocks this up indefinitely and any concurrent up for 600s.",
      "reasoning": "GIT_SSH_COMMAND ConnectTimeout or a timeout wrapper matches the bounded-probe discipline the rest of the file follows.  [reported by bug-hunter (fable, adversarial) as CR-20; file .devcontainer/dw.sh:412]",
      "failure_scenario": "Forge TCP blackholes (VPN half-up). 'up N' acquires the lock, then blocks in ls-remote with no bound (contrast the curl probes' --max-time 20 and container-init's timeout'd ls-remote). A second 'up N' waits the full 600s and dies telling the operator to hand-remove the lock dir — for a hang the first process never reports.",
      "file": ".devcontainer/dw.sh",
      "line": 412,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-20"
    },
    {
      "id": "CR-29",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "settings_seed_json silently degrades a malformed host settings.json to '{}' — the container comes up with no permission mode/allowlist and nobody is told.",
      "reasoning": "Failing closed on CONTENT is right, but the degradation is indistinguishable from 'host declares nothing' — it should warn.  [reported by bug-hunter (fable, adversarial) as CR-21; file .devcontainer/dw.sh:394]",
      "failure_scenario": "Host settings.json gains a trailing-comma typo. jq fails, '|| printf {}' swallows it, the container seeds empty settings; the session runs in default permission mode instead of the operator's auto mode, and an intended-unattended run stalls on its first permission prompt with no indication the host config was dropped.",
      "file": ".devcontainer/dw.sh",
      "line": 394,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-21"
    },
    {
      "id": "CR-30",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-3 races the live repo: the integration tip is read before 'up', and a peer session merging to main mid-scenario makes 'pin sha == tip' a false FAIL.",
      "reasoning": "Flake, not a false pass — but CLAUDE.md's Learnings already record one full sweep invalidated by peer churn; re-reading the tip on mismatch makes the assertion race-tolerant.  [reported by bug-hunter (fable, adversarial) as CR-23; file .devcontainer/selftest.sh:447]",
      "failure_scenario": "This repo's parallel-sessions model lands merges on main routinely. ac3 reads branch_tip(main), then 'up' clones the pinned suite minutes later at the NEW tip -> 'provenance sha before repin is X, want Y' FAIL implicating the pin mechanism for ordinary repo motion.",
      "file": ".devcontainer/selftest.sh",
      "line": 447,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-23"
    },
    {
      "id": "CR-31",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "The is_ready early-return skips verify_forge_ssh, so reattach never re-validates the freshly re-pushed SSH material — a rotated forge host key or broken key surfaces only later, mid-session, at push time.",
      "reasoning": "verify_forge_ssh is one bounded ls-remote; it sits in the volume-resident section by placement, not cost. Moving it above the is_ready return matches the 'probe before anything slow' contract.  [reported by bug-hunter (fable, adversarial) as CR-24; file .devcontainer/container-init.sh:322]",
      "failure_scenario": "Forge rotates its host key. 'dw.sh up N' on the existing container re-seeds ssh config/key/known_hosts (seed_host_material runs every up), init probes both credentials, hits is_ready -> 'already ready'. The operator enters a 'ready' container whose first git push/fetch fails on host-key mismatch — the exact failure the fail-loud-at-start design exists to front-load.",
      "file": ".devcontainer/container-init.sh",
      "line": 322,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-24"
    },
    {
      "id": "CR-32",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-16's 'setup does not re-run' half is verified only by grepping the reattach log for one of several hardcoded human-readable phrases (already-ready/already set up/already provisioned/already initialized), not by an independent behavioral signal.",
      "reasoning": "Matches SREQ.md's own AC-16 row ('assert...init reports already-ready'), so the message-based check is the specified verification method, not an ad hoc shortcut -- kept as low severity/deferrable rather than blocking.  [reported by test-quality-audit (sonnet) as CR-4; file .devcontainer/selftest.sh:1021]",
      "failure_scenario": "A correct implementation that phrases its reattach message differently (e.g. 'skipping provisioning: workspace exists') would false-FAIL this scenario. Conversely, an implementation that always prints one of the matched phrases regardless of whether it actually re-ran setup would false-PASS the 'does not re-run' claim -- the file-marker checks above it only prove state survived, not that setup was skipped.",
      "file": ".devcontainer/selftest.sh",
      "line": 1021,
      "source_role": "test-quality-audit (sonnet)",
      "source_id": "CR-4"
    },
    {
      "id": "CR-33",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "CLAUDE.md's Learnings section asserts that .devcontainer/selftest.sh has static absence scans for BOTH AC-13 and AC-20; only AC-13's exists.",
      "reasoning": "The entry reads: \".devcontainer/selftest.sh's static absence scans (AC-13/AC-20) have no comment exemption and no allowlist\". AC-13's scan is real (selftest.sh:901-928). AC-20 has no static scan at all (see CR-2). The claim is already merged to main, so the project's own durable record now vouches for a check that does not exist - which is how CR-2 survived develop's own review. Must be corrected in the same commit that adds the missing scan, or the next reader inherits the same false assurance.  [raised by the QA driver while verifying CR-2; corroborated independently in test-quality-audit's CR-1 reasoning]",
      "failure_scenario": "A future contributor reads the Learnings entry, believes an audio-residue scan guards .devcontainer/, and re-adds an audio package or socket mount; nothing catches it, because the scan the record promises was never written.",
      "file": "CLAUDE.md",
      "line": 310,
      "source_role": "qa-driver (opus, loop control)",
      "source_id": "CR-33"
    },
    {
      "id": "CR-34",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "Post-hoc sanity check: this round's rebase onto origin/main auto-resolved one CLAUDE.md conflict; the resolution was a judgement call nothing else reviewed.",
      "reasoning": "rebase-onto-base.md requires every auto-resolution to be carried as a Finding, because an auto-resolution is a judgement call no reviewer saw. The conflict was in CLAUDE.md's Learnings list: main had appended the #46 SHIPPED_SKILLS entry while this branch appended two #61 entries. Both sides were non-overlapping additions to the same list, so the resolution kept both, base's entry first. No content from either side was dropped or edited.  [raised by the QA driver per rebase-onto-base.md Step 2]",
      "failure_scenario": "If the reconciliation had been wrong, a learning from either side would be silently missing from main with a clean-looking history.",
      "file": "CLAUDE.md",
      "line": 303,
      "source_role": "qa-driver (opus, loop control)",
      "source_id": "CR-34"
    }
  ],
  "artifacts": {
    "report_path": ".devwork/feature-devcontainer-plugin-era-refresh/qa-code-validate-round1.md",
    "roles_run": [
      "bug-hunter (fable) - adversarial, 25 findings",
      "spec-checker (sonnet) - SREQ AC-1..AC-23 traceability, 22/23 implemented-and-asserted, 0 orphan scenarios",
      "static-security-reviewer (sonnet) - 9 controls verified, 2 findings",
      "test-quality-audit (sonnet) - 23/23 scenarios can fail; 4 findings"
    ],
    "head_reviewed": "0495ca034da26f9ec3f5260c6871c2663c8f0cc8"
  }
}
<!-- qa-report:v1 issue=61 skill=qa domain=code phase=validate --> ```json { "outcome": "issues-found", "summary": "34 findings from four roles over the .devcontainer diff (dw.sh, container-init.sh, selftest.sh, Dockerfile). 18 blocking. The load-bearing result is that 8 of them are defects in selftest.sh itself - the harness that grades all 23 acceptance criteria - so this round does NOT treat a selftest green as evidence until they are fixed.", "findings": [ { "id": "CR-1", "category": "in-scope-blocking", "severity": "high", "summary": "refresh-creds replaces the forge-token file but never re-registers it with tea, and its probe validates the OLD login — the verb's forge-token half is inert and its verdict misleading.", "reasoning": "The only consumer of $DW_DIR/forge-token is setup_forge_login (container-init.sh:119-121), which the DW_PROBE_ONLY path never calls; probe_forge_token goes through tea's registered login, not the file. README ('Re-copies ... the forge token in ... and re-probes both') documents behavior the code does not have. [reported by bug-hunter (fable, adversarial) as CR-1; file .devcontainer/dw.sh:826]", "failure_scenario": "Operator rotates the forge token (old revoked, new written to ~/.config/dw/tea-token) and runs 'dw.sh refresh-creds N'. dw.sh pushes the new token to ~/.claude/.dw/forge-token, then runs container-init with DW_PROBE_ONLY=1, which skips setup_forge_login (container-init.sh:303-309) and probes via 'tea api /user' (container-init.sh:92) — tea uses its STORED login, i.e. the revoked token -> probe fails -> exit 1 'refreshed credentials did not probe clean' even though the new token is good. Converse: old token still valid but expiring — probe passes, dw.sh prints 'refreshed the credentials inside dw-N', but tea keeps using the old token until the next full 'up', so an unattended run dies mid-flight at exactly the moment refresh-creds exists to prevent.", "file": ".devcontainer/dw.sh", "line": 826, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-1" }, { "id": "CR-2", "category": "in-scope-blocking", "severity": "high", "summary": "AC-20's required static scan for audio packages/socket mounts in .devcontainer/ is not implemented anywhere in the diff, despite selftest.sh's own comment and the WU-61-3-1 commit message claiming it exists and passes.", "reasoning": "SREQ's Acceptance Criteria table for AC-20 explicitly lists two verification approaches: 'assert clean start and usable session; plus a scan asserting no audio package or socket mount remains in .devcontainer/'. Only the first exists. Traced the claimed second half back through git history (3d2acf6's commit message says '.devcontainer/ scans clean for audio and IDE-integration tokens' but describes a manual check, not committed code) and through df6584a (the original failing-test commit for the harness, which already lacked it) -- it was never written, only asserted to exist. [reported by spec-checker (sonnet) as CR-1; file .devcontainer/selftest.sh:1136]", "failure_scenario": "A future change reintroduces an audio package or a pulse socket mount into .devcontainer/ (e.g. someone restores /voice support) -- no mechanical gate catches it, because ac20() only checks the runtime environment (env -u PULSE_SERVER -u XDG_RUNTIME_DIR), never the file contents.", "file": ".devcontainer/selftest.sh", "line": 1136, "source_role": "spec-checker (sonnet)", "source_id": "CR-1" }, { "id": "CR-3", "category": "in-scope-blocking", "severity": "medium", "summary": "seed_ssh_material's 'no IdentityFile' guard is dead code: ssh -G always emits the default identity list, so a stanza without IdentityFile silently copies ~/.ssh/id_rsa — a potentially unrelated private key — into the container.", "reasoning": "ssh -G's default-identity emission is unconditional (verified empirically this session with a nonexistent alias). The check must distinguish an explicitly configured IdentityFile from the default list. [reported by bug-hunter (fable, adversarial) as CR-5; file .devcontainer/dw.sh:330]", "failure_scenario": "Operator's Host forge-devwork stanza omits IdentityFile (relies on agent/defaults). 'ssh -G forge-devwork' then prints the default chain (verified on this host: id_rsa, id_ecdsa, id_ed25519, ...); ssh_setting takes the first line, so idfile=~/.ssh/id_rsa — never empty, so the die at line 332 cannot fire. If that personal key exists it is pushed into the container (line 355); if it is also registered on the forge, everything works silently on a key with broader reach — the exact over-exposure the 'only the key for THIS forge' design promises to prevent. The hostname guard at line 331 is dead the same way (ssh -G echoes the alias as hostname).", "file": ".devcontainer/dw.sh", "line": 330, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-5" }, { "id": "CR-4", "category": "in-scope-blocking", "severity": "medium", "summary": "AC-5's peer-workspace before/after comparison has no rc or non-empty check on the dexec calls that compute the two hashes being compared to each other.", "reasoning": "This is the one place in the file where two independently-computed values are compared only to each other rather than to a known non-empty expected constant (contrast with AC-21/AC-22, which compare dexec output to a concrete expected branch/sha and would correctly fail on an empty result). It fits section-6 item 6: 'a scenario whose setup silently no-ops, so it asserts on a state it never created.' [reported by test-quality-audit (sonnet) as CR-2; file .devcontainer/selftest.sh:559]", "failure_scenario": "If 'dexec \"$peer\" ...' fails identically both times (e.g. a regression breaks /workspace access specifically for a second/peer container while the primary container A still works), peer_ws_before and peer_ws_after would both come back as empty strings, and the equality check would pass -- the scenario would report no violation despite never having actually hashed the peer's workspace either time.", "file": ".devcontainer/selftest.sh", "line": 559, "source_role": "test-quality-audit (sonnet)", "source_id": "CR-2" }, { "id": "CR-5", "category": "in-scope-blocking", "severity": "medium", "summary": "AC-11 never verifies that the in-container credential was actually refreshed; it only checks that dw.sh refresh-creds exits 0 and that the host credential file's hash/mtime are unchanged.", "reasoning": "Matches this repo's own SREQ.md AC-11 row, which also only specifies the writability + host-unchanged checks -- so this traces to how the criterion was scoped, not solely to the test author's choice. Still worth flagging per the audit's 'single-character change' method: no change to the container-side refresh behavior itself would break this test. [reported by test-quality-audit (sonnet) as CR-3; file .devcontainer/selftest.sh:854]", "failure_scenario": "A refresh-creds implementation that is a complete no-op (does nothing to the container's credential file, just returns 0) passes this scenario, because the only assertions are: (1) the container path is writable, (2) refresh-creds exits 0, (3) the host file didn't change. The AC's own title, 'credential refreshable in place,' is half-untested.", "file": ".devcontainer/selftest.sh", "line": 854, "source_role": "test-quality-audit (sonnet)", "source_id": "CR-3" }, { "id": "CR-6", "category": "in-scope-blocking", "severity": "medium", "summary": "The token least-privilege guard fails OPEN on response-shape drift: 'refused' is inferred from 'body is not a JSON array', so a forge upgrade that changes either probe endpoint's 200 body to an object envelope green-lights over-scoped tokens.", "reasoning": "The guard's dangerous direction should fail closed: distinguish HTTP 403/401 (refused) from HTTP 200 with any parseable body (reachable), rather than keying acceptance on one specific success shape measured on today's forge. [reported by bug-hunter (fable, adversarial) as CR-7; file .devcontainer/dw.sh:286]", "failure_scenario": "A future Gitea/Forgejo version paginates /repos/{o}/{r}/branch_protections or /admin/users as {\"data\":[...],\"total\":N}. probe_reachable's 'jq type==array' fails -> guard concludes 'token could not reach it' -> a repository- or admin-scoped token passes assert_token_least_privilege on every up/recreate/refresh, silently reopening the unprotect-then-force-push escalation. selftest ac7 mirrors the same shape test (~lines 795-805), so the harness goes blind at the same moment.", "file": ".devcontainer/dw.sh", "line": 286, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-7" }, { "id": "CR-7", "category": "in-scope-blocking", "severity": "medium", "summary": "up/rm adopt ANY container named dw-<N> without checking the dev.devwork.managed-by label — credentials get seeded into, or removal destroys, a container dw.sh does not own.", "reasoning": "create_container sets dev.devwork.managed-by=dw.sh (line 475) precisely so managed containers are identifiable, but no lifecycle verb checks it before adopting or destroying by name. One label inspect before adoption closes it. [reported by bug-hunter (fable, adversarial) as CR-2; file .devcontainer/dw.sh:587]", "failure_scenario": "Host has an unrelated container named dw-7 (short generic name; any devcontainer-derived image has a 'vscode' user). 'dw.sh up 7' finds state=running, takes the reattach path, and push_file copies the Claude OAuth credential, the forge token and the SSH private key INTO that foreign container, then execs init in it. 'dw.sh rm 7' similarly 'docker rm -f's it and deletes any volumes named dw-7-*. cmd_ls filters by the label (line 857) but up/rm/stop/repin/refresh-creds never verify it.", "file": ".devcontainer/dw.sh", "line": 587, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-2" }, { "id": "CR-8", "category": "in-scope-blocking", "severity": "medium", "summary": "The rm unpushed-work guard ('git log --branches --not --remotes') is blind to detached-HEAD commits and stashes — rm destroys them with exit 0.", "reasoning": "--branches enumerates refs/heads/* only; detached HEAD and refs/stash are excluded by construction. The stated design intent is 'err toward refusing'; this errs toward deleting. Add HEAD/--reflog/refs/stash to the walk or refuse on detached HEAD. [reported by bug-hunter (fable, adversarial) as CR-3; file .devcontainer/dw.sh:650]", "failure_scenario": "Session inside dw-N does 'git checkout <sha>', commits an experiment (or stashes work), then returns to the branch with a clean tree. inspect_workspace: porcelain empty, and the detached commits / refs/stash are reachable from no refs/heads/* ref, so '--branches --not --remotes' prints nothing -> both lists empty -> 'dw.sh rm N' proceeds and deletes the only copy, despite the guard existing exactly to refuse this.", "file": ".devcontainer/dw.sh", "line": 650, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-3" }, { "id": "CR-9", "category": "in-scope-blocking", "severity": "medium", "summary": "PID 1 is 'sleep infinity' with no init: it ignores SIGTERM, so every 'dw.sh stop' hangs 10s and ends in SIGKILL — 'normal stop' is mechanically identical to the 'abnormal exit' AC-17 simulates.", "reasoning": "Harmless to state today (volumes carry everything, AC-17 proves kill-safety), but --init/tini or an exec-form trap loop makes stop immediate and genuinely graceful. [reported by bug-hunter (fable, adversarial) as CR-11; file .devcontainer/Dockerfile:155]", "failure_scenario": "'dw.sh stop N' -> docker sends SIGTERM to PID 1; sleep as PID 1 has no handler and default-signal immunity, ignores it; docker waits the 10s grace period, then SIGKILLs. Every stop takes 10+ seconds, and any future entrypoint logic assuming a graceful-shutdown window would silently never get one.", "file": ".devcontainer/Dockerfile", "line": 155, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-11" }, { "id": "CR-10", "category": "in-scope-blocking", "severity": "low", "summary": "AC-22's preexisting-branch sanity check is vacuous: contains(\"-\"+$n+\"-\") can never match feature/<N>-slug, where N is preceded by '/', not '-'.", "reasoning": "A belt-and-braces check that cannot catch the exact case it was written for; startswith(\"feature/\"+$n+\"-\") is the predicate dw.sh itself matches on. [reported by bug-hunter (fable, adversarial) as CR-12; file .devcontainer/selftest.sh:1190]", "failure_scenario": "A branch feature/123-anything already exists for scratch issue 123 (leftover from a crashed run). The guard computes contains(\"-123-\") against 'feature/123-anything' -> no match ('/123-') -> preexisting=0 -> the scenario proceeds on a contaminated precondition and a later assertion fails with a misleading reason (or dw.sh's two-branch refusal fires and reads as a dw.sh bug).", "file": ".devcontainer/selftest.sh", "line": 1190, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-12" }, { "id": "CR-11", "category": "in-scope-blocking", "severity": "low", "summary": "AC-23's permission-mode assertion passes vacuously when neither side declares a mode, and the host side hardcodes $HOME/.claude while the rest of the harness honors CLAUDE_CONFIG_DIR.", "reasoning": "Vacuity in the direction a harness must not have (a broken seeder passes), plus inconsistency with HOST_CLAUDE_CREDENTIALS (selftest.sh:48), which does honor CLAUDE_CONFIG_DIR. [reported by bug-hunter (fable, adversarial) as CR-13; file .devcontainer/selftest.sh:1218]", "failure_scenario": "(a) Host has no settings.json/defaultMode: host_mode='' — and if dw.sh's seeding broke entirely and wrote nothing, ct_mode='' too; '' = '' -> PASS with the inheritance mechanism completely absent. (b) Operator sets CLAUDE_CONFIG_DIR=/custom: dw.sh seeds from /custom/settings.json (dw.sh:77,377) but ac23 reads $HOME/.claude/settings.json -> modes differ -> false FAIL.", "file": ".devcontainer/selftest.sh", "line": 1218, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-13" }, { "id": "CR-12", "category": "in-scope-blocking", "severity": "low", "summary": "AC-6 never checks the rc of its marker commit; if the commit or dexec fails, marker_sha is the main tip (present in host .git -> spurious VIOLATION) or empty ('grep -rl \"\"' matches every file -> guaranteed spurious VIOLATION).", "reasoning": "Fail-safe direction (false FAIL, not false PASS), but a harness that can fabricate an 'escape' finding costs a debugging cycle exactly where trust matters most. Check both rcs and non-emptiness before grepping. [reported by bug-hunter (fable, adversarial) as CR-14; file .devcontainer/selftest.sh:653]", "failure_scenario": "A container hiccup makes 'git rev-parse HEAD' via dexec return empty -> grep -rl '' \"$REPO_ROOT\" matches everything -> AC-6 reports 'unpushed commit visible under REPO_ROOT' — a fabricated isolation violation. Alternatively the unchecked commit (line 651) fails, HEAD is origin/main's tip, whose sha legitimately appears in the host checkout's packed-refs/reflogs -> same spurious violation.", "file": ".devcontainer/selftest.sh", "line": 653, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-14" }, { "id": "CR-13", "category": "in-scope-blocking", "severity": "low", "summary": "The harness's SKIP verdict (return 2) is dead code — no scenario ever returns 2, and preflight's promise that token-less criteria will 'fail or SKIP' can only ever produce FAIL.", "reasoning": "Scenario functions return only 0/1; mapping dw.sh's exit-2 guard refusals on 'up' to SKIP would make the documented contract real. [reported by bug-hunter (fable, adversarial) as CR-22; file .devcontainer/selftest.sh:380]", "failure_scenario": "Run without a container tea token: preflight WARNs and promises per-criterion 'fail or SKIP ... each with dw.sh's own reason'; every criterion then FAILs at 'dw.sh up exited 2', indistinguishable in results.tsv from genuine regressions — 23 red rows for one missing precondition, defeating the per-criterion attribution the soft gate was argued on.", "file": ".devcontainer/selftest.sh", "line": 380, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-22" }, { "id": "CR-14", "category": "in-scope-blocking", "severity": "low", "summary": "push_file() creates the destination file with the shell's default umask and only chmod's it to the intended restrictive mode afterward, leaving a brief but real window where credential material is not yet at 0600.", "reasoning": "real gap introduced by this feature's own new code, in the direct credential-handling path named in the task brief; not pre-existing. [reported by static-security-reviewer (sonnet) as CR-1; file .devcontainer/dw.sh:193]", "failure_scenario": "push_file() is dw.sh's ONLY mechanism for copying the Claude OAuth credential (line 502/839), the forge token (via push_string -> push_file, line 503/840), and the SSH private key (line 355) into a container. Its implementation is: sh -c 'mkdir -p ... && cat > \"$DW_DEST\" && chmod \"$DW_MODE\" \"$DW_DEST\"'. The 'cat >' redirection creates $DW_DEST at whatever mode the container's default umask yields (typically 0022 -> 0644, world-readable) and chmod to 0600 only runs as a separate, later step in the same command chain. Any other process already running inside that container namespace (a background job the container's own Claude session started, a leftover process from a previous 'up', or a second exec racing this one) can read the OAuth accessToken/refreshToken, the forge token, or the SSH private key during that window. container-init.sh:65 demonstrates the atomic, race-free alternative already in use elsewhere in this same feature: install -m 0600 SRC DST sets the mode at creation with no window at all.", "file": ".devcontainer/dw.sh", "line": 193, "source_role": "static-security-reviewer (sonnet)", "source_id": "CR-1" }, { "id": "CR-15", "category": "in-scope-blocking", "severity": "low", "summary": "The rm-guard's throwaway inspection container is claimed unable to 'reach the network' (comment + README line 135), but it runs on the default bridge with full egress — only credentials are absent.", "reasoning": "docker run at line 641 passes no --network; '--network none' would make the stated property true for one flag. [reported by bug-hunter (fable, adversarial) as CR-18; file .devcontainer/dw.sh:641]", "failure_scenario": "None today (the inspection script only reads local git state), but the documented isolation property is false as stated: any future edit that makes the inspection script network-touching would work fine and inherit the overclaim, and the README's safety narrative leans on it.", "file": ".devcontainer/dw.sh", "line": 641, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-18" }, { "id": "CR-16", "category": "in-scope-blocking", "severity": "low", "summary": "seed_host_material's header claims a re-entered container 'never runs on staler material than the host has', but the settings seed is only APPLIED when reseed=1 or the file is missing — host settings drift never reaches an existing container via 'up'.", "reasoning": "Behavior is arguably right (a session may own its settings); the in-file comment is what a future editor will trust, and it is wrong. Doc-level fix. [reported by bug-hunter (fable, adversarial) as CR-25; file .devcontainer/dw.sh:495]", "failure_scenario": "Operator changes host permission rules, runs 'dw.sh up N' on the existing container trusting the comment: the fresh seed lands at ~/.claude/.dw/settings-seed.json but seed_settings (container-init.sh:216-222) leaves the live settings.json untouched (DW_RESEED=0 on reattach). Only credentials/token are actually fresh; settings require 'recreate' — which the README says, contradicting this header.", "file": ".devcontainer/dw.sh", "line": 495, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-25" }, { "id": "CR-17", "category": "in-scope-blocking", "severity": "low", "summary": "ac2()'s jq>=1.7 version-floor check cites 'the CLAUDE.md floor' in its comment, but CLAUDE.md documents no such floor (readiness-check.md only checks jq presence, not version); the actual source is preq.original.md's incidental mention of 'jq 1.7.1'.", "reasoning": "Grepped CLAUDE.md and plugin/skills/_shared/procedures/readiness-check.md for any jq version requirement -- none exists, only a presence check (command -v jq). The only place '1.7' appears in this feature's own paper trail is .devwork/feature-devcontainer-plugin-era-refresh/preq.original.md:33 ('jq 1.7.1'), which is a fact about the OLD devcontainer setup, not a stated project-wide floor. Not a functional defect since the pinned jq (1.8.1, Dockerfile:26) trivially clears any real floor. [reported by spec-checker (sonnet) as CR-2; file .devcontainer/selftest.sh:415]", "failure_scenario": "A future reader trusts the comment, goes looking in CLAUDE.md for a documented jq version requirement to reconcile against, and finds nothing -- wasted investigation, or a future change to jq's pinned version relies on a floor that isn't actually a suite-wide contract.", "file": ".devcontainer/selftest.sh", "line": 415, "source_role": "spec-checker (sonnet)", "source_id": "CR-2" }, { "id": "CR-18", "category": "in-scope-blocking", "severity": "medium", "summary": "AC-5's peer-workspace before/after digest hashes only the FILE LIST (find | sort | sha256sum), never file content — a content-only modification of the peer workspace cannot fail the assertion (distinct from the already-known missing rc guard on the same line).", "reasoning": "This is the only check on the peer's workspace in AC-5, and it cannot fail for a whole class of violation even when both dexecs succeed. Reusing the dirhash pattern inside the container fixes both this and the known vacuity. [reported by bug-hunter (fable, adversarial) as CR-6; file .devcontainer/selftest.sh:559]", "failure_scenario": "If isolation broke such that container A could rewrite the CONTENTS of existing files in peer B's /workspace (no adds/deletes), peer_ws_before and peer_ws_after are still identical — the pipeline hashes the sorted list of file NAMES, not bytes (contrast dirhash(), which hashes relpath+content). The scenario PASSES while the property it reports ('container B's OWN workspace changed') is violated.", "file": ".devcontainer/selftest.sh", "line": 559, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-6" }, { "id": "CR-19", "category": "in-scope-deferrable", "severity": "medium", "summary": "A partial clone (SIGKILL/host reboot mid-clone) leaves /workspace/.git present; setup_workspace early-returns on '-d .git' and write_state marks the container ready on a corrupt workspace.", "reasoning": "git cleans up its own failures but cannot after SIGKILL/power loss, and volumes persist exactly those states. A cheap 'git -C /workspace rev-parse --verify HEAD' before early-returning fails loudly instead. [reported by bug-hunter (fable, adversarial) as CR-4; file .devcontainer/container-init.sh:176]", "failure_scenario": "First 'up N': host reboots mid 'git clone' into /workspace; the volume keeps a partial .git. Next 'up N': setup_workspace sees [ -d /workspace/.git ] and returns 0 with no integrity check; is_ready's check is also just '-d .git' (line 289); write_state records status ready with branch '-' (rev-parse failure swallowed at line 246). Every subsequent up reattaches to a broken workspace and nothing ever re-clones — manual surgery required.", "file": ".devcontainer/container-init.sh", "line": 176, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-4" }, { "id": "CR-20", "category": "in-scope-deferrable", "severity": "low", "summary": "The forge token is passed as a plaintext command-line argument to curl (dw.sh) and to 'tea login add' (container-init.sh), where it is visible via the process table for the life of that process.", "reasoning": "low practical severity given the token's intended recipient already holds it through the sanctioned path (the file at $TEA_TOKEN_FILE / $CLAUDE_HOME/.dw/forge-token); genuinely fixable (curl supports --header @file / a netrc-style approach, tea supports STDIN or a config file) but not gating given the narrow, single-tenant threat model this container design otherwise assumes. [reported by static-security-reviewer (sonnet) as CR-2; file .devcontainer/dw.sh:280]", "failure_scenario": "dw.sh:280, dw.sh:295 (probe_reachable/assert_token_least_privilege) and dw.sh:445 (resolve_branch) all build curl ... -H \"Authorization: token $tok\" ... -- the token appears verbatim in that curl process's argv on the HOST for as long as the request is in flight, readable by any other local user via ps -eo args or /proc/<pid>/cmdline. container-init.sh:125 does the same thing inside the container with tea login add --token \"$tok\". This is the classic CWE-214 secrets-on-the-command-line pattern. Impact is bounded here because the intended recipient (the operator on the host; the container's own Claude/tea session) already has legitimate access to the same token via the token file / forge-token volume file, so this does not disclose the secret to a new principal in the common case -- it only widens the window and the audience (any other local host user, any other process already running in that same container) beyond what's necessary.", "file": ".devcontainer/dw.sh", "line": 280, "source_role": "static-security-reviewer (sonnet)", "source_id": "CR-2" }, { "id": "CR-21", "category": "in-scope-deferrable", "severity": "low", "summary": "cmd_rm takes no per-issue lock, so rm can interleave with a concurrent 'up N' — removing the container/volumes mid-create, or destroying a commit made between inspection and rm -f (TOCTOU).", "reasoning": "cmd_up and cmd_recreate call acquire_lock; cmd_rm never does. Both windows are narrow but the fix is the existing acquire_lock call. [reported by bug-hunter (fable, adversarial) as CR-8; file .devcontainer/dw.sh:655]", "failure_scenario": "(a) 'dw.sh rm N' runs while an unattended 'up N' is mid-clone: inspect_workspace reports '== no-repo ==' (clone not started in the volume yet) -> rm proceeds, deletes container and volumes out from under the in-flight up, which dies mid-seed with a confusing docker error. (b) A running session commits in the seconds between inspect_workspace's clean verdict and 'docker rm -f' -> the commit is destroyed despite the guard.", "file": ".devcontainer/dw.sh", "line": 655, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-8" }, { "id": "CR-22", "category": "in-scope-deferrable", "severity": "low", "summary": "acquire_lock's stale-lock clearing has a TOCTOU: two waiters can both observe the dead pid, and the slower one's rm -rf deletes the lock the faster one just re-acquired — yielding two concurrent holders.", "reasoning": "Check-then-remove is not atomic. Removing only the pid file and retrying mkdir, or re-verifying ownership after rm, narrows it; likelihood low, mechanism textbook. [reported by bug-hunter (fable, adversarial) as CR-9; file .devcontainer/dw.sh:129]", "failure_scenario": "Holder dies. Waiters B and C both read the stale pid and pass kill -0. B: rm -rf, mkdir, writes pid, proceeds. C (decision already made in the same loop iteration): rm -rf removes B's fresh lock dir, loops, mkdir succeeds -> B and C both run 'up N' concurrently — exactly the docker-create race the lock exists to serialize.", "file": ".devcontainer/dw.sh", "line": 129, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-9" }, { "id": "CR-23", "category": "in-scope-deferrable", "severity": "low", "summary": "require_issue accepts leading zeros, so 'up 07' and 'up 7' create rival containers (dw-07 vs dw-7) and eventually two branches for the same logical issue.", "reasoning": "One-line fix: normalize with $((10#$n)) or reject leading zeros. Mechanically certain; needs an operator typo or padding script to trigger. [reported by bug-hunter (fable, adversarial) as CR-10; file .devcontainer/dw.sh:153]", "failure_scenario": "A script zero-pads issue numbers and runs 'dw.sh up 07'. '07' passes the [!0-9] filter -> container dw-07, volumes dw-07-*; branch resolution ls-remotes 'refs/heads/feature/07-*', finds nothing (the real branch is feature/7-...), and cuts a NEW branch feature/07-<slug> from main. Two containers and two divergent branches for one issue — the one-issue-one-container invariant silently broken.", "file": ".devcontainer/dw.sh", "line": 153, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-10" }, { "id": "CR-24", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-4/AC-11 assert the host credential file's hash (and mtime) unchanged across multi-minute scenarios — a legitimate OAuth refresh by any host-side claude session mid-scenario is reported as a violation.", "reasoning": "The assertion conflates 'a container wrote the host file' with 'the host file changed for any reason'. At minimum the failure message should name the benign cause; ac11 (line 863) shares the pattern via mtime. [reported by bug-hunter (fable, adversarial) as CR-15; file .devcontainer/selftest.sh:499]", "failure_scenario": "While ac4 runs (two full 'up's, several minutes), the operator's own host claude session refreshes its OAuth token, rewriting ~/.claude/.credentials.json. host_hash_before != host_hash_after -> AC-4 FAILs claiming 'host Claude credential file changed during this scenario', implicating container isolation for a change no container made. On a box running parallel Claude sessions by design, this is a live flake.", "file": ".devcontainer/selftest.sh", "line": 499, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-15" }, { "id": "CR-25", "category": "in-scope-deferrable", "severity": "low", "summary": "--ssh-agent is silently ignored whenever the container already exists — 'up N --ssh-agent' on a running/stopped container forwards nothing and says nothing.", "reasoning": "Code-path certain: the state-nonempty branch never touches $agent. A warn naming recreate is the minimal fix. [reported by bug-hunter (fable, adversarial) as CR-16; file .devcontainer/dw.sh:561]", "failure_scenario": "Operator needs agent-held keys for a one-off operation and runs 'dw.sh up N --ssh-agent' on existing dw-N. The flag is only consulted in create_container (line 481), which the reattach path never calls; the session enters with no agent socket, ssh fails, and nothing hints the flag was a no-op or that 'recreate' is the fix.", "file": ".devcontainer/dw.sh", "line": 561, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-16" }, { "id": "CR-26", "category": "in-scope-deferrable", "severity": "low", "summary": "'exec docker exec -it ...' on the attach path replaces the shell, so the EXIT trap never runs and $TMPROOT (probe bodies, ls-remote output, issue.json, known_hosts copy) is left behind on every interactive up.", "reasoning": "rm -rf \"$TMPROOT\" immediately before the exec (release_lock is already done there) closes it. Mechanically certain; impact is litter, not leakage. [reported by bug-hunter (fable, adversarial) as CR-17; file .devcontainer/dw.sh:621]", "failure_scenario": "Each attended 'dw.sh up N' leaves one /tmp/dw.XXXXXX directory permanently (cleanup() only runs on non-exec exits). No secret persists — push.tmp holding the token is rm'd inline at line 202 — but forge API bodies accumulate in /tmp indefinitely.", "file": ".devcontainer/dw.sh", "line": 621, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-17" }, { "id": "CR-27", "category": "in-scope-deferrable", "severity": "low", "summary": "setup_forge_login deletes the existing tea login before attempting the add — if the add fails, the container is left with NO forge login, strictly worse than before the up.", "reasoning": "Add-then-swap or tolerating add-over-existing removes the regression window; the next successful up repairs it, hence low. [reported by bug-hunter (fable, adversarial) as CR-19; file .devcontainer/container-init.sh:124]", "failure_scenario": "Reattach 'up' during a brief forge outage: 'tea login delete devwork' succeeds, 'tea login add' fails against the unreachable forge -> die. The container, which had a working login a second earlier, now has none; any in-container helper run before the next successful up fails on auth. Also a one-command window on every up where a concurrently running in-container session's tea calls fail.", "file": ".devcontainer/container-init.sh", "line": 124, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-19" }, { "id": "CR-28", "category": "in-scope-deferrable", "severity": "low", "summary": "resolve_branch's host-side 'git ls-remote' has no timeout and (on the create path) runs while the per-issue lock is held — a hung forge connection blocks this up indefinitely and any concurrent up for 600s.", "reasoning": "GIT_SSH_COMMAND ConnectTimeout or a timeout wrapper matches the bounded-probe discipline the rest of the file follows. [reported by bug-hunter (fable, adversarial) as CR-20; file .devcontainer/dw.sh:412]", "failure_scenario": "Forge TCP blackholes (VPN half-up). 'up N' acquires the lock, then blocks in ls-remote with no bound (contrast the curl probes' --max-time 20 and container-init's timeout'd ls-remote). A second 'up N' waits the full 600s and dies telling the operator to hand-remove the lock dir — for a hang the first process never reports.", "file": ".devcontainer/dw.sh", "line": 412, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-20" }, { "id": "CR-29", "category": "in-scope-deferrable", "severity": "low", "summary": "settings_seed_json silently degrades a malformed host settings.json to '{}' — the container comes up with no permission mode/allowlist and nobody is told.", "reasoning": "Failing closed on CONTENT is right, but the degradation is indistinguishable from 'host declares nothing' — it should warn. [reported by bug-hunter (fable, adversarial) as CR-21; file .devcontainer/dw.sh:394]", "failure_scenario": "Host settings.json gains a trailing-comma typo. jq fails, '|| printf {}' swallows it, the container seeds empty settings; the session runs in default permission mode instead of the operator's auto mode, and an intended-unattended run stalls on its first permission prompt with no indication the host config was dropped.", "file": ".devcontainer/dw.sh", "line": 394, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-21" }, { "id": "CR-30", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-3 races the live repo: the integration tip is read before 'up', and a peer session merging to main mid-scenario makes 'pin sha == tip' a false FAIL.", "reasoning": "Flake, not a false pass — but CLAUDE.md's Learnings already record one full sweep invalidated by peer churn; re-reading the tip on mismatch makes the assertion race-tolerant. [reported by bug-hunter (fable, adversarial) as CR-23; file .devcontainer/selftest.sh:447]", "failure_scenario": "This repo's parallel-sessions model lands merges on main routinely. ac3 reads branch_tip(main), then 'up' clones the pinned suite minutes later at the NEW tip -> 'provenance sha before repin is X, want Y' FAIL implicating the pin mechanism for ordinary repo motion.", "file": ".devcontainer/selftest.sh", "line": 447, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-23" }, { "id": "CR-31", "category": "in-scope-deferrable", "severity": "low", "summary": "The is_ready early-return skips verify_forge_ssh, so reattach never re-validates the freshly re-pushed SSH material — a rotated forge host key or broken key surfaces only later, mid-session, at push time.", "reasoning": "verify_forge_ssh is one bounded ls-remote; it sits in the volume-resident section by placement, not cost. Moving it above the is_ready return matches the 'probe before anything slow' contract. [reported by bug-hunter (fable, adversarial) as CR-24; file .devcontainer/container-init.sh:322]", "failure_scenario": "Forge rotates its host key. 'dw.sh up N' on the existing container re-seeds ssh config/key/known_hosts (seed_host_material runs every up), init probes both credentials, hits is_ready -> 'already ready'. The operator enters a 'ready' container whose first git push/fetch fails on host-key mismatch — the exact failure the fail-loud-at-start design exists to front-load.", "file": ".devcontainer/container-init.sh", "line": 322, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-24" }, { "id": "CR-32", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-16's 'setup does not re-run' half is verified only by grepping the reattach log for one of several hardcoded human-readable phrases (already-ready/already set up/already provisioned/already initialized), not by an independent behavioral signal.", "reasoning": "Matches SREQ.md's own AC-16 row ('assert...init reports already-ready'), so the message-based check is the specified verification method, not an ad hoc shortcut -- kept as low severity/deferrable rather than blocking. [reported by test-quality-audit (sonnet) as CR-4; file .devcontainer/selftest.sh:1021]", "failure_scenario": "A correct implementation that phrases its reattach message differently (e.g. 'skipping provisioning: workspace exists') would false-FAIL this scenario. Conversely, an implementation that always prints one of the matched phrases regardless of whether it actually re-ran setup would false-PASS the 'does not re-run' claim -- the file-marker checks above it only prove state survived, not that setup was skipped.", "file": ".devcontainer/selftest.sh", "line": 1021, "source_role": "test-quality-audit (sonnet)", "source_id": "CR-4" }, { "id": "CR-33", "category": "in-scope-blocking", "severity": "low", "summary": "CLAUDE.md's Learnings section asserts that .devcontainer/selftest.sh has static absence scans for BOTH AC-13 and AC-20; only AC-13's exists.", "reasoning": "The entry reads: \".devcontainer/selftest.sh's static absence scans (AC-13/AC-20) have no comment exemption and no allowlist\". AC-13's scan is real (selftest.sh:901-928). AC-20 has no static scan at all (see CR-2). The claim is already merged to main, so the project's own durable record now vouches for a check that does not exist - which is how CR-2 survived develop's own review. Must be corrected in the same commit that adds the missing scan, or the next reader inherits the same false assurance. [raised by the QA driver while verifying CR-2; corroborated independently in test-quality-audit's CR-1 reasoning]", "failure_scenario": "A future contributor reads the Learnings entry, believes an audio-residue scan guards .devcontainer/, and re-adds an audio package or socket mount; nothing catches it, because the scan the record promises was never written.", "file": "CLAUDE.md", "line": 310, "source_role": "qa-driver (opus, loop control)", "source_id": "CR-33" }, { "id": "CR-34", "category": "in-scope-deferrable", "severity": "low", "summary": "Post-hoc sanity check: this round's rebase onto origin/main auto-resolved one CLAUDE.md conflict; the resolution was a judgement call nothing else reviewed.", "reasoning": "rebase-onto-base.md requires every auto-resolution to be carried as a Finding, because an auto-resolution is a judgement call no reviewer saw. The conflict was in CLAUDE.md's Learnings list: main had appended the #46 SHIPPED_SKILLS entry while this branch appended two #61 entries. Both sides were non-overlapping additions to the same list, so the resolution kept both, base's entry first. No content from either side was dropped or edited. [raised by the QA driver per rebase-onto-base.md Step 2]", "failure_scenario": "If the reconciliation had been wrong, a learning from either side would be silently missing from main with a clean-looking history.", "file": "CLAUDE.md", "line": 303, "source_role": "qa-driver (opus, loop control)", "source_id": "CR-34" } ], "artifacts": { "report_path": ".devwork/feature-devcontainer-plugin-era-refresh/qa-code-validate-round1.md", "roles_run": [ "bug-hunter (fable) - adversarial, 25 findings", "spec-checker (sonnet) - SREQ AC-1..AC-23 traceability, 22/23 implemented-and-asserted, 0 orphan scenarios", "static-security-reviewer (sonnet) - 9 controls verified, 2 findings", "test-quality-audit (sonnet) - 23/23 scenarios can fail; 4 findings" ], "head_reviewed": "0495ca034da26f9ec3f5260c6871c2663c8f0cc8" } } ```
Author
Owner
{
  "outcome": "clean",
  "summary": "Three regression guards written by a separate actor before the fixes existed, all mutation-verified red-on-defect. The two that could not be written at first were unblocked by a source-detection seam on dw.sh, which is itself the reason two guards could sit dead and open.",
  "findings": [],
  "artifacts": {
    "test_commit": "09efa2403917ef6ecfc206e4c0cdd85e686f2218",
    "test_files": [
      ".devcontainer/selftest.sh"
    ],
    "test_marker": {
      "runner": "custom shell harness (.devcontainer/selftest.sh)",
      "write": "a scenario function returning 1 with AC_REASON set; red until the fix lands",
      "promote": "no marker to drop - the scenario goes green when the code under test is corrected"
    },
    "tests_written": [
      {
        "finding": "CR-1",
        "where": "ac11_forge_login_refreshed(), called from ac11()",
        "asserts": "deletes the container tea login, confirms a tracker call then fails, runs refresh-creds, requires the tracker call to work again",
        "written_red_at": "0dfe00dae11601c599b66381faa0964dde379505",
        "red_before_fix": true
      },
      {
        "finding": "CR-3",
        "where": "cr3_case()/ac_cr3(), registered as criterion CR-3",
        "asserts": "ssh_names_explicit_identity returns 1 for a stanza with no IdentityFile, 0 for an explicit one, and 1 for a wildcard Host * key",
        "mutation_verified": "neutering the discriminator gives: returned 0, want 1"
      },
      {
        "finding": "CR-6",
        "where": "cr6_probe_case()/ac_cr6(), registered as criterion CR-6",
        "asserts": "probe_reachable returns 0 for 200+array, 1 for 403, and 2 for 200+object - the last being the exact fail-open",
        "mutation_verified": "collapsing indeterminate into refused gives: returned 1, want 2"
      }
    ],
    "note": "Both new criteria are unit-style: no docker, no forge, no scratch issues. They source dw.sh directly and stub the one external tool each guard shells out to (ssh, curl). This is also their limit - see the fix-phase report on the set -e regression they could not see."
  }
}
<!-- qa-report:v1 issue=61 skill=qa domain=code phase=tests --> ```json { "outcome": "clean", "summary": "Three regression guards written by a separate actor before the fixes existed, all mutation-verified red-on-defect. The two that could not be written at first were unblocked by a source-detection seam on dw.sh, which is itself the reason two guards could sit dead and open.", "findings": [], "artifacts": { "test_commit": "09efa2403917ef6ecfc206e4c0cdd85e686f2218", "test_files": [ ".devcontainer/selftest.sh" ], "test_marker": { "runner": "custom shell harness (.devcontainer/selftest.sh)", "write": "a scenario function returning 1 with AC_REASON set; red until the fix lands", "promote": "no marker to drop - the scenario goes green when the code under test is corrected" }, "tests_written": [ { "finding": "CR-1", "where": "ac11_forge_login_refreshed(), called from ac11()", "asserts": "deletes the container tea login, confirms a tracker call then fails, runs refresh-creds, requires the tracker call to work again", "written_red_at": "0dfe00dae11601c599b66381faa0964dde379505", "red_before_fix": true }, { "finding": "CR-3", "where": "cr3_case()/ac_cr3(), registered as criterion CR-3", "asserts": "ssh_names_explicit_identity returns 1 for a stanza with no IdentityFile, 0 for an explicit one, and 1 for a wildcard Host * key", "mutation_verified": "neutering the discriminator gives: returned 0, want 1" }, { "finding": "CR-6", "where": "cr6_probe_case()/ac_cr6(), registered as criterion CR-6", "asserts": "probe_reachable returns 0 for 200+array, 1 for 403, and 2 for 200+object - the last being the exact fail-open", "mutation_verified": "collapsing indeterminate into refused gives: returned 1, want 2" } ], "note": "Both new criteria are unit-style: no docker, no forge, no scratch issues. They source dw.sh directly and stub the one external tool each guard shells out to (ssh, curl). This is also their limit - see the fix-phase report on the set -e regression they could not see." } } ```
Author
Owner
{
  "outcome": "issues-found",
  "summary": "Fixed 12 of 19 blocking findings, per an explicit operator scoping decision: the 8 harness defects plus the 3 dw.sh guards plus the record correction. 7 blocking and 15 deferrable findings remain open and are NOT fixed. Full harness run is 25/25 PASS at 3e8c877 - the first green on this feature that is worth reading, because the checks producing it can now fail.",
  "findings": [
    {
      "id": "CR-7",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "up/rm adopt ANY container named dw-<N> without checking the dev.devwork.managed-by label — credentials get seeded into, or removal destroys, a container dw.sh does not own.",
      "reasoning": "create_container sets dev.devwork.managed-by=dw.sh (line 475) precisely so managed containers are identifiable, but no lifecycle verb checks it before adopting or destroying by name. One label inspect before adoption closes it.  [reported by bug-hunter (fable, adversarial) as CR-2; file .devcontainer/dw.sh:587]",
      "failure_scenario": "Host has an unrelated container named dw-7 (short generic name; any devcontainer-derived image has a 'vscode' user). 'dw.sh up 7' finds state=running, takes the reattach path, and push_file copies the Claude OAuth credential, the forge token and the SSH private key INTO that foreign container, then execs init in it. 'dw.sh rm 7' similarly 'docker rm -f's it and deletes any volumes named dw-7-*. cmd_ls filters by the label (line 857) but up/rm/stop/repin/refresh-creds never verify it.",
      "file": ".devcontainer/dw.sh",
      "line": 587,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-2"
    },
    {
      "id": "CR-8",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "The rm unpushed-work guard ('git log --branches --not --remotes') is blind to detached-HEAD commits and stashes — rm destroys them with exit 0.",
      "reasoning": "--branches enumerates refs/heads/* only; detached HEAD and refs/stash are excluded by construction. The stated design intent is 'err toward refusing'; this errs toward deleting. Add HEAD/--reflog/refs/stash to the walk or refuse on detached HEAD.  [reported by bug-hunter (fable, adversarial) as CR-3; file .devcontainer/dw.sh:650]",
      "failure_scenario": "Session inside dw-N does 'git checkout <sha>', commits an experiment (or stashes work), then returns to the branch with a clean tree. inspect_workspace: porcelain empty, and the detached commits / refs/stash are reachable from no refs/heads/* ref, so '--branches --not --remotes' prints nothing -> both lists empty -> 'dw.sh rm N' proceeds and deletes the only copy, despite the guard existing exactly to refuse this.",
      "file": ".devcontainer/dw.sh",
      "line": 650,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-3"
    },
    {
      "id": "CR-9",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "PID 1 is 'sleep infinity' with no init: it ignores SIGTERM, so every 'dw.sh stop' hangs 10s and ends in SIGKILL — 'normal stop' is mechanically identical to the 'abnormal exit' AC-17 simulates.",
      "reasoning": "Harmless to state today (volumes carry everything, AC-17 proves kill-safety), but --init/tini or an exec-form trap loop makes stop immediate and genuinely graceful.  [reported by bug-hunter (fable, adversarial) as CR-11; file .devcontainer/Dockerfile:155]",
      "failure_scenario": "'dw.sh stop N' -> docker sends SIGTERM to PID 1; sleep as PID 1 has no handler and default-signal immunity, ignores it; docker waits the 10s grace period, then SIGKILLs. Every stop takes 10+ seconds, and any future entrypoint logic assuming a graceful-shutdown window would silently never get one.",
      "file": ".devcontainer/Dockerfile",
      "line": 155,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-11"
    },
    {
      "id": "CR-14",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "push_file() creates the destination file with the shell's default umask and only chmod's it to the intended restrictive mode afterward, leaving a brief but real window where credential material is not yet at 0600.",
      "reasoning": "real gap introduced by this feature's own new code, in the direct credential-handling path named in the task brief; not pre-existing.  [reported by static-security-reviewer (sonnet) as CR-1; file .devcontainer/dw.sh:193]",
      "failure_scenario": "push_file() is dw.sh's ONLY mechanism for copying the Claude OAuth credential (line 502/839), the forge token (via push_string -> push_file, line 503/840), and the SSH private key (line 355) into a container. Its implementation is: sh -c 'mkdir -p ... && cat > \"$DW_DEST\" && chmod \"$DW_MODE\" \"$DW_DEST\"'. The 'cat >' redirection creates $DW_DEST at whatever mode the container's default umask yields (typically 0022 -> 0644, world-readable) and chmod to 0600 only runs as a separate, later step in the same command chain. Any other process already running inside that container namespace (a background job the container's own Claude session started, a leftover process from a previous 'up', or a second exec racing this one) can read the OAuth accessToken/refreshToken, the forge token, or the SSH private key during that window. container-init.sh:65 demonstrates the atomic, race-free alternative already in use elsewhere in this same feature: install -m 0600 SRC DST sets the mode at creation with no window at all.",
      "file": ".devcontainer/dw.sh",
      "line": 193,
      "source_role": "static-security-reviewer (sonnet)",
      "source_id": "CR-1"
    },
    {
      "id": "CR-15",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "The rm-guard's throwaway inspection container is claimed unable to 'reach the network' (comment + README line 135), but it runs on the default bridge with full egress — only credentials are absent.",
      "reasoning": "docker run at line 641 passes no --network; '--network none' would make the stated property true for one flag.  [reported by bug-hunter (fable, adversarial) as CR-18; file .devcontainer/dw.sh:641]",
      "failure_scenario": "None today (the inspection script only reads local git state), but the documented isolation property is false as stated: any future edit that makes the inspection script network-touching would work fine and inherit the overclaim, and the README's safety narrative leans on it.",
      "file": ".devcontainer/dw.sh",
      "line": 641,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-18"
    },
    {
      "id": "CR-16",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "seed_host_material's header claims a re-entered container 'never runs on staler material than the host has', but the settings seed is only APPLIED when reseed=1 or the file is missing — host settings drift never reaches an existing container via 'up'.",
      "reasoning": "Behavior is arguably right (a session may own its settings); the in-file comment is what a future editor will trust, and it is wrong. Doc-level fix.  [reported by bug-hunter (fable, adversarial) as CR-25; file .devcontainer/dw.sh:495]",
      "failure_scenario": "Operator changes host permission rules, runs 'dw.sh up N' on the existing container trusting the comment: the fresh seed lands at ~/.claude/.dw/settings-seed.json but seed_settings (container-init.sh:216-222) leaves the live settings.json untouched (DW_RESEED=0 on reattach). Only credentials/token are actually fresh; settings require 'recreate' — which the README says, contradicting this header.",
      "file": ".devcontainer/dw.sh",
      "line": 495,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-25"
    },
    {
      "id": "CR-17",
      "category": "in-scope-blocking",
      "severity": "low",
      "summary": "ac2()'s jq>=1.7 version-floor check cites 'the CLAUDE.md floor' in its comment, but CLAUDE.md documents no such floor (readiness-check.md only checks jq presence, not version); the actual source is preq.original.md's incidental mention of 'jq 1.7.1'.",
      "reasoning": "Grepped CLAUDE.md and plugin/skills/_shared/procedures/readiness-check.md for any jq version requirement -- none exists, only a presence check (command -v jq). The only place '1.7' appears in this feature's own paper trail is .devwork/feature-devcontainer-plugin-era-refresh/preq.original.md:33 ('jq 1.7.1'), which is a fact about the OLD devcontainer setup, not a stated project-wide floor. Not a functional defect since the pinned jq (1.8.1, Dockerfile:26) trivially clears any real floor.  [reported by spec-checker (sonnet) as CR-2; file .devcontainer/selftest.sh:415]",
      "failure_scenario": "A future reader trusts the comment, goes looking in CLAUDE.md for a documented jq version requirement to reconcile against, and finds nothing -- wasted investigation, or a future change to jq's pinned version relies on a floor that isn't actually a suite-wide contract.",
      "file": ".devcontainer/selftest.sh",
      "line": 415,
      "source_role": "spec-checker (sonnet)",
      "source_id": "CR-2"
    },
    {
      "id": "CR-19",
      "category": "in-scope-deferrable",
      "severity": "medium",
      "summary": "A partial clone (SIGKILL/host reboot mid-clone) leaves /workspace/.git present; setup_workspace early-returns on '-d .git' and write_state marks the container ready on a corrupt workspace.",
      "reasoning": "git cleans up its own failures but cannot after SIGKILL/power loss, and volumes persist exactly those states. A cheap 'git -C /workspace rev-parse --verify HEAD' before early-returning fails loudly instead.  [reported by bug-hunter (fable, adversarial) as CR-4; file .devcontainer/container-init.sh:176]",
      "failure_scenario": "First 'up N': host reboots mid 'git clone' into /workspace; the volume keeps a partial .git. Next 'up N': setup_workspace sees [ -d /workspace/.git ] and returns 0 with no integrity check; is_ready's check is also just '-d .git' (line 289); write_state records status ready with branch '-' (rev-parse failure swallowed at line 246). Every subsequent up reattaches to a broken workspace and nothing ever re-clones — manual surgery required.",
      "file": ".devcontainer/container-init.sh",
      "line": 176,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-4"
    },
    {
      "id": "CR-20",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "The forge token is passed as a plaintext command-line argument to curl (dw.sh) and to 'tea login add' (container-init.sh), where it is visible via the process table for the life of that process.",
      "reasoning": "low practical severity given the token's intended recipient already holds it through the sanctioned path (the file at $TEA_TOKEN_FILE / $CLAUDE_HOME/.dw/forge-token); genuinely fixable (curl supports --header @file / a netrc-style approach, tea supports STDIN or a config file) but not gating given the narrow, single-tenant threat model this container design otherwise assumes.  [reported by static-security-reviewer (sonnet) as CR-2; file .devcontainer/dw.sh:280]",
      "failure_scenario": "dw.sh:280, dw.sh:295 (probe_reachable/assert_token_least_privilege) and dw.sh:445 (resolve_branch) all build curl ... -H \"Authorization: token $tok\" ... -- the token appears verbatim in that curl process's argv on the HOST for as long as the request is in flight, readable by any other local user via ps -eo args or /proc/<pid>/cmdline. container-init.sh:125 does the same thing inside the container with tea login add --token \"$tok\". This is the classic CWE-214 secrets-on-the-command-line pattern. Impact is bounded here because the intended recipient (the operator on the host; the container's own Claude/tea session) already has legitimate access to the same token via the token file / forge-token volume file, so this does not disclose the secret to a new principal in the common case -- it only widens the window and the audience (any other local host user, any other process already running in that same container) beyond what's necessary.",
      "file": ".devcontainer/dw.sh",
      "line": 280,
      "source_role": "static-security-reviewer (sonnet)",
      "source_id": "CR-2"
    },
    {
      "id": "CR-21",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "cmd_rm takes no per-issue lock, so rm can interleave with a concurrent 'up N' — removing the container/volumes mid-create, or destroying a commit made between inspection and rm -f (TOCTOU).",
      "reasoning": "cmd_up and cmd_recreate call acquire_lock; cmd_rm never does. Both windows are narrow but the fix is the existing acquire_lock call.  [reported by bug-hunter (fable, adversarial) as CR-8; file .devcontainer/dw.sh:655]",
      "failure_scenario": "(a) 'dw.sh rm N' runs while an unattended 'up N' is mid-clone: inspect_workspace reports '== no-repo ==' (clone not started in the volume yet) -> rm proceeds, deletes container and volumes out from under the in-flight up, which dies mid-seed with a confusing docker error. (b) A running session commits in the seconds between inspect_workspace's clean verdict and 'docker rm -f' -> the commit is destroyed despite the guard.",
      "file": ".devcontainer/dw.sh",
      "line": 655,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-8"
    },
    {
      "id": "CR-22",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "acquire_lock's stale-lock clearing has a TOCTOU: two waiters can both observe the dead pid, and the slower one's rm -rf deletes the lock the faster one just re-acquired — yielding two concurrent holders.",
      "reasoning": "Check-then-remove is not atomic. Removing only the pid file and retrying mkdir, or re-verifying ownership after rm, narrows it; likelihood low, mechanism textbook.  [reported by bug-hunter (fable, adversarial) as CR-9; file .devcontainer/dw.sh:129]",
      "failure_scenario": "Holder dies. Waiters B and C both read the stale pid and pass kill -0. B: rm -rf, mkdir, writes pid, proceeds. C (decision already made in the same loop iteration): rm -rf removes B's fresh lock dir, loops, mkdir succeeds -> B and C both run 'up N' concurrently — exactly the docker-create race the lock exists to serialize.",
      "file": ".devcontainer/dw.sh",
      "line": 129,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-9"
    },
    {
      "id": "CR-23",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "require_issue accepts leading zeros, so 'up 07' and 'up 7' create rival containers (dw-07 vs dw-7) and eventually two branches for the same logical issue.",
      "reasoning": "One-line fix: normalize with $((10#$n)) or reject leading zeros. Mechanically certain; needs an operator typo or padding script to trigger.  [reported by bug-hunter (fable, adversarial) as CR-10; file .devcontainer/dw.sh:153]",
      "failure_scenario": "A script zero-pads issue numbers and runs 'dw.sh up 07'. '07' passes the [!0-9] filter -> container dw-07, volumes dw-07-*; branch resolution ls-remotes 'refs/heads/feature/07-*', finds nothing (the real branch is feature/7-...), and cuts a NEW branch feature/07-<slug> from main. Two containers and two divergent branches for one issue — the one-issue-one-container invariant silently broken.",
      "file": ".devcontainer/dw.sh",
      "line": 153,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-10"
    },
    {
      "id": "CR-24",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-4/AC-11 assert the host credential file's hash (and mtime) unchanged across multi-minute scenarios — a legitimate OAuth refresh by any host-side claude session mid-scenario is reported as a violation.",
      "reasoning": "The assertion conflates 'a container wrote the host file' with 'the host file changed for any reason'. At minimum the failure message should name the benign cause; ac11 (line 863) shares the pattern via mtime.  [reported by bug-hunter (fable, adversarial) as CR-15; file .devcontainer/selftest.sh:499]",
      "failure_scenario": "While ac4 runs (two full 'up's, several minutes), the operator's own host claude session refreshes its OAuth token, rewriting ~/.claude/.credentials.json. host_hash_before != host_hash_after -> AC-4 FAILs claiming 'host Claude credential file changed during this scenario', implicating container isolation for a change no container made. On a box running parallel Claude sessions by design, this is a live flake.",
      "file": ".devcontainer/selftest.sh",
      "line": 499,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-15"
    },
    {
      "id": "CR-25",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "--ssh-agent is silently ignored whenever the container already exists — 'up N --ssh-agent' on a running/stopped container forwards nothing and says nothing.",
      "reasoning": "Code-path certain: the state-nonempty branch never touches $agent. A warn naming recreate is the minimal fix.  [reported by bug-hunter (fable, adversarial) as CR-16; file .devcontainer/dw.sh:561]",
      "failure_scenario": "Operator needs agent-held keys for a one-off operation and runs 'dw.sh up N --ssh-agent' on existing dw-N. The flag is only consulted in create_container (line 481), which the reattach path never calls; the session enters with no agent socket, ssh fails, and nothing hints the flag was a no-op or that 'recreate' is the fix.",
      "file": ".devcontainer/dw.sh",
      "line": 561,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-16"
    },
    {
      "id": "CR-26",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "'exec docker exec -it ...' on the attach path replaces the shell, so the EXIT trap never runs and $TMPROOT (probe bodies, ls-remote output, issue.json, known_hosts copy) is left behind on every interactive up.",
      "reasoning": "rm -rf \"$TMPROOT\" immediately before the exec (release_lock is already done there) closes it. Mechanically certain; impact is litter, not leakage.  [reported by bug-hunter (fable, adversarial) as CR-17; file .devcontainer/dw.sh:621]",
      "failure_scenario": "Each attended 'dw.sh up N' leaves one /tmp/dw.XXXXXX directory permanently (cleanup() only runs on non-exec exits). No secret persists — push.tmp holding the token is rm'd inline at line 202 — but forge API bodies accumulate in /tmp indefinitely.",
      "file": ".devcontainer/dw.sh",
      "line": 621,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-17"
    },
    {
      "id": "CR-27",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "setup_forge_login deletes the existing tea login before attempting the add — if the add fails, the container is left with NO forge login, strictly worse than before the up.",
      "reasoning": "Add-then-swap or tolerating add-over-existing removes the regression window; the next successful up repairs it, hence low.  [reported by bug-hunter (fable, adversarial) as CR-19; file .devcontainer/container-init.sh:124]",
      "failure_scenario": "Reattach 'up' during a brief forge outage: 'tea login delete devwork' succeeds, 'tea login add' fails against the unreachable forge -> die. The container, which had a working login a second earlier, now has none; any in-container helper run before the next successful up fails on auth. Also a one-command window on every up where a concurrently running in-container session's tea calls fail.",
      "file": ".devcontainer/container-init.sh",
      "line": 124,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-19"
    },
    {
      "id": "CR-28",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "resolve_branch's host-side 'git ls-remote' has no timeout and (on the create path) runs while the per-issue lock is held — a hung forge connection blocks this up indefinitely and any concurrent up for 600s.",
      "reasoning": "GIT_SSH_COMMAND ConnectTimeout or a timeout wrapper matches the bounded-probe discipline the rest of the file follows.  [reported by bug-hunter (fable, adversarial) as CR-20; file .devcontainer/dw.sh:412]",
      "failure_scenario": "Forge TCP blackholes (VPN half-up). 'up N' acquires the lock, then blocks in ls-remote with no bound (contrast the curl probes' --max-time 20 and container-init's timeout'd ls-remote). A second 'up N' waits the full 600s and dies telling the operator to hand-remove the lock dir — for a hang the first process never reports.",
      "file": ".devcontainer/dw.sh",
      "line": 412,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-20"
    },
    {
      "id": "CR-29",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "settings_seed_json silently degrades a malformed host settings.json to '{}' — the container comes up with no permission mode/allowlist and nobody is told.",
      "reasoning": "Failing closed on CONTENT is right, but the degradation is indistinguishable from 'host declares nothing' — it should warn.  [reported by bug-hunter (fable, adversarial) as CR-21; file .devcontainer/dw.sh:394]",
      "failure_scenario": "Host settings.json gains a trailing-comma typo. jq fails, '|| printf {}' swallows it, the container seeds empty settings; the session runs in default permission mode instead of the operator's auto mode, and an intended-unattended run stalls on its first permission prompt with no indication the host config was dropped.",
      "file": ".devcontainer/dw.sh",
      "line": 394,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-21"
    },
    {
      "id": "CR-30",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-3 races the live repo: the integration tip is read before 'up', and a peer session merging to main mid-scenario makes 'pin sha == tip' a false FAIL.",
      "reasoning": "Flake, not a false pass — but CLAUDE.md's Learnings already record one full sweep invalidated by peer churn; re-reading the tip on mismatch makes the assertion race-tolerant.  [reported by bug-hunter (fable, adversarial) as CR-23; file .devcontainer/selftest.sh:447]",
      "failure_scenario": "This repo's parallel-sessions model lands merges on main routinely. ac3 reads branch_tip(main), then 'up' clones the pinned suite minutes later at the NEW tip -> 'provenance sha before repin is X, want Y' FAIL implicating the pin mechanism for ordinary repo motion.",
      "file": ".devcontainer/selftest.sh",
      "line": 447,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-23"
    },
    {
      "id": "CR-31",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "The is_ready early-return skips verify_forge_ssh, so reattach never re-validates the freshly re-pushed SSH material — a rotated forge host key or broken key surfaces only later, mid-session, at push time.",
      "reasoning": "verify_forge_ssh is one bounded ls-remote; it sits in the volume-resident section by placement, not cost. Moving it above the is_ready return matches the 'probe before anything slow' contract.  [reported by bug-hunter (fable, adversarial) as CR-24; file .devcontainer/container-init.sh:322]",
      "failure_scenario": "Forge rotates its host key. 'dw.sh up N' on the existing container re-seeds ssh config/key/known_hosts (seed_host_material runs every up), init probes both credentials, hits is_ready -> 'already ready'. The operator enters a 'ready' container whose first git push/fetch fails on host-key mismatch — the exact failure the fail-loud-at-start design exists to front-load.",
      "file": ".devcontainer/container-init.sh",
      "line": 322,
      "source_role": "bug-hunter (fable, adversarial)",
      "source_id": "CR-24"
    },
    {
      "id": "CR-32",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-16's 'setup does not re-run' half is verified only by grepping the reattach log for one of several hardcoded human-readable phrases (already-ready/already set up/already provisioned/already initialized), not by an independent behavioral signal.",
      "reasoning": "Matches SREQ.md's own AC-16 row ('assert...init reports already-ready'), so the message-based check is the specified verification method, not an ad hoc shortcut -- kept as low severity/deferrable rather than blocking.  [reported by test-quality-audit (sonnet) as CR-4; file .devcontainer/selftest.sh:1021]",
      "failure_scenario": "A correct implementation that phrases its reattach message differently (e.g. 'skipping provisioning: workspace exists') would false-FAIL this scenario. Conversely, an implementation that always prints one of the matched phrases regardless of whether it actually re-ran setup would false-PASS the 'does not re-run' claim -- the file-marker checks above it only prove state survived, not that setup was skipped.",
      "file": ".devcontainer/selftest.sh",
      "line": 1021,
      "source_role": "test-quality-audit (sonnet)",
      "source_id": "CR-4"
    },
    {
      "id": "CR-34",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "Post-hoc sanity check: this round's rebase onto origin/main auto-resolved one CLAUDE.md conflict; the resolution was a judgement call nothing else reviewed.",
      "reasoning": "rebase-onto-base.md requires every auto-resolution to be carried as a Finding, because an auto-resolution is a judgement call no reviewer saw. The conflict was in CLAUDE.md's Learnings list: main had appended the #46 SHIPPED_SKILLS entry while this branch appended two #61 entries. Both sides were non-overlapping additions to the same list, so the resolution kept both, base's entry first. No content from either side was dropped or edited.  [raised by the QA driver per rebase-onto-base.md Step 2]",
      "failure_scenario": "If the reconciliation had been wrong, a learning from either side would be silently missing from main with a clean-looking history.",
      "file": "CLAUDE.md",
      "line": 303,
      "source_role": "qa-driver (opus, loop control)",
      "source_id": "CR-34"
    },
    {
      "id": "CR-35",
      "category": "in-scope-blocking",
      "severity": "high",
      "summary": "The CR-6 fix introduced a set -e regression that killed every `dw.sh up` silently. Found by the full run, fixed within the round at 3e8c877.",
      "reasoning": "Recorded as a finding of this round rather than folded silently into the fix, because what it demonstrates outlives it. `probe_reachable ...; rc=$?` looks like status capture but is not: under `set -e` a bare command returning non-zero kills the shell BEFORE the next line reads $?, and returning 1 (refused) is that function normal result on every healthy run. So assert_token_least_privilege exited 1 printing nothing, and with it every up. Every cheap check stayed green - `dw.sh ls` never reaches that code path, and the two new unit guards call probe_reachable inside `if`, which is exempt from set -e. Only the full container run could see it, and its signature (23 uniform failures at one call site, 23 zero-byte logs) is the one CLAUDE.md already records for environment/regression rather than scenario logic. Fixed with `rc=0; probe_reachable ... || rc=$?`; swept dw.sh and container-init.sh for the same pattern with no other instances.",
      "failure_scenario": "Any `dw.sh up N` on a correctly-scoped token: probe_reachable returns 1 (the expected refusal), set -e exits the shell at that line, up fails with status 1 and no output.",
      "file": ".devcontainer/dw.sh",
      "line": 321,
      "source_role": "qa-driver (opus, loop control) - self-inflicted, caught by the sweep",
      "status": "fixed-this-round"
    }
  ],
  "artifacts": {
    "fix_commits": [
      "1cbf963 - 8 harness defects (CR-2,4,5,10,11,12,13,18) + CLAUDE.md correction (CR-33)",
      "9f92514 - dw.sh/container-init.sh guards (CR-1, CR-3, CR-6) + the source-detection seam",
      "3e8c877 - the set -e regression the CR-6 fix introduced (CR-35)"
    ],
    "sweep_head": "3e8c877",
    "sweep_result": "25/25 PASS (AC-1..AC-23 plus unit criteria CR-3 and CR-6)",
    "fixed_this_round": [
      "CR-1",
      "CR-2",
      "CR-3",
      "CR-4",
      "CR-5",
      "CR-6",
      "CR-10",
      "CR-11",
      "CR-12",
      "CR-13",
      "CR-18",
      "CR-33"
    ],
    "still_open_blocking": [
      "CR-7",
      "CR-8",
      "CR-9",
      "CR-14",
      "CR-15",
      "CR-16",
      "CR-17"
    ],
    "still_open_deferrable": [
      "CR-19",
      "CR-20",
      "CR-21",
      "CR-22",
      "CR-23",
      "CR-24",
      "CR-25",
      "CR-26",
      "CR-27",
      "CR-28",
      "CR-29",
      "CR-30",
      "CR-31",
      "CR-32",
      "CR-34"
    ],
    "scoping_note": "The 7 remaining blocking findings were deliberately deferred by the operator to a later round, to get the grading surface honest first and buy a trustworthy 25-criterion result before committing to the long tail. They are mostly documentation overclaims and low-severity hardening; none is a wrong verdict in the harness."
  }
}
<!-- qa-report:v1 issue=61 skill=qa domain=code phase=fix --> ```json { "outcome": "issues-found", "summary": "Fixed 12 of 19 blocking findings, per an explicit operator scoping decision: the 8 harness defects plus the 3 dw.sh guards plus the record correction. 7 blocking and 15 deferrable findings remain open and are NOT fixed. Full harness run is 25/25 PASS at 3e8c877 - the first green on this feature that is worth reading, because the checks producing it can now fail.", "findings": [ { "id": "CR-7", "category": "in-scope-blocking", "severity": "medium", "summary": "up/rm adopt ANY container named dw-<N> without checking the dev.devwork.managed-by label — credentials get seeded into, or removal destroys, a container dw.sh does not own.", "reasoning": "create_container sets dev.devwork.managed-by=dw.sh (line 475) precisely so managed containers are identifiable, but no lifecycle verb checks it before adopting or destroying by name. One label inspect before adoption closes it. [reported by bug-hunter (fable, adversarial) as CR-2; file .devcontainer/dw.sh:587]", "failure_scenario": "Host has an unrelated container named dw-7 (short generic name; any devcontainer-derived image has a 'vscode' user). 'dw.sh up 7' finds state=running, takes the reattach path, and push_file copies the Claude OAuth credential, the forge token and the SSH private key INTO that foreign container, then execs init in it. 'dw.sh rm 7' similarly 'docker rm -f's it and deletes any volumes named dw-7-*. cmd_ls filters by the label (line 857) but up/rm/stop/repin/refresh-creds never verify it.", "file": ".devcontainer/dw.sh", "line": 587, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-2" }, { "id": "CR-8", "category": "in-scope-blocking", "severity": "medium", "summary": "The rm unpushed-work guard ('git log --branches --not --remotes') is blind to detached-HEAD commits and stashes — rm destroys them with exit 0.", "reasoning": "--branches enumerates refs/heads/* only; detached HEAD and refs/stash are excluded by construction. The stated design intent is 'err toward refusing'; this errs toward deleting. Add HEAD/--reflog/refs/stash to the walk or refuse on detached HEAD. [reported by bug-hunter (fable, adversarial) as CR-3; file .devcontainer/dw.sh:650]", "failure_scenario": "Session inside dw-N does 'git checkout <sha>', commits an experiment (or stashes work), then returns to the branch with a clean tree. inspect_workspace: porcelain empty, and the detached commits / refs/stash are reachable from no refs/heads/* ref, so '--branches --not --remotes' prints nothing -> both lists empty -> 'dw.sh rm N' proceeds and deletes the only copy, despite the guard existing exactly to refuse this.", "file": ".devcontainer/dw.sh", "line": 650, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-3" }, { "id": "CR-9", "category": "in-scope-blocking", "severity": "medium", "summary": "PID 1 is 'sleep infinity' with no init: it ignores SIGTERM, so every 'dw.sh stop' hangs 10s and ends in SIGKILL — 'normal stop' is mechanically identical to the 'abnormal exit' AC-17 simulates.", "reasoning": "Harmless to state today (volumes carry everything, AC-17 proves kill-safety), but --init/tini or an exec-form trap loop makes stop immediate and genuinely graceful. [reported by bug-hunter (fable, adversarial) as CR-11; file .devcontainer/Dockerfile:155]", "failure_scenario": "'dw.sh stop N' -> docker sends SIGTERM to PID 1; sleep as PID 1 has no handler and default-signal immunity, ignores it; docker waits the 10s grace period, then SIGKILLs. Every stop takes 10+ seconds, and any future entrypoint logic assuming a graceful-shutdown window would silently never get one.", "file": ".devcontainer/Dockerfile", "line": 155, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-11" }, { "id": "CR-14", "category": "in-scope-blocking", "severity": "low", "summary": "push_file() creates the destination file with the shell's default umask and only chmod's it to the intended restrictive mode afterward, leaving a brief but real window where credential material is not yet at 0600.", "reasoning": "real gap introduced by this feature's own new code, in the direct credential-handling path named in the task brief; not pre-existing. [reported by static-security-reviewer (sonnet) as CR-1; file .devcontainer/dw.sh:193]", "failure_scenario": "push_file() is dw.sh's ONLY mechanism for copying the Claude OAuth credential (line 502/839), the forge token (via push_string -> push_file, line 503/840), and the SSH private key (line 355) into a container. Its implementation is: sh -c 'mkdir -p ... && cat > \"$DW_DEST\" && chmod \"$DW_MODE\" \"$DW_DEST\"'. The 'cat >' redirection creates $DW_DEST at whatever mode the container's default umask yields (typically 0022 -> 0644, world-readable) and chmod to 0600 only runs as a separate, later step in the same command chain. Any other process already running inside that container namespace (a background job the container's own Claude session started, a leftover process from a previous 'up', or a second exec racing this one) can read the OAuth accessToken/refreshToken, the forge token, or the SSH private key during that window. container-init.sh:65 demonstrates the atomic, race-free alternative already in use elsewhere in this same feature: install -m 0600 SRC DST sets the mode at creation with no window at all.", "file": ".devcontainer/dw.sh", "line": 193, "source_role": "static-security-reviewer (sonnet)", "source_id": "CR-1" }, { "id": "CR-15", "category": "in-scope-blocking", "severity": "low", "summary": "The rm-guard's throwaway inspection container is claimed unable to 'reach the network' (comment + README line 135), but it runs on the default bridge with full egress — only credentials are absent.", "reasoning": "docker run at line 641 passes no --network; '--network none' would make the stated property true for one flag. [reported by bug-hunter (fable, adversarial) as CR-18; file .devcontainer/dw.sh:641]", "failure_scenario": "None today (the inspection script only reads local git state), but the documented isolation property is false as stated: any future edit that makes the inspection script network-touching would work fine and inherit the overclaim, and the README's safety narrative leans on it.", "file": ".devcontainer/dw.sh", "line": 641, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-18" }, { "id": "CR-16", "category": "in-scope-blocking", "severity": "low", "summary": "seed_host_material's header claims a re-entered container 'never runs on staler material than the host has', but the settings seed is only APPLIED when reseed=1 or the file is missing — host settings drift never reaches an existing container via 'up'.", "reasoning": "Behavior is arguably right (a session may own its settings); the in-file comment is what a future editor will trust, and it is wrong. Doc-level fix. [reported by bug-hunter (fable, adversarial) as CR-25; file .devcontainer/dw.sh:495]", "failure_scenario": "Operator changes host permission rules, runs 'dw.sh up N' on the existing container trusting the comment: the fresh seed lands at ~/.claude/.dw/settings-seed.json but seed_settings (container-init.sh:216-222) leaves the live settings.json untouched (DW_RESEED=0 on reattach). Only credentials/token are actually fresh; settings require 'recreate' — which the README says, contradicting this header.", "file": ".devcontainer/dw.sh", "line": 495, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-25" }, { "id": "CR-17", "category": "in-scope-blocking", "severity": "low", "summary": "ac2()'s jq>=1.7 version-floor check cites 'the CLAUDE.md floor' in its comment, but CLAUDE.md documents no such floor (readiness-check.md only checks jq presence, not version); the actual source is preq.original.md's incidental mention of 'jq 1.7.1'.", "reasoning": "Grepped CLAUDE.md and plugin/skills/_shared/procedures/readiness-check.md for any jq version requirement -- none exists, only a presence check (command -v jq). The only place '1.7' appears in this feature's own paper trail is .devwork/feature-devcontainer-plugin-era-refresh/preq.original.md:33 ('jq 1.7.1'), which is a fact about the OLD devcontainer setup, not a stated project-wide floor. Not a functional defect since the pinned jq (1.8.1, Dockerfile:26) trivially clears any real floor. [reported by spec-checker (sonnet) as CR-2; file .devcontainer/selftest.sh:415]", "failure_scenario": "A future reader trusts the comment, goes looking in CLAUDE.md for a documented jq version requirement to reconcile against, and finds nothing -- wasted investigation, or a future change to jq's pinned version relies on a floor that isn't actually a suite-wide contract.", "file": ".devcontainer/selftest.sh", "line": 415, "source_role": "spec-checker (sonnet)", "source_id": "CR-2" }, { "id": "CR-19", "category": "in-scope-deferrable", "severity": "medium", "summary": "A partial clone (SIGKILL/host reboot mid-clone) leaves /workspace/.git present; setup_workspace early-returns on '-d .git' and write_state marks the container ready on a corrupt workspace.", "reasoning": "git cleans up its own failures but cannot after SIGKILL/power loss, and volumes persist exactly those states. A cheap 'git -C /workspace rev-parse --verify HEAD' before early-returning fails loudly instead. [reported by bug-hunter (fable, adversarial) as CR-4; file .devcontainer/container-init.sh:176]", "failure_scenario": "First 'up N': host reboots mid 'git clone' into /workspace; the volume keeps a partial .git. Next 'up N': setup_workspace sees [ -d /workspace/.git ] and returns 0 with no integrity check; is_ready's check is also just '-d .git' (line 289); write_state records status ready with branch '-' (rev-parse failure swallowed at line 246). Every subsequent up reattaches to a broken workspace and nothing ever re-clones — manual surgery required.", "file": ".devcontainer/container-init.sh", "line": 176, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-4" }, { "id": "CR-20", "category": "in-scope-deferrable", "severity": "low", "summary": "The forge token is passed as a plaintext command-line argument to curl (dw.sh) and to 'tea login add' (container-init.sh), where it is visible via the process table for the life of that process.", "reasoning": "low practical severity given the token's intended recipient already holds it through the sanctioned path (the file at $TEA_TOKEN_FILE / $CLAUDE_HOME/.dw/forge-token); genuinely fixable (curl supports --header @file / a netrc-style approach, tea supports STDIN or a config file) but not gating given the narrow, single-tenant threat model this container design otherwise assumes. [reported by static-security-reviewer (sonnet) as CR-2; file .devcontainer/dw.sh:280]", "failure_scenario": "dw.sh:280, dw.sh:295 (probe_reachable/assert_token_least_privilege) and dw.sh:445 (resolve_branch) all build curl ... -H \"Authorization: token $tok\" ... -- the token appears verbatim in that curl process's argv on the HOST for as long as the request is in flight, readable by any other local user via ps -eo args or /proc/<pid>/cmdline. container-init.sh:125 does the same thing inside the container with tea login add --token \"$tok\". This is the classic CWE-214 secrets-on-the-command-line pattern. Impact is bounded here because the intended recipient (the operator on the host; the container's own Claude/tea session) already has legitimate access to the same token via the token file / forge-token volume file, so this does not disclose the secret to a new principal in the common case -- it only widens the window and the audience (any other local host user, any other process already running in that same container) beyond what's necessary.", "file": ".devcontainer/dw.sh", "line": 280, "source_role": "static-security-reviewer (sonnet)", "source_id": "CR-2" }, { "id": "CR-21", "category": "in-scope-deferrable", "severity": "low", "summary": "cmd_rm takes no per-issue lock, so rm can interleave with a concurrent 'up N' — removing the container/volumes mid-create, or destroying a commit made between inspection and rm -f (TOCTOU).", "reasoning": "cmd_up and cmd_recreate call acquire_lock; cmd_rm never does. Both windows are narrow but the fix is the existing acquire_lock call. [reported by bug-hunter (fable, adversarial) as CR-8; file .devcontainer/dw.sh:655]", "failure_scenario": "(a) 'dw.sh rm N' runs while an unattended 'up N' is mid-clone: inspect_workspace reports '== no-repo ==' (clone not started in the volume yet) -> rm proceeds, deletes container and volumes out from under the in-flight up, which dies mid-seed with a confusing docker error. (b) A running session commits in the seconds between inspect_workspace's clean verdict and 'docker rm -f' -> the commit is destroyed despite the guard.", "file": ".devcontainer/dw.sh", "line": 655, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-8" }, { "id": "CR-22", "category": "in-scope-deferrable", "severity": "low", "summary": "acquire_lock's stale-lock clearing has a TOCTOU: two waiters can both observe the dead pid, and the slower one's rm -rf deletes the lock the faster one just re-acquired — yielding two concurrent holders.", "reasoning": "Check-then-remove is not atomic. Removing only the pid file and retrying mkdir, or re-verifying ownership after rm, narrows it; likelihood low, mechanism textbook. [reported by bug-hunter (fable, adversarial) as CR-9; file .devcontainer/dw.sh:129]", "failure_scenario": "Holder dies. Waiters B and C both read the stale pid and pass kill -0. B: rm -rf, mkdir, writes pid, proceeds. C (decision already made in the same loop iteration): rm -rf removes B's fresh lock dir, loops, mkdir succeeds -> B and C both run 'up N' concurrently — exactly the docker-create race the lock exists to serialize.", "file": ".devcontainer/dw.sh", "line": 129, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-9" }, { "id": "CR-23", "category": "in-scope-deferrable", "severity": "low", "summary": "require_issue accepts leading zeros, so 'up 07' and 'up 7' create rival containers (dw-07 vs dw-7) and eventually two branches for the same logical issue.", "reasoning": "One-line fix: normalize with $((10#$n)) or reject leading zeros. Mechanically certain; needs an operator typo or padding script to trigger. [reported by bug-hunter (fable, adversarial) as CR-10; file .devcontainer/dw.sh:153]", "failure_scenario": "A script zero-pads issue numbers and runs 'dw.sh up 07'. '07' passes the [!0-9] filter -> container dw-07, volumes dw-07-*; branch resolution ls-remotes 'refs/heads/feature/07-*', finds nothing (the real branch is feature/7-...), and cuts a NEW branch feature/07-<slug> from main. Two containers and two divergent branches for one issue — the one-issue-one-container invariant silently broken.", "file": ".devcontainer/dw.sh", "line": 153, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-10" }, { "id": "CR-24", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-4/AC-11 assert the host credential file's hash (and mtime) unchanged across multi-minute scenarios — a legitimate OAuth refresh by any host-side claude session mid-scenario is reported as a violation.", "reasoning": "The assertion conflates 'a container wrote the host file' with 'the host file changed for any reason'. At minimum the failure message should name the benign cause; ac11 (line 863) shares the pattern via mtime. [reported by bug-hunter (fable, adversarial) as CR-15; file .devcontainer/selftest.sh:499]", "failure_scenario": "While ac4 runs (two full 'up's, several minutes), the operator's own host claude session refreshes its OAuth token, rewriting ~/.claude/.credentials.json. host_hash_before != host_hash_after -> AC-4 FAILs claiming 'host Claude credential file changed during this scenario', implicating container isolation for a change no container made. On a box running parallel Claude sessions by design, this is a live flake.", "file": ".devcontainer/selftest.sh", "line": 499, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-15" }, { "id": "CR-25", "category": "in-scope-deferrable", "severity": "low", "summary": "--ssh-agent is silently ignored whenever the container already exists — 'up N --ssh-agent' on a running/stopped container forwards nothing and says nothing.", "reasoning": "Code-path certain: the state-nonempty branch never touches $agent. A warn naming recreate is the minimal fix. [reported by bug-hunter (fable, adversarial) as CR-16; file .devcontainer/dw.sh:561]", "failure_scenario": "Operator needs agent-held keys for a one-off operation and runs 'dw.sh up N --ssh-agent' on existing dw-N. The flag is only consulted in create_container (line 481), which the reattach path never calls; the session enters with no agent socket, ssh fails, and nothing hints the flag was a no-op or that 'recreate' is the fix.", "file": ".devcontainer/dw.sh", "line": 561, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-16" }, { "id": "CR-26", "category": "in-scope-deferrable", "severity": "low", "summary": "'exec docker exec -it ...' on the attach path replaces the shell, so the EXIT trap never runs and $TMPROOT (probe bodies, ls-remote output, issue.json, known_hosts copy) is left behind on every interactive up.", "reasoning": "rm -rf \"$TMPROOT\" immediately before the exec (release_lock is already done there) closes it. Mechanically certain; impact is litter, not leakage. [reported by bug-hunter (fable, adversarial) as CR-17; file .devcontainer/dw.sh:621]", "failure_scenario": "Each attended 'dw.sh up N' leaves one /tmp/dw.XXXXXX directory permanently (cleanup() only runs on non-exec exits). No secret persists — push.tmp holding the token is rm'd inline at line 202 — but forge API bodies accumulate in /tmp indefinitely.", "file": ".devcontainer/dw.sh", "line": 621, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-17" }, { "id": "CR-27", "category": "in-scope-deferrable", "severity": "low", "summary": "setup_forge_login deletes the existing tea login before attempting the add — if the add fails, the container is left with NO forge login, strictly worse than before the up.", "reasoning": "Add-then-swap or tolerating add-over-existing removes the regression window; the next successful up repairs it, hence low. [reported by bug-hunter (fable, adversarial) as CR-19; file .devcontainer/container-init.sh:124]", "failure_scenario": "Reattach 'up' during a brief forge outage: 'tea login delete devwork' succeeds, 'tea login add' fails against the unreachable forge -> die. The container, which had a working login a second earlier, now has none; any in-container helper run before the next successful up fails on auth. Also a one-command window on every up where a concurrently running in-container session's tea calls fail.", "file": ".devcontainer/container-init.sh", "line": 124, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-19" }, { "id": "CR-28", "category": "in-scope-deferrable", "severity": "low", "summary": "resolve_branch's host-side 'git ls-remote' has no timeout and (on the create path) runs while the per-issue lock is held — a hung forge connection blocks this up indefinitely and any concurrent up for 600s.", "reasoning": "GIT_SSH_COMMAND ConnectTimeout or a timeout wrapper matches the bounded-probe discipline the rest of the file follows. [reported by bug-hunter (fable, adversarial) as CR-20; file .devcontainer/dw.sh:412]", "failure_scenario": "Forge TCP blackholes (VPN half-up). 'up N' acquires the lock, then blocks in ls-remote with no bound (contrast the curl probes' --max-time 20 and container-init's timeout'd ls-remote). A second 'up N' waits the full 600s and dies telling the operator to hand-remove the lock dir — for a hang the first process never reports.", "file": ".devcontainer/dw.sh", "line": 412, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-20" }, { "id": "CR-29", "category": "in-scope-deferrable", "severity": "low", "summary": "settings_seed_json silently degrades a malformed host settings.json to '{}' — the container comes up with no permission mode/allowlist and nobody is told.", "reasoning": "Failing closed on CONTENT is right, but the degradation is indistinguishable from 'host declares nothing' — it should warn. [reported by bug-hunter (fable, adversarial) as CR-21; file .devcontainer/dw.sh:394]", "failure_scenario": "Host settings.json gains a trailing-comma typo. jq fails, '|| printf {}' swallows it, the container seeds empty settings; the session runs in default permission mode instead of the operator's auto mode, and an intended-unattended run stalls on its first permission prompt with no indication the host config was dropped.", "file": ".devcontainer/dw.sh", "line": 394, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-21" }, { "id": "CR-30", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-3 races the live repo: the integration tip is read before 'up', and a peer session merging to main mid-scenario makes 'pin sha == tip' a false FAIL.", "reasoning": "Flake, not a false pass — but CLAUDE.md's Learnings already record one full sweep invalidated by peer churn; re-reading the tip on mismatch makes the assertion race-tolerant. [reported by bug-hunter (fable, adversarial) as CR-23; file .devcontainer/selftest.sh:447]", "failure_scenario": "This repo's parallel-sessions model lands merges on main routinely. ac3 reads branch_tip(main), then 'up' clones the pinned suite minutes later at the NEW tip -> 'provenance sha before repin is X, want Y' FAIL implicating the pin mechanism for ordinary repo motion.", "file": ".devcontainer/selftest.sh", "line": 447, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-23" }, { "id": "CR-31", "category": "in-scope-deferrable", "severity": "low", "summary": "The is_ready early-return skips verify_forge_ssh, so reattach never re-validates the freshly re-pushed SSH material — a rotated forge host key or broken key surfaces only later, mid-session, at push time.", "reasoning": "verify_forge_ssh is one bounded ls-remote; it sits in the volume-resident section by placement, not cost. Moving it above the is_ready return matches the 'probe before anything slow' contract. [reported by bug-hunter (fable, adversarial) as CR-24; file .devcontainer/container-init.sh:322]", "failure_scenario": "Forge rotates its host key. 'dw.sh up N' on the existing container re-seeds ssh config/key/known_hosts (seed_host_material runs every up), init probes both credentials, hits is_ready -> 'already ready'. The operator enters a 'ready' container whose first git push/fetch fails on host-key mismatch — the exact failure the fail-loud-at-start design exists to front-load.", "file": ".devcontainer/container-init.sh", "line": 322, "source_role": "bug-hunter (fable, adversarial)", "source_id": "CR-24" }, { "id": "CR-32", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-16's 'setup does not re-run' half is verified only by grepping the reattach log for one of several hardcoded human-readable phrases (already-ready/already set up/already provisioned/already initialized), not by an independent behavioral signal.", "reasoning": "Matches SREQ.md's own AC-16 row ('assert...init reports already-ready'), so the message-based check is the specified verification method, not an ad hoc shortcut -- kept as low severity/deferrable rather than blocking. [reported by test-quality-audit (sonnet) as CR-4; file .devcontainer/selftest.sh:1021]", "failure_scenario": "A correct implementation that phrases its reattach message differently (e.g. 'skipping provisioning: workspace exists') would false-FAIL this scenario. Conversely, an implementation that always prints one of the matched phrases regardless of whether it actually re-ran setup would false-PASS the 'does not re-run' claim -- the file-marker checks above it only prove state survived, not that setup was skipped.", "file": ".devcontainer/selftest.sh", "line": 1021, "source_role": "test-quality-audit (sonnet)", "source_id": "CR-4" }, { "id": "CR-34", "category": "in-scope-deferrable", "severity": "low", "summary": "Post-hoc sanity check: this round's rebase onto origin/main auto-resolved one CLAUDE.md conflict; the resolution was a judgement call nothing else reviewed.", "reasoning": "rebase-onto-base.md requires every auto-resolution to be carried as a Finding, because an auto-resolution is a judgement call no reviewer saw. The conflict was in CLAUDE.md's Learnings list: main had appended the #46 SHIPPED_SKILLS entry while this branch appended two #61 entries. Both sides were non-overlapping additions to the same list, so the resolution kept both, base's entry first. No content from either side was dropped or edited. [raised by the QA driver per rebase-onto-base.md Step 2]", "failure_scenario": "If the reconciliation had been wrong, a learning from either side would be silently missing from main with a clean-looking history.", "file": "CLAUDE.md", "line": 303, "source_role": "qa-driver (opus, loop control)", "source_id": "CR-34" }, { "id": "CR-35", "category": "in-scope-blocking", "severity": "high", "summary": "The CR-6 fix introduced a set -e regression that killed every `dw.sh up` silently. Found by the full run, fixed within the round at 3e8c877.", "reasoning": "Recorded as a finding of this round rather than folded silently into the fix, because what it demonstrates outlives it. `probe_reachable ...; rc=$?` looks like status capture but is not: under `set -e` a bare command returning non-zero kills the shell BEFORE the next line reads $?, and returning 1 (refused) is that function normal result on every healthy run. So assert_token_least_privilege exited 1 printing nothing, and with it every up. Every cheap check stayed green - `dw.sh ls` never reaches that code path, and the two new unit guards call probe_reachable inside `if`, which is exempt from set -e. Only the full container run could see it, and its signature (23 uniform failures at one call site, 23 zero-byte logs) is the one CLAUDE.md already records for environment/regression rather than scenario logic. Fixed with `rc=0; probe_reachable ... || rc=$?`; swept dw.sh and container-init.sh for the same pattern with no other instances.", "failure_scenario": "Any `dw.sh up N` on a correctly-scoped token: probe_reachable returns 1 (the expected refusal), set -e exits the shell at that line, up fails with status 1 and no output.", "file": ".devcontainer/dw.sh", "line": 321, "source_role": "qa-driver (opus, loop control) - self-inflicted, caught by the sweep", "status": "fixed-this-round" } ], "artifacts": { "fix_commits": [ "1cbf963 - 8 harness defects (CR-2,4,5,10,11,12,13,18) + CLAUDE.md correction (CR-33)", "9f92514 - dw.sh/container-init.sh guards (CR-1, CR-3, CR-6) + the source-detection seam", "3e8c877 - the set -e regression the CR-6 fix introduced (CR-35)" ], "sweep_head": "3e8c877", "sweep_result": "25/25 PASS (AC-1..AC-23 plus unit criteria CR-3 and CR-6)", "fixed_this_round": [ "CR-1", "CR-2", "CR-3", "CR-4", "CR-5", "CR-6", "CR-10", "CR-11", "CR-12", "CR-13", "CR-18", "CR-33" ], "still_open_blocking": [ "CR-7", "CR-8", "CR-9", "CR-14", "CR-15", "CR-16", "CR-17" ], "still_open_deferrable": [ "CR-19", "CR-20", "CR-21", "CR-22", "CR-23", "CR-24", "CR-25", "CR-26", "CR-27", "CR-28", "CR-29", "CR-30", "CR-31", "CR-32", "CR-34" ], "scoping_note": "The 7 remaining blocking findings were deliberately deferred by the operator to a later round, to get the grading surface honest first and buy a trustworthy 25-criterion result before committing to the long tail. They are mostly documentation overclaims and low-severity hardening; none is a wrong verdict in the harness." } } ```
Author
Owner
{
  "next_state": "qa",
  "produced": [],
  "findings": [
    {
      "category": "in-scope-blocking",
      "severity": "high",
      "summary": "7 blocking findings from round 1 validate remain unfixed (CR-7, CR-8, CR-9, CR-14, CR-15, CR-16, CR-17), by an explicit operator scoping decision.",
      "reasoning": "Round 1 raised 19 blocking findings; 12 were fixed. The operator scoped the round to the 8 harness defects plus the 3 dw.sh guards, to make the grading surface honest and buy a trustworthy 25-criterion result before committing to the long tail. The remainder are container-adoption without a label check (CR-7), the rm guard being blind to detached-HEAD commits and stashes (CR-8), PID 1 ignoring SIGTERM so a normal stop is mechanically a kill (CR-9), push_file create-then-chmod (CR-14), and three doc/comment overclaims (CR-15, CR-16, CR-17). None is a wrong verdict in the harness; all are real.",
      "proposed_action": "fix-now",
      "fix_cost": "moderate",
      "feature_value": "core",
      "adjacent_to_blocking": true,
      "requires_product_decision": false,
      "id": "F-PO-61-4-1"
    },
    {
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "RV-4: refresh-creds now deletes the container tea login before re-adding it, so a failed add leaves the container with NO forge login where it previously had a working one.",
      "reasoning": "Introduced by this round own CR-1 fix and recorded rather than quietly carried. It is strictly better than the behaviour it replaced (which silently validated the OLD token and reported success), but it is a genuine worse-off window that did not exist before, plus a brief interval on every up where a session already running inside the container loses forge auth. Closing it properly means validating the new token before destroying the working login - add under a temporary name, probe, then swap - which is a design choice rather than a repair, so it was not taken mid-round.",
      "proposed_action": "fix-now",
      "fix_cost": "small",
      "feature_value": "core",
      "adjacent_to_blocking": true,
      "requires_product_decision": false,
      "id": "F-PO-61-4-2"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "15 deferrable findings from round 1 validate remain open and need dispositions (CR-19..CR-32, CR-34).",
      "reasoning": "Concurrency and lock TOCTOUs, an unbounded ls-remote holding the per-issue lock, leading-zero issue ids creating rival containers, peer-churn flakes in AC-3/AC-4, --ssh-agent silently ignored on reattach, a TMPROOT leak on the attach path, and the AC-16 message-grep. Each needs a fix-now / defer-to-issue / accept disposition before a later round tests or fixes against them.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "moderate",
      "feature_value": "incidental",
      "adjacent_to_blocking": false,
      "requires_product_decision": true,
      "id": "F-PO-61-4-3"
    },
    {
      "category": "pre-existing",
      "severity": "medium",
      "summary": "The set -e capture trap was hit TWICE by the same actor in one round, in two different constructs, and neither cheap gate could see it.",
      "reasoning": "Recorded as a process finding because the recurrence is the signal. (a) `cmd; rc=$?` killed every dw.sh up; (b) `x=$(... | while ...)` in a later fix killed the ssh guard on the host own correct config. Both are set -e exempt only inside a condition context, which is exactly where the unit tests exercised them - so bash -n, lint, and the unit guards were all green while the product path was dead. Only the full container sweep could see either. The generalisable rule: in a `set -e` file, a helper whose non-zero return is a NORMAL outcome must be called in a form whose status is always zero, and a unit test that calls it inside `if` proves the function and not its callers.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "feature_value": "none",
      "adjacent_to_blocking": false,
      "requires_product_decision": false,
      "id": "F-PO-61-4-4"
    }
  ],
  "pending_decisions": [
    {
      "id": "D-PO-61-4-1",
      "type": "scope-disposition",
      "blocking": true,
      "question": "Round 1 fixed 12 of 19 blocking findings by your scoping decision, and the sweep is 25/25 green at 78574f3. The 7 remaining blocking findings (CR-7,8,9,14,15,16,17) plus RV-4 are still open. Fix them in a round 2, or re-classify some as deferrable and move toward the UAT gate?",
      "options": [
        "fix-now",
        "defer-to-issue",
        "accept"
      ],
      "recommended": "fix-now",
      "finding_ref": "F-PO-61-4-1",
      "reasoning": "fix-now is recommended: the remaining set is small and mostly mechanical, and three of the seven (CR-15, CR-16, CR-17) are comment/README statements that assert behaviour the code does not have - the same defect class as CR-33, which is the likeliest reason the AC-20 gap survived develop review in the first place. Leaving those in place re-arms the trap for the next reader. CR-8 (rm destroys detached-HEAD commits and stashes with exit 0) and CR-9 (a normal stop is mechanically a SIGKILL) are the two with real user-visible consequences and both are cheap. RV-4 is in the same round because it is a regression this round introduced. Re-classifying is defensible for CR-7 and CR-14 specifically, whose failure scenarios need a second actor inside the container or a hostile local user - neither of which fits a single-operator box."
    },
    {
      "id": "D-PO-61-4-2",
      "type": "scope-disposition",
      "blocking": false,
      "question": "15 deferrable findings from round 1 are still undispositioned. Take them as one batch (defer-to-issue into a single follow-up), or walk them individually?",
      "options": [
        "fix-now",
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-61-4-3",
      "reasoning": "defer-to-issue as one batch is recommended. They share a theme - concurrency, locking and reattach edge cases in dw.sh - so they read better as one hardening slice than as fifteen separate dispositions, and the QA loop cannot proceed to another tests/fix stage while any of them sits open. Two are worth pulling out if you disagree with batching: CR-23 (leading-zero issue ids silently create rival containers dw-07 and dw-7, eventually two branches for one issue) is a correctness bug rather than hardening, and CR-24/CR-30 are peer-churn flakes that will produce confusing red runs on this specific box, where parallel sessions are the working model."
    }
  ],
  "suite": {
    "source": "git",
    "sha": "78574f3716598e6668a3c9902e908ff3507f51f4",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-61-4 skill=qa --> ```json { "next_state": "qa", "produced": [], "findings": [ { "category": "in-scope-blocking", "severity": "high", "summary": "7 blocking findings from round 1 validate remain unfixed (CR-7, CR-8, CR-9, CR-14, CR-15, CR-16, CR-17), by an explicit operator scoping decision.", "reasoning": "Round 1 raised 19 blocking findings; 12 were fixed. The operator scoped the round to the 8 harness defects plus the 3 dw.sh guards, to make the grading surface honest and buy a trustworthy 25-criterion result before committing to the long tail. The remainder are container-adoption without a label check (CR-7), the rm guard being blind to detached-HEAD commits and stashes (CR-8), PID 1 ignoring SIGTERM so a normal stop is mechanically a kill (CR-9), push_file create-then-chmod (CR-14), and three doc/comment overclaims (CR-15, CR-16, CR-17). None is a wrong verdict in the harness; all are real.", "proposed_action": "fix-now", "fix_cost": "moderate", "feature_value": "core", "adjacent_to_blocking": true, "requires_product_decision": false, "id": "F-PO-61-4-1" }, { "category": "in-scope-blocking", "severity": "medium", "summary": "RV-4: refresh-creds now deletes the container tea login before re-adding it, so a failed add leaves the container with NO forge login where it previously had a working one.", "reasoning": "Introduced by this round own CR-1 fix and recorded rather than quietly carried. It is strictly better than the behaviour it replaced (which silently validated the OLD token and reported success), but it is a genuine worse-off window that did not exist before, plus a brief interval on every up where a session already running inside the container loses forge auth. Closing it properly means validating the new token before destroying the working login - add under a temporary name, probe, then swap - which is a design choice rather than a repair, so it was not taken mid-round.", "proposed_action": "fix-now", "fix_cost": "small", "feature_value": "core", "adjacent_to_blocking": true, "requires_product_decision": false, "id": "F-PO-61-4-2" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "15 deferrable findings from round 1 validate remain open and need dispositions (CR-19..CR-32, CR-34).", "reasoning": "Concurrency and lock TOCTOUs, an unbounded ls-remote holding the per-issue lock, leading-zero issue ids creating rival containers, peer-churn flakes in AC-3/AC-4, --ssh-agent silently ignored on reattach, a TMPROOT leak on the attach path, and the AC-16 message-grep. Each needs a fix-now / defer-to-issue / accept disposition before a later round tests or fixes against them.", "proposed_action": "defer-to-issue", "fix_cost": "moderate", "feature_value": "incidental", "adjacent_to_blocking": false, "requires_product_decision": true, "id": "F-PO-61-4-3" }, { "category": "pre-existing", "severity": "medium", "summary": "The set -e capture trap was hit TWICE by the same actor in one round, in two different constructs, and neither cheap gate could see it.", "reasoning": "Recorded as a process finding because the recurrence is the signal. (a) `cmd; rc=$?` killed every dw.sh up; (b) `x=$(... | while ...)` in a later fix killed the ssh guard on the host own correct config. Both are set -e exempt only inside a condition context, which is exactly where the unit tests exercised them - so bash -n, lint, and the unit guards were all green while the product path was dead. Only the full container sweep could see either. The generalisable rule: in a `set -e` file, a helper whose non-zero return is a NORMAL outcome must be called in a form whose status is always zero, and a unit test that calls it inside `if` proves the function and not its callers.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "feature_value": "none", "adjacent_to_blocking": false, "requires_product_decision": false, "id": "F-PO-61-4-4" } ], "pending_decisions": [ { "id": "D-PO-61-4-1", "type": "scope-disposition", "blocking": true, "question": "Round 1 fixed 12 of 19 blocking findings by your scoping decision, and the sweep is 25/25 green at 78574f3. The 7 remaining blocking findings (CR-7,8,9,14,15,16,17) plus RV-4 are still open. Fix them in a round 2, or re-classify some as deferrable and move toward the UAT gate?", "options": [ "fix-now", "defer-to-issue", "accept" ], "recommended": "fix-now", "finding_ref": "F-PO-61-4-1", "reasoning": "fix-now is recommended: the remaining set is small and mostly mechanical, and three of the seven (CR-15, CR-16, CR-17) are comment/README statements that assert behaviour the code does not have - the same defect class as CR-33, which is the likeliest reason the AC-20 gap survived develop review in the first place. Leaving those in place re-arms the trap for the next reader. CR-8 (rm destroys detached-HEAD commits and stashes with exit 0) and CR-9 (a normal stop is mechanically a SIGKILL) are the two with real user-visible consequences and both are cheap. RV-4 is in the same round because it is a regression this round introduced. Re-classifying is defensible for CR-7 and CR-14 specifically, whose failure scenarios need a second actor inside the container or a hostile local user - neither of which fits a single-operator box." }, { "id": "D-PO-61-4-2", "type": "scope-disposition", "blocking": false, "question": "15 deferrable findings from round 1 are still undispositioned. Take them as one batch (defer-to-issue into a single follow-up), or walk them individually?", "options": [ "fix-now", "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-61-4-3", "reasoning": "defer-to-issue as one batch is recommended. They share a theme - concurrency, locking and reattach edge cases in dw.sh - so they read better as one hardening slice than as fifteen separate dispositions, and the QA loop cannot proceed to another tests/fix stage while any of them sits open. Two are worth pulling out if you disagree with batching: CR-23 (leading-zero issue ids silently create rival containers dw-07 and dw-7, eventually two branches for one issue) is a correctness bug rather than hardening, and CR-24/CR-30 are peer-churn flakes that will produce confusing red runs on this specific box, where parallel sessions are the working model." } ], "suite": { "source": "git", "sha": "78574f3716598e6668a3c9902e908ff3507f51f4", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "fix-now",
  "rationale": "Operator directed resolution as recommended, 2026-08-26. Grounded in what this round measured rather than a preference for thoroughness. Three of the seven (CR-15 README/rm-guard network claim, CR-16 seed_host_material header, CR-17 ac2 jq-floor attribution) are statements asserting behaviour the code does not have. That is the same defect class as CR-33, and CR-33 is the likeliest reason the AC-20 gap survived develop review at all: the project record vouched for a scan that had never been written, so nobody looked. Leaving these three re-arms that trap for the next reader, and they are the cheapest items in the set. CR-8 (rm destroys detached-HEAD commits and stashes and exits 0) and CR-9 (PID 1 is sleep infinity with no init, so it ignores SIGTERM and every \"normal stop\" is mechanically the SIGKILL that AC-17 simulates as the ABNORMAL case) are the two with real user-visible consequences, and both are small. RV-4 is included because it is a regression this round introduced - refresh-creds now deletes the container tea login before re-adding it, so a failed add leaves the container with no login where it had a working one; closing it means adding under a temporary name, probing, then swapping.",
  "rejected_alternative": "Re-classifying CR-7 and CR-14 as deferrable was seriously considered and remains defensible: CR-7 (up/rm adopt any container named dw-<N> without checking the managed-by label) needs a foreign container to already occupy the name, and CR-14 (push_file creates then chmods, leaving a brief 0644 window on credential material) needs a hostile local user or a second uid inside the container - and the image pins a single user, so every process that could read during that window already can at 0600. Neither fits a single-operator box. They are kept in scope anyway because both are cheap and both sit directly on the credential/isolation path this feature exists to defend, where the cost of being wrong is not symmetric with the cost of the fix. Deferring the whole set to reach the UAT gate sooner was rejected: the sweep is green but seven known-real blocking findings would ship behind it."
}
<!-- decision-resolution:v1 ref=D-PO-61-4-1 --> ```json { "status": "resolved", "chosen_option": "fix-now", "rationale": "Operator directed resolution as recommended, 2026-08-26. Grounded in what this round measured rather than a preference for thoroughness. Three of the seven (CR-15 README/rm-guard network claim, CR-16 seed_host_material header, CR-17 ac2 jq-floor attribution) are statements asserting behaviour the code does not have. That is the same defect class as CR-33, and CR-33 is the likeliest reason the AC-20 gap survived develop review at all: the project record vouched for a scan that had never been written, so nobody looked. Leaving these three re-arms that trap for the next reader, and they are the cheapest items in the set. CR-8 (rm destroys detached-HEAD commits and stashes and exits 0) and CR-9 (PID 1 is sleep infinity with no init, so it ignores SIGTERM and every \"normal stop\" is mechanically the SIGKILL that AC-17 simulates as the ABNORMAL case) are the two with real user-visible consequences, and both are small. RV-4 is included because it is a regression this round introduced - refresh-creds now deletes the container tea login before re-adding it, so a failed add leaves the container with no login where it had a working one; closing it means adding under a temporary name, probing, then swapping.", "rejected_alternative": "Re-classifying CR-7 and CR-14 as deferrable was seriously considered and remains defensible: CR-7 (up/rm adopt any container named dw-<N> without checking the managed-by label) needs a foreign container to already occupy the name, and CR-14 (push_file creates then chmods, leaving a brief 0644 window on credential material) needs a hostile local user or a second uid inside the container - and the image pins a single user, so every process that could read during that window already can at 0600. Neither fits a single-operator box. They are kept in scope anyway because both are cheap and both sit directly on the credential/isolation path this feature exists to defend, where the cost of being wrong is not symmetric with the cost of the fix. Deferring the whole set to reach the UAT gate sooner was rejected: the sweep is green but seven known-real blocking findings would ship behind it." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "Operator directed resolution as recommended, 2026-08-26. Batched rather than walked individually because the fifteen share one theme - concurrency, locking and reattach edge cases in dw.sh - and read as a single hardening slice rather than fifteen separate scope judgements. The QA loop also cannot enter another tests/fix stage while any of them sits open, so batching is what unblocks round 2 on the seven blocking items. Spawning a new issue rather than folding into an existing one: no open issue is a topical home for dw.sh lifecycle hardening (the nearest, #62, is about the slot recipe schema), and spawning is the non-destructive default when the match is not confident.",
  "rejected_alternative": "Walking them individually was the alternative, and it is the better choice for two of the fifteen if this batch turns out to be too coarse. CR-23 (require_issue accepts leading zeros, so \"up 07\" and \"up 7\" build rival containers dw-07 and dw-7 and eventually two branches for one logical issue) is a correctness bug rather than hardening and would justify its own fix-now. CR-24 and CR-30 (AC-4/AC-11 host-credential hash checks and AC-3 reading the integration tip before \"up\") are peer-churn flakes that will produce confusing red runs specifically on this box, where parallel sessions are the working model - they cost debugging time rather than correctness. Both concerns are carried into the spawned issue body so the batch does not flatten them."
}
<!-- decision-resolution:v1 ref=D-PO-61-4-2 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "Operator directed resolution as recommended, 2026-08-26. Batched rather than walked individually because the fifteen share one theme - concurrency, locking and reattach edge cases in dw.sh - and read as a single hardening slice rather than fifteen separate scope judgements. The QA loop also cannot enter another tests/fix stage while any of them sits open, so batching is what unblocks round 2 on the seven blocking items. Spawning a new issue rather than folding into an existing one: no open issue is a topical home for dw.sh lifecycle hardening (the nearest, #62, is about the slot recipe schema), and spawning is the non-destructive default when the match is not confident.", "rejected_alternative": "Walking them individually was the alternative, and it is the better choice for two of the fifteen if this batch turns out to be too coarse. CR-23 (require_issue accepts leading zeros, so \"up 07\" and \"up 7\" build rival containers dw-07 and dw-7 and eventually two branches for one logical issue) is a correctness bug rather than hardening and would justify its own fix-now. CR-24 and CR-30 (AC-4/AC-11 host-credential hash checks and AC-3 reading the integration tip before \"up\") are peer-churn flakes that will produce confusing red runs specifically on this box, where parallel sessions are the working model - they cost debugging time rather than correctness. Both concerns are carried into the spawned issue body so the batch does not flatten them." } ```
Author
Owner

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

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

QA round 1 — handoff (issue #61, devcontainer plugin-era refresh)

Written 2026-08-26, at context compaction. The durable record is the Gitea tracker; this file is
the working state a resuming session needs so it does not have to re-derive it.

Where the feature is

  • Branch feature/61-devcontainer-plugin-era-refresh, worktree
    .claude/worktrees/issue-61-devcontainer. HEAD 78574f3, clean, rebased onto origin/main.
  • Latest Phase Outcome PO-61-4 (skill=qa, next_state: qa, comment 2040).
  • Zero open decisions. Both PO-61-4 decisions resolved 2026-08-26.
  • Full sweep 25/25 PASS at 78574f3 (AC-1..AC-23 + unit criteria CR-3, CR-6), clean tree, nothing
    modified after it.

What round 2 must do (this is the immediate next work)

D-PO-61-4-1 resolved fix-now, so these are promoted to in-scope-blocking and the next fix round
owes them. Full text at issue #61 comment 1924 (qa-report:v1 domain=code phase=validate).

id file what
CR-7 dw.sh:587 up/rm adopt ANY container named dw-<N> without checking the dev.devwork.managed-by label
CR-8 dw.sh:650 the rm unpushed-work guard is blind to detached-HEAD commits and stashes — rm destroys them with exit 0
CR-9 Dockerfile:155 PID 1 is sleep infinity with no init, so it ignores SIGTERM: every "normal stop" is mechanically the SIGKILL that AC-17 simulates as the ABNORMAL case
CR-14 dw.sh:193 push_file creates then chmods, leaving a brief 0644 window on credential material
CR-15 dw.sh:641 + README:135 both claim the rm-guard's inspection container cannot reach the network; it runs on the default bridge with full egress
CR-16 dw.sh:495 seed_host_material's header claims a re-entered container never runs on staler material than the host; the settings seed is only APPLIED on reseed
CR-17 selftest.sh:415 ac2 cites a "CLAUDE.md" jq ≥1.7 floor that does not exist there (real source: preq.original.md:33)
RV-4 container-init.sh:310 a regression this round introduced: refresh-creds deletes the container tea login before re-adding, so a failed add leaves it with none. Fix = add under a temp name, probe, then swap

CR-15/CR-16/CR-17 are comments asserting behaviour the code does not have — the same class as
CR-33, and CR-33 is the likeliest reason the AC-20 gap survived develop review at all.

Deferred, already homed

  • #353 — dw.sh hardening, the 15 deferrable findings batched (CR-19..CR-32, CR-34). Two are
    flagged in its body as NOT hardening: CR-23 (leading-zero issue ids build rival containers) is a
    correctness bug; CR-24/CR-30 are peer-churn false failures.
  • #259 — Claude OAuth refresh-token rotation with N containers sharing one credential.
  • #260tea api exits 0 on HTTP errors and prefixes NOTE: lines to stdout.

Round 1 commits

78574f3  RV-2, RV-3, RV-5, RV-6 from fresh-context re-validation
3e8c877  repair a set -e regression my own CR-6 fix introduced
09efa24  unit-style regression guards for CR-3, CR-6
9f92514  CR-1, CR-3, CR-6 — the three guards that did not guard
0dfe00d  red test for CR-1, written before the fix
1cbf963  make the harness's own verdicts honest (8 harness defects + CLAUDE.md)
0495ca0  (rebase base) docs: capture learnings

Operational facts a resuming session needs

  • Docker needs sg docker -c '...' in THIS session. jochem is in the docker group but a process
    acquires supplementary groups only at start; a fresh login gets it ambiently. selftest.sh already
    wraps its own calls.
  • selftest.sh requires an explicit argument. bash .devcontainer/selftest.sh all, or named
    criteria (AC-1 CR-3). A bare invocation prints usage and exits 1 — do not read that as a red run.
  • A full run takes roughly 20 minutes and creates ~25 containers plus real forge scratch issues; it
    cleans up after itself. The two CR-* criteria are unit-style (no docker, no forge) and run in seconds.
  • Branch protection on main is PROTECTED = true; the container token is scoped read:user + read:issue + write:issue, no repository scope.
  • Gates: bash scripts/lint-conventions.sh and bash scripts/test-lint-conventions.sh, both clean.

Two things worth not re-learning

  1. The set -e capture trap bit twice in this one round, in two different constructs:
    cmd; rc=$? (killed every dw.sh up) and x="$(... | while ...)" (killed the ssh guard on the
    host's own correct config). In a set -e file, a helper whose non-zero return is a NORMAL outcome
    must be called in a form whose status is always zero — || rc=$?, or an explicit if.
    A unit test that calls such a helper inside if proves the function, not its callers: bash -n,
    lint and both unit guards were green while the product path was dead. Only the full sweep saw it.
  2. Every stage found a defect in the stage before it. Validators → 34 findings; my fixes introduced a
    regression; the sweep caught it; fresh-context re-validation caught a wrong-key defect in the fix;
    fixing that hit the set -e trap again. That is builder≠reviewer working, not thrash — do not
    collapse the stages to save time.
# QA round 1 — handoff (issue #61, devcontainer plugin-era refresh) **Written 2026-08-26, at context compaction.** The durable record is the Gitea tracker; this file is the working state a resuming session needs so it does not have to re-derive it. ## Where the feature is - Branch `feature/61-devcontainer-plugin-era-refresh`, worktree `.claude/worktrees/issue-61-devcontainer`. **HEAD `78574f3`**, clean, rebased onto `origin/main`. - Latest Phase Outcome **PO-61-4** (`skill=qa`, `next_state: qa`, comment 2040). - **Zero open decisions.** Both PO-61-4 decisions resolved 2026-08-26. - **Full sweep 25/25 PASS at `78574f3`** (AC-1..AC-23 + unit criteria CR-3, CR-6), clean tree, nothing modified after it. ## What round 2 must do (this is the immediate next work) `D-PO-61-4-1` resolved **fix-now**, so these are promoted to in-scope-blocking and the next fix round owes them. Full text at issue #61 comment **1924** (`qa-report:v1 domain=code phase=validate`). | id | file | what | |---|---|---| | `CR-7` | dw.sh:587 | `up`/`rm` adopt ANY container named `dw-<N>` without checking the `dev.devwork.managed-by` label | | `CR-8` | dw.sh:650 | the rm unpushed-work guard is blind to detached-HEAD commits and stashes — `rm` destroys them with exit 0 | | `CR-9` | Dockerfile:155 | PID 1 is `sleep infinity` with no init, so it ignores SIGTERM: every "normal stop" is mechanically the SIGKILL that AC-17 simulates as the ABNORMAL case | | `CR-14` | dw.sh:193 | `push_file` creates then chmods, leaving a brief 0644 window on credential material | | `CR-15` | dw.sh:641 + README:135 | both claim the rm-guard's inspection container cannot reach the network; it runs on the default bridge with full egress | | `CR-16` | dw.sh:495 | `seed_host_material`'s header claims a re-entered container never runs on staler material than the host; the settings seed is only APPLIED on reseed | | `CR-17` | selftest.sh:415 | `ac2` cites a "CLAUDE.md" jq ≥1.7 floor that does not exist there (real source: `preq.original.md:33`) | | `RV-4` | container-init.sh:310 | **a regression this round introduced**: `refresh-creds` deletes the container tea login before re-adding, so a failed add leaves it with none. Fix = add under a temp name, probe, then swap | `CR-15`/`CR-16`/`CR-17` are comments asserting behaviour the code does not have — the same class as CR-33, and CR-33 is the likeliest reason the AC-20 gap survived develop review at all. ## Deferred, already homed - **#353** — dw.sh hardening, the 15 deferrable findings batched (`CR-19`..`CR-32`, `CR-34`). Two are flagged in its body as NOT hardening: `CR-23` (leading-zero issue ids build rival containers) is a correctness bug; `CR-24`/`CR-30` are peer-churn false failures. - **#259** — Claude OAuth refresh-token rotation with N containers sharing one credential. - **#260** — `tea api` exits 0 on HTTP errors and prefixes `NOTE:` lines to stdout. ## Round 1 commits ``` 78574f3 RV-2, RV-3, RV-5, RV-6 from fresh-context re-validation 3e8c877 repair a set -e regression my own CR-6 fix introduced 09efa24 unit-style regression guards for CR-3, CR-6 9f92514 CR-1, CR-3, CR-6 — the three guards that did not guard 0dfe00d red test for CR-1, written before the fix 1cbf963 make the harness's own verdicts honest (8 harness defects + CLAUDE.md) 0495ca0 (rebase base) docs: capture learnings ``` ## Operational facts a resuming session needs - **Docker needs `sg docker -c '...'` in THIS session.** `jochem` is in the `docker` group but a process acquires supplementary groups only at start; a fresh login gets it ambiently. `selftest.sh` already wraps its own calls. - **`selftest.sh` requires an explicit argument.** `bash .devcontainer/selftest.sh all`, or named criteria (`AC-1 CR-3`). A bare invocation prints usage and **exits 1** — do not read that as a red run. - A full run takes roughly 20 minutes and creates ~25 containers plus real forge scratch issues; it cleans up after itself. The two `CR-*` criteria are unit-style (no docker, no forge) and run in seconds. - Branch protection on `main` is `PROTECTED = true`; the container token is scoped `read:user + read:issue + write:issue`, no repository scope. - Gates: `bash scripts/lint-conventions.sh` and `bash scripts/test-lint-conventions.sh`, both clean. ## Two things worth not re-learning 1. **The `set -e` capture trap bit twice in this one round**, in two different constructs: `cmd; rc=$?` (killed every `dw.sh up`) and `x="$(... | while ...)"` (killed the ssh guard on the host's own correct config). In a `set -e` file, a helper whose non-zero return is a NORMAL outcome must be called in a form whose status is always zero — `|| rc=$?`, or an explicit `if`. **A unit test that calls such a helper inside `if` proves the function, not its callers**: `bash -n`, lint and both unit guards were green while the product path was dead. Only the full sweep saw it. 2. **Every stage found a defect in the stage before it.** Validators → 34 findings; my fixes introduced a regression; the sweep caught it; fresh-context re-validation caught a wrong-key defect in the fix; fixing that hit the `set -e` trap again. That is builder≠reviewer working, not thrash — do not collapse the stages to save time.
Author
Owner
{
  "outcome": "clean",
  "summary": "Six new selftest criteria written red for the eight blocking findings; each failed for the exact defect it names before any fix landed.",
  "findings": [],
  "artifacts": {
    "test_files": [".devcontainer/selftest.sh"],
    "test_commit": "7fd49ec",
    "test_marker": {
      "runner": "bash .devcontainer/selftest.sh (repo-local criterion harness)",
      "write": "add the criterion to ac_meta() and ALL_ACS; it FAILs with a reason naming the defect",
      "promote": "no marker to drop — the criterion is live from the moment it is added, and the fix is what turns it green"
    }
  }
}

Criteria added, and the red each one produced at 7fd49ec:

id covers observed red
CR-7 rm adopting a container it did not create dw.sh rm DESTROYED an unmanaged container named dw-997114 (exit 0)
CR-8 the rm guard's blind spots dw.sh rm DESTROYED a workspace holding a detached-HEAD commit and a stash (exit 0)
CR-9 PID 1 ignoring SIGTERM create_container does not pass --init
CR-14 the readable window on pushed credentials a 0600 push_file destination existed at mode 664 while its content was still arriving
CR-15 the inspection container's egress inspect_workspace does not pass --network none
RV-4 a failed forge-login add stranding the container after a failed add the container is left with no usable forge login

Why there is no TDD-red marker to promote. This harness has no skip/fixme
qualifier: a criterion is live the moment it is listed, and the fix is the only
thing that turns it green. The write↔promote pair above records that honestly
rather than inventing a marker the runner does not have.

On re-implementation. Every criterion drives the shipped function itself,
never a copy of its logic. Two devices do that: a stubbed docker/tea on
PATH for the shape-and-ordering checks, and a real container or volume where
the property is only observable live (CR-7, CR-8 and half of CR-9/CR-15
run against real docker objects). container-init.sh gained the
source-detection guard dw.sh already carried, which is what makes
setup_forge_login callable directly.

CR-14 deserves a note on method. The window it tests is far too short to
catch by polling. Rather than sampling and hoping, the criterion recovers the
remote command push_file actually sends and runs it against a fifo nobody
writes to — which parks it exactly at the moment the destination exists and no
content has arrived yet, and holds it there for as long as the check needs.

<!-- qa-report:v1 issue=61 skill=qa domain=code phase=tests --> ```json { "outcome": "clean", "summary": "Six new selftest criteria written red for the eight blocking findings; each failed for the exact defect it names before any fix landed.", "findings": [], "artifacts": { "test_files": [".devcontainer/selftest.sh"], "test_commit": "7fd49ec", "test_marker": { "runner": "bash .devcontainer/selftest.sh (repo-local criterion harness)", "write": "add the criterion to ac_meta() and ALL_ACS; it FAILs with a reason naming the defect", "promote": "no marker to drop — the criterion is live from the moment it is added, and the fix is what turns it green" } } } ``` **Criteria added, and the red each one produced at `7fd49ec`:** | id | covers | observed red | |---|---|---| | `CR-7` | `rm` adopting a container it did not create | `dw.sh rm DESTROYED an unmanaged container named dw-997114 (exit 0)` | | `CR-8` | the rm guard's blind spots | `dw.sh rm DESTROYED a workspace holding a detached-HEAD commit and a stash (exit 0)` | | `CR-9` | PID 1 ignoring SIGTERM | `create_container does not pass --init` | | `CR-14` | the readable window on pushed credentials | `a 0600 push_file destination existed at mode 664 while its content was still arriving` | | `CR-15` | the inspection container's egress | `inspect_workspace does not pass --network none` | | `RV-4` | a failed forge-login add stranding the container | `after a failed add the container is left with no usable forge login` | **Why there is no TDD-red marker to promote.** This harness has no skip/fixme qualifier: a criterion is live the moment it is listed, and the fix is the only thing that turns it green. The write↔promote pair above records that honestly rather than inventing a marker the runner does not have. **On re-implementation.** Every criterion drives the shipped function itself, never a copy of its logic. Two devices do that: a stubbed `docker`/`tea` on `PATH` for the shape-and-ordering checks, and a real container or volume where the property is only observable live (`CR-7`, `CR-8` and half of `CR-9`/`CR-15` run against real docker objects). `container-init.sh` gained the source-detection guard `dw.sh` already carried, which is what makes `setup_forge_login` callable directly. **`CR-14` deserves a note on method.** The window it tests is far too short to catch by polling. Rather than sampling and hoping, the criterion recovers the remote command `push_file` actually sends and runs it against a fifo nobody writes to — which parks it exactly at the moment the destination exists and no content has arrived yet, and holds it there for as long as the check needs.
Author
Owner
{
  "outcome": "clean",
  "summary": "All eight findings promoted fix-now by D-PO-61-4-1 are fixed and covered by a criterion that was seen red first. Full sweep 31/31 PASS at ab6bb78, clean tree, nothing modified after it.",
  "findings": [],
  "artifacts": {
    "test_files": [".devcontainer/selftest.sh"],
    "test_commit": "7fd49ec",
    "report_path": ".devwork/feature-devcontainer-plugin-era-refresh/qa-round-1-handoff.md"
  }
}

What was fixed

id fix
CR-7 Ownership is decided by the dev.devwork.managed-by label, not by the name dw-<N>. up, rm, stop, recreate, repin and refresh-creds all refuse a container dw.sh did not create. --force does not override it: --force means "discard my unsaved work", never "destroy what was never mine".
CR-8 The rm guard walks HEAD alongside --branches and lists stash entries. A commit made on a detached HEAD and a stash entry — both ordinary states for an interrupted session — now refuse removal and are named in the refusal, each under its own heading with a recovery hint.
CR-9 Containers start with --init, giving them a PID 1 that forwards SIGTERM. The Dockerfile's CMD now says why it must never be PID 1 itself.
CR-14 push_file sets the destination's mode before any content reaches it and bounds the create itself with umask 077, so a 0600 credential is never briefly readable.
CR-15 The rm-guard's inspection container runs with --network none. Mounting no credentials was never isolation — it had full egress while the comment and the README both said it could not reach the network. Both texts now describe what the code does.
CR-16 seed_host_material's header separates what is adopted on every up (credential, forge token, SSH material) from the settings seed, which is pushed on the same schedule but only applied on create or recreate.
CR-17 The jq >= 1.7 floor is cited to the PREQ, which is where it comes from. CLAUDE.md makes jq a hard dependency but sets no version.
RV-4 setup_forge_login snapshots the forge CLI's config before the delete and restores it if the add fails, so no failure path leaves the container without a login.

The round found a regression in its own fix, and that is the part worth reading

The first full sweep after the fixes came back 0/23 on every container
criterion
, all at dw.sh up exited 1 — the uniform whole-suite signature
CLAUDE.md already records as "environment or a shared dependency, not logic."
It was logic: my RV-4 fix.

RV-4's stated remedy was add under a temp name, probe, then swap. That
shape is not available: the forge CLI keeps one token to one login and
rejects the same token under a second name, so the second add died with
token already been used on the very first up.

The stub tea in the RV-4 criterion did not carry that constraint, so it
green-lit a repair the real tool refuses.
This is the same class as RV-5
last round — a guard that passes its own test while doing the thing the guard
exists to prevent — and it is the second time in two rounds that a fix's own
test was the weakest link rather than the fix.

Two changes came out of it:

  1. The stub now models the constraint, so any trial-then-swap
    implementation fails against it.
  2. The criterion gained the case that catches this class: the path where
    nothing fails must still end with the new token registered under the real
    name and nothing else left behind. The failure case alone is satisfied by a
    change that never succeeds at all — which is exactly what shipped.

The repair itself stopped being about ordering and became about undo: every
login lives in one config file, so it is snapshotted before the delete and put
back if the add fails. The container ends up either re-registered or exactly as
it started, never in between.

Verified by mutation: the corrected criterion fails against the version that
reached the sweep, and passes against the one that replaced it. A green test
proves nothing until it has been seen red for the right reason.

Verification at ab6bb78

  • Full sweep 31/31 PASS — AC-1..AC-23 plus CR-3, CR-6, CR-7, CR-8, CR-9, CR-14, CR-15, RV-4.
  • Clean tree, nothing modified after the sweep.
  • bash scripts/lint-conventions.sh — clean. bash scripts/test-lint-conventions.sh — clean.

The 31/31 differs materially from round 1's 25/25: six criteria that did not
exist now run, and two of them (CR-7, CR-8) demonstrated live destruction of
real docker objects before the fix landed.

Commits

ab6bb78  repair the RV-4 fix, and the stub that let it through
50a54b5  the eight blocking findings
7fd49ec  red tests for the eight blocking findings
<!-- qa-report:v1 issue=61 skill=qa domain=code phase=fix --> ```json { "outcome": "clean", "summary": "All eight findings promoted fix-now by D-PO-61-4-1 are fixed and covered by a criterion that was seen red first. Full sweep 31/31 PASS at ab6bb78, clean tree, nothing modified after it.", "findings": [], "artifacts": { "test_files": [".devcontainer/selftest.sh"], "test_commit": "7fd49ec", "report_path": ".devwork/feature-devcontainer-plugin-era-refresh/qa-round-1-handoff.md" } } ``` ## What was fixed | id | fix | |---|---| | `CR-7` | Ownership is decided by the `dev.devwork.managed-by` label, not by the name `dw-<N>`. `up`, `rm`, `stop`, `recreate`, `repin` and `refresh-creds` all refuse a container `dw.sh` did not create. `--force` does **not** override it: `--force` means "discard my unsaved work", never "destroy what was never mine". | | `CR-8` | The rm guard walks `HEAD` alongside `--branches` and lists stash entries. A commit made on a detached HEAD and a stash entry — both ordinary states for an interrupted session — now refuse removal and are named in the refusal, each under its own heading with a recovery hint. | | `CR-9` | Containers start with `--init`, giving them a PID 1 that forwards SIGTERM. The Dockerfile's `CMD` now says why it must never be PID 1 itself. | | `CR-14` | `push_file` sets the destination's mode before any content reaches it and bounds the create itself with `umask 077`, so a 0600 credential is never briefly readable. | | `CR-15` | The rm-guard's inspection container runs with `--network none`. Mounting no credentials was never isolation — it had full egress while the comment and the README both said it could not reach the network. Both texts now describe what the code does. | | `CR-16` | `seed_host_material`'s header separates what is *adopted* on every `up` (credential, forge token, SSH material) from the settings seed, which is pushed on the same schedule but only applied on create or `recreate`. | | `CR-17` | The `jq >= 1.7` floor is cited to the PREQ, which is where it comes from. CLAUDE.md makes `jq` a hard dependency but sets no version. | | `RV-4` | `setup_forge_login` snapshots the forge CLI's config before the delete and restores it if the add fails, so no failure path leaves the container without a login. | ## The round found a regression in its own fix, and that is the part worth reading The first full sweep after the fixes came back **0/23 on every container criterion**, all at `dw.sh up exited 1` — the uniform whole-suite signature CLAUDE.md already records as "environment or a shared dependency, not logic." It was logic: my `RV-4` fix. `RV-4`'s stated remedy was *add under a temp name, probe, then swap*. That shape is not available: the forge CLI keeps **one token to one login** and rejects the same token under a second name, so the second add died with `token already been used` on the very first `up`. **The stub `tea` in the `RV-4` criterion did not carry that constraint, so it green-lit a repair the real tool refuses.** This is the same class as `RV-5` last round — a guard that passes its own test while doing the thing the guard exists to prevent — and it is the second time in two rounds that a fix's own test was the weakest link rather than the fix. Two changes came out of it: 1. **The stub now models the constraint**, so any trial-then-swap implementation fails against it. 2. **The criterion gained the case that catches this class**: the path where *nothing* fails must still end with the new token registered under the real name and nothing else left behind. The failure case alone is satisfied by a change that never succeeds at all — which is exactly what shipped. The repair itself stopped being about ordering and became about **undo**: every login lives in one config file, so it is snapshotted before the delete and put back if the add fails. The container ends up either re-registered or exactly as it started, never in between. **Verified by mutation**: the corrected criterion fails against the version that reached the sweep, and passes against the one that replaced it. A green test proves nothing until it has been seen red for the right reason. ## Verification at `ab6bb78` - **Full sweep 31/31 PASS** — AC-1..AC-23 plus CR-3, CR-6, CR-7, CR-8, CR-9, CR-14, CR-15, RV-4. - Clean tree, nothing modified after the sweep. - `bash scripts/lint-conventions.sh` — clean. `bash scripts/test-lint-conventions.sh` — clean. The 31/31 differs materially from round 1's 25/25: six criteria that did not exist now run, and two of them (`CR-7`, `CR-8`) demonstrated live destruction of real docker objects before the fix landed. ## Commits ``` ab6bb78 repair the RV-4 fix, and the stub that let it through 50a54b5 the eight blocking findings 7fd49ec red tests for the eight blocking findings ```
Author
Owner
{
  "next_state": "qa",
  "produced": [
    {
      "kind": "qa-report",
      "ref": "2190",
      "summary": "code domain, phase=tests — six criteria written red for the eight blocking findings"
    },
    {
      "kind": "qa-report",
      "ref": "2191",
      "summary": "code domain, phase=fix — all eight fixed; sweep 31/31 at ab6bb78"
    }
  ],
  "findings": [],
  "pending_decisions": [
    {
      "id": "D-PO-61-5-1",
      "type": "precondition",
      "blocking": true,
      "question": "Round 2's fixes have not been read by anyone but their author. Is a fresh-context adversarial re-validation (a round 3) a precondition for taking #61 out of QA, or is the 31/31 sweep enough?",
      "options": [
        "confirmed",
        "waived"
      ],
      "recommended": "confirmed",
      "reasoning": "Recommend confirmed — run the re-validation. Two rounds in a row, the weakest link was a fix's own test rather than the fix: round 1's fresh-context pass found RV-5 (a guard that passed its own test while copying the operator's personal key, the exact thing it existed to prevent), and this round the RV-4 stub green-lit a repair the real forge CLI refuses outright, taking all 23 container criteria down. The sweep catches what the harness knows to ask; it cannot notice a question nobody wrote. Both defects were found by a reader who had not written the code. Against that, the cost is one more pass and the fixes are small and now covered. Waived is defensible if the priority is landing #61 — the 31/31 is real, every fix was seen red first, and the RV-4 repair was mutation-verified — but it accepts that the only reviewer of round 2 was the author."
    }
  ],
  "suite": {
    "source": "git",
    "sha": "ab6bb782268c92b11169ba4a90a278a05c8e8963",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-61-5 skill=qa --> ```json { "next_state": "qa", "produced": [ { "kind": "qa-report", "ref": "2190", "summary": "code domain, phase=tests — six criteria written red for the eight blocking findings" }, { "kind": "qa-report", "ref": "2191", "summary": "code domain, phase=fix — all eight fixed; sweep 31/31 at ab6bb78" } ], "findings": [], "pending_decisions": [ { "id": "D-PO-61-5-1", "type": "precondition", "blocking": true, "question": "Round 2's fixes have not been read by anyone but their author. Is a fresh-context adversarial re-validation (a round 3) a precondition for taking #61 out of QA, or is the 31/31 sweep enough?", "options": [ "confirmed", "waived" ], "recommended": "confirmed", "reasoning": "Recommend confirmed — run the re-validation. Two rounds in a row, the weakest link was a fix's own test rather than the fix: round 1's fresh-context pass found RV-5 (a guard that passed its own test while copying the operator's personal key, the exact thing it existed to prevent), and this round the RV-4 stub green-lit a repair the real forge CLI refuses outright, taking all 23 container criteria down. The sweep catches what the harness knows to ask; it cannot notice a question nobody wrote. Both defects were found by a reader who had not written the code. Against that, the cost is one more pass and the fixes are small and now covered. Waived is defensible if the priority is landing #61 — the 31/31 is real, every fix was seen red first, and the RV-4 repair was mutation-verified — but it accepts that the only reviewer of round 2 was the author." } ], "suite": { "source": "git", "sha": "ab6bb782268c92b11169ba4a90a278a05c8e8963", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "confirmed",
  "rationale": "Operator approved the recommendation. The precondition holds: round 2's fixes have been read by nobody but their author, and in each of the last two rounds the defect that mattered was found by a reader who had not written the code (round 1's RV-5: a guard that passed its own test while copying the operator's personal key; round 2's RV-4: a stub that green-lit a repair the real forge CLI refuses, taking all 23 container criteria down). Both were invisible to the sweep, because a sweep only asks the questions the harness already knows to ask. Round 3 runs three fresh-context reviewers on distinct lenses per the cold-reader sampling rule (one reviewer is a weak estimator; take the union of three).",
  "rejected_alternative": "Waived — advance on the strength of the 31/31 sweep, every fix having been seen red first, and the RV-4 repair being mutation-verified. Turned down because that evidence is exactly what was also true of round 2 at the moment its RV-4 fix was committed, and it was wrong. The cost of one more pass is small against a tool whose destructive verb is being changed."
}

<!-- decision-resolution:v1 ref=D-PO-61-5-1 --> ```json { "status": "resolved", "chosen_option": "confirmed", "rationale": "Operator approved the recommendation. The precondition holds: round 2's fixes have been read by nobody but their author, and in each of the last two rounds the defect that mattered was found by a reader who had not written the code (round 1's RV-5: a guard that passed its own test while copying the operator's personal key; round 2's RV-4: a stub that green-lit a repair the real forge CLI refuses, taking all 23 container criteria down). Both were invisible to the sweep, because a sweep only asks the questions the harness already knows to ask. Round 3 runs three fresh-context reviewers on distinct lenses per the cold-reader sampling rule (one reviewer is a weak estimator; take the union of three).", "rejected_alternative": "Waived — advance on the strength of the 31/31 sweep, every fix having been seen red first, and the RV-4 repair being mutation-verified. Turned down because that evidence is exactly what was also true of round 2 at the moment its RV-4 fix was committed, and it was wrong. The cost of one more pass is small against a tool whose destructive verb is being changed." } ```
Author
Owner
{
  "outcome": "issues-found",
  "summary": "Fresh-context re-validation of round 2 at ab6bb78 by three reviewers on distinct lenses: 15 findings, all in-scope-blocking. 7 are code defects (two of them silent data loss), 8 are defects in the criteria written to guard round 2's own fixes.",
  "findings": [
    {"id": "CR-35", "category": "in-scope-blocking", "severity": "critical",
     "summary": "`rm` destroys the two named volumes with no ownership check at all — the container guard short-circuits when no container exists, and volumes carry no label",
     "reasoning": "Found independently by two reviewers, each with its own live reproduction. The CR-7 fix guarded the container and left the volumes open, and the volumes are the irreplaceable half by this design's own argument: `recreate` rebuilds the container onto them. `--force` is not needed. dw.sh prints 'nothing else on this host was touched', which is false."},
    {"id": "CR-36", "category": "in-scope-blocking", "severity": "critical",
     "summary": "The unpushed-work guard fails OPEN when git fails: `set -u` without `-e` lets every section come back empty, the report still reaches `== end ==` and exits 0, so a corrupt repository reads as 'nothing to lose'",
     "reasoning": "Reproduced live by the safety reviewer on a workspace holding an unpushed commit, a dirty file and a stash, and confirmed independently here by replicating the inline script against a failing git. The fail-closed machinery already exists in cmd_rm; the script was simply never wired into it."},
    {"id": "CR-37", "category": "in-scope-blocking", "severity": "serious",
     "summary": "`push_file` follows a symlink at the destination, so a live credential can be written into the git working tree",
     "reasoning": "The CR-14 fix set the mode correctly and still wrote through the link. Reproduced: with ~/.claude/.credentials.json symlinked to /workspace/leaked-cred.json, push_file's exact remote command wrote the secret into the git clone at mode 600. The same hole exists one level up, through a symlinked parent directory that `mkdir -p` walks."},
    {"id": "CR-38", "category": "in-scope-blocking", "severity": "serious",
     "summary": "The RV-4 snapshot uses one fixed path and starts by deleting it, and neither `refresh-creds` nor `repin` takes the per-issue lock — so two overlapping runs destroy each other's snapshot and leave the container with no forge login",
     "reasoning": "The exact outcome RV-4 exists to prevent, reached by the fix itself. `refresh-creds` is the remedy printed by every credential guard, i.e. what an operator runs while an `up` is in flight. Reproduced against the shipped container-init.sh with a timing-only tea stub: both runs printed that they had restored the login, and the config ended empty."},
    {"id": "CR-39", "category": "in-scope-blocking", "severity": "minor",
     "summary": "The restore's failure is swallowed by `|| true` while the `die` on the next line asserts unconditionally that the login was put back",
     "reasoning": "The code has an error path its own message denies exists — the same overclaiming class as CR-15 and CR-16, introduced while fixing them. This is what makes CR-38 silent instead of loud, but it is independently wrong."},
    {"id": "CR-40", "category": "in-scope-blocking", "severity": "minor",
     "summary": "`git stash list` reads the stash REFLOG, not `refs/stash`, so a workspace whose stashed work is fully recoverable is destroyed with exit 0 and nothing printed",
     "reasoning": "Same silent-destruction shape CR-8 was written to close. Demonstrated: with .git/logs/refs/stash removed, `git stash list` is empty at rc 0 while `git stash apply refs/stash` still restores the content in full."},
    {"id": "CR-41", "category": "in-scope-blocking", "severity": "minor",
     "summary": "The managed-by label is an accident guard, not a security boundary, and the comment does not say so",
     "reasoning": "Anything with docker access can set the label. Correctly reasoned by the reviewer as a same-privilege confused-deputy risk, not escalation, on a tool that already requires docker access. Accepted as a limitation; what needs fixing is the comment, since overclaiming is the defect three of round 2's findings were about. The reviewer separately verified the check fails CLOSED when `docker inspect` errors."},
    {"id": "CR-42", "category": "in-scope-blocking", "severity": "serious",
     "summary": "CR-8 seeds a detached-HEAD commit AND a stash into one workspace and asserts only the exit code, so restoring either half of the two-part defect alone leaves it green",
     "reasoning": "Mutation-verified both ways: dropping HEAD from the unpushed walk passes, dropping the stash listing passes; only removing both goes red. Either one is a silently-destroys-your-work bug that would ship undetected."},
    {"id": "CR-43", "category": "in-scope-blocking", "severity": "serious",
     "summary": "CR-9's live half never calls `dw.sh stop` — it builds its own container and only times the stop, asserting that it is FAST rather than GRACEFUL",
     "reasoning": "Mutation-verified: `cmd_stop` using `docker kill` passes; `--stop-signal SIGKILL` at create passes. Also, `docker stop` on an already-exited container returns 0 in 0s, so an image whose PID 1 dies on startup passes too. The distinguishing signal is the exit code — 143 for a graceful stop, 137 for a kill."},
    {"id": "CR-44", "category": "in-scope-blocking", "severity": "serious",
     "summary": "CR-14 supplies DW_MODE=0600 to the recovered command from its own environment instead of taking it from push_file's recorded argv, so a push_file that ignores its mode argument passes",
     "reasoning": "Mutation-verified: hardcoding `-e DW_MODE=0644` leaves CR-14 green while the credential and forge token land permanently world-readable — not for the length of the copy, permanently. The criterion asserts a property it provides itself, the same shape as round 1's RV-5. No other criterion in the harness checks the mode of anything pushed into a container."},
    {"id": "CR-45", "category": "in-scope-blocking", "severity": "serious",
     "summary": "`cr_argv_pair` and CR-9's grep scan the entire multi-call stub log with no call boundary, so a flag on ANY docker invocation satisfies an assertion about the one that matters",
     "reasoning": "Mutation-verified on two criteria: adding a throwaway `--network none` precheck while networking the real run passes CR-15, and moving `--init` to an unrelated call while creating the session container without it passes CR-9. The stub already writes a blank line between calls, so the boundary information is present and simply unused."},
    {"id": "CR-46", "category": "in-scope-blocking", "severity": "serious",
     "summary": "CR-15 asserts only the network half of the two-part isolation claim its own header quotes; the 'mounts no credentials' half is unchecked",
     "reasoning": "Mutation-verified: bind-mounting the operator's ~/.ssh and ~/.config/tea into the inspection container leaves CR-15 green. That container is where an unknown workspace's git is run, which is the entire reason the isolation argument exists."},
    {"id": "CR-47", "category": "in-scope-blocking", "severity": "serious",
     "summary": "CR-7 has a refusal case but no acceptance case, and accepts any non-zero exit, so an ownership check that refuses EVERYTHING — including dw.sh's own containers — passes",
     "reasoning": "Mutation-verified by pointing container_is_managed at a label nothing sets: the tool becomes entirely non-functional and CR-7 stays green on both halves. `[ \"$rc\" -ne 0 ]` also reads an unrelated crash in cmd_rm as a successful refusal, where CR-8 and AC-18 both assert -eq 2."},
    {"id": "CR-48", "category": "in-scope-blocking", "severity": "minor",
     "summary": "CR-14 probes only the content window, not the create→chmod window that `umask 077` exists to close",
     "reasoning": "Mutation-verified: dropping the umask leaves CR-14 green. Only an empty file is exposed in that window, which is why it is minor, but push_file's docstring makes the claim explicitly and nothing tests it."},
    {"id": "CR-49", "category": "in-scope-blocking", "severity": "minor",
     "summary": "RV-4's stub tea discards `--url`, so a login registered against the wrong forge passes",
     "reasoning": "Mutation-verified with `--url https://example.invalid`. Bounded in practice — an unreachable URL makes the real add fail loudly — but a reachable wrong instance would register a working login pointing at the wrong server."}
  ],
  "artifacts": {
    "report_path": ".devwork/feature-devcontainer-plugin-era-refresh/qa-round-3-findings.md"
  }
}

Method

Three fresh-context reviewers at ab6bb78, on deliberately disjoint lenses, none of
them the author of the code. One ran on a different model family. Each was told to
verify by executing rather than by reading, and to report what it checked even where
it found nothing.

lens brief
correctness do the fixes do what their comments claim, and what did they break adjacent?
test adequacy mutation-test all six new criteria — assume each is inadequate until shown otherwise
safety defeat the three new guarantees: ownership, the unpushed-work guard, credential handling

The union is what counts, not any one reviewer. CR-35 was found independently
by two of the three, each with its own live reproduction — that is corroboration, not
duplication. No reviewer found all of any other reviewer's findings.

What this round says about round 2

Eight of the fifteen are defects in the tests, not the code. That is the third
consecutive round in which a fix's own test was the weakest link:

  • Round 1: a guard passed its own test while copying the operator's personal SSH key — the exact thing it existed to prevent.
  • Round 2: a stub green-lit a repair the real forge CLI refuses, taking all 23 container criteria down.
  • Round 3: six criteria written alongside their fixes, and mutation testing broke five of them.

The pattern is specific and worth naming: a criterion written in the same sitting as
the fix it guards tends to encode the fix, not the property.
CR-14 supplies the very
mode it claims to verify; CR-15 checks the flag that was added and not the guarantee the
flag serves; CR-9 measures the symptom the fix relieved rather than the behaviour it
restored; CR-7 tests the refusal that was written and never the acceptance that must
survive it.

The counter-example is in the same set and points the same way. RV-4 held up against
every mutation
, including the trial-then-swap that broke round 2 — and RV-4 is the one
criterion that was rewritten after being burned, against a real failure, rather than
drafted alongside its fix.

What holds

Recorded because a re-validation that only lists defects tells you nothing about coverage:

  • --init does not disturb session_running()'s /proc walk (verified live: PID 1 is docker-init, a process named claude is still found). Graceful stop yields exit 143, not 137.
  • umask 077 creates only ~/.ssh (0700, which is what ssh wants) and ~/.claude/.dw (0700); the volume root and the 0755 push are unaffected.
  • No section of the rm-guard's report can contain a line that looks like a == marker == — verified by seeding stashes literally named == end == and == unpushed ==. dw.sh is the report's only reader.
  • The container half of CR-7 works across all six verbs, and --force does not override it. docker inspect --format behaves correctly on an unlabelled container and on a missing one.
  • The RV-4 restore is byte-identical against the real tea 0.15.1 in a real container, at mode 600, with no backup left behind — in the single-process case.
  • The CR-17 citation is accurate against preq.original.md:33.
<!-- qa-report:v1 issue=61 skill=qa domain=code phase=validate --> ```json { "outcome": "issues-found", "summary": "Fresh-context re-validation of round 2 at ab6bb78 by three reviewers on distinct lenses: 15 findings, all in-scope-blocking. 7 are code defects (two of them silent data loss), 8 are defects in the criteria written to guard round 2's own fixes.", "findings": [ {"id": "CR-35", "category": "in-scope-blocking", "severity": "critical", "summary": "`rm` destroys the two named volumes with no ownership check at all — the container guard short-circuits when no container exists, and volumes carry no label", "reasoning": "Found independently by two reviewers, each with its own live reproduction. The CR-7 fix guarded the container and left the volumes open, and the volumes are the irreplaceable half by this design's own argument: `recreate` rebuilds the container onto them. `--force` is not needed. dw.sh prints 'nothing else on this host was touched', which is false."}, {"id": "CR-36", "category": "in-scope-blocking", "severity": "critical", "summary": "The unpushed-work guard fails OPEN when git fails: `set -u` without `-e` lets every section come back empty, the report still reaches `== end ==` and exits 0, so a corrupt repository reads as 'nothing to lose'", "reasoning": "Reproduced live by the safety reviewer on a workspace holding an unpushed commit, a dirty file and a stash, and confirmed independently here by replicating the inline script against a failing git. The fail-closed machinery already exists in cmd_rm; the script was simply never wired into it."}, {"id": "CR-37", "category": "in-scope-blocking", "severity": "serious", "summary": "`push_file` follows a symlink at the destination, so a live credential can be written into the git working tree", "reasoning": "The CR-14 fix set the mode correctly and still wrote through the link. Reproduced: with ~/.claude/.credentials.json symlinked to /workspace/leaked-cred.json, push_file's exact remote command wrote the secret into the git clone at mode 600. The same hole exists one level up, through a symlinked parent directory that `mkdir -p` walks."}, {"id": "CR-38", "category": "in-scope-blocking", "severity": "serious", "summary": "The RV-4 snapshot uses one fixed path and starts by deleting it, and neither `refresh-creds` nor `repin` takes the per-issue lock — so two overlapping runs destroy each other's snapshot and leave the container with no forge login", "reasoning": "The exact outcome RV-4 exists to prevent, reached by the fix itself. `refresh-creds` is the remedy printed by every credential guard, i.e. what an operator runs while an `up` is in flight. Reproduced against the shipped container-init.sh with a timing-only tea stub: both runs printed that they had restored the login, and the config ended empty."}, {"id": "CR-39", "category": "in-scope-blocking", "severity": "minor", "summary": "The restore's failure is swallowed by `|| true` while the `die` on the next line asserts unconditionally that the login was put back", "reasoning": "The code has an error path its own message denies exists — the same overclaiming class as CR-15 and CR-16, introduced while fixing them. This is what makes CR-38 silent instead of loud, but it is independently wrong."}, {"id": "CR-40", "category": "in-scope-blocking", "severity": "minor", "summary": "`git stash list` reads the stash REFLOG, not `refs/stash`, so a workspace whose stashed work is fully recoverable is destroyed with exit 0 and nothing printed", "reasoning": "Same silent-destruction shape CR-8 was written to close. Demonstrated: with .git/logs/refs/stash removed, `git stash list` is empty at rc 0 while `git stash apply refs/stash` still restores the content in full."}, {"id": "CR-41", "category": "in-scope-blocking", "severity": "minor", "summary": "The managed-by label is an accident guard, not a security boundary, and the comment does not say so", "reasoning": "Anything with docker access can set the label. Correctly reasoned by the reviewer as a same-privilege confused-deputy risk, not escalation, on a tool that already requires docker access. Accepted as a limitation; what needs fixing is the comment, since overclaiming is the defect three of round 2's findings were about. The reviewer separately verified the check fails CLOSED when `docker inspect` errors."}, {"id": "CR-42", "category": "in-scope-blocking", "severity": "serious", "summary": "CR-8 seeds a detached-HEAD commit AND a stash into one workspace and asserts only the exit code, so restoring either half of the two-part defect alone leaves it green", "reasoning": "Mutation-verified both ways: dropping HEAD from the unpushed walk passes, dropping the stash listing passes; only removing both goes red. Either one is a silently-destroys-your-work bug that would ship undetected."}, {"id": "CR-43", "category": "in-scope-blocking", "severity": "serious", "summary": "CR-9's live half never calls `dw.sh stop` — it builds its own container and only times the stop, asserting that it is FAST rather than GRACEFUL", "reasoning": "Mutation-verified: `cmd_stop` using `docker kill` passes; `--stop-signal SIGKILL` at create passes. Also, `docker stop` on an already-exited container returns 0 in 0s, so an image whose PID 1 dies on startup passes too. The distinguishing signal is the exit code — 143 for a graceful stop, 137 for a kill."}, {"id": "CR-44", "category": "in-scope-blocking", "severity": "serious", "summary": "CR-14 supplies DW_MODE=0600 to the recovered command from its own environment instead of taking it from push_file's recorded argv, so a push_file that ignores its mode argument passes", "reasoning": "Mutation-verified: hardcoding `-e DW_MODE=0644` leaves CR-14 green while the credential and forge token land permanently world-readable — not for the length of the copy, permanently. The criterion asserts a property it provides itself, the same shape as round 1's RV-5. No other criterion in the harness checks the mode of anything pushed into a container."}, {"id": "CR-45", "category": "in-scope-blocking", "severity": "serious", "summary": "`cr_argv_pair` and CR-9's grep scan the entire multi-call stub log with no call boundary, so a flag on ANY docker invocation satisfies an assertion about the one that matters", "reasoning": "Mutation-verified on two criteria: adding a throwaway `--network none` precheck while networking the real run passes CR-15, and moving `--init` to an unrelated call while creating the session container without it passes CR-9. The stub already writes a blank line between calls, so the boundary information is present and simply unused."}, {"id": "CR-46", "category": "in-scope-blocking", "severity": "serious", "summary": "CR-15 asserts only the network half of the two-part isolation claim its own header quotes; the 'mounts no credentials' half is unchecked", "reasoning": "Mutation-verified: bind-mounting the operator's ~/.ssh and ~/.config/tea into the inspection container leaves CR-15 green. That container is where an unknown workspace's git is run, which is the entire reason the isolation argument exists."}, {"id": "CR-47", "category": "in-scope-blocking", "severity": "serious", "summary": "CR-7 has a refusal case but no acceptance case, and accepts any non-zero exit, so an ownership check that refuses EVERYTHING — including dw.sh's own containers — passes", "reasoning": "Mutation-verified by pointing container_is_managed at a label nothing sets: the tool becomes entirely non-functional and CR-7 stays green on both halves. `[ \"$rc\" -ne 0 ]` also reads an unrelated crash in cmd_rm as a successful refusal, where CR-8 and AC-18 both assert -eq 2."}, {"id": "CR-48", "category": "in-scope-blocking", "severity": "minor", "summary": "CR-14 probes only the content window, not the create→chmod window that `umask 077` exists to close", "reasoning": "Mutation-verified: dropping the umask leaves CR-14 green. Only an empty file is exposed in that window, which is why it is minor, but push_file's docstring makes the claim explicitly and nothing tests it."}, {"id": "CR-49", "category": "in-scope-blocking", "severity": "minor", "summary": "RV-4's stub tea discards `--url`, so a login registered against the wrong forge passes", "reasoning": "Mutation-verified with `--url https://example.invalid`. Bounded in practice — an unreachable URL makes the real add fail loudly — but a reachable wrong instance would register a working login pointing at the wrong server."} ], "artifacts": { "report_path": ".devwork/feature-devcontainer-plugin-era-refresh/qa-round-3-findings.md" } } ``` ## Method Three fresh-context reviewers at `ab6bb78`, on deliberately disjoint lenses, none of them the author of the code. One ran on a different model family. Each was told to verify by executing rather than by reading, and to report what it checked even where it found nothing. | lens | brief | |---|---| | correctness | do the fixes do what their comments claim, and what did they break adjacent? | | test adequacy | mutation-test all six new criteria — assume each is inadequate until shown otherwise | | safety | defeat the three new guarantees: ownership, the unpushed-work guard, credential handling | **The union is what counts, not any one reviewer.** `CR-35` was found independently by two of the three, each with its own live reproduction — that is corroboration, not duplication. No reviewer found all of any other reviewer's findings. ## What this round says about round 2 **Eight of the fifteen are defects in the tests, not the code.** That is the third consecutive round in which a fix's own test was the weakest link: - Round 1: a guard passed its own test while copying the operator's personal SSH key — the exact thing it existed to prevent. - Round 2: a stub green-lit a repair the real forge CLI refuses, taking all 23 container criteria down. - Round 3: six criteria written alongside their fixes, and mutation testing broke five of them. The pattern is specific and worth naming: **a criterion written in the same sitting as the fix it guards tends to encode the fix, not the property.** CR-14 supplies the very mode it claims to verify; CR-15 checks the flag that was added and not the guarantee the flag serves; CR-9 measures the symptom the fix relieved rather than the behaviour it restored; CR-7 tests the refusal that was written and never the acceptance that must survive it. The counter-example is in the same set and points the same way. **RV-4 held up against every mutation**, including the trial-then-swap that broke round 2 — and RV-4 is the one criterion that was rewritten *after* being burned, against a real failure, rather than drafted alongside its fix. ## What holds Recorded because a re-validation that only lists defects tells you nothing about coverage: - `--init` does not disturb `session_running()`'s /proc walk (verified live: PID 1 is `docker-init`, a process named `claude` is still found). Graceful stop yields exit 143, not 137. - `umask 077` creates only `~/.ssh` (0700, which is what ssh wants) and `~/.claude/.dw` (0700); the volume root and the 0755 push are unaffected. - No section of the rm-guard's report can contain a line that looks like a `== marker ==` — verified by seeding stashes literally named `== end ==` and `== unpushed ==`. dw.sh is the report's only reader. - The container half of CR-7 works across all six verbs, and `--force` does not override it. `docker inspect --format` behaves correctly on an unlabelled container and on a missing one. - The RV-4 restore is byte-identical against the real `tea` 0.15.1 in a real container, at mode 600, with no backup left behind — in the single-process case. - The CR-17 citation is accurate against `preq.original.md:33`.
Author
Owner
{
  "outcome": "clean",
  "summary": "All 15 findings from the round-3 re-validation are fixed. Full sweep 37/37 PASS at 5f9d7f8, clean tree, nothing modified after it.",
  "findings": [],
  "artifacts": {
    "test_files": [".devcontainer/selftest.sh"],
    "test_commit": "9492dc7"
  }
}

The seven code defects

id fix
CR-35 rm checks the volumes, each on its own, and refuses any it did not create. Volumes are labelled at creation; an unlabelled one is not adopted by up either, since adopting it would make it unremovable ever after. --force does not buy past this.
CR-36 The inspection runs under set -eu, so a failing git takes it down rather than leaving an empty section. The deeper half of the same defect: an unreadable repository was answering "no repository here", so the structural question is asked first and the probe must then succeed.
CR-37 push_file writes to a temporary file beside the destination and renames it into place, so a symlink is replaced rather than followed. The resolved parent is bounded to the container's home or /tmp, since a symlinked directory is the same hole one level up.
CR-38 A private snapshot file per run, and refresh-creds/repin take the per-issue lock up already took.
CR-39 A restore that fails is reported as a failure instead of asserted away.
CR-40 Stashed work is found via refs/stash; the listing is used only to describe what the ref finds, and a ref with no reflog behind it is named explicitly.
CR-41 The ownership comment says plainly that the label guards against accidents and is not a security boundary.

The eight criteria repairs, and the two harness defects underneath them

The five round-2 criteria that mutation testing broke were rebuilt around the
property rather than the fix: CR-14 recovers the mode from what push_file
actually sent instead of supplying it; CR-15 asserts both halves of the
isolation claim it quotes; CR-9 stops dw.sh's own container through dw.sh stop
and asserts exit 143, because a kill gives 137 and timing cannot tell them
apart; CR-7 gained the acceptance case that a refuse-everything guard would
fail; CR-8 was split into two workspaces, each asserting the refusal names its
own finding.

Fixing them surfaced two defects in the harness itself, and both are worth
recording because they degrade quietly:

  1. The stub could not represent a multi-line argument. docker run … bash -c '<a 30-line script>' is one argv element; recorded one-line-per-argument, a
    blank line inside it reads as a call boundary. Newlines are folded now.
  2. cr_call_record doubled every record. awk's exit runs the END block,
    so a record found mid-file was printed on the way out and again by END. Pair
    assertions survive a doubled record silently — only counting the arguments
    exposed it, which is how CR-15's new mount-set check found it.

The second one is the more instructive: it means every argv assertion in round 2
was running against doubled input and nobody could have noticed, because the
only thing a doubled record changes is a count, and round 2 counted nothing.

Verification at 5f9d7f8

  • Full sweep 37/37 PASS — AC-1..AC-23, CR-3, CR-6, CR-7, CR-8, CR-9, CR-14, CR-15, CR-35..CR-40, RV-4.
  • Clean tree, nothing modified after the sweep.
  • bash scripts/lint-conventions.sh and bash scripts/test-lint-conventions.sh — both clean.

Each of the six new criteria was seen red for its own defect before its fix
existed, and two of them (CR-35, CR-40) demonstrated live destruction of a
real volume holding real recoverable work.

Commits

5f9d7f8  the seven code defects the re-validation found
9492dc7  repair the round-2 criteria, and add six for what they missed
<!-- qa-report:v1 issue=61 skill=qa domain=code phase=fix --> ```json { "outcome": "clean", "summary": "All 15 findings from the round-3 re-validation are fixed. Full sweep 37/37 PASS at 5f9d7f8, clean tree, nothing modified after it.", "findings": [], "artifacts": { "test_files": [".devcontainer/selftest.sh"], "test_commit": "9492dc7" } } ``` ## The seven code defects | id | fix | |---|---| | `CR-35` | `rm` checks the volumes, each on its own, and refuses any it did not create. Volumes are labelled at creation; an unlabelled one is not adopted by `up` either, since adopting it would make it unremovable ever after. `--force` does not buy past this. | | `CR-36` | The inspection runs under `set -eu`, so a failing git takes it down rather than leaving an empty section. The deeper half of the same defect: an unreadable repository was answering "no repository here", so the structural question is asked first and the probe must then succeed. | | `CR-37` | `push_file` writes to a temporary file beside the destination and renames it into place, so a symlink is replaced rather than followed. The resolved parent is bounded to the container's home or `/tmp`, since a symlinked directory is the same hole one level up. | | `CR-38` | A private snapshot file per run, and `refresh-creds`/`repin` take the per-issue lock `up` already took. | | `CR-39` | A restore that fails is reported as a failure instead of asserted away. | | `CR-40` | Stashed work is found via `refs/stash`; the listing is used only to describe what the ref finds, and a ref with no reflog behind it is named explicitly. | | `CR-41` | The ownership comment says plainly that the label guards against accidents and is not a security boundary. | ## The eight criteria repairs, and the two harness defects underneath them The five round-2 criteria that mutation testing broke were rebuilt around the property rather than the fix: CR-14 recovers the mode from what `push_file` actually sent instead of supplying it; CR-15 asserts both halves of the isolation claim it quotes; CR-9 stops dw.sh's own container through `dw.sh stop` and asserts exit 143, because a kill gives 137 and timing cannot tell them apart; CR-7 gained the acceptance case that a refuse-everything guard would fail; CR-8 was split into two workspaces, each asserting the refusal names its own finding. Fixing them surfaced two defects in the harness itself, and both are worth recording because they degrade quietly: 1. **The stub could not represent a multi-line argument.** `docker run … bash -c '<a 30-line script>'` is one argv element; recorded one-line-per-argument, a blank line inside it reads as a call boundary. Newlines are folded now. 2. **`cr_call_record` doubled every record.** awk's `exit` runs the END block, so a record found mid-file was printed on the way out and again by END. Pair assertions survive a doubled record silently — only counting the arguments exposed it, which is how CR-15's new mount-set check found it. The second one is the more instructive: it means every argv assertion in round 2 was running against doubled input and nobody could have noticed, because the only thing a doubled record changes is a count, and round 2 counted nothing. ## Verification at `5f9d7f8` - **Full sweep 37/37 PASS** — AC-1..AC-23, CR-3, CR-6, CR-7, CR-8, CR-9, CR-14, CR-15, CR-35..CR-40, RV-4. - Clean tree, nothing modified after the sweep. - `bash scripts/lint-conventions.sh` and `bash scripts/test-lint-conventions.sh` — both clean. Each of the six new criteria was seen red for its own defect before its fix existed, and two of them (`CR-35`, `CR-40`) demonstrated live destruction of a real volume holding real recoverable work. ## Commits ``` 5f9d7f8 the seven code defects the re-validation found 9492dc7 repair the round-2 criteria, and add six for what they missed ```
Author
Owner
{
  "next_state": "qa",
  "produced": [
    {
      "kind": "qa-report",
      "ref": "2271",
      "summary": "code domain, phase=validate — round-3 re-validation, 15 findings from three fresh-context reviewers"
    },
    {
      "kind": "qa-report",
      "ref": "2306",
      "summary": "code domain, phase=fix — all 15 fixed; sweep 37/37 at 5f9d7f8"
    }
  ],
  "findings": [],
  "pending_decisions": [
    {
      "id": "D-PO-61-6-1",
      "type": "precondition",
      "blocking": true,
      "question": "Round 3 found 15 defects, 7 of them in code and 2 in the test harness itself. Its own fixes have again been read only by their author. Run a fourth re-validation scoped to round 3's diff, or take #61 out of QA now?",
      "options": [
        "confirmed",
        "waived"
      ],
      "recommended": "confirmed",
      "reasoning": "Recommend confirmed, but narrowly scoped and with a stopping rule, because 'run another round' does not converge on its own. The case for one more: round 3 changed the HARNESS, and two of its defects were in the harness — a stub that could not represent a multi-line argument, and a record extractor that doubled every record. Those underwrite all 37 verdicts, so a mistake there is not one wrong answer but an unknown number of them, and they are the least-reviewed lines in the change. The severity trend also has not turned: round 1 found a credential written from the wrong key, round 2 a total breakage, round 3 silent destruction of unpushed work. Nothing in that sequence looks like diminishing returns yet. Scope for round 4: the diff 78574f3..5f9d7f8, weighted to the harness changes and the rm/credential paths; NOT a re-review of what earlier rounds already settled. Stopping rule, so this terminates: stop when a round produces no finding that would lose data or leak a credential — test-only and cosmetic findings get batched to a follow-up issue rather than starting another round. Waived is defensible: the 37/37 is real, every fix was seen red first, and the two most dangerous defects in the feature are now covered by criteria that demonstrated live destruction before they were fixed."
    }
  ],
  "suite": {
    "source": "git",
    "sha": "5f9d7f867913fed8ae9603c3217ca05db6209955",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-61-6 skill=qa --> ```json { "next_state": "qa", "produced": [ { "kind": "qa-report", "ref": "2271", "summary": "code domain, phase=validate — round-3 re-validation, 15 findings from three fresh-context reviewers" }, { "kind": "qa-report", "ref": "2306", "summary": "code domain, phase=fix — all 15 fixed; sweep 37/37 at 5f9d7f8" } ], "findings": [], "pending_decisions": [ { "id": "D-PO-61-6-1", "type": "precondition", "blocking": true, "question": "Round 3 found 15 defects, 7 of them in code and 2 in the test harness itself. Its own fixes have again been read only by their author. Run a fourth re-validation scoped to round 3's diff, or take #61 out of QA now?", "options": [ "confirmed", "waived" ], "recommended": "confirmed", "reasoning": "Recommend confirmed, but narrowly scoped and with a stopping rule, because 'run another round' does not converge on its own. The case for one more: round 3 changed the HARNESS, and two of its defects were in the harness — a stub that could not represent a multi-line argument, and a record extractor that doubled every record. Those underwrite all 37 verdicts, so a mistake there is not one wrong answer but an unknown number of them, and they are the least-reviewed lines in the change. The severity trend also has not turned: round 1 found a credential written from the wrong key, round 2 a total breakage, round 3 silent destruction of unpushed work. Nothing in that sequence looks like diminishing returns yet. Scope for round 4: the diff 78574f3..5f9d7f8, weighted to the harness changes and the rm/credential paths; NOT a re-review of what earlier rounds already settled. Stopping rule, so this terminates: stop when a round produces no finding that would lose data or leak a credential — test-only and cosmetic findings get batched to a follow-up issue rather than starting another round. Waived is defensible: the 37/37 is real, every fix was seen red first, and the two most dangerous defects in the feature are now covered by criteria that demonstrated live destruction before they were fixed." } ], "suite": { "source": "git", "sha": "5f9d7f867913fed8ae9603c3217ca05db6209955", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "confirmed",
  "rationale": "Operator approved the recommendation, including its scope and its stopping rule. The precondition holds on the same grounds it did for round 3, plus one that is new and stronger: round 3 changed the HARNESS, and two of its own defects were in the harness — a stub that could not represent a multi-line argv element, and a record extractor that doubled every record because awk's exit runs the END block. Those two underwrite all 37 verdicts, so an error there is not one wrong answer but an unknown number of them. The doubling in particular was invisible to every pair assertion round 2 made and surfaced only once something counted arguments. Round 4 is scoped to 78574f3..5f9d7f8, weighted to the harness changes and the rm/credential paths, and is NOT a re-review of what earlier rounds settled. It terminates by the recorded stopping rule: a round that produces no finding which would lose data or leak a credential ends the sequence, and test-only or cosmetic findings are batched to a follow-up issue rather than starting another round.",
  "rejected_alternative": "Waived — advance on the 37/37 sweep, every fix having been seen red for its own defect first, and the two most dangerous defects now covered by criteria that demonstrated live destruction of a real volume before their fix existed. Turned down because the same evidence was equally true at the end of rounds 2 and 3, and both times the next reader found silent data loss. The severity sequence — a credential written from the wrong key, a total breakage, destruction of unpushed work — has not yet turned downward, which is the signal that would justify stopping."
}

<!-- decision-resolution:v1 ref=D-PO-61-6-1 --> ```json { "status": "resolved", "chosen_option": "confirmed", "rationale": "Operator approved the recommendation, including its scope and its stopping rule. The precondition holds on the same grounds it did for round 3, plus one that is new and stronger: round 3 changed the HARNESS, and two of its own defects were in the harness — a stub that could not represent a multi-line argv element, and a record extractor that doubled every record because awk's exit runs the END block. Those two underwrite all 37 verdicts, so an error there is not one wrong answer but an unknown number of them. The doubling in particular was invisible to every pair assertion round 2 made and surfaced only once something counted arguments. Round 4 is scoped to 78574f3..5f9d7f8, weighted to the harness changes and the rm/credential paths, and is NOT a re-review of what earlier rounds settled. It terminates by the recorded stopping rule: a round that produces no finding which would lose data or leak a credential ends the sequence, and test-only or cosmetic findings are batched to a follow-up issue rather than starting another round.", "rejected_alternative": "Waived — advance on the 37/37 sweep, every fix having been seen red for its own defect first, and the two most dangerous defects now covered by criteria that demonstrated live destruction of a real volume before their fix existed. Turned down because the same evidence was equally true at the end of rounds 2 and 3, and both times the next reader found silent data loss. The severity sequence — a credential written from the wrong key, a total breakage, destruction of unpushed work — has not yet turned downward, which is the signal that would justify stopping." } ```
Author
Owner
{
  "outcome": "issues-found",
  "summary": "Round-4 re-validation of 78574f3..5f9d7f8 by three fresh-context reviewers: 18 findings (4 blocking, 9 serious, 5 minor). Round 3's own fixes introduced two of the blocking ones. The finding count has now risen three rounds running (8, 15, 18), which is the signal that adding guards is not converging.",
  "findings": [
    {"id": "CR-50", "category": "in-scope-blocking", "severity": "critical",
     "summary": "The `[ ! -e .git ]` early exit answers \"nothing to lose\" for any repository the guard merely cannot SEE — an unreadable /workspace, a dangling .git symlink, or a repo one directory down",
     "reasoning": "Introduced in round 3 as the fix for CR-36, and it bypasses the fail-closed design it was added to complete, because it runs BEFORE the probe. Three states destroyed end-to-end with exit 0, including a root-owned /workspace (reachable: the image gives the session user passwordless sudo) and a repo at /workspace/myrepo with an unpushed commit in it."},
    {"id": "CR-51", "category": "in-scope-blocking", "severity": "critical",
     "summary": "`git status --porcelain` is not a report of unsaved work: it obeys .gitignore, repo-local config and the main worktree only",
     "reasoning": "Invisible and destroyed: gitignored files, a linked worktree's uncommitted work, a detached-HEAD commit INSIDE a linked worktree, and a commit reachable only from a local tag. Repo-local settings a developer legitimately sets (status.showUntrackedFiles=no, .git/info/exclude, assume-unchanged) blank the dirty section outright, and a stale remote-tracking ref makes an unpushed commit read as pushed. This lands hardest on this repo: .devwork/ is gitignored and holds real analysis, and the parallel-sessions model puts linked worktrees under .claude/worktrees/, also gitignored."},
    {"id": "CR-52", "category": "in-scope-blocking", "severity": "serious",
     "summary": "CR-37 asserts only the symlinked-FILE half of push_file's defence; deleting the resolved-parent bound leaves it and CR-14 green while a symlinked DIRECTORY lands the credential in the git working tree",
     "reasoning": "dw.sh's own comment claims both halves; one is asserted. Same shape as the round-2 CR-15 defect, in the criterion written to replace it."},
    {"id": "CR-53", "category": "in-scope-blocking", "severity": "serious",
     "summary": "CR-35 exercises only the workspace volume, so the ownership check on the claude volume can be deleted with every criterion green",
     "reasoning": "One-line edit, and it is the line CR-35's own header argues for. An unmanaged dw-<N>-claude is then destroyed under the message 'nothing else on this host was touched'."},
    {"id": "CR-54", "category": "in-scope-blocking", "severity": "serious",
     "summary": "`cmd_rm` is the only destructive verb that takes no lock and never checks container state",
     "reasoning": "It deleted a running container and both volumes while another dw.sh held the issue's lock. Its verdict is also stale by the time it acts: the inspection is a ~1s throwaway container and nothing stops a live session writing between it and `docker volume rm`."},
    {"id": "CR-55", "category": "in-scope-blocking", "severity": "serious",
     "summary": "A pair where one volume is unlabelled is wedged — neither usable nor removable — and the refusal's only actionable advice destroys the data",
     "reasoning": "Docker has no volume rename, so 'rename or remove that volume yourself' reduces to `docker volume rm`. This is the state of every container created by a pre-label dw.sh."},
    {"id": "CR-56", "category": "in-scope-blocking", "severity": "serious",
     "summary": "CR-36 pins fail-closed to a failure of the FIRST git probe, so a failure swallowed in any later section is undetected",
     "reasoning": "Adding `2>/dev/null || true` to the unpushed walk — an ordinary silence-the-noisy-git edit — destroys a workspace with a real unpushed commit while CR-36, CR-8, CR-40 and CR-7 all stay green."},
    {"id": "CR-57", "category": "in-scope-blocking", "severity": "serious",
     "summary": "CR-38 cannot detect the opposite failure: an overlapping failed run silently REVERTING a successful refresh, because reverting is what the criterion calls success",
     "reasoning": "Demonstrated at HEAD with the harness's own stubs. The lock half of the round-3 fix is asserted by nothing — CR-38 never invokes dw.sh at all, and no criterion runs two dw.sh verbs concurrently. The lock is also per-TMPDIR, so two shells with different TMPDIR take different locks."},
    {"id": "CR-58", "category": "in-scope-blocking", "severity": "serious",
     "summary": "No criterion covers the `up` half of volume ownership; the stub reports every volume absent, so the adoption-refusal branch is never entered",
     "reasoning": "Delete the guard and `up` seeds a clone over a volume belonging to something else — which, per dw.sh's own comment, `rm` then refuses to clean up ever after."},
    {"id": "CR-59", "category": "in-scope-blocking", "severity": "serious",
     "summary": "`cr_argv_count` counts one spelling of a flag, so an extra mount passed as `--volume` is invisible to CR-15's 'nothing but the workspace' assertion",
     "reasoning": "Mutation-verified: the inspection container bind-mounted the operator's ~/.ssh with CR-15 green. `--env-file` is likewise unchecked."},
    {"id": "CR-60", "category": "in-scope-blocking", "severity": "serious",
     "summary": "The config snapshot holds the live forge token in plaintext with no trap, so a kill between the snapshot and its cleanup leaves it permanently in the container's writable layer",
     "reasoning": "Reproduced against a real container with a synthetic token: after SIGKILL the backup remained at mode 0600 with the token readable. /tmp is not one of the two tracked volumes, so it is invisible to the model that only reasons about those."},
    {"id": "CR-61", "category": "in-scope-blocking", "severity": "serious",
     "summary": "`HOME=\"\"` collapses push_file's parent bound `\"$HOME\"/*` to the literal glob `/*`, which matches every absolute path",
     "reasoning": "The guard silently becomes a no-op. The reviewer verified the safe directions: unset HOME aborts loudly under set -u, HOME=/ fails closed. Only the empty string fails open."},
    {"id": "CR-62", "category": "in-scope-blocking", "severity": "serious",
     "summary": "If DW_DEST is a directory, `mv -f` moves the temp file INTO it and push_file exits 0 having never installed the credential",
     "reasoning": "A bug the round-3 temp-then-rename fix introduced. Every subsequent up/refresh-creds reports success while no usable credential exists at the expected path, and the real secret sits in a child file instead."},
    {"id": "CR-63", "category": "in-scope-blocking", "severity": "minor",
     "summary": "CR-39 has no acceptance case: an implementation that ALWAYS reports the restore as failed passes CR-39, RV-4 and CR-38",
     "reasoning": "In violation of the block's own stated rule 3. Nothing anywhere asserts the success message on a restore that did happen, so the operator would be sent chasing a credential problem that does not exist."},
    {"id": "CR-64", "category": "in-scope-blocking", "severity": "minor",
     "summary": "The inspection executes a program named by the workspace's own core.fsmonitor",
     "reasoning": "Demonstrated: a hook in the workspace ran as uid 1000 inside the inspection container and wrote a file into the volume it was certifying. Not the read-only observer that '--network none plus one mount' implies. The reviewer could not make it defeat a verdict, so the demonstrated impact is execution and mutation, not a false certification."},
    {"id": "CR-65", "category": "in-scope-blocking", "severity": "minor",
     "summary": "`dw-<N>-claude` is deleted with no inspection of any kind",
     "reasoning": "It holds session transcripts, settings and the pinned suite — material that also exists nowhere else, and which this repo's own practice treats as run evidence."},
    {"id": "CR-66", "category": "in-scope-blocking", "severity": "minor",
     "summary": "The restore uses `cp -p`, a non-atomic copy-in-place, contradicting the comment's 'never in between'",
     "reasoning": "A kill inside the copy leaves config.yml truncated. The window is sub-millisecond today and grows if the config does; reasoned, not executed."},
    {"id": "CR-67", "category": "in-scope-blocking", "severity": "minor",
     "summary": "The push_file temp name `.dw-push.$$` is predictable and created without O_EXCL",
     "reasoning": "The reviewer tried ~40 real invocations against ~18,000 pre-sprayed symlinks and never won the race, and notes it gains a same-uid attacker nothing they do not already have. Cheap to close with mktemp regardless."}
  ],
  "artifacts": {}
}

Method

Three fresh-context reviewers at 5f9d7f8, scoped to 78574f3..5f9d7f8 and told
not to re-review settled ground. Lenses: harness integrity and mutation testing
of the six criteria round 3 wrote alongside its fixes; the destructive paths; the
credential paths. One ran on a different model family. Every finding above was
reproduced by execution except CR-66, which is labelled as reasoned.

The result that matters more than any single finding

Findings per round: 8, then 15, then 18. They are going up, not down. The
guard surface grew each round and each round's new guards brought their own
defects — round 3's fixes are directly responsible for CR-50 (the early exit
that bypasses the fail-closed design it completes) and CR-62 (the rename that
silently misfiles a credential into a directory).

CR-51 says why, and it is a design fact rather than a bug: "is there work here
that exists nowhere else" is not decidable by a fixed list of git queries.
Every
round has found another place work can hide — a detached HEAD, a stash, a stash
whose reflog is gone, a linked worktree, a local tag, a gitignored file, a repo one
directory down. There will be another. Enumerating hiding places does not converge,
and the criteria that guard each enumeration are themselves an infinite regress:
six of this round's findings are coverage gaps in tests, and a sufficiently
creative mutation can always find one more.

The stopping rule recorded on D-PO-61-6-1 — stop when a round finds nothing that
loses data or leaks a credential — has not been met. But meeting it by adding an
eighth guard is not the way to meet it, because that is what the last two rounds
did and the count rose both times.

What holds

Recorded because a re-validation that lists only defects says nothing about coverage:

  • Marker injection is impossible. Untracked files named == end ==, a commit subject of == unpushed ==, a stash message of == end ==: every line git emits carries a prefix (?? , a sha, stash@{0}: ) and control characters are C-quoted onto one line, so no bare marker line can be produced. Branch names cannot carry it at all.
  • --force does not override ownership, in either direction of the unlabelled-volume pair.
  • Both round-3 guarantees work: a detached-HEAD commit in the main worktree is listed, and a refs/stash with no reflog is reported rather than read as empty.
  • The round-3 symlink fix holds: a dangling link at the destination became a regular file and the target was never created.
  • tea 0.15.1 honours XDG_CONFIG_HOME — so the snapshot points at the file tea actually uses; had it not, the whole mechanism would be inert in production and RV-4, CR-38 and CR-39 would all have been green against nothing. One-token-one-login is real and enforced offline, before any network call.
  • Secrets never reach argv or env: only DW_DEST and DW_MODE are passed with -e; content goes over stdin.
  • cr_call_record's doubling is genuinely fixed, verified on a synthetic three-record log, including the no-trailing-blank-line case.
  • sg docker -c propagates the inner exit code exactly (probed 0/1/2/7), which is what every [ "$rc" -eq 2 ] assertion rests on.
<!-- qa-report:v1 issue=61 skill=qa domain=code phase=validate --> ```json { "outcome": "issues-found", "summary": "Round-4 re-validation of 78574f3..5f9d7f8 by three fresh-context reviewers: 18 findings (4 blocking, 9 serious, 5 minor). Round 3's own fixes introduced two of the blocking ones. The finding count has now risen three rounds running (8, 15, 18), which is the signal that adding guards is not converging.", "findings": [ {"id": "CR-50", "category": "in-scope-blocking", "severity": "critical", "summary": "The `[ ! -e .git ]` early exit answers \"nothing to lose\" for any repository the guard merely cannot SEE — an unreadable /workspace, a dangling .git symlink, or a repo one directory down", "reasoning": "Introduced in round 3 as the fix for CR-36, and it bypasses the fail-closed design it was added to complete, because it runs BEFORE the probe. Three states destroyed end-to-end with exit 0, including a root-owned /workspace (reachable: the image gives the session user passwordless sudo) and a repo at /workspace/myrepo with an unpushed commit in it."}, {"id": "CR-51", "category": "in-scope-blocking", "severity": "critical", "summary": "`git status --porcelain` is not a report of unsaved work: it obeys .gitignore, repo-local config and the main worktree only", "reasoning": "Invisible and destroyed: gitignored files, a linked worktree's uncommitted work, a detached-HEAD commit INSIDE a linked worktree, and a commit reachable only from a local tag. Repo-local settings a developer legitimately sets (status.showUntrackedFiles=no, .git/info/exclude, assume-unchanged) blank the dirty section outright, and a stale remote-tracking ref makes an unpushed commit read as pushed. This lands hardest on this repo: .devwork/ is gitignored and holds real analysis, and the parallel-sessions model puts linked worktrees under .claude/worktrees/, also gitignored."}, {"id": "CR-52", "category": "in-scope-blocking", "severity": "serious", "summary": "CR-37 asserts only the symlinked-FILE half of push_file's defence; deleting the resolved-parent bound leaves it and CR-14 green while a symlinked DIRECTORY lands the credential in the git working tree", "reasoning": "dw.sh's own comment claims both halves; one is asserted. Same shape as the round-2 CR-15 defect, in the criterion written to replace it."}, {"id": "CR-53", "category": "in-scope-blocking", "severity": "serious", "summary": "CR-35 exercises only the workspace volume, so the ownership check on the claude volume can be deleted with every criterion green", "reasoning": "One-line edit, and it is the line CR-35's own header argues for. An unmanaged dw-<N>-claude is then destroyed under the message 'nothing else on this host was touched'."}, {"id": "CR-54", "category": "in-scope-blocking", "severity": "serious", "summary": "`cmd_rm` is the only destructive verb that takes no lock and never checks container state", "reasoning": "It deleted a running container and both volumes while another dw.sh held the issue's lock. Its verdict is also stale by the time it acts: the inspection is a ~1s throwaway container and nothing stops a live session writing between it and `docker volume rm`."}, {"id": "CR-55", "category": "in-scope-blocking", "severity": "serious", "summary": "A pair where one volume is unlabelled is wedged — neither usable nor removable — and the refusal's only actionable advice destroys the data", "reasoning": "Docker has no volume rename, so 'rename or remove that volume yourself' reduces to `docker volume rm`. This is the state of every container created by a pre-label dw.sh."}, {"id": "CR-56", "category": "in-scope-blocking", "severity": "serious", "summary": "CR-36 pins fail-closed to a failure of the FIRST git probe, so a failure swallowed in any later section is undetected", "reasoning": "Adding `2>/dev/null || true` to the unpushed walk — an ordinary silence-the-noisy-git edit — destroys a workspace with a real unpushed commit while CR-36, CR-8, CR-40 and CR-7 all stay green."}, {"id": "CR-57", "category": "in-scope-blocking", "severity": "serious", "summary": "CR-38 cannot detect the opposite failure: an overlapping failed run silently REVERTING a successful refresh, because reverting is what the criterion calls success", "reasoning": "Demonstrated at HEAD with the harness's own stubs. The lock half of the round-3 fix is asserted by nothing — CR-38 never invokes dw.sh at all, and no criterion runs two dw.sh verbs concurrently. The lock is also per-TMPDIR, so two shells with different TMPDIR take different locks."}, {"id": "CR-58", "category": "in-scope-blocking", "severity": "serious", "summary": "No criterion covers the `up` half of volume ownership; the stub reports every volume absent, so the adoption-refusal branch is never entered", "reasoning": "Delete the guard and `up` seeds a clone over a volume belonging to something else — which, per dw.sh's own comment, `rm` then refuses to clean up ever after."}, {"id": "CR-59", "category": "in-scope-blocking", "severity": "serious", "summary": "`cr_argv_count` counts one spelling of a flag, so an extra mount passed as `--volume` is invisible to CR-15's 'nothing but the workspace' assertion", "reasoning": "Mutation-verified: the inspection container bind-mounted the operator's ~/.ssh with CR-15 green. `--env-file` is likewise unchecked."}, {"id": "CR-60", "category": "in-scope-blocking", "severity": "serious", "summary": "The config snapshot holds the live forge token in plaintext with no trap, so a kill between the snapshot and its cleanup leaves it permanently in the container's writable layer", "reasoning": "Reproduced against a real container with a synthetic token: after SIGKILL the backup remained at mode 0600 with the token readable. /tmp is not one of the two tracked volumes, so it is invisible to the model that only reasons about those."}, {"id": "CR-61", "category": "in-scope-blocking", "severity": "serious", "summary": "`HOME=\"\"` collapses push_file's parent bound `\"$HOME\"/*` to the literal glob `/*`, which matches every absolute path", "reasoning": "The guard silently becomes a no-op. The reviewer verified the safe directions: unset HOME aborts loudly under set -u, HOME=/ fails closed. Only the empty string fails open."}, {"id": "CR-62", "category": "in-scope-blocking", "severity": "serious", "summary": "If DW_DEST is a directory, `mv -f` moves the temp file INTO it and push_file exits 0 having never installed the credential", "reasoning": "A bug the round-3 temp-then-rename fix introduced. Every subsequent up/refresh-creds reports success while no usable credential exists at the expected path, and the real secret sits in a child file instead."}, {"id": "CR-63", "category": "in-scope-blocking", "severity": "minor", "summary": "CR-39 has no acceptance case: an implementation that ALWAYS reports the restore as failed passes CR-39, RV-4 and CR-38", "reasoning": "In violation of the block's own stated rule 3. Nothing anywhere asserts the success message on a restore that did happen, so the operator would be sent chasing a credential problem that does not exist."}, {"id": "CR-64", "category": "in-scope-blocking", "severity": "minor", "summary": "The inspection executes a program named by the workspace's own core.fsmonitor", "reasoning": "Demonstrated: a hook in the workspace ran as uid 1000 inside the inspection container and wrote a file into the volume it was certifying. Not the read-only observer that '--network none plus one mount' implies. The reviewer could not make it defeat a verdict, so the demonstrated impact is execution and mutation, not a false certification."}, {"id": "CR-65", "category": "in-scope-blocking", "severity": "minor", "summary": "`dw-<N>-claude` is deleted with no inspection of any kind", "reasoning": "It holds session transcripts, settings and the pinned suite — material that also exists nowhere else, and which this repo's own practice treats as run evidence."}, {"id": "CR-66", "category": "in-scope-blocking", "severity": "minor", "summary": "The restore uses `cp -p`, a non-atomic copy-in-place, contradicting the comment's 'never in between'", "reasoning": "A kill inside the copy leaves config.yml truncated. The window is sub-millisecond today and grows if the config does; reasoned, not executed."}, {"id": "CR-67", "category": "in-scope-blocking", "severity": "minor", "summary": "The push_file temp name `.dw-push.$$` is predictable and created without O_EXCL", "reasoning": "The reviewer tried ~40 real invocations against ~18,000 pre-sprayed symlinks and never won the race, and notes it gains a same-uid attacker nothing they do not already have. Cheap to close with mktemp regardless."} ], "artifacts": {} } ``` ## Method Three fresh-context reviewers at `5f9d7f8`, scoped to `78574f3..5f9d7f8` and told not to re-review settled ground. Lenses: harness integrity and mutation testing of the six criteria round 3 wrote alongside its fixes; the destructive paths; the credential paths. One ran on a different model family. Every finding above was reproduced by execution except `CR-66`, which is labelled as reasoned. ## The result that matters more than any single finding **Findings per round: 8, then 15, then 18.** They are going up, not down. The guard surface grew each round and each round's new guards brought their own defects — round 3's fixes are directly responsible for `CR-50` (the early exit that bypasses the fail-closed design it completes) and `CR-62` (the rename that silently misfiles a credential into a directory). `CR-51` says why, and it is a design fact rather than a bug: **"is there work here that exists nowhere else" is not decidable by a fixed list of git queries.** Every round has found another place work can hide — a detached HEAD, a stash, a stash whose reflog is gone, a linked worktree, a local tag, a gitignored file, a repo one directory down. There will be another. Enumerating hiding places does not converge, and the criteria that guard each enumeration are themselves an infinite regress: six of this round's findings are coverage gaps in tests, and a sufficiently creative mutation can always find one more. The stopping rule recorded on `D-PO-61-6-1` — stop when a round finds nothing that loses data or leaks a credential — has not been met. But meeting it by adding an eighth guard is not the way to meet it, because that is what the last two rounds did and the count rose both times. ## What holds Recorded because a re-validation that lists only defects says nothing about coverage: - **Marker injection is impossible.** Untracked files named `== end ==`, a commit subject of `== unpushed ==`, a stash message of `== end ==`: every line git emits carries a prefix (`?? `, a sha, `stash@{0}: `) and control characters are C-quoted onto one line, so no bare marker line can be produced. Branch names cannot carry it at all. - **`--force` does not override ownership**, in either direction of the unlabelled-volume pair. - **Both round-3 guarantees work**: a detached-HEAD commit in the main worktree is listed, and a `refs/stash` with no reflog is reported rather than read as empty. - **The round-3 symlink fix holds**: a dangling link at the destination became a regular file and the target was never created. - **`tea` 0.15.1 honours `XDG_CONFIG_HOME`** — so the snapshot points at the file tea actually uses; had it not, the whole mechanism would be inert in production and RV-4, CR-38 and CR-39 would all have been green against nothing. One-token-one-login is real and enforced offline, before any network call. - **Secrets never reach argv or env**: only `DW_DEST` and `DW_MODE` are passed with `-e`; content goes over stdin. - **`cr_call_record`'s doubling is genuinely fixed**, verified on a synthetic three-record log, including the no-trailing-blank-line case. - **`sg docker -c` propagates the inner exit code exactly** (probed 0/1/2/7), which is what every `[ "$rc" -eq 2 ]` assertion rests on.
Author
Owner
{
  "next_state": "qa",
  "produced": [
    {
      "kind": "qa-report",
      "ref": "2320",
      "summary": "code domain, phase=validate — round-4 re-validation, 18 findings, and the non-convergence signal"
    }
  ],
  "findings": [],
  "pending_decisions": [
    {
      "id": "D-PO-61-7-1",
      "type": "approval",
      "blocking": true,
      "question": "Findings per round are 8, then 15, then 18 — rising. Change approach for the rm guard: replace the enumeration of hiding places with one positive-certification rule, rather than adding an eighth enumeration?",
      "options": [
        "approve",
        "reject"
      ],
      "recommended": "approve",
      "reasoning": "Recommend approve. Every round has found another place unsaved work can hide — a detached HEAD, a stash, a stash with no reflog, a linked worktree, a local tag, a gitignored file, a repo one directory down — and there will be another, because 'work that exists nowhere else' is not decidable by a fixed list of git queries. Two of this round's blocking findings were introduced by last round's fixes. The criteria guarding each enumeration are their own regress: six of this round's 18 are coverage gaps in tests, and a creative enough mutation will always find one more. THE CHANGE: inspect_workspace returns exactly one of clean (positively proven: a git repo at the top of /workspace, EVERY query succeeded, tree clean, no stash ref, nothing reachable from any ref or any worktree HEAD that is not on a remote, no linked worktree holding state), empty (the volume has no entries at all), or undetermined with a reason. cmd_rm proceeds only on clean or empty; everything else refuses and names why. That subsumes CR-50, CR-51 and CR-56 as one rule instead of three guards, and it is testable by one property — feed it adversarial workspaces and assert refusal — which is far harder to make vacuous than N independent enumerations. Also in this round regardless of the answer: the credential bugs (CR-60 through CR-62, CR-66, CR-67) and the ownership gaps (CR-53 through CR-55, CR-58), which are ordinary defects with ordinary fixes. The pure test-coverage findings (CR-52, CR-57, CR-59, CR-63) get fixed here too since they are cheap, but they are the class to batch to a follow-up if scope needs cutting. COST: rm will refuse more often, because gitignored files now count as unsaved work; the answer is a narrow --discard-ignored flag rather than pushing operators toward --force. Reject means fixing all 18 as enumerated and accepting that round 5 most likely finds a nineteenth hiding place."
    }
  ],
  "suite": {
    "source": "git",
    "sha": "5f9d7f867913fed8ae9603c3217ca05db6209955",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-61-7 skill=qa --> ```json { "next_state": "qa", "produced": [ { "kind": "qa-report", "ref": "2320", "summary": "code domain, phase=validate — round-4 re-validation, 18 findings, and the non-convergence signal" } ], "findings": [], "pending_decisions": [ { "id": "D-PO-61-7-1", "type": "approval", "blocking": true, "question": "Findings per round are 8, then 15, then 18 — rising. Change approach for the rm guard: replace the enumeration of hiding places with one positive-certification rule, rather than adding an eighth enumeration?", "options": [ "approve", "reject" ], "recommended": "approve", "reasoning": "Recommend approve. Every round has found another place unsaved work can hide — a detached HEAD, a stash, a stash with no reflog, a linked worktree, a local tag, a gitignored file, a repo one directory down — and there will be another, because 'work that exists nowhere else' is not decidable by a fixed list of git queries. Two of this round's blocking findings were introduced by last round's fixes. The criteria guarding each enumeration are their own regress: six of this round's 18 are coverage gaps in tests, and a creative enough mutation will always find one more. THE CHANGE: inspect_workspace returns exactly one of clean (positively proven: a git repo at the top of /workspace, EVERY query succeeded, tree clean, no stash ref, nothing reachable from any ref or any worktree HEAD that is not on a remote, no linked worktree holding state), empty (the volume has no entries at all), or undetermined with a reason. cmd_rm proceeds only on clean or empty; everything else refuses and names why. That subsumes CR-50, CR-51 and CR-56 as one rule instead of three guards, and it is testable by one property — feed it adversarial workspaces and assert refusal — which is far harder to make vacuous than N independent enumerations. Also in this round regardless of the answer: the credential bugs (CR-60 through CR-62, CR-66, CR-67) and the ownership gaps (CR-53 through CR-55, CR-58), which are ordinary defects with ordinary fixes. The pure test-coverage findings (CR-52, CR-57, CR-59, CR-63) get fixed here too since they are cheap, but they are the class to batch to a follow-up if scope needs cutting. COST: rm will refuse more often, because gitignored files now count as unsaved work; the answer is a narrow --discard-ignored flag rather than pushing operators toward --force. Reject means fixing all 18 as enumerated and accepting that round 5 most likely finds a nineteenth hiding place." } ], "suite": { "source": "git", "sha": "5f9d7f867913fed8ae9603c3217ca05db6209955", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "approve",
  "rationale": "Operator approved. The evidence for changing approach rather than adding another guard is the finding count itself: 8, then 15, then 18, with two of round 4's blocking findings introduced by round 3's fixes. Enumerating the places unsaved work can hide has produced a new hiding place every round — detached HEAD, stash, stash without a reflog, linked worktree, local tag, gitignored file, repository in a subdirectory — because the question is not decidable by a fixed list of git queries. The replacement is a single rule: inspect_workspace returns clean, empty, or undetermined-with-a-reason, and rm proceeds only on the first two. Three blocking findings (CR-50, CR-51, CR-56) collapse into that one rule, and it is testable by one property — feed it adversarial workspaces and assert refusal — where the enumeration needed a separate non-vacuous criterion per hiding place and did not get one.",
  "rejected_alternative": "Fix all 18 as enumerated and keep the guard's current shape. Turned down because it is what rounds 2 and 3 did, and the count rose both times; the honest expectation was that round 5 would find a nineteenth hiding place. The enumeration is not wrong in any single case — every guard it added does work — it is that the list has no end and each addition carries its own defect rate."
}

<!-- decision-resolution:v1 ref=D-PO-61-7-1 --> ```json { "status": "resolved", "chosen_option": "approve", "rationale": "Operator approved. The evidence for changing approach rather than adding another guard is the finding count itself: 8, then 15, then 18, with two of round 4's blocking findings introduced by round 3's fixes. Enumerating the places unsaved work can hide has produced a new hiding place every round — detached HEAD, stash, stash without a reflog, linked worktree, local tag, gitignored file, repository in a subdirectory — because the question is not decidable by a fixed list of git queries. The replacement is a single rule: inspect_workspace returns clean, empty, or undetermined-with-a-reason, and rm proceeds only on the first two. Three blocking findings (CR-50, CR-51, CR-56) collapse into that one rule, and it is testable by one property — feed it adversarial workspaces and assert refusal — where the enumeration needed a separate non-vacuous criterion per hiding place and did not get one.", "rejected_alternative": "Fix all 18 as enumerated and keep the guard's current shape. Turned down because it is what rounds 2 and 3 did, and the count rose both times; the honest expectation was that round 5 would find a nineteenth hiding place. The enumeration is not wrong in any single case — every guard it added does work — it is that the list has no end and each addition carries its own defect rate." } ```
Author
Owner
{
  "outcome": "clean",
  "summary": "All 18 round-4 findings are fixed, the rm guard is rebuilt on positive certification per D-PO-61-7-1, and the full sweep is 38/38 PASS at c4ae3f6 with nothing modified after it.",
  "findings": [],
  "artifacts": {
    "test_files": [".devcontainer/selftest.sh"],
    "test_commit": "c4ae3f6"
  }
}

The redesign

inspect_workspace no longer looks for work. It tries to certify there is
none
, and returns exactly one of empty, checked (with everything the
questions turned up), or undetermined (with a reason). rm proceeds on the
first two only.

That one rule subsumes three of the round's blocking findings, and it reached
cases nobody had enumerated: --all covers tags and every ref namespace, the
worktree HEADs cover detached HEADs inside linked worktrees, and linked
worktrees are walked for their own uncommitted files. The volume is now mounted
read-only, which closes the workspace-controlled-code class outright rather
than neutralising it setting by setting — a core.fsmonitor hook had written
into the volume it was being asked to certify.

The payoff the decision predicted did arrive on the test side. One criterion,
CR-68, carries the entire property across eleven cases — eight refusals, three
acceptances — where the enumeration needed a separate non-vacuous criterion per
hiding place and never had one.

Two defects the reviewers did not find, that the work did

  1. An unreadable directory looks empty. The first certification pass had the
    unreadable shape certify as empty and get destroyed. "I looked and there
    was nothing" and "I could not look" produce identical output from ls, so
    the ability to look is now established before the emptiness question is asked.

  2. A per-run snapshot does not fix the revert case. A slow failing refresh
    restores its own stale snapshot over a fast successful one, and the operator
    is told the refresh worked while the container holds the old token. Snapshots
    stop two runs destroying each other; only exclusion stops one overwriting the
    other's result. The forge login is now serialised with flock inside the
    container
    , where the resource is — a host lock cannot cover a
    container-init run, and two host verbs with different TMPDIR take
    different host locks.

And one self-inflicted, caught by the criterion that already existed: the /tmp
sweep added to close the SIGKILL leak was deleting a concurrent run's backup —
reintroducing the exact bug being fixed. It sweeps by age now, never by pattern.

Everything else

ids fix
CR-52CR-55 rm takes the per-issue lock, refuses while a session is running, checks each volume separately, and the unmanaged-volume refusal prints how to copy the data out first — Docker has no volume rename, so the old advice reduced to "delete the only copy". --force-unmanaged is the deliberate override.
CR-57, CR-60, CR-66 the snapshot lives beside the config rather than in /tmp, is trapped on every signal path, is swept by age, and the restore renames instead of copying in place.
CR-59, CR-61, CR-62, CR-67 push_file rejects an empty HOME (the bound had degraded to the glob /*), refuses a directory at the destination (mv files the credential inside it and reports success), uses mktemp rather than a pid-derived name, and bounds /tmp itself rather than the whole subtree.
CR-63, CR-65 CR-39 gained the acceptance case its own rule demanded; the removal message names the session-state volume.
CR-58, CR-64 create_container refusing to adopt an unmanaged volume is now covered live, and the read-only mount replaces the per-setting neutralisation.
harness cr_argv_count takes every spelling of a flag — an extra mount passed as --volume was invisible to a check counting only -v.

Verification at c4ae3f6

  • Full sweep 38/38 PASS, clean tree, nothing modified after it.
  • bash scripts/lint-conventions.sh and bash scripts/test-lint-conventions.sh — both clean.
  • Every new or changed criterion was seen red for its own defect first, including the eleven CR-68 cases.
<!-- qa-report:v1 issue=61 skill=qa domain=code phase=fix --> ```json { "outcome": "clean", "summary": "All 18 round-4 findings are fixed, the rm guard is rebuilt on positive certification per D-PO-61-7-1, and the full sweep is 38/38 PASS at c4ae3f6 with nothing modified after it.", "findings": [], "artifacts": { "test_files": [".devcontainer/selftest.sh"], "test_commit": "c4ae3f6" } } ``` ## The redesign `inspect_workspace` no longer looks for work. It tries to **certify there is none**, and returns exactly one of `empty`, `checked` (with everything the questions turned up), or `undetermined` (with a reason). `rm` proceeds on the first two only. That one rule subsumes three of the round's blocking findings, and it reached cases nobody had enumerated: `--all` covers tags and every ref namespace, the worktree HEADs cover detached HEADs *inside linked worktrees*, and linked worktrees are walked for their own uncommitted files. The volume is now mounted **read-only**, which closes the workspace-controlled-code class outright rather than neutralising it setting by setting — a `core.fsmonitor` hook had written into the volume it was being asked to certify. The payoff the decision predicted did arrive on the test side. One criterion, `CR-68`, carries the entire property across eleven cases — eight refusals, three acceptances — where the enumeration needed a separate non-vacuous criterion per hiding place and never had one. ## Two defects the reviewers did not find, that the work did 1. **An unreadable directory looks empty.** The first certification pass had the `unreadable` shape certify as `empty` and get destroyed. "I looked and there was nothing" and "I could not look" produce identical output from `ls`, so the ability to look is now established before the emptiness question is asked. 2. **A per-run snapshot does not fix the revert case.** A slow failing refresh restores its own stale snapshot over a fast successful one, and the operator is told the refresh worked while the container holds the old token. Snapshots stop two runs destroying each other; only exclusion stops one overwriting the other's result. The forge login is now serialised with `flock` **inside the container**, where the resource is — a host lock cannot cover a `container-init` run, and two host verbs with different `TMPDIR` take different host locks. And one self-inflicted, caught by the criterion that already existed: the `/tmp` sweep added to close the SIGKILL leak was deleting a concurrent run's backup — reintroducing the exact bug being fixed. It sweeps by age now, never by pattern. ## Everything else | ids | fix | |---|---| | `CR-52`–`CR-55` | `rm` takes the per-issue lock, refuses while a session is running, checks each volume separately, and the unmanaged-volume refusal prints how to copy the data out first — Docker has no volume rename, so the old advice reduced to "delete the only copy". `--force-unmanaged` is the deliberate override. | | `CR-57`, `CR-60`, `CR-66` | the snapshot lives beside the config rather than in `/tmp`, is trapped on every signal path, is swept by age, and the restore renames instead of copying in place. | | `CR-59`, `CR-61`, `CR-62`, `CR-67` | `push_file` rejects an empty `HOME` (the bound had degraded to the glob `/*`), refuses a directory at the destination (`mv` files the credential *inside* it and reports success), uses `mktemp` rather than a pid-derived name, and bounds `/tmp` itself rather than the whole subtree. | | `CR-63`, `CR-65` | `CR-39` gained the acceptance case its own rule demanded; the removal message names the session-state volume. | | `CR-58`, `CR-64` | `create_container` refusing to adopt an unmanaged volume is now covered live, and the read-only mount replaces the per-setting neutralisation. | | harness | `cr_argv_count` takes every spelling of a flag — an extra mount passed as `--volume` was invisible to a check counting only `-v`. | ## Verification at `c4ae3f6` - **Full sweep 38/38 PASS**, clean tree, nothing modified after it. - `bash scripts/lint-conventions.sh` and `bash scripts/test-lint-conventions.sh` — both clean. - Every new or changed criterion was seen red for its own defect first, including the eleven `CR-68` cases.
Author
Owner
{
  "next_state": "qa",
  "produced": [
    {
      "kind": "qa-report",
      "ref": "2385",
      "summary": "code domain, phase=fix — 18 findings fixed, rm rebuilt on certification; sweep 38/38 at c4ae3f6"
    }
  ],
  "findings": [],
  "pending_decisions": [
    {
      "id": "D-PO-61-8-1",
      "type": "precondition",
      "blocking": true,
      "question": "The stopping rule recorded on D-PO-61-6-1 says the sequence ends when a round finds nothing that loses data or leaks a credential. Round 4 found plenty, so the rule says run round 5 — scoped to c4ae3f6. Confirm, or waive and advance?",
      "options": [
        "confirmed",
        "waived"
      ],
      "recommended": "confirmed",
      "reasoning": "Recommend confirmed, and this one is the decisive test rather than another turn of the same handle. Rounds 2, 3 and 4 each found defects in the round before, with the count rising 8, 15, 18 — and the round-4 decision changed the approach specifically to break that. Round 5 is the experiment that says whether it worked. Two outcomes, both useful: few or no data-losing findings means the certification rule did what the enumeration could not, the stopping rule fires on evidence, and #61 leaves QA having earned it; a fresh crop of data-losing findings means the redesign did not help either and the honest conclusion is that this guard cannot be made trustworthy on the current budget — at which point the right move is to ship rm as an explicitly UNGUARDED verb with a loud warning, rather than one carrying a safety claim it cannot keep. That second outcome is worth knowing and is not reachable without running the round. Scope: the diff 78574f3..c4ae3f6, weighted to the new certification path, the flock serialisation and push_file; the eleven CR-68 cases and the three self-inflicted defects this round produced (an unreadable directory certifying as empty, the revert case, the sweep that deleted a live snapshot) are the places to push hardest, because all three were found by building rather than by reviewing and their neighbours have had no reader at all. Waived is defensible if the priority is landing #61: the 38/38 is real, the redesign is a genuine simplification, and the two most dangerous defects in the feature are covered by criteria that demonstrated live destruction before their fix existed."
    }
  ],
  "suite": {
    "source": "git",
    "sha": "c4ae3f618231fe567dc177079d5d87d9bcf33d38",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-61-8 skill=qa --> ```json { "next_state": "qa", "produced": [ { "kind": "qa-report", "ref": "2385", "summary": "code domain, phase=fix — 18 findings fixed, rm rebuilt on certification; sweep 38/38 at c4ae3f6" } ], "findings": [], "pending_decisions": [ { "id": "D-PO-61-8-1", "type": "precondition", "blocking": true, "question": "The stopping rule recorded on D-PO-61-6-1 says the sequence ends when a round finds nothing that loses data or leaks a credential. Round 4 found plenty, so the rule says run round 5 — scoped to c4ae3f6. Confirm, or waive and advance?", "options": [ "confirmed", "waived" ], "recommended": "confirmed", "reasoning": "Recommend confirmed, and this one is the decisive test rather than another turn of the same handle. Rounds 2, 3 and 4 each found defects in the round before, with the count rising 8, 15, 18 — and the round-4 decision changed the approach specifically to break that. Round 5 is the experiment that says whether it worked. Two outcomes, both useful: few or no data-losing findings means the certification rule did what the enumeration could not, the stopping rule fires on evidence, and #61 leaves QA having earned it; a fresh crop of data-losing findings means the redesign did not help either and the honest conclusion is that this guard cannot be made trustworthy on the current budget — at which point the right move is to ship rm as an explicitly UNGUARDED verb with a loud warning, rather than one carrying a safety claim it cannot keep. That second outcome is worth knowing and is not reachable without running the round. Scope: the diff 78574f3..c4ae3f6, weighted to the new certification path, the flock serialisation and push_file; the eleven CR-68 cases and the three self-inflicted defects this round produced (an unreadable directory certifying as empty, the revert case, the sweep that deleted a live snapshot) are the places to push hardest, because all three were found by building rather than by reviewing and their neighbours have had no reader at all. Waived is defensible if the priority is landing #61: the 38/38 is real, the redesign is a genuine simplification, and the two most dangerous defects in the feature are covered by criteria that demonstrated live destruction before their fix existed." } ], "suite": { "source": "git", "sha": "c4ae3f618231fe567dc177079d5d87d9bcf33d38", "dirty": false } } ```
Author
Owner

Working handoff for issue #61, posted at context compaction because .devwork/ is gitignored by design and the tracker is the durable record. Supersedes the round-1 handoff at comment 2050.

QA handoff — issue #61, devcontainer plugin-era refresh

Written 2026-08-31, at context compaction. Replaces qa-round-1-handoff.md, which is stale.
The Gitea tracker is the durable record; this file is the working state a resuming session needs.

Where the feature is

  • Branch feature/61-devcontainer-plugin-era-refresh, worktree .claude/worktrees/issue-61-devcontainer.
  • HEAD c4ae3f6, clean tree.
  • Full sweep 38/38 PASS at that HEAD, nothing modified after it. Both gates
    (scripts/lint-conventions.sh, scripts/test-lint-conventions.sh) clean.
  • Latest Phase Outcome PO-61-8 (comment 2386), next_state: qa.

The one open decision

D-PO-61-8-1 — run a fifth review round, or stop and advance?

Recommended confirmed (run it). The reasoning is on the issue; the short form:

  • Four rounds are done. Each found defects in the round before it. Counts rose: 8, 15, 18.
  • Round 4 changed the design of the rm guard specifically to break that trend.
  • The stopping rule agreed on D-PO-61-6-1: stop when a round finds nothing that loses data or
    leaks a credential.
    Round 4 found several, so the rule says continue.
  • Round 5 is the experiment that tells us whether the redesign worked. Either it comes back nearly
    empty (the rule fires on evidence and #61 leaves QA), or it does not — and then the honest answer
    is to ship rm as an explicitly UNGUARDED verb with a loud warning, rather than one carrying a
    safety claim it cannot keep. That second outcome is not reachable without running the round.

Scope for round 5: the diff 78574f3..c4ae3f6, weighted to the new certification path, the
flock serialisation, and push_file. Push hardest on the three defects found by BUILDING rather
than reviewing — an unreadable directory certifying as empty, the revert case, and the /tmp sweep
that deleted a live snapshot — because their neighbours have had no reader at all.

What round 4 changed (the design, not just fixes)

inspect_workspace no longer looks for unsaved work. It tries to certify there is none, and
returns exactly one of:

verdict rm
empty — the volume holds nothing proceeds
checked — a repo, every question answered, plus what they turned up proceeds only if nothing turned up
undetermined — something here cannot be certified, and why refuses

Why: "is there work here that exists nowhere else" is not decidable by a fixed list of git queries.
Every round found another hiding place (detached HEAD, stash, stash without a reflog, linked worktree,
local tag, gitignored file, repo one directory down). A guard built from a list is wrong by default,
because anything not on the list reads as "nothing to lose".

Also in round 4: the inspection volume is mounted read-only (a core.fsmonitor hook had written
into the volume it was certifying); the forge login is serialised with flock inside the container
(a host lock cannot cover a container-init run); rm takes the per-issue lock, refuses while a
session runs, checks each volume separately, and gained --discard-ignored and --force-unmanaged.

Round history

round findings notes
1 34 8 were defects in selftest.sh itself
2 8 blocking promoted fix round; one regression broke all 23 container criteria
3 15 8 were defects in the criteria round 2 wrote
4 18 4 blocking; 2 introduced by round 3's fixes; triggered the redesign

The recurring lesson, three rounds running: a criterion written in the same sitting as its fix
encodes THE FIX, not THE PROPERTY. The only criterion that survived every mutation was RV-4 — the
one rewritten after a real failure rather than drafted alongside its fix.

Key comment ids on issue #61

  • 2320 — round-4 qa-report:v1 domain=code phase=validate (all 18 findings, CR-50..CR-67)
  • 2385 — round-4 phase=fix
  • 2386PO-61-8, carrying the open decision
  • 2271 / 2306 — round-3 validate / fix
  • 1924 — round-1 validate (the original CR-* findings)

Deferred, already homed

  • #353 — dw.sh hardening, 15 batched findings
  • #259 — Claude OAuth refresh-token rotation across N containers
  • #260tea api exits 0 on HTTP errors

Operational facts that cost time

  • Docker needs sg docker -c '...' in a session whose process started before the group was
    granted. selftest.sh wraps its own calls.
  • selftest.sh requires an argument (all, or named criteria). A bare call prints usage and
    exits 1, which reads exactly like a red run.
  • A full sweep takes ~35 minutes and creates ~25 containers plus real forge scratch issues; it cleans
    up after itself. The unit-style criteria (CR-14, CR-37, CR-38, CR-39, RV-4) run in seconds.
  • Never edit .devcontainer/ while a run is in flight. A running bash keeps reading the old
    inode and executes the previous version to completion, silently.
  • Reviewer subagents have twice gone idle without sending their report, which was sitting complete
    in their transcript. Check ~/.claude/projects/<proj>/<session>/subagents/*.jsonl before asking.
  • A uniform whole-suite failure at the same step is a logic regression in dw.sh, not the environment
    — check the first failure's reason before debugging anything else.
Working handoff for issue #61, posted at context compaction because `.devwork/` is gitignored by design and the tracker is the durable record. Supersedes the round-1 handoff at comment 2050. # QA handoff — issue #61, devcontainer plugin-era refresh **Written 2026-08-31, at context compaction. Replaces `qa-round-1-handoff.md`, which is stale.** The Gitea tracker is the durable record; this file is the working state a resuming session needs. ## Where the feature is - Branch `feature/61-devcontainer-plugin-era-refresh`, worktree `.claude/worktrees/issue-61-devcontainer`. - **HEAD `c4ae3f6`**, clean tree. - **Full sweep 38/38 PASS at that HEAD**, nothing modified after it. Both gates (`scripts/lint-conventions.sh`, `scripts/test-lint-conventions.sh`) clean. - Latest Phase Outcome **PO-61-8** (comment 2386), `next_state: qa`. ## The one open decision **`D-PO-61-8-1`** — run a fifth review round, or stop and advance? Recommended `confirmed` (run it). The reasoning is on the issue; the short form: - Four rounds are done. Each found defects in the round before it. Counts rose: **8, 15, 18**. - Round 4 changed the *design* of the `rm` guard specifically to break that trend. - The stopping rule agreed on `D-PO-61-6-1`: **stop when a round finds nothing that loses data or leaks a credential.** Round 4 found several, so the rule says continue. - Round 5 is the experiment that tells us whether the redesign worked. Either it comes back nearly empty (the rule fires on evidence and #61 leaves QA), or it does not — and then the honest answer is to ship `rm` as an explicitly UNGUARDED verb with a loud warning, rather than one carrying a safety claim it cannot keep. That second outcome is not reachable without running the round. **Scope for round 5:** the diff `78574f3..c4ae3f6`, weighted to the new certification path, the `flock` serialisation, and `push_file`. Push hardest on the three defects found by BUILDING rather than reviewing — an unreadable directory certifying as `empty`, the revert case, and the `/tmp` sweep that deleted a live snapshot — because their neighbours have had no reader at all. ## What round 4 changed (the design, not just fixes) `inspect_workspace` no longer looks for unsaved work. It tries to **certify there is none**, and returns exactly one of: | verdict | `rm` | |---|---| | `empty` — the volume holds nothing | proceeds | | `checked` — a repo, every question answered, plus what they turned up | proceeds only if nothing turned up | | `undetermined` — something here cannot be certified, and why | **refuses** | Why: "is there work here that exists nowhere else" is not decidable by a fixed list of git queries. Every round found another hiding place (detached HEAD, stash, stash without a reflog, linked worktree, local tag, gitignored file, repo one directory down). A guard built from a list is wrong by default, because anything not on the list reads as "nothing to lose". Also in round 4: the inspection volume is mounted **read-only** (a `core.fsmonitor` hook had written into the volume it was certifying); the forge login is serialised with **`flock` inside the container** (a host lock cannot cover a `container-init` run); `rm` takes the per-issue lock, refuses while a session runs, checks each volume separately, and gained `--discard-ignored` and `--force-unmanaged`. ## Round history | round | findings | notes | |---|---|---| | 1 | 34 | 8 were defects in `selftest.sh` itself | | 2 | 8 blocking promoted | fix round; one regression broke all 23 container criteria | | 3 | 15 | 8 were defects in the criteria round 2 wrote | | 4 | 18 | 4 blocking; 2 introduced by round 3's fixes; triggered the redesign | **The recurring lesson, three rounds running:** a criterion written in the same sitting as its fix encodes THE FIX, not THE PROPERTY. The only criterion that survived every mutation was `RV-4` — the one rewritten *after* a real failure rather than drafted alongside its fix. ## Key comment ids on issue #61 - **2320** — round-4 `qa-report:v1 domain=code phase=validate` (all 18 findings, `CR-50`..`CR-67`) - **2385** — round-4 `phase=fix` - **2386** — **PO-61-8**, carrying the open decision - 2271 / 2306 — round-3 validate / fix - 1924 — round-1 validate (the original `CR-*` findings) ## Deferred, already homed - **#353** — dw.sh hardening, 15 batched findings - **#259** — Claude OAuth refresh-token rotation across N containers - **#260** — `tea api` exits 0 on HTTP errors ## Operational facts that cost time - **Docker needs `sg docker -c '...'`** in a session whose process started before the group was granted. `selftest.sh` wraps its own calls. - **`selftest.sh` requires an argument** (`all`, or named criteria). A bare call prints usage and **exits 1**, which reads exactly like a red run. - A full sweep takes ~35 minutes and creates ~25 containers plus real forge scratch issues; it cleans up after itself. The unit-style criteria (`CR-14`, `CR-37`, `CR-38`, `CR-39`, `RV-4`) run in seconds. - **Never edit `.devcontainer/` while a run is in flight.** A running `bash` keeps reading the old inode and executes the previous version to completion, silently. - Reviewer subagents have twice gone idle **without sending their report**, which was sitting complete in their transcript. Check `~/.claude/projects/<proj>/<session>/subagents/*.jsonl` before asking. - A uniform whole-suite failure at the same step is a logic regression in `dw.sh`, not the environment — check the first failure's reason before debugging anything else.
Author
Owner
{
  "status": "resolved",
  "chosen_option": "confirmed",
  "rationale": "Resolved on the operator's explicit instruction this sitting (\"Let us proceed with round five\"), and it is what the recorded stopping rule requires: D-PO-61-6-1 says the sequence ends when a round finds nothing that loses data or leaks a credential, and round 4 found four blocking findings of exactly that kind, two of them introduced by round 3's own fixes. The checkable ground is the findings-per-round series 8, 15, 18 — rising, not converging — which is why round 4 changed the design of the rm guard rather than adding an eighth enumerated check. Round 5 measures whether that change worked, against the diff 78574f3..c4ae3f6.",
  "rejected_alternative": "Waived — land #61 on the strength of the 38/38 sweep at c4ae3f6. Turned down because it would close QA on the round whose own fixes are the least reviewed: the three most instructive defects of round 4 (an unreadable directory certifying as empty, the forge-login revert case, and a /tmp sweep that deleted a concurrent run's live snapshot) were all found by building the fix, not by reading it, so the code around them has had no reader at all. Waiving also forecloses the outcome that matters most if the redesign did not help — shipping rm as an explicitly unguarded verb with a loud warning is only reachable by running the round."
}

<!-- decision-resolution:v1 ref=D-PO-61-8-1 --> ```json { "status": "resolved", "chosen_option": "confirmed", "rationale": "Resolved on the operator's explicit instruction this sitting (\"Let us proceed with round five\"), and it is what the recorded stopping rule requires: D-PO-61-6-1 says the sequence ends when a round finds nothing that loses data or leaks a credential, and round 4 found four blocking findings of exactly that kind, two of them introduced by round 3's own fixes. The checkable ground is the findings-per-round series 8, 15, 18 — rising, not converging — which is why round 4 changed the design of the rm guard rather than adding an eighth enumerated check. Round 5 measures whether that change worked, against the diff 78574f3..c4ae3f6.", "rejected_alternative": "Waived — land #61 on the strength of the 38/38 sweep at c4ae3f6. Turned down because it would close QA on the round whose own fixes are the least reviewed: the three most instructive defects of round 4 (an unreadable directory certifying as empty, the forge-login revert case, and a /tmp sweep that deleted a concurrent run's live snapshot) were all found by building the fix, not by reading it, so the code around them has had no reader at all. Waiving also forecloses the outcome that matters most if the redesign did not help — shipping rm as an explicitly unguarded verb with a loud warning is only reachable by running the round." } ```
Author
Owner

{
"outcome": "issues-found",
"summary": "Round 5, three fresh-context reviewers on 78574f3..c4ae3f6: 5 blocking, 15 non-blocking. The credential half converged (0 blocking). The rm guard did not — two unread channels in the certification, and three criteria that stay green while a mutant destroys a volume or mounts the forge token into the inspection container.",
"findings": [
{
"id": "CR-69",
"category": "in-scope-blocking",
"severity": "critical",
"summary": "An unreadable SUBdirectory certifies as checked with no findings, and rm destroys the volume",
"reasoning": "dw.sh:944-957. Round 4 established that an unreadable TOP LEVEL must not certify as empty; one level down there is no such check. git status cannot open a directory it lacks read/search permission on, so it prints warning: could not open directory .../: Permission denied TO STDERR AND EXITS 0. set -eu sees nothing, no finding: line is emitted for that subtree, and verdict: checked is reached; cmd_rm prints inspect.err only when rc != 0, so the warning is discarded unseen. No root needed — a directory the session user owns at mode 000 is unreadable by its own owner. Reproduced end-to-end through the real verb: dw.sh rm 515002 exited 0 and the volume is GONE with private/urgent.txt on it. Independently re-confirmed host-side by the orchestrator: rc=0, empty stdout for the directory, warning on stderr only. Root cause shared with CR-70: the certification reads one channel (stdout) of one repository (the top level)."
},
{
"id": "CR-70",
"category": "in-scope-blocking",
"severity": "serious",
"summary": "A submodule holding a commit that exists nowhere else certifies as checked with no findings",
"reasoning": "dw.sh:944-990. Every question is asked of exactly one repository. git status reports a submodule only through its gitlink, so a submodule whose checked-out commit matches the superproject index reads clean regardless of what its own object store holds; G log --all --not --remotes walks the SUPERPROJECT refs and never enters .git/modules/. An untracked nested clone is caught as ?? dir/; a submodule is by construction not untracked, so nothing reports it. Reproduced by the reviewer and independently re-confirmed by the orchestrator on a separate setup: superproject status --porcelain EMPTY, log --all --not --remotes EMPTY, while the submodule holds commit fda12ce reachable from no remote. Likelihood is lower than CR-69 (this repo has no submodules today) but the guard is written as a general certification. Minimal fix: a .gitmodules or a non-empty .git/modules/ makes the verdict undetermined — the inspection need not learn to walk submodules, only to admit it cannot certify one."
},
{
"id": "CR-71",
"category": "in-scope-blocking",
"severity": "critical",
"summary": "CR-68 worktree case never exercises the linked-worktree traversal it is named for",
"reasoning": "selftest.sh:2603-2605 vs dw.sh:962-971. The shape builds the linked worktree INSIDE the main worktree at /workspace/side, so the top-level status already reports ?? side/ and that one finding is enough to refuse. Deleting the entire per-worktree traversal (sed 962,971d) leaves CR-68 GREEN. This matters because in this repo own working model linked worktrees live under .claude/worktrees/, which is GITIGNORED — the top-level status reports them as !!, --discard-ignored drops that finding, and the traversal is then the only thing between rm --discard-ignored and uncommitted work in a linked worktree. Demonstrated end-to-end: unmutated refuses (exit 2, volume survives); mutant removes the volume (exit 0) with only-here.txt gone. git worktree add appears exactly once in the whole 2875-line harness. Fix is to the CASE, not the code: seed the linked worktree at a gitignored path."
},
{
"id": "CR-72",
"category": "in-scope-blocking",
"severity": "critical",
"summary": "CR-15 counts only the space-separated spelling of docker injection flags, so a --volume=-spelled mount of the forge token passes green",
"reasoning": "selftest.sh:1655 (cr_argv_count uses grep -cxF, a whole-line fixed match) and :1666 (CR_INJECT_FLAGS), consumed at :2299-2302. Docker/pflag also accepts --volume=X, --mount=X, --env=X, --env-file=X, --device=X and the joined short forms -v/x:/y and -eFOO=1; none is a whole-line match. The criterion own comment describes exactly this bug class — round 4 added the separate word --volume and not the --volume= form, so the class survived the fix aimed at it. Demonstrated: adding --volume=$HOME/.config/dw:/home/vscode/.config/dw:ro to the inspection call leaves CR-15 GREEN, with the mount plainly in the recorded argv; the space-separated control IS caught, so it is a spelling gap and not a broken criterion. Both spellings really mount (verified against real docker). $HOME/.config/dw is where the container forge token lives (dw.sh:71), and CR-15 is the ONLY criterion asserting what enters the container that runs an unknown workspace git. Fix: match a prefix, not a whole line."
},
{
"id": "CR-73",
"category": "in-scope-blocking",
"severity": "critical",
"summary": "Nothing anywhere tests that --discard-ignored is narrow; widening it into a full --force keeps CR-68 green",
"reasoning": "dw.sh:1064-1069 vs selftest.sh:2735, the ONLY occurrence of --discard-ignored in the harness — and it is an acceptance case. The block own rule 3 (every refusal needs an acceptance case beside it) has no mirror: the one acceptance has no refusal beside it proving the flag does not swallow anything else. Replacing the filter with blocking=\"\" leaves CR-68 GREEN, and end-to-end the mutant destroys a volume carrying an ignored node_modules/ AND a real unpushed commit (exit 0, commit gone) where the unmutated tool refuses (exit 2, volume survives). The whole value of the narrower flag is that it is narrower, and that is the one property nothing asserts. Fix: one more cr68_case — the ignored shape plus an unpushed commit, run with --discard-ignored, expecting rc 2 and survival."
},
{
"id": "CR-74",
"category": "in-scope-deferrable",
"severity": "moderate",
"summary": "--not --remotes trusts a remote-tracking REF, not the remote itself",
"reasoning": "dw.sh:983. Remote-tracking refs are local files; nothing checks the remote still holds the commit or exists at all. Mechanism reproduced (origin set to /nonexistent.git with a hand-written refs/remotes/origin/main suppresses the unpushed finding), but no realistic route to a stale-but-populated remote-tracking ref exists in dw.sh own workflow — container-init.sh:274 clones from the host real forge origin. The safe direction holds where it matters: a repo with NO remotes reports every commit as unpushed. Becomes real only if a branch is force-deleted or rewritten on the forge after a push and before rm. Deferrable: mechanism real, reachable path unproven."
},
{
"id": "CR-75",
"category": "in-scope-deferrable",
"severity": "moderate",
"summary": "The selftest unreadable shape covers only the top level, which is why 38/38 coexists with CR-69",
"reasoning": "selftest.sh:2623-2626, 2649-2653, 2728. The shape chmod-000s .git/objects AND chmod-700s /workspace. It is NOT vacuous (verified: removing the top-level readability check turns it red and the volume is destroyed), but there is no shape for an unreadable SUBdirectory and none for a submodule. Rolls up into the fix for CR-69/CR-70 — the criterion additions are the same work item, listed separately so the coverage gap is on the record in its own right."
},
{
"id": "CR-76",
"category": "in-scope-deferrable",
"severity": "minor",
"summary": "A commit reachable only from the reflog produces no finding (reasoned, not reproduced)",
"reasoning": "--all walks refs, so a commit orphaned by git reset --hard is invisible to the certification. Arguably work the operator deliberately discarded, which is why it is not blocking. Honestly labelled: the reviewer did not build this case."
},
{
"id": "CR-77",
"category": "in-scope-deferrable",
"severity": "serious",
"summary": "A SIGTERM during the login swap deletes the snapshot before the restore reads it, leaving the container with no forge login",
"reasoning": "container-init.sh:183 (trap on EXIT INT TERM) versus the restore at :193-200. On SIGTERM bash kills the child, runs the handler (deleting the snapshot), and only then enters the failure branch that reads it. Reproduced with a stubbed tea under setsid: cp: cannot stat .../.dw-tea-bak.Sx7bKo then the loud this container has no working forge login now. It falsifies the invariant stated at :150-152. Deferrable rather than blocking: it is reported loudly with the right remedy, the token still exists on the host, nothing is destroyed that exists nowhere else, and the shipped path is docker exec without -t, where a host Ctrl-C is not forwarded and docker stop signals PID 1 rather than the process it started. Fix shape: restore-then-remove in the handler, or trap EXIT only."
},
{
"id": "CR-78",
"category": "in-scope-deferrable",
"severity": "moderate",
"summary": "CR-38 can no longer detect the defect its own comment describes, because round 4 flock removed the overlap it builds",
"reasoning": "selftest.sh:2420-2500. Instrumented timestamps show run B whole life is the 30ms after A releases the lock. Four-way mutation: pristine PASS; shared backup path with the lock kept PASS (the defect the comment names is masked); lock removed with per-run backup FAIL; both removed FAIL. Ranked deferrable rather than passes-on-broken-code because under the lock a shared backup path is genuinely safe — the mutant is not broken code. What is stale is the comment. Note the good news in the same experiment: CR-38 part 2 DOES catch removal of the flock, which matters because the string flock appears nowhere in selftest.sh."
},
{
"id": "CR-79",
"category": "in-scope-deferrable",
"severity": "moderate",
"summary": "No criterion covers a signal arriving during the login swap",
"reasoning": "Signal assertions in the harness cover container stop only (~selftest.sh:2056-2093). container-init.sh:174-177 claims the signal path is handled; CR-77 shows it is not. Pairs with CR-77 as one work item."
},
{
"id": "CR-80",
"category": "in-scope-deferrable",
"severity": "moderate",
"summary": "The restore staging file $cfg.dw-restore.$$ is a plaintext token copy that nothing cleans up",
"reasoning": "container-init.sh:196. Not named by the trap and not matched by the age sweep at :179 (-name .dw-tea-bak.*). A SIGKILL between the cp -p and the mv -f leaves it in ~/.config/tea/ permanently. Reproduced against the sweep verbatim: survivor config.yml.dw-restore.12345. Same directory and mode as the config it stages, so residue rather than new exposure."
},
{
"id": "CR-81",
"category": "pre-existing",
"severity": "moderate",
"summary": "The forge token is passed in argv, and /proc//cmdline is world-readable",
"reasoning": "dw.sh:400, :424, :647 and container-init.sh:190. Reproduced with a fake token: cmdline perms 444, token plainly visible, hidepid not set. Pre-existing — git log -S dates it to a046148 (WU-61-3-2), before this diff. Mitigated by a single-operator workstation and a least-privilege token (asserted by assert_token_least_privilege). Recorded because push_file goes to real trouble to keep the same token off docker exec -e and out of argv, so the two paths disagree about their own threat model."
},
{
"id": "CR-82",
"category": "in-scope-deferrable",
"severity": "minor",
"summary": "The stated reason for placing the login snapshot beside the config is factually wrong",
"reasoning": "container-init.sh:166-171 argues the snapshot must not live in /tmp because /tmp is the container writable layer, outside both volumes dw.sh tracks. But create_container mounts exactly two volumes (dw.sh:81, :691-693), and ~/.config/tea/ is a sibling of .claude — so it is the writable layer too, exactly like /tmp. The placement is still right; the justification is not, and a wrong justification is what a later reader will reason from."
},
{
"id": "CR-83",
"category": "in-scope-deferrable",
"severity": "minor",
"summary": "push_file creates directories outside the home bound before refusing",
"reasoning": "dw.sh:274-276: mkdir -p runs before the resolved-parent bound is applied. Reproduced: rc=1 and no content written, but out7/created/deep now exists. Empty directories only; cosmetic."
},
{
"id": "CR-84",
"category": "in-scope-deferrable",
"severity": "moderate",
"summary": "A truncated input stream is renamed into place and reported as success (script level only; not reproduced end-to-end)",
"reasoning": "dw.sh:303-304: cat > $t; mv -f $t $DW_DEST, and cat returns 0 when the producer closes early. Reproduces at script level through a fifo (22 bytes installed, rc=0) but the reviewer could NOT reproduce a short stream through docker exec -i with a plain-file redirect, so the reachable half is unproven and is labelled as such. Every real caller is covered downstream anyway (jq -e plus a live probe; tea login add plus probe_forge_token; git ls-remote). A size check after the rename would close it cheaply."
},
{
"id": "CR-85",
"category": "in-scope-deferrable",
"severity": "minor",
"summary": "The container-side flock has no timeout and prints nothing while waiting; fd 9 is inherited by children",
"reasoning": "container-init.sh:127-128. Verified in the real image: a child sees fd 9, and a child outliving the shell keeps the lock even after exec 9>&-. Nothing in the locked function daemonizes, so that half is theoretical. The practical half is that flock 9 blocks forever with no -w and no message, so an up meeting a stuck lock hangs unexplained — unlike the host lock at dw.sh:124-142, which warns, times out at 600s and names the remedy."
},
{
"id": "CR-86",
"category": "in-scope-deferrable",
"severity": "minor",
"summary": "CR-68 dangling-git case is a duplicate of files-no-repo and buys no coverage",
"reasoning": "selftest.sh:2599-2602. The seed makes .git a symlink to a non-existent path, so [ -e .git ] (which follows symlinks) is false and the guard takes the same branch as files-no-repo, printing an identical reason — confirmed in the baseline log at CR-68.log:6 and :18. Redundant rather than vacuous: one mutation kills both, and it costs a container per run. If the intent was a repository git cannot open, the shape needs a .git that EXISTS and is corrupt, which reaches G rev-parse --is-inside-work-tree."
},
{
"id": "CR-87",
"category": "in-scope-deferrable",
"severity": "minor",
"summary": "cr68_case scratch issue numbers collide between shapes",
"reasoning": "selftest.sh:2637 derives the number from two cksum characters: in the baseline run dangling-git and clean both got 9776032, ignored and unreadable both got 9776026. Harmless today because cases run strictly in sequence and each removes its volume first, but the criterion can never be parallelised and a future reordering that leaves a volume behind would have one shape inspecting another workspace. Cheap fix: append the shape name instead of a 2-char hash."
},
{
"id": "CR-88",
"category": "out-of-scope",
"severity": "minor",
"summary": "A stray unlabelled dw--workspace volume from an earlier round is still on the box",
"reasoning": "docker volume ls shows dw--workspace created 2026-08-26, no labels. It cannot come from current dw.sh — require_issue (dw.sh:152-157) rejects an empty argument — so it is residue from a hand-run docker volume create in an earlier round. Housekeeping on the operator box, not a defect in the deliverable; recorded so it gets swept rather than puzzled over later."
}
],
"artifacts": {
"report_path": ".devwork/feature-devcontainer-plugin-era-refresh/qa-round-5/",
"test_files": [
".devcontainer/selftest.sh"
]
}
}

<!-- qa-report:v1 issue=61 skill=qa domain=code phase=validate --> { "outcome": "issues-found", "summary": "Round 5, three fresh-context reviewers on 78574f3..c4ae3f6: 5 blocking, 15 non-blocking. The credential half converged (0 blocking). The rm guard did not — two unread channels in the certification, and three criteria that stay green while a mutant destroys a volume or mounts the forge token into the inspection container.", "findings": [ { "id": "CR-69", "category": "in-scope-blocking", "severity": "critical", "summary": "An unreadable SUBdirectory certifies as `checked` with no findings, and rm destroys the volume", "reasoning": "dw.sh:944-957. Round 4 established that an unreadable TOP LEVEL must not certify as `empty`; one level down there is no such check. `git status` cannot open a directory it lacks read/search permission on, so it prints `warning: could not open directory .../: Permission denied` TO STDERR AND EXITS 0. set -eu sees nothing, no finding: line is emitted for that subtree, and `verdict: checked` is reached; cmd_rm prints inspect.err only when rc != 0, so the warning is discarded unseen. No root needed — a directory the session user owns at mode 000 is unreadable by its own owner. Reproduced end-to-end through the real verb: `dw.sh rm 515002` exited 0 and the volume is GONE with private/urgent.txt on it. Independently re-confirmed host-side by the orchestrator: rc=0, empty stdout for the directory, warning on stderr only. Root cause shared with CR-70: the certification reads one channel (stdout) of one repository (the top level)." }, { "id": "CR-70", "category": "in-scope-blocking", "severity": "serious", "summary": "A submodule holding a commit that exists nowhere else certifies as `checked` with no findings", "reasoning": "dw.sh:944-990. Every question is asked of exactly one repository. `git status` reports a submodule only through its gitlink, so a submodule whose checked-out commit matches the superproject index reads clean regardless of what its own object store holds; `G log --all --not --remotes` walks the SUPERPROJECT refs and never enters .git/modules/<name>. An untracked nested clone is caught as `?? dir/`; a submodule is by construction not untracked, so nothing reports it. Reproduced by the reviewer and independently re-confirmed by the orchestrator on a separate setup: superproject status --porcelain EMPTY, log --all --not --remotes EMPTY, while the submodule holds commit fda12ce reachable from no remote. Likelihood is lower than CR-69 (this repo has no submodules today) but the guard is written as a general certification. Minimal fix: a .gitmodules or a non-empty .git/modules/ makes the verdict `undetermined` — the inspection need not learn to walk submodules, only to admit it cannot certify one." }, { "id": "CR-71", "category": "in-scope-blocking", "severity": "critical", "summary": "CR-68 worktree case never exercises the linked-worktree traversal it is named for", "reasoning": "selftest.sh:2603-2605 vs dw.sh:962-971. The shape builds the linked worktree INSIDE the main worktree at /workspace/side, so the top-level status already reports `?? side/` and that one finding is enough to refuse. Deleting the entire per-worktree traversal (`sed 962,971d`) leaves CR-68 GREEN. This matters because in this repo own working model linked worktrees live under .claude/worktrees/, which is GITIGNORED — the top-level status reports them as `!!`, --discard-ignored drops that finding, and the traversal is then the only thing between `rm --discard-ignored` and uncommitted work in a linked worktree. Demonstrated end-to-end: unmutated refuses (exit 2, volume survives); mutant removes the volume (exit 0) with only-here.txt gone. `git worktree add` appears exactly once in the whole 2875-line harness. Fix is to the CASE, not the code: seed the linked worktree at a gitignored path." }, { "id": "CR-72", "category": "in-scope-blocking", "severity": "critical", "summary": "CR-15 counts only the space-separated spelling of docker injection flags, so a `--volume=`-spelled mount of the forge token passes green", "reasoning": "selftest.sh:1655 (cr_argv_count uses `grep -cxF`, a whole-line fixed match) and :1666 (CR_INJECT_FLAGS), consumed at :2299-2302. Docker/pflag also accepts --volume=X, --mount=X, --env=X, --env-file=X, --device=X and the joined short forms -v/x:/y and -eFOO=1; none is a whole-line match. The criterion own comment describes exactly this bug class — round 4 added the separate word --volume and not the --volume= form, so the class survived the fix aimed at it. Demonstrated: adding `--volume=$HOME/.config/dw:/home/vscode/.config/dw:ro` to the inspection call leaves CR-15 GREEN, with the mount plainly in the recorded argv; the space-separated control IS caught, so it is a spelling gap and not a broken criterion. Both spellings really mount (verified against real docker). $HOME/.config/dw is where the container forge token lives (dw.sh:71), and CR-15 is the ONLY criterion asserting what enters the container that runs an unknown workspace git. Fix: match a prefix, not a whole line." }, { "id": "CR-73", "category": "in-scope-blocking", "severity": "critical", "summary": "Nothing anywhere tests that --discard-ignored is narrow; widening it into a full --force keeps CR-68 green", "reasoning": "dw.sh:1064-1069 vs selftest.sh:2735, the ONLY occurrence of --discard-ignored in the harness — and it is an acceptance case. The block own rule 3 (every refusal needs an acceptance case beside it) has no mirror: the one acceptance has no refusal beside it proving the flag does not swallow anything else. Replacing the filter with `blocking=\"\"` leaves CR-68 GREEN, and end-to-end the mutant destroys a volume carrying an ignored node_modules/ AND a real unpushed commit (exit 0, commit gone) where the unmutated tool refuses (exit 2, volume survives). The whole value of the narrower flag is that it is narrower, and that is the one property nothing asserts. Fix: one more cr68_case — the ignored shape plus an unpushed commit, run with --discard-ignored, expecting rc 2 and survival." }, { "id": "CR-74", "category": "in-scope-deferrable", "severity": "moderate", "summary": "--not --remotes trusts a remote-tracking REF, not the remote itself", "reasoning": "dw.sh:983. Remote-tracking refs are local files; nothing checks the remote still holds the commit or exists at all. Mechanism reproduced (origin set to /nonexistent.git with a hand-written refs/remotes/origin/main suppresses the unpushed finding), but no realistic route to a stale-but-populated remote-tracking ref exists in dw.sh own workflow — container-init.sh:274 clones from the host real forge origin. The safe direction holds where it matters: a repo with NO remotes reports every commit as unpushed. Becomes real only if a branch is force-deleted or rewritten on the forge after a push and before rm. Deferrable: mechanism real, reachable path unproven." }, { "id": "CR-75", "category": "in-scope-deferrable", "severity": "moderate", "summary": "The selftest `unreadable` shape covers only the top level, which is why 38/38 coexists with CR-69", "reasoning": "selftest.sh:2623-2626, 2649-2653, 2728. The shape chmod-000s .git/objects AND chmod-700s /workspace. It is NOT vacuous (verified: removing the top-level readability check turns it red and the volume is destroyed), but there is no shape for an unreadable SUBdirectory and none for a submodule. Rolls up into the fix for CR-69/CR-70 — the criterion additions are the same work item, listed separately so the coverage gap is on the record in its own right." }, { "id": "CR-76", "category": "in-scope-deferrable", "severity": "minor", "summary": "A commit reachable only from the reflog produces no finding (reasoned, not reproduced)", "reasoning": "--all walks refs, so a commit orphaned by `git reset --hard` is invisible to the certification. Arguably work the operator deliberately discarded, which is why it is not blocking. Honestly labelled: the reviewer did not build this case." }, { "id": "CR-77", "category": "in-scope-deferrable", "severity": "serious", "summary": "A SIGTERM during the login swap deletes the snapshot before the restore reads it, leaving the container with no forge login", "reasoning": "container-init.sh:183 (trap on EXIT INT TERM) versus the restore at :193-200. On SIGTERM bash kills the child, runs the handler (deleting the snapshot), and only then enters the failure branch that reads it. Reproduced with a stubbed tea under setsid: `cp: cannot stat .../.dw-tea-bak.Sx7bKo` then the loud `this container has no working forge login now`. It falsifies the invariant stated at :150-152. Deferrable rather than blocking: it is reported loudly with the right remedy, the token still exists on the host, nothing is destroyed that exists nowhere else, and the shipped path is `docker exec` without -t, where a host Ctrl-C is not forwarded and `docker stop` signals PID 1 rather than the process it started. Fix shape: restore-then-remove in the handler, or trap EXIT only." }, { "id": "CR-78", "category": "in-scope-deferrable", "severity": "moderate", "summary": "CR-38 can no longer detect the defect its own comment describes, because round 4 flock removed the overlap it builds", "reasoning": "selftest.sh:2420-2500. Instrumented timestamps show run B whole life is the 30ms after A releases the lock. Four-way mutation: pristine PASS; shared backup path with the lock kept PASS (the defect the comment names is masked); lock removed with per-run backup FAIL; both removed FAIL. Ranked deferrable rather than passes-on-broken-code because under the lock a shared backup path is genuinely safe — the mutant is not broken code. What is stale is the comment. Note the good news in the same experiment: CR-38 part 2 DOES catch removal of the flock, which matters because the string flock appears nowhere in selftest.sh." }, { "id": "CR-79", "category": "in-scope-deferrable", "severity": "moderate", "summary": "No criterion covers a signal arriving during the login swap", "reasoning": "Signal assertions in the harness cover container stop only (~selftest.sh:2056-2093). container-init.sh:174-177 claims the signal path is handled; CR-77 shows it is not. Pairs with CR-77 as one work item." }, { "id": "CR-80", "category": "in-scope-deferrable", "severity": "moderate", "summary": "The restore staging file $cfg.dw-restore.$$ is a plaintext token copy that nothing cleans up", "reasoning": "container-init.sh:196. Not named by the trap and not matched by the age sweep at :179 (-name .dw-tea-bak.*). A SIGKILL between the cp -p and the mv -f leaves it in ~/.config/tea/ permanently. Reproduced against the sweep verbatim: survivor config.yml.dw-restore.12345. Same directory and mode as the config it stages, so residue rather than new exposure." }, { "id": "CR-81", "category": "pre-existing", "severity": "moderate", "summary": "The forge token is passed in argv, and /proc/<pid>/cmdline is world-readable", "reasoning": "dw.sh:400, :424, :647 and container-init.sh:190. Reproduced with a fake token: cmdline perms 444, token plainly visible, hidepid not set. Pre-existing — `git log -S` dates it to a046148 (WU-61-3-2), before this diff. Mitigated by a single-operator workstation and a least-privilege token (asserted by assert_token_least_privilege). Recorded because push_file goes to real trouble to keep the same token off docker exec -e and out of argv, so the two paths disagree about their own threat model." }, { "id": "CR-82", "category": "in-scope-deferrable", "severity": "minor", "summary": "The stated reason for placing the login snapshot beside the config is factually wrong", "reasoning": "container-init.sh:166-171 argues the snapshot must not live in /tmp because /tmp is the container writable layer, outside both volumes dw.sh tracks. But create_container mounts exactly two volumes (dw.sh:81, :691-693), and ~/.config/tea/ is a sibling of .claude — so it is the writable layer too, exactly like /tmp. The placement is still right; the justification is not, and a wrong justification is what a later reader will reason from." }, { "id": "CR-83", "category": "in-scope-deferrable", "severity": "minor", "summary": "push_file creates directories outside the home bound before refusing", "reasoning": "dw.sh:274-276: mkdir -p runs before the resolved-parent bound is applied. Reproduced: rc=1 and no content written, but out7/created/deep now exists. Empty directories only; cosmetic." }, { "id": "CR-84", "category": "in-scope-deferrable", "severity": "moderate", "summary": "A truncated input stream is renamed into place and reported as success (script level only; not reproduced end-to-end)", "reasoning": "dw.sh:303-304: `cat > $t; mv -f $t $DW_DEST`, and cat returns 0 when the producer closes early. Reproduces at script level through a fifo (22 bytes installed, rc=0) but the reviewer could NOT reproduce a short stream through `docker exec -i` with a plain-file redirect, so the reachable half is unproven and is labelled as such. Every real caller is covered downstream anyway (jq -e plus a live probe; tea login add plus probe_forge_token; git ls-remote). A size check after the rename would close it cheaply." }, { "id": "CR-85", "category": "in-scope-deferrable", "severity": "minor", "summary": "The container-side flock has no timeout and prints nothing while waiting; fd 9 is inherited by children", "reasoning": "container-init.sh:127-128. Verified in the real image: a child sees fd 9, and a child outliving the shell keeps the lock even after `exec 9>&-`. Nothing in the locked function daemonizes, so that half is theoretical. The practical half is that `flock 9` blocks forever with no -w and no message, so an `up` meeting a stuck lock hangs unexplained — unlike the host lock at dw.sh:124-142, which warns, times out at 600s and names the remedy." }, { "id": "CR-86", "category": "in-scope-deferrable", "severity": "minor", "summary": "CR-68 dangling-git case is a duplicate of files-no-repo and buys no coverage", "reasoning": "selftest.sh:2599-2602. The seed makes .git a symlink to a non-existent path, so `[ -e .git ]` (which follows symlinks) is false and the guard takes the same branch as files-no-repo, printing an identical reason — confirmed in the baseline log at CR-68.log:6 and :18. Redundant rather than vacuous: one mutation kills both, and it costs a container per run. If the intent was a repository git cannot open, the shape needs a .git that EXISTS and is corrupt, which reaches G rev-parse --is-inside-work-tree." }, { "id": "CR-87", "category": "in-scope-deferrable", "severity": "minor", "summary": "cr68_case scratch issue numbers collide between shapes", "reasoning": "selftest.sh:2637 derives the number from two cksum characters: in the baseline run dangling-git and clean both got 9776032, ignored and unreadable both got 9776026. Harmless today because cases run strictly in sequence and each removes its volume first, but the criterion can never be parallelised and a future reordering that leaves a volume behind would have one shape inspecting another workspace. Cheap fix: append the shape name instead of a 2-char hash." }, { "id": "CR-88", "category": "out-of-scope", "severity": "minor", "summary": "A stray unlabelled dw--workspace volume from an earlier round is still on the box", "reasoning": "docker volume ls shows dw--workspace created 2026-08-26, no labels. It cannot come from current dw.sh — require_issue (dw.sh:152-157) rejects an empty argument — so it is residue from a hand-run docker volume create in an earlier round. Housekeeping on the operator box, not a defect in the deliverable; recorded so it gets swept rather than puzzled over later." } ], "artifacts": { "report_path": ".devwork/feature-devcontainer-plugin-era-refresh/qa-round-5/", "test_files": [ ".devcontainer/selftest.sh" ] } }
Author
Owner
{
  "next_state": "qa",
  "produced": [],
  "findings": [
    {
      "id": "F-PO-61-9-1",
      "category": "in-scope-blocking",
      "severity": "critical",
      "summary": "An unreadable SUBdirectory certifies as `checked` with no findings, and rm destroys the volume",
      "reasoning": "dw.sh:944-957. Round 4 established that an unreadable TOP LEVEL must not certify as `empty`; one level down there is no such check. `git status` cannot open a directory it lacks read/search permission on, so it prints `warning: could not open directory .../: Permission denied` TO STDERR AND EXITS 0. set -eu sees nothing, no finding: line is emitted for that subtree, and `verdict: checked` is reached; cmd_rm prints inspect.err only when rc != 0, so the warning is discarded unseen. No root needed — a directory the session user owns at mode 000 is unreadable by its own owner. Reproduced end-to-end through the real verb: `dw.sh rm 515002` exited 0 and the volume is GONE with private/urgent.txt on it. Independently re-confirmed host-side by the orchestrator: rc=0, empty stdout for the directory, warning on stderr only. Root cause shared with CR-70: the certification reads one channel (stdout) of one repository (the top level)."
    },
    {
      "id": "F-PO-61-9-2",
      "category": "in-scope-blocking",
      "severity": "serious",
      "summary": "A submodule holding a commit that exists nowhere else certifies as `checked` with no findings",
      "reasoning": "dw.sh:944-990. Every question is asked of exactly one repository. `git status` reports a submodule only through its gitlink, so a submodule whose checked-out commit matches the superproject index reads clean regardless of what its own object store holds; `G log --all --not --remotes` walks the SUPERPROJECT refs and never enters .git/modules/<name>. An untracked nested clone is caught as `?? dir/`; a submodule is by construction not untracked, so nothing reports it. Reproduced by the reviewer and independently re-confirmed by the orchestrator on a separate setup: superproject status --porcelain EMPTY, log --all --not --remotes EMPTY, while the submodule holds commit fda12ce reachable from no remote. Likelihood is lower than CR-69 (this repo has no submodules today) but the guard is written as a general certification. Minimal fix: a .gitmodules or a non-empty .git/modules/ makes the verdict `undetermined` — the inspection need not learn to walk submodules, only to admit it cannot certify one."
    },
    {
      "id": "F-PO-61-9-3",
      "category": "in-scope-blocking",
      "severity": "critical",
      "summary": "CR-68 worktree case never exercises the linked-worktree traversal it is named for",
      "reasoning": "selftest.sh:2603-2605 vs dw.sh:962-971. The shape builds the linked worktree INSIDE the main worktree at /workspace/side, so the top-level status already reports `?? side/` and that one finding is enough to refuse. Deleting the entire per-worktree traversal (`sed 962,971d`) leaves CR-68 GREEN. This matters because in this repo own working model linked worktrees live under .claude/worktrees/, which is GITIGNORED — the top-level status reports them as `!!`, --discard-ignored drops that finding, and the traversal is then the only thing between `rm --discard-ignored` and uncommitted work in a linked worktree. Demonstrated end-to-end: unmutated refuses (exit 2, volume survives); mutant removes the volume (exit 0) with only-here.txt gone. `git worktree add` appears exactly once in the whole 2875-line harness. Fix is to the CASE, not the code: seed the linked worktree at a gitignored path."
    },
    {
      "id": "F-PO-61-9-4",
      "category": "in-scope-blocking",
      "severity": "critical",
      "summary": "CR-15 counts only the space-separated spelling of docker injection flags, so a `--volume=`-spelled mount of the forge token passes green",
      "reasoning": "selftest.sh:1655 (cr_argv_count uses `grep -cxF`, a whole-line fixed match) and :1666 (CR_INJECT_FLAGS), consumed at :2299-2302. Docker/pflag also accepts --volume=X, --mount=X, --env=X, --env-file=X, --device=X and the joined short forms -v/x:/y and -eFOO=1; none is a whole-line match. The criterion own comment describes exactly this bug class — round 4 added the separate word --volume and not the --volume= form, so the class survived the fix aimed at it. Demonstrated: adding `--volume=$HOME/.config/dw:/home/vscode/.config/dw:ro` to the inspection call leaves CR-15 GREEN, with the mount plainly in the recorded argv; the space-separated control IS caught, so it is a spelling gap and not a broken criterion. Both spellings really mount (verified against real docker). $HOME/.config/dw is where the container forge token lives (dw.sh:71), and CR-15 is the ONLY criterion asserting what enters the container that runs an unknown workspace git. Fix: match a prefix, not a whole line."
    },
    {
      "id": "F-PO-61-9-5",
      "category": "in-scope-blocking",
      "severity": "critical",
      "summary": "Nothing anywhere tests that --discard-ignored is narrow; widening it into a full --force keeps CR-68 green",
      "reasoning": "dw.sh:1064-1069 vs selftest.sh:2735, the ONLY occurrence of --discard-ignored in the harness — and it is an acceptance case. The block own rule 3 (every refusal needs an acceptance case beside it) has no mirror: the one acceptance has no refusal beside it proving the flag does not swallow anything else. Replacing the filter with `blocking=\"\"` leaves CR-68 GREEN, and end-to-end the mutant destroys a volume carrying an ignored node_modules/ AND a real unpushed commit (exit 0, commit gone) where the unmutated tool refuses (exit 2, volume survives). The whole value of the narrower flag is that it is narrower, and that is the one property nothing asserts. Fix: one more cr68_case — the ignored shape plus an unpushed commit, run with --discard-ignored, expecting rc 2 and survival."
    }
  ],
  "pending_decisions": [
    {
      "id": "D-PO-61-9-1",
      "type": "precondition",
      "blocking": true,
      "question": "Round 5 found five blocking defects, three of them demonstrated end-to-end as a destroyed volume or the forge token mounted into the inspection container. The credential half of the feature came back clean. Should we stop trying to make the rm guard decide correctly on its own, and instead make it report what it found and require the operator to confirm before it destroys anything (confirmed) — or keep the current design, fix the five findings in place, and run a sixth review round (waived)?",
      "options": [
        "confirmed",
        "waived"
      ],
      "recommended": "confirmed",
      "reasoning": "Recommend confirmed. What five rounds have actually established is narrower and more useful than \"the guard is still buggy\": every round has found the same defect in a new place, and the place is always a question the list of git queries did not ask. Round 4 renamed the approach to \"certify, do not enumerate\", but the implementation still asks a fixed list of questions and then prints the word \"checked\", which claims a completeness a fixed list cannot deliver. Round 5 found two more (CR-69: git exits 0 and says \"could not open directory\" on stderr, a channel nothing reads; CR-70: every question is asked of the top-level repository only, so a submodule is invisible). Neither was on anyone list, and there is no reason to believe the next round would not find a third.\n\nThe harness findings say the same thing from the other side, and they are the stronger evidence. CR-71, CR-72 and CR-73 are three criteria that stay GREEN while a mutant destroys a volume holding work that exists nowhere else, or mounts the host forge token into the container that runs an unknown workspace git. The suite has read 38/38 for two rounds while those holes were open. That is what happens when you try to test a property that is not testable: \"the certification is complete\" has no finite test, so each criterion ends up testing the particular hiding place its author had in mind, which is exactly the recurring lesson recorded on D-PO-61-6-1 and again on PO-61-8.\n\nConfirmed changes the property to one that IS testable. If rm never destroys without an explicit operator confirmation, then the thing to test is \"does rm ever destroy without confirmation\", which is one small assertion with a handful of cases, not an open-ended search. The inspection keeps all of its value — it is what tells the operator what is on the volume — but it stops being the last word, and its message can say honestly what it checked and that it cannot promise there is nothing else. A hiding place nobody thought of then costs an incomplete report instead of silent destruction. It also deletes surface: --discard-ignored exists so that getting past build output does not mean reaching for --force, and under confirm-before-destroy the operator simply sees the ignored findings and confirms, so the flag and the whole CR-73 hole can go away.\n\nScope if confirmed: make rm confirm before destroying; fix CR-69 (non-empty git stderr becomes undetermined — verified against seven benign repo shapes, all silent, so this is a general rule and not another list entry) and CR-70 (a .gitmodules or non-empty .git/modules/ becomes undetermined); fix the three criteria, including the CR-72 prefix match that is a real credential-leak hole in the only criterion guarding the inspection container; batch the fifteen non-blocking findings to #353. Then one sweep, and out of QA.\n\nWaived is defensible if the judgement is that confirm-before-destroy is worse ergonomics for unattended container use, which is the whole point of #61 — a prompt no one is there to answer stops an automated run. That is the real argument against, and it deserves an answer: the answer is that rm is an operator verb, not something an unattended session calls, and a --force path stays for scripts. But if the intent is that unattended cleanup runs rm itself, then confirmed is wrong and waived plus a sixth round is the honest choice.\n\nStopping now and shipping as-is was considered and rejected: CR-69 is not a mutant, it is live in the code at c4ae3f6, and it silently destroyed a real volume during this round."
    }
  ],
  "suite": {
    "source": "git",
    "sha": "bd2532c494039780d8af631752e0b22bfbb2cdbd",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-61-9 skill=qa --> ```json { "next_state": "qa", "produced": [], "findings": [ { "id": "F-PO-61-9-1", "category": "in-scope-blocking", "severity": "critical", "summary": "An unreadable SUBdirectory certifies as `checked` with no findings, and rm destroys the volume", "reasoning": "dw.sh:944-957. Round 4 established that an unreadable TOP LEVEL must not certify as `empty`; one level down there is no such check. `git status` cannot open a directory it lacks read/search permission on, so it prints `warning: could not open directory .../: Permission denied` TO STDERR AND EXITS 0. set -eu sees nothing, no finding: line is emitted for that subtree, and `verdict: checked` is reached; cmd_rm prints inspect.err only when rc != 0, so the warning is discarded unseen. No root needed — a directory the session user owns at mode 000 is unreadable by its own owner. Reproduced end-to-end through the real verb: `dw.sh rm 515002` exited 0 and the volume is GONE with private/urgent.txt on it. Independently re-confirmed host-side by the orchestrator: rc=0, empty stdout for the directory, warning on stderr only. Root cause shared with CR-70: the certification reads one channel (stdout) of one repository (the top level)." }, { "id": "F-PO-61-9-2", "category": "in-scope-blocking", "severity": "serious", "summary": "A submodule holding a commit that exists nowhere else certifies as `checked` with no findings", "reasoning": "dw.sh:944-990. Every question is asked of exactly one repository. `git status` reports a submodule only through its gitlink, so a submodule whose checked-out commit matches the superproject index reads clean regardless of what its own object store holds; `G log --all --not --remotes` walks the SUPERPROJECT refs and never enters .git/modules/<name>. An untracked nested clone is caught as `?? dir/`; a submodule is by construction not untracked, so nothing reports it. Reproduced by the reviewer and independently re-confirmed by the orchestrator on a separate setup: superproject status --porcelain EMPTY, log --all --not --remotes EMPTY, while the submodule holds commit fda12ce reachable from no remote. Likelihood is lower than CR-69 (this repo has no submodules today) but the guard is written as a general certification. Minimal fix: a .gitmodules or a non-empty .git/modules/ makes the verdict `undetermined` — the inspection need not learn to walk submodules, only to admit it cannot certify one." }, { "id": "F-PO-61-9-3", "category": "in-scope-blocking", "severity": "critical", "summary": "CR-68 worktree case never exercises the linked-worktree traversal it is named for", "reasoning": "selftest.sh:2603-2605 vs dw.sh:962-971. The shape builds the linked worktree INSIDE the main worktree at /workspace/side, so the top-level status already reports `?? side/` and that one finding is enough to refuse. Deleting the entire per-worktree traversal (`sed 962,971d`) leaves CR-68 GREEN. This matters because in this repo own working model linked worktrees live under .claude/worktrees/, which is GITIGNORED — the top-level status reports them as `!!`, --discard-ignored drops that finding, and the traversal is then the only thing between `rm --discard-ignored` and uncommitted work in a linked worktree. Demonstrated end-to-end: unmutated refuses (exit 2, volume survives); mutant removes the volume (exit 0) with only-here.txt gone. `git worktree add` appears exactly once in the whole 2875-line harness. Fix is to the CASE, not the code: seed the linked worktree at a gitignored path." }, { "id": "F-PO-61-9-4", "category": "in-scope-blocking", "severity": "critical", "summary": "CR-15 counts only the space-separated spelling of docker injection flags, so a `--volume=`-spelled mount of the forge token passes green", "reasoning": "selftest.sh:1655 (cr_argv_count uses `grep -cxF`, a whole-line fixed match) and :1666 (CR_INJECT_FLAGS), consumed at :2299-2302. Docker/pflag also accepts --volume=X, --mount=X, --env=X, --env-file=X, --device=X and the joined short forms -v/x:/y and -eFOO=1; none is a whole-line match. The criterion own comment describes exactly this bug class — round 4 added the separate word --volume and not the --volume= form, so the class survived the fix aimed at it. Demonstrated: adding `--volume=$HOME/.config/dw:/home/vscode/.config/dw:ro` to the inspection call leaves CR-15 GREEN, with the mount plainly in the recorded argv; the space-separated control IS caught, so it is a spelling gap and not a broken criterion. Both spellings really mount (verified against real docker). $HOME/.config/dw is where the container forge token lives (dw.sh:71), and CR-15 is the ONLY criterion asserting what enters the container that runs an unknown workspace git. Fix: match a prefix, not a whole line." }, { "id": "F-PO-61-9-5", "category": "in-scope-blocking", "severity": "critical", "summary": "Nothing anywhere tests that --discard-ignored is narrow; widening it into a full --force keeps CR-68 green", "reasoning": "dw.sh:1064-1069 vs selftest.sh:2735, the ONLY occurrence of --discard-ignored in the harness — and it is an acceptance case. The block own rule 3 (every refusal needs an acceptance case beside it) has no mirror: the one acceptance has no refusal beside it proving the flag does not swallow anything else. Replacing the filter with `blocking=\"\"` leaves CR-68 GREEN, and end-to-end the mutant destroys a volume carrying an ignored node_modules/ AND a real unpushed commit (exit 0, commit gone) where the unmutated tool refuses (exit 2, volume survives). The whole value of the narrower flag is that it is narrower, and that is the one property nothing asserts. Fix: one more cr68_case — the ignored shape plus an unpushed commit, run with --discard-ignored, expecting rc 2 and survival." } ], "pending_decisions": [ { "id": "D-PO-61-9-1", "type": "precondition", "blocking": true, "question": "Round 5 found five blocking defects, three of them demonstrated end-to-end as a destroyed volume or the forge token mounted into the inspection container. The credential half of the feature came back clean. Should we stop trying to make the rm guard decide correctly on its own, and instead make it report what it found and require the operator to confirm before it destroys anything (confirmed) — or keep the current design, fix the five findings in place, and run a sixth review round (waived)?", "options": [ "confirmed", "waived" ], "recommended": "confirmed", "reasoning": "Recommend confirmed. What five rounds have actually established is narrower and more useful than \"the guard is still buggy\": every round has found the same defect in a new place, and the place is always a question the list of git queries did not ask. Round 4 renamed the approach to \"certify, do not enumerate\", but the implementation still asks a fixed list of questions and then prints the word \"checked\", which claims a completeness a fixed list cannot deliver. Round 5 found two more (CR-69: git exits 0 and says \"could not open directory\" on stderr, a channel nothing reads; CR-70: every question is asked of the top-level repository only, so a submodule is invisible). Neither was on anyone list, and there is no reason to believe the next round would not find a third.\n\nThe harness findings say the same thing from the other side, and they are the stronger evidence. CR-71, CR-72 and CR-73 are three criteria that stay GREEN while a mutant destroys a volume holding work that exists nowhere else, or mounts the host forge token into the container that runs an unknown workspace git. The suite has read 38/38 for two rounds while those holes were open. That is what happens when you try to test a property that is not testable: \"the certification is complete\" has no finite test, so each criterion ends up testing the particular hiding place its author had in mind, which is exactly the recurring lesson recorded on D-PO-61-6-1 and again on PO-61-8.\n\nConfirmed changes the property to one that IS testable. If rm never destroys without an explicit operator confirmation, then the thing to test is \"does rm ever destroy without confirmation\", which is one small assertion with a handful of cases, not an open-ended search. The inspection keeps all of its value — it is what tells the operator what is on the volume — but it stops being the last word, and its message can say honestly what it checked and that it cannot promise there is nothing else. A hiding place nobody thought of then costs an incomplete report instead of silent destruction. It also deletes surface: --discard-ignored exists so that getting past build output does not mean reaching for --force, and under confirm-before-destroy the operator simply sees the ignored findings and confirms, so the flag and the whole CR-73 hole can go away.\n\nScope if confirmed: make rm confirm before destroying; fix CR-69 (non-empty git stderr becomes undetermined — verified against seven benign repo shapes, all silent, so this is a general rule and not another list entry) and CR-70 (a .gitmodules or non-empty .git/modules/ becomes undetermined); fix the three criteria, including the CR-72 prefix match that is a real credential-leak hole in the only criterion guarding the inspection container; batch the fifteen non-blocking findings to #353. Then one sweep, and out of QA.\n\nWaived is defensible if the judgement is that confirm-before-destroy is worse ergonomics for unattended container use, which is the whole point of #61 — a prompt no one is there to answer stops an automated run. That is the real argument against, and it deserves an answer: the answer is that rm is an operator verb, not something an unattended session calls, and a --force path stays for scripts. But if the intent is that unattended cleanup runs rm itself, then confirmed is wrong and waived plus a sixth round is the honest choice.\n\nStopping now and shipping as-is was considered and rejected: CR-69 is not a mutant, it is live in the code at c4ae3f6, and it silently destroyed a real volume during this round." } ], "suite": { "source": "git", "sha": "bd2532c494039780d8af631752e0b22bfbb2cdbd", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "confirmed",
  "rationale": "Resolved confirmed on the operator explicit instruction (\"Please resolve as recommended\"), against the evidence recorded in the round-5 qa-report (comment 2496). The checkable ground: three of the five blocking findings are criteria that stayed GREEN while a mutant destroyed a volume holding work that exists nowhere else (CR-71, CR-73) or mounted the host forge token into the container that runs an unknown workspace git (CR-72) — the suite has read 38/38 for two rounds with those holes open. That is the signature of testing a claim with no finite test: \"the certification is complete\" cannot be proven by a list of checks, so each criterion tests the one hiding place its author had in mind. Confirmed replaces it with a property that does have a finite test — rm never destroys without an explicit operator confirmation. The inspection keeps its value as information and stops being the last word, so a hiding place nobody thought of costs an incomplete report rather than a deleted volume.",
  "rejected_alternative": "Waived — keep the current design, fix the five findings in place, and run a sixth review round. Turned down because nothing in five rounds suggests it converges: every round has found the same defect in a new place (detached HEAD, stash, stash without reflog, linked worktree, local tag, gitignored file, a repo one directory down, and now an unreadable subdirectory and a submodule), and the list is still short by one each time. The real argument for waived was ergonomic — a confirmation prompt stops an unattended run, and unattended runs are the point of #61 — and it was answered rather than dismissed: rm is an operator verb, not something an unattended session calls, and a --force path stays for scripts. Shipping as-is was also considered and rejected: CR-69 is live in the code at c4ae3f6, not a mutant, and it silently destroyed a real volume during this round."
}

<!-- decision-resolution:v1 ref=D-PO-61-9-1 --> ```json { "status": "resolved", "chosen_option": "confirmed", "rationale": "Resolved confirmed on the operator explicit instruction (\"Please resolve as recommended\"), against the evidence recorded in the round-5 qa-report (comment 2496). The checkable ground: three of the five blocking findings are criteria that stayed GREEN while a mutant destroyed a volume holding work that exists nowhere else (CR-71, CR-73) or mounted the host forge token into the container that runs an unknown workspace git (CR-72) — the suite has read 38/38 for two rounds with those holes open. That is the signature of testing a claim with no finite test: \"the certification is complete\" cannot be proven by a list of checks, so each criterion tests the one hiding place its author had in mind. Confirmed replaces it with a property that does have a finite test — rm never destroys without an explicit operator confirmation. The inspection keeps its value as information and stops being the last word, so a hiding place nobody thought of costs an incomplete report rather than a deleted volume.", "rejected_alternative": "Waived — keep the current design, fix the five findings in place, and run a sixth review round. Turned down because nothing in five rounds suggests it converges: every round has found the same defect in a new place (detached HEAD, stash, stash without reflog, linked worktree, local tag, gitignored file, a repo one directory down, and now an unreadable subdirectory and a submodule), and the list is still short by one each time. The real argument for waived was ergonomic — a confirmation prompt stops an unattended run, and unattended runs are the point of #61 — and it was answered rather than dismissed: rm is an operator verb, not something an unattended session calls, and a --force path stays for scripts. Shipping as-is was also considered and rejected: CR-69 is live in the code at c4ae3f6, not a mutant, and it silently destroyed a real volume during this round." } ```
Author
Owner

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

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

{
"outcome": "clean",
"summary": "All 5 blocking round-5 findings are fixed and mutation-verified, rm is rebuilt to report-and-confirm per D-PO-61-9-1, and the full sweep is 39/39 PASS at b7e62f4 with nothing modified after it. The 15 non-blocking findings are batched to #353 (CR-75 and CR-86 were unfolded — they were fixed here, not deferred).",
"findings": [],
"artifacts": {
"test_files": [
".devcontainer/selftest.sh"
],
"test_commit": "b7e62f4"
}
}

<!-- qa-report:v1 issue=61 skill=qa domain=code phase=fix --> { "outcome": "clean", "summary": "All 5 blocking round-5 findings are fixed and mutation-verified, rm is rebuilt to report-and-confirm per D-PO-61-9-1, and the full sweep is 39/39 PASS at b7e62f4 with nothing modified after it. The 15 non-blocking findings are batched to #353 (CR-75 and CR-86 were unfolded — they were fixed here, not deferred).", "findings": [], "artifacts": { "test_files": [ ".devcontainer/selftest.sh" ], "test_commit": "b7e62f4" } }
Author
Owner
{
  "next_state": "integrating",
  "produced": [
    {
      "kind": "code",
      "ref": ".devcontainer/dw.sh",
      "summary": "rm rebuilt on report-and-confirm: it prints what the inspection found, states that this is not a guarantee, and destroys nothing until the operator types the issue number. --yes answers in advance, --force skips inspection and question both, --discard-ignored is retired. Also CR-69 (git stderr is read; anything on it means the answers were incomplete) and CR-70 (a submodule makes the verdict undetermined)."
    },
    {
      "kind": "test",
      "ref": ".devcontainer/selftest.sh",
      "summary": "CR-89 added — rm destroys nothing without an explicit confirmation, seven cases on a pristine workspace. CR-68 reworked to assert what the report SAYS, with unreadable-subdir and submodule shapes added and the worktree shape moved to a gitignored path. cr_argv_count now matches a prefix (CR-72). Every guard criterion that had gone vacuous under the confirmation now passes --yes."
    },
    {
      "kind": "docs",
      "ref": ".devcontainer/README.md",
      "summary": "The rm section rewritten around the prompt, with a worked example of the report and the reason the tool no longer decides."
    }
  ],
  "findings": [],
  "pending_decisions": [],
  "suite": {
    "source": "git",
    "sha": "bd2532c494039780d8af631752e0b22bfbb2cdbd",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-61-10 skill=qa --> ```json { "next_state": "integrating", "produced": [ { "kind": "code", "ref": ".devcontainer/dw.sh", "summary": "rm rebuilt on report-and-confirm: it prints what the inspection found, states that this is not a guarantee, and destroys nothing until the operator types the issue number. --yes answers in advance, --force skips inspection and question both, --discard-ignored is retired. Also CR-69 (git stderr is read; anything on it means the answers were incomplete) and CR-70 (a submodule makes the verdict undetermined)." }, { "kind": "test", "ref": ".devcontainer/selftest.sh", "summary": "CR-89 added — rm destroys nothing without an explicit confirmation, seven cases on a pristine workspace. CR-68 reworked to assert what the report SAYS, with unreadable-subdir and submodule shapes added and the worktree shape moved to a gitignored path. cr_argv_count now matches a prefix (CR-72). Every guard criterion that had gone vacuous under the confirmation now passes --yes." }, { "kind": "docs", "ref": ".devcontainer/README.md", "summary": "The rm section rewritten around the prompt, with a worked example of the report and the reason the tool no longer decides." } ], "findings": [], "pending_decisions": [], "suite": { "source": "git", "sha": "bd2532c494039780d8af631752e0b22bfbb2cdbd", "dirty": false } } ```
Sign in to join this conversation.
No description provided.