devcontainer: refresh for the plugin era — per-container claude state, worktree plugin wiring, parallel-safe compose naming #61
Labels
No labels
component:adapters
component:lifecycle
component:qa
component:setup
component:shared
component:worktrees
phase/accepted
phase/backlog
phase/deployed
phase/developing
phase/integrating
phase/planning
phase/qa
phase/requirements
phase/uat
priority:critical
priority:critical
priority:high
priority:high
priority:low
priority:low
priority:medium
priority:medium
type:bug
type:chore
type:docs
type:feature
type:infra
type:tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jbr870/devwork-skills#61
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
PREQ: 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:
fenced so a mistake it makes cannot reach the host checkout, the other runs, or the machine.
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):
devplugin dangles. The container bind-mounts the host's whole~/.claude, and~/.claude/skills/devis 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 nohelper resolves in a container session. The container cannot run the suite at all.
started for a second issue clashes with the first.
~/.claudeis mounted into the hostsession 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 thatthe 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:
host's, so sessions do not race, and so the plugin can be wired per container.
/dev:*skills and helpers from the checkoutit is working in, so it executes the suite it is dogfooding, and the run record says which revision
that was.
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.
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
/dev:*skills, so that unattendedwork on an issue can actually execute the pipeline instead of failing on the first skill call.
and my own host session do not corrupt each other's sessions and config.
refs, so that a permission-relaxed run that goes wrong cannot damage my checkout, my other runs, or the
branches they depend on.
that I can run several in parallel without VS Code and without editing files to disambiguate them.
container and is reviewable after it is gone.
Acceptance Criteria
The suite runs in there
/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.
bash(≥ 3.2),jq,gitand 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/touchrequirement islocal-fs-only and does not apply — this projectdeclares
tea-cli.)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
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.
performed across its workspace, then the host checkout, every host worktree, and every other
container's workspace compare equal to their prior state.
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.)
integration branch, then the forge refuses — a container holding the host's credentials cannot
rewrite what other runs and the host depend on.
against the project's declared forge instance without an interactive authentication step.
copied, then the push is refused and the refusal is visible to the session — it never silently
overwrites the newer state.
Credentials
starts, then it is already authenticated — no interactive login step is required.
refreshed in place inside the container and the session continues; and after the container has
run, the host's own credential file is unchanged.
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).
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.
displaces the other on account of naming, and each can be addressed, stopped and removed
individually without disturbing the other.
issue again, then they are placed in the existing container — a second, rival container for one
issue is never created.
state and its workspace — including uncommitted changes — are as they were left, and first-time
setup does not run again.
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.
then removal is refused and the unpushed commits are named; removal proceeds only on an explicit
override.
Claude state are destroyed, its name is free for re-use, and no other running container is
affected.
yields a usable session.
Workspace seed
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.
the same Claude configuration a host session would (settings and plugin/skill wiring), while
session, task and history state start empty.
Out of Scope
script. Which prompt, which permission mode, and how a failed run is surfaced are run-orchestration
decisions that deserve their own feature.
/voiceaudio plumbing. The host audio socket mount and the audio packages are removed. Unattendedruns have no one to talk to, and it is the most host-coupled part of the image.
matter to an editor UI. The lifecycle is terminal-first because the operator never uses VS Code.
is nothing to allocate or collide over.
parallel_dev:slot recipe. The suite's slot schema cannot currently express a services-freeproject (filed as #62); this feature does not work around that, and does not adopt slots.
plugin/)..devcontainer/is repo-local infra. Any suite-levelgap this work exposes is filed as its own issue rather than fixed here.
Dependencies
git.wihslon.com,jbr870/devwork-skills)— read and write, via a token supplied to the container;
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 thecontainer needs that configuration and not merely the key material — a keys-only mount resolves
nothing.
(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.
their versions should be pinned so a rebuild is reproducible.
relies on it rather than introducing a new mechanism.
(slot recipe schema). Neither gates this feature.
Timeline
Notes
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".
host, and a branch must be pushed before a reviewer can see it. This is also why removal is
guarded on unpushed commits.
option gives up most of the isolation the container exists to provide.
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.
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.
unattended run can be resumed across sittings and — more importantly — so a failed run is still
there to inspect.
would publish work from a run that may have gone wrong.
identity, and SSH agent forwarding all stay.
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.
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.
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.
originby its host-alias, and a workingforge token for the declared tracker instance.
operations).
Required Test Data
have, for the non-fast-forward case.
garbage forge token — to inject for the failure-path scenarios.
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, nomissing skill."
/dev:*skill that reads from the tracker.no-such-file error naming a host path.
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,gitand the POSIX utilities named in CLAUDE.md'stwo-tier baseline table are present and usable, and the project's declared forge CLI is installed and
authenticated against the declared instance."
two-tier baseline table.
bashreports at least the required minimum version.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."
from.
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."
mounted.
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."
tracked files across several directories of its workspace, including its repository root.
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."
container B.
reach.
shared git directory — the enumerated list contains no second egress path for code.
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."
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."
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."
container's copy.
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."
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."
trigger a credential write).
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."
rather than partway through work.
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."
then a usable session (Scenario 1's check), then stop, then remove.
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."
restart the first.
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."
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."
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."
must clean up by hand.
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."
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."
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."
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."
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."
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."
to it.
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.
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.
indefinitely, and it does not report the issue or branch as missing.
local work.
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.
create-or-enter for issue B.
lifecycle documents.
(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:
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
/dev:technical-plan;an absent lane means
e2e-browserby default, which will need correcting for essentially everyscenario in this plan — none of them is a browser scenario.
changes what the session executes.
from inside a container. Both need the environment to be arranged, not just observed.
Required Test Data. Capturing it after the fact makes them unrunnable.
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(whoseskills/devsymlink points at a host path absent in the container), uses a fixedcompose 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 aper-container
~/.claudevolume, and fix the plugin symlink.Pros: Smallest visible diff; keeps a declarative config file;
devcontainer.jsonstays 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 isunavoidable anyway: push-only isolation requires replacing the
..:/workspacebind mount with a clonedprivate 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, nodevcontainer.jsonSummary:
.devcontainer/dw.showns the whole lifecycle against one container and two named volumesper 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.ymlfor declarative volumes/env; a wrapper setsCOMPOSE_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.jsonwould be config nothing exercises — and unexercisedconfig is what rots into a misleading state. The directory keeps its name for familiarity.
Architecture
Component Overview
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 forissue N (an existing
feature/N-*on the forge, else a new one from the integration branch tip); createsboth volumes; starts the container; runs
container-init.shviadocker exec, which clones the repo into/workspaceat that branch, clones the integration branch into~/.claude/dev-suite, points~/.claude/skills/devatdev-suite/plugin, copies the credentials in, allowlist-seedssettings.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
upstate machineupis the single idempotent verb (enteris an alias). Its four states, and AC-15/AC-16/AC-17 fallingout of them rather than being special cases:
dw-<N>.dw-state.jsonabsent orstatus != "ready"dw.sh rm <N> --forceand retry) — never reattach to a half-built containerreadydocker start, re-probe credentials, exec a shellreadyA crash or host reboot lands in stopped, so AC-17 is the same path as AC-16 with no extra machinery.
Two concurrent
up Ninvocations are serialised by a lock file on the host; the loser waits and thenre-evaluates the state machine rather than racing
docker create.External Data Contracts
~/.claude/.credentials.json)accessToken,refreshToken,expiresAt,refreshTokenExpiresAt,scopes[],subscriptionType— rewritten in place on refreshrecorded→external-contracts/claude-credential.provenance.jsonunix:///var/run/docker.sock)dockergroup; no rootless socket; no podman. Gated.originover theforge-devworkSSH alias (git.wihslon.com:2222)ls-remote/clone/pushfrom inside a containerteaTEA_TOKENfrom a.envthat does not exist. Gated.jbr870/devwork-skillsmainhasPROTECTED = false(tea branches)Key Decisions
~/.claude/dev-suite/, symlinked from~/.claude/skills/dev. Not a live symlink to/workspace/plugin, and not a file copy./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.dw-<N>-claudevolume, next to the symlink that points at itrecreate-preserving-volumes would strand a dangling link. Same volume, same lifetime.dw.sh repin N(to the integration tip) andrepin N --from-workspace(to exercise an in-flight edit)--from-workspacealso stamps honestly, because/workspaceis 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.recreatedoes not strand them.forge-devworkhost stanza, the key it names, and the matchingknown_hostsentry — not all of~/.ssh--ssh-agent), off by defaultteaconfig in at createTEA_TOKENfrom.env) is broken on this host: no.envexists and the failure is swallowed by|| true. The host tea config is the token source that actually exists.settings.jsonseedingapiKeyHelper, 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.@{u}..) inside a throwaway container on the workspace volume — no networkls-remote. Remote-tracking refs answer the question offline. If they are stale the check errs toward refusing removal, which is the safe direction.dw-<issue>container,dw-<issue>-workspace/dw-<issue>-claudevolumessudo.dw.sh recreate N— new container from the current image, both volumes preservedrm(which destroys the workspace). This verb is the reason everything persistent had to be volume-resident.set -euo pipefail; all diagnostics to stderr; exit 2 = guard refusal, exit 1 = failureselftest.shboth need to distinguish "refused, as designed" from "broke".Technical Risks
dw.shbecomes load-bearing shell in a repo whose only gate isbash -n.devcontainer/*.shtolint-conventions.sh's sweep;selftest.shis the mechanical surface the ACs map to.known_hostsknown_hostsentry in with the key; fail loudly if the host key does not verify — never disable strict host-key checking.dw.sh lsreports each container's pinned revision; the #57 stamp names it on every posted record.feature/*force-push rule would collide with the rebase/dev:integrateperforms. Recorded as a finding and as an explicit narrowing of the isolation claim, not silently absorbed.recreatere-seeds.selftest.shcannot run at all until the Docker access precondition resolvesExpert Review
Reviewers
dw.sh↔container-init.shcontract and its success marker were undefined, soupwould 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.rmguard 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
its symlink. (architect ×2 — and the copy variant was verified to break AC-3.)
upstate machine with acreated-uninitstate that re-runs init or refuseswith a named remedy, plus a host lock against concurrent
up. (backend)dw.sh recreatepreserving volumes, and moved all persistent state — credentials, pin,marker — into volumes. (architect + backend, converging)
rmguard now covers a dirty working tree as well as unpushed commits. (UX)up, not only at create; added a re-copy verb. (UX)forge-devworkSSH stanza + its key + known_hosts are copied, not all of~/.ssh;agent forwarding became opt-in. (security)
settings.jsonseeding is an allowlist. (security + architect, converging)dw.sh lsas a state dashboard,repinguarded against a livesession, and README recovery guidance for a refused push and for an unreachable Docker socket. (UX + backend)
--credential-sourceoverride so AC-12 is mechanically testable, with a bounded probe timeout. (backend)sudovs group membership), not merelythat access exists. (architect + backend, converging)
Noted (not actioned)
feature/*. It would close sibling-branch rewriting, but collideswith the rebase
/dev:integrateperforms on feature branches. Left to the operator as a finding; theisolation claim is narrowed in the meantime rather than overstated.
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.
dw.sh ls. Accepted in spirit and folded into thelsdashboard rowrather than tracked as its own item.
Acceptance Criteria
/dev:*skill loads and its helpers execute in a container sessionselftest.sh: run a suite helper through the pinned path; assert exit 0 and real outputselftest.sh: version-probe each tool against the CLAUDE.md baseline;tea login listnames the declared instanceselftest.sh: assert the stamp issource:"git"with the pinned sha (notsource:"release");repin --from-workspace; assert the stamp changed to the workspace shaselftest.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/afterselftest.sh: representative create/edit/delete set, then compare host checkout, every host worktree and container B's workspace against pre-captured hashesselftest.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 pushselftest.shattempts 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 preconditionselftest.sh: read issue, post comment, read back, with stdin closed; assert no promptselftest.sh: advance the branch on the forge, push from the container, assert non-zero exit and unchanged forge tipselftest.sh: non-interactive session prompt answers with no login stepselftest.sh: assert the container credential path is writable by the session uid and write to it; host file hash + mtime unchangedselftest.sh:up --credential-source <fixture>with an invalid credential; assert non-zero exit naming the credential within the defined probe timeoutselftest.shdrives up → session → stop → rm viadw.sh; plus a scan asserting no.devcontainer/file references an editor or GUI stepselftest.sh: two containers up, distinctly named; stop/remove one, assert the other untouchedselftest.sh: marker, secondup, assert marker present and exactly one container matchesselftest.sh: uncommitted edit + session state, stop,up, assert both intact and init reports already-readyselftest.sh:docker kill, thenup; assert entry succeeds with no manual cleanup and state intactselftest.sh: unpushed commit →rmexits 2 naming the commits, container survives; also dirty-tree-only →rmexits 2 naming the dirty paths; after push+commit clean,rmsucceeds;--forcesucceeds on unpushedselftest.sh: both volumes absent, sibling container unaffected, re-upyields a clean workspaceselftest.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/selftest.sh: assert branch name and HEAD match the forge tip recorded before createselftest.sh: assert branch name, merge-base with the integration tip, and that the forge still has no such branch after createselftest.sh: compare effective permission mode and resolved suite skill list against the host's; assert history/sessions/tasks emptyMechanical 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 mounttable, the
.devcontainer/sources and the stamp's own shape — not spot checks of behaviour. AC-6 is theone 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.shandpost-create.share deleted outright, not left as compatibility shims.Implementation Scope
Areas
.devcontainer/dw.shup/enter,stop,rm,recreate,repin,refresh-creds,ls.devcontainer/container-init.shentrypoint.sh+post-create.sh).devcontainer/Dockerfile.devcontainer/docker-compose.yml,devcontainer.json,entrypoint.sh,post-create.sh.devcontainer/selftest.shscripts/lint-conventions.sh.devcontainer/*.shin thebash -nsweep.devcontainer/README.mdFile Boundaries
Dockerfileis independent and can be done in parallel with anything.dw.shandcontainer-init.sharea 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.shdrivestheir interface and so must follow them.
README.mdand the lint extension are independent tail work.Dependencies & Sequencing
Mixed.
Dockerfile,README.mdand the lint extension are independent. The core is sequential:dw.sh+container-init.sh(one unit) →selftest.sh. Nothing can be validated until the Dockeraccess precondition resolves — the build can proceed, but every acceptance criterion's verification
needs a runnable container.
Constraints & Non-Goals
Constraints:
.devcontainer/is repo-local infra andexplicitly exempt. The scripts still carry
#!/usr/bin/env bashand stay within the helper tier'sbash 3.2 surface, for consistency with the rest of the repo's shell.
#49) —
selftest.shmust obey this or its own greens are untrustworthy.Non-goals (do NOT build):
/voiceaudio support.parallel_dev:slot recipe.plugin/.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.
originby its host-alias, and a workingforge token for the declared tracker instance.
operations).
Required Test Data
have, for the non-fast-forward case.
garbage forge token — to inject for the failure-path scenarios.
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, nomissing skill."
Lane:
integration-covered—.devcontainer/selftest.sh/dev:*skill that reads from the tracker.no-such-file error naming a host path.
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,gitand the POSIX utilities named in CLAUDE.md'stwo-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.shtwo-tier baseline table.
bashreports at least the required minimum version.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.shfrom.
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.shmounted.
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.shtracked files across several directories of its workspace, including its repository root.
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.shcontainer B.
reach.
shared git directory — the enumerated list contains no second egress path for code.
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.shExpected 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.shExpected 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.shcontainer's copy.
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.shExpected 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.shtrigger a credential write).
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.shrather than partway through work.
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.shthen a usable session (Scenario 1's check), then stop, then remove.
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.shrestart the first.
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.shExpected 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.shreset.
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.shmust clean up by hand.
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.shExpected 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.shno 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.shExpected 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.shExpected 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.shuntil 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.shto it.
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.shExpected 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)indefinitely, and it does not report the issue or branch as missing.
local work.
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.shcreate-or-enter for issue B.
lifecycle documents.
(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:
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-plan2.6b (the scenarios themselves are unchanged — they remainPREQ-derived).
e2e-browserwas not assignable at all: the project declarese2einqa_domains.not_applicable("markdown+shell skill suite — no application or browser surface"), so theschema's default lane would have been wrong for all 26. 24 scenarios are
integration-coveredby.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 unreachableforge) 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.shneeds a runnable container, and the operator's user cannot reach the Dockerdaemon (not in the
dockergroup; no rootless socket; no podman — verified at plan time). The lanesabove 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:
maincurrently hasPROTECTED = falseon the forge, so thecriterion 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.
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), sowithin-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
Dockerfileisindependent 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 belongswith 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 byinspection 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.shandcontainer-init.share one unit by the SREQ'sexplicit 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 criteriaalone, and the Implementer owns
dw.sh+container-init.sh. The AC table's "Verificationapproach" column is literally a specification for
selftest.sh, so the Test Writer has a definedscope 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.mdsupplies the documented lifecycle AC-13 requires the operator to follow; thelint-conventions.shextension brings.devcontainer/*.shinto the repo's only mechanical gate,which is the mitigation recorded against the "load-bearing shell behind only
bash -n" risk. It runslast 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
scopeannotation 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.
The container's tea token. D-PO-61-2-2 requires containers to carry a least-privilege token and
dw.sh upto assert that scope at create, but the host holds only the admin-capablewihslonlogin. The operator will mint a scoped token (issue/comment write, repository read, no
repo-admin).
dw.shtherefore reads the container token from a dedicated source(
$DW_TEA_TOKEN, else~/.config/dw/tea-token), asserts by probing an admin-only endpoint that itlacks 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.
selftest.sh's forge blast radius. It creates and cleans up its own scratch objects: itsown
dw-selftest/*branches and its own scratch tracker issues, deleted / closed at the end of therun. 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
mainareexpected to be refused (AC-7) — the assertion is that they fail and that
main's tip isunchanged.
Development journal — WU-61-3-1 (image refresh, compose/IDE stack retirement)
2026-08-25T15:40 DISCOVERY:
install.sh | bashcannot pin the Claude CLI in this imageContext: 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 thatonly show up once you read it:
and only then runs
claude install <VERSION>— so the build is not reproducible from the versionargument alone.
$HOME, and in this design/home/vscode/.claudeis a named volume at runtime.Anything the installer wrote there during the build would be shadowed the moment the volume mounts.
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>"].checksumfrom that version'smanifest.json, andchmod +x. Doing exactlythat in the Dockerfile — to
/usr/local/bin/claude, root-owned, with the version and the sha256 asbuild ARGs — pins the CLI properly. The standalone binary needs no
claude install: it reports2.1.231 (Claude Code)straight away.DISABLE_AUTOUPDATER=1keeps 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
$HOMEis a volume — the install silently evaporates atruntime, 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
Context: The design mounts two named volumes per issue —
dw-<N>-workspaceat/workspaceanddw-<N>-claudeat/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 acontainer running as uid 1000 cannot write to it. Creating both directories in the image with
install -d -o vscode -g vscodeis what makes the volumes come up owned by uid 1000 — verified:drwxr-xr-x 2 vscode vscode /workspaceand the same for~/.claude.Why it matters: this is an image-side property that only fails on the consumer side
(
container-init.shfailing to write), so it would be debugged in the wrong file. Recorded here so thelifecycle unit does not have to chase it.
2026-08-25T16:08 STRUGGLE: the residue scan trips on the comments announcing the removal
Problem: AC-20 and AC-13 are scan assertions: no file under
.devcontainer/may reference anaudio 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 isnamed
vscode(the base image's user, and the path the SREQ itself specifies), so the AC-13 scan mustmatch IDE-integration tokens (
devcontainer.json,dockerComposeFile,customizations,extensions,anthropic.claude-code,postCreateCommand,.vscode) rather than the bare stringvscode.2026-08-25T16:20 COMPLETE
Hardest part: Working out that the Claude installer's
VERSIONargument does not actually give areproducible 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.
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 bybehavioural assertions. Per
/dev:develop§3.2 it therefore ran Implementer-only, with no TestWriter; 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.user's login shell), claude 2.1.231, openssh-client 9.2p1. POSIX baseline utilities all present.
changes it) and confirmed by
idin a running container.Entrypoint: null,Cmd: ["sleep","infinity"],User: vscode,WorkingDir: /workspace.container-init.shis deliberately not baked into the image..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):
install.sh. Theinstaller 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 volumehere, 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 sha256from that version's official manifest, installs it root-owned at
/usr/local/bin/claude, and setsDISABLE_AUTOUPDATER=1so the pin holds against a session that could otherwise update out of it. jqand 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.
/workspaceand/home/vscode/.claudeare 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>-workspaceanddw-<N>-claudecome up root-owned andcontainer-init.shcannot writeto 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"}) plusdev.devwork.{claude,jq,tea}-versionlabels, soselftest.shanddw.sh lscan assert against amachine-readable record instead of parsing
--versionoutput. Also: the AC-13 scan must match IDEintegration tokens, never the bare string
vscode— the container user is namedvscodeand theSREQ 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.shandREADME.mdland. The authentication half of AC-2 and the runtimehalf of AC-20 belong to WU-61-3-2.
Pin choice, decided at the lead:
CLAUDE_VERSION=2.1.231tracks thestablechannel pointer(
latestwas 2.1.245 at build time).stableis the right default for a box that runs unattended.Development journal — WU-61-3-2 (dw.sh + container-init.sh)
2026-08-25T16:50 DISCOVERY:
ssh -G <alias>beats parsing the ssh_config stanzaContext: The SREQ says to copy "the
forge-devworkSSH stanza, the key it names, and thematching
known_hostsentry" 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 thecontainer gets a synthesized stanza that is exactly equivalent and strictly narrower than the literal
text — no
Included file, no unrelatedMatchblock, no sibling host.Why it matters: The known_hosts lookup key has to be derived from the same resolution
(
[host]:portwhen the port is not 22, bare host otherwise), or the copied entry silently fails tomatch and the container falls back to asking about an unknown host. Deriving both from one
ssh -Gcall keeps them consistent by construction.
2026-08-25T16:58 STRUGGLE:
${var#~/}tilde-expands the patternProblem:
ssh -Greports the identity file as~/.ssh/<key>; that had to become an absolute path.Expected:
case "$f" in "~/"*) f="$HOME/${f#~/}" ;; esacstrips 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. Thecasepattern is quoted andtherefore literal, which is exactly why the
casematched while the strip inside it did not — the twohalves 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
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 -pwrites a transcript underprojects/for every run.Pattern: Copy the credential into
$(mktemp -d), run the probe underCLAUDE_CONFIG_DIR=<that dir>, delete the directory. The credential under test is byte-identical, andevery artefact the probe creates dies with the temp dir.
Why it matters: Without this the probe would have to run in
~/.claudeand then selectively deletewhat 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 apiprefixes advisory NOTE lines to its outputContext: The forge-token probe read
tea api /userand asked jq for.login.Pattern: When no configured login matches the repository exactly,
teawritesNOTE: 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 itstarts at byte zero.
Why it matters: This compounds the already-recorded fact that
tea apiexits 0 even on anHTTP-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 apioutput to jq is exposed to the same thing.2026-08-25T17:35 DISCOVERY: a ready-marker short-circuit skipped
recreate's re-seedContext:
recreateis defined as "new container, both volumes preserved, configuration re-seeded".Pattern: Because the volumes are ready,
is_readyreturns true and the whole volume-residentblock — which is where
seed_settingsoriginally lived — never runs. The re-seed therefore has to sitwith 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
recreaterather than byreading the code — the code reads correctly either way.
2026-08-25T17:40 DISCOVERY (PRODUCT PROBLEM):
branch_protectionscannot serve as an admin-scope probeThis 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
repositorytoken scope — and jbr870 owns the repo, so therole half is always satisfied. Measured on the real least-privilege token (
admin: No access): theGET is reachable, and the POST creates branch-protection rules. Meanwhile
/admin/usersiscorrectly 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.
branch_protectionsrefuses every token that could actuallywork and passes none — it does not discriminate. The probe must be an endpoint gated by the
admin scope.
dw.shnow names it in one constant,ADMIN_PROBE_PATH, because the shape of aleast-privilege token is expected to keep narrowing.
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.shthrough 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
This is a problem in the product, not a learning about the codebase.
Context:
preflight()treats a missing container forge token asfail=1, which aborts the entirerun 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
Problem: Choosing the endpoint the token-privilege guard probes.
Expected:
branch_protectionswould answer only for an over-privileged token.Actual: Measured against the first token minted (which carried
repositoryscope), it answered200 — so I concluded it "does not discriminate" and replaced it with
/admin/users.Attempts:
branch_protectionsonly — refused the first token. Correct outcome, but I read the reason wrong./admin/usersonly — passes arepository-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
repositoryscope — whichis 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
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./workspaceand then callsrmexpecting exit 0 — while AC-18(b) writes an untracked file into
/workspaceand requiresrmtoexit 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.main's tip with zerocommits, so
push --force HEAD:mainproposes no ref update, printsEverything up-to-date, andexits 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.)
skill directories, but the host's locale collates
_sharedbetweensetupandsimplify-reportwhile the container's C locale puts it first. Sort both sides under
LC_ALL=C, or compare sets..claude/worktrees/lives under the main checkout and held four worktrees during the run;
dirhashwalks in withfind, so a peer session writing a file registers as "the host checkout changed". On a box whereparallel 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.
Full 2-phase TDD. The Test Writer owned
.devcontainer/selftest.sh, written from the acceptancecriteria 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, theuid-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 -nclean on both files,lint-conventions.shexit 0,--cleanleavesnothing, zero containers and volumes at rest, forge back to its two resting branches.
commit_refsincludese082d91, which belongs to this unit but carries no[WU-61-3-2]marker in itssubject, 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
mainon its failure pathEvery defect found across runs 3–6 was in the test harness or the environment, not in
dw.shorcontainer-init.sh. The implementation was substantially right from its first commit; what took fivefurther 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:
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.jsonevery 5s for the full sweep — 240 samples, one distinctreading: mtime and
expiresAtunchanged 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-3waived. See the Finding on theterminal Phase Outcome.
Carried forward, not fixed here
selftest.sh'sup-to-datebackstop greps the cumulative log, which already holdsdw.sh up'sclone 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.
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/*.shaddable at allContext: before widening
helper_filesto.devcontainer/*.sh, I checked whether the three scriptswould trip the bash-3.2 ceiling rules.
Pattern: they do produce raw matches —
dw.sh:21andselftest.sh:12each namemapfile/readarray, declare -A, local -n, ${var,,}in a header comment declaring that the file avoidsthem.
helper_hits()strips pure-comment lines (grep -Ev '^[0-9]+:[[:space:]]*#'), so all four hitsare 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 notContext: the SREQ's Technical Risks table asks for
.devcontainer/*.shin the lint sweep. The openquestion 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 itat these scripts would add a pass that can never fire. Writing a docker-flavoured variant instead would
fire falsely on correct code:
dw.sh:235pipesdocker images … | sort | head -n1 | cut -f1anddw.sh:857pipesdocker 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 byselftest.sh's own verdict rule.2026-08-25T22:05 DISCOVERY: the AC-13 scan reads README.md, and it reads it whole
Context: writing
.devcontainer/README.mdagainst a scan that has no allowlist and nocomment-stripping.
Pattern: the scan loops
for f in "$HERE"/*over every regular file exceptselftest.shitself, andgrep -qFfor eight literal tokens. A README is therefore held to exactly the same rule as a configfile: 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.
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 ranImplementer-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 printshelper-tier scan examined 120 scripts/of those, 3 are .devcontainer/*.sh.bash scripts/test-lint-conventions.sh→ PASS, including the two new scenariosdevcontainer_sh_in_helper_tieranddevcontainer_population_non_vacuity..devcontainer/both return no matchoutside
selftest.shitself, which the real scan excludes. The README passes the scan that has noallowlist 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
mapfilein a synthetic.devcontainer/bad.shmust be named atits line, while a comment naming the same construct must not be — mirroring
dw.sh's own header), andad hoc against the real files in a scratch tree outside the worktree, where a planted
declare -Aand adangling
ifwere 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:
package-manager and test-runner verbs (
pnpm|npm|yarn|pytest|vitest|jest|tsc), none of which occurin
.devcontainer/— pointing it there adds a pass that can never fire. A docker-flavoured variantwould fire falsely on correct code:
dw.shpipesdocker images … | sort | headanddocker ps -a … | sort -V, which are data queries, not gates. The rule is obeyed by constructionthroughout these files (
cmd > "$OUT" 2>&1; rc=$?)..devcontainer/is exempt by explicitSREQ constraint; these scripts carry their own bash shebang and do not ship.
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.shis now exempt from the construct scans(still
bash -n'd). To prove a ceiling rule fires, the harness must write a fixture containing the veryconstruct 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
$SELFexclusion.test-plugin-gates.shstaysfully checked.
Three defects noticed in
dw.shand reported rather than edited (that file belongs to a closed workunit) — all carried as Findings on the terminal Phase Outcome:
usage()omitsrecreate's--ssh-agentflag, whichcmd_recreatedoes parse — sodw.sh --helpand the README now disagree.
cmd_lsmixes streams: header and data rows to stdout, the trailing legend to stderr. The table isthe one output a human is likely to pipe.
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.
tea apiexits 0 on HTTP errors and prefixes NOTE: lines to stdout — audit every helper that pipes it into jq #260Linked: this issue is sibling #259 (recorded by the devwork pipeline).
Linked: this issue is sibling #260 (recorded by the devwork pipeline).
Linked: this issue is sibling #353 (recorded by the devwork pipeline).
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
feature/61-devcontainer-plugin-era-refresh, worktree.claude/worktrees/issue-61-devcontainer. HEAD78574f3, clean, rebased ontoorigin/main.skill=qa,next_state: qa, comment 2040).78574f3(AC-1..AC-23 + unit criteria CR-3, CR-6), clean tree, nothingmodified after it.
What round 2 must do (this is the immediate next work)
D-PO-61-4-1resolved fix-now, so these are promoted to in-scope-blocking and the next fix roundowes them. Full text at issue #61 comment 1924 (
qa-report:v1 domain=code phase=validate).CR-7up/rmadopt ANY container nameddw-<N>without checking thedev.devwork.managed-bylabelCR-8rmdestroys them with exit 0CR-9sleep infinitywith no init, so it ignores SIGTERM: every "normal stop" is mechanically the SIGKILL that AC-17 simulates as the ABNORMAL caseCR-14push_filecreates then chmods, leaving a brief 0644 window on credential materialCR-15CR-16seed_host_material's header claims a re-entered container never runs on staler material than the host; the settings seed is only APPLIED on reseedCR-17ac2cites a "CLAUDE.md" jq ≥1.7 floor that does not exist there (real source:preq.original.md:33)RV-4refresh-credsdeletes the container tea login before re-adding, so a failed add leaves it with none. Fix = add under a temp name, probe, then swapCR-15/CR-16/CR-17are comments asserting behaviour the code does not have — the same class asCR-33, and CR-33 is the likeliest reason the AC-20 gap survived develop review at all.
Deferred, already homed
CR-19..CR-32,CR-34). Two areflagged in its body as NOT hardening:
CR-23(leading-zero issue ids build rival containers) is acorrectness bug;
CR-24/CR-30are peer-churn false failures.tea apiexits 0 on HTTP errors and prefixesNOTE:lines to stdout.Round 1 commits
Operational facts a resuming session needs
sg docker -c '...'in THIS session.jochemis in thedockergroup but a processacquires supplementary groups only at start; a fresh login gets it ambiently.
selftest.shalreadywraps its own calls.
selftest.shrequires an explicit argument.bash .devcontainer/selftest.sh all, or namedcriteria (
AC-1 CR-3). A bare invocation prints usage and exits 1 — do not read that as a red run.cleans up after itself. The two
CR-*criteria are unit-style (no docker, no forge) and run in seconds.mainisPROTECTED = true; the container token is scopedread:user + read:issue + write:issue, no repository scope.bash scripts/lint-conventions.shandbash scripts/test-lint-conventions.sh, both clean.Two things worth not re-learning
set -ecapture trap bit twice in this one round, in two different constructs:cmd; rc=$?(killed everydw.sh up) andx="$(... | while ...)"(killed the ssh guard on thehost's own correct config). In a
set -efile, a helper whose non-zero return is a NORMAL outcomemust be called in a form whose status is always zero —
|| rc=$?, or an explicitif.A unit test that calls such a helper inside
ifproves 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.
regression; the sweep caught it; fresh-context re-validation caught a wrong-key defect in the fix;
fixing that hit the
set -etrap again. That is builder≠reviewer working, not thrash — do notcollapse the stages to save time.
Criteria added, and the red each one produced at
7fd49ec:CR-7rmadopting a container it did not createdw.sh rm DESTROYED an unmanaged container named dw-997114 (exit 0)CR-8dw.sh rm DESTROYED a workspace holding a detached-HEAD commit and a stash (exit 0)CR-9create_container does not pass --initCR-14a 0600 push_file destination existed at mode 664 while its content was still arrivingCR-15inspect_workspace does not pass --network noneRV-4after a failed add the container is left with no usable forge loginWhy 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/teaonPATHfor the shape-and-ordering checks, and a real container or volume wherethe property is only observable live (
CR-7,CR-8and half ofCR-9/CR-15run against real docker objects).
container-init.shgained thesource-detection guard
dw.shalready carried, which is what makessetup_forge_logincallable directly.CR-14deserves a note on method. The window it tests is far too short tocatch by polling. Rather than sampling and hoping, the criterion recovers the
remote command
push_fileactually sends and runs it against a fifo nobodywrites 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.
What was fixed
CR-7dev.devwork.managed-bylabel, not by the namedw-<N>.up,rm,stop,recreate,repinandrefresh-credsall refuse a containerdw.shdid not create.--forcedoes not override it:--forcemeans "discard my unsaved work", never "destroy what was never mine".CR-8HEADalongside--branchesand 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--init, giving them a PID 1 that forwards SIGTERM. The Dockerfile'sCMDnow says why it must never be PID 1 itself.CR-14push_filesets the destination's mode before any content reaches it and bounds the create itself withumask 077, so a 0600 credential is never briefly readable.CR-15--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-16seed_host_material's header separates what is adopted on everyup(credential, forge token, SSH material) from the settings seed, which is pushed on the same schedule but only applied on create orrecreate.CR-17jq >= 1.7floor is cited to the PREQ, which is where it comes from. CLAUDE.md makesjqa hard dependency but sets no version.RV-4setup_forge_loginsnapshots 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 signatureCLAUDE.md already records as "environment or a shared dependency, not logic."
It was logic: my
RV-4fix.RV-4's stated remedy was add under a temp name, probe, then swap. Thatshape 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 usedon the very firstup.The stub
teain theRV-4criterion did not carry that constraint, so itgreen-lit a repair the real tool refuses. This is the same class as
RV-5last 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:
implementation fails against it.
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
ab6bb78bash 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 ofreal docker objects before the fix landed.
Commits
Method
Three fresh-context reviewers at
ab6bb78, on deliberately disjoint lenses, none ofthem 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.
The union is what counts, not any one reviewer.
CR-35was found independentlyby 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:
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:
--initdoes not disturbsession_running()'s /proc walk (verified live: PID 1 isdocker-init, a process namedclaudeis still found). Graceful stop yields exit 143, not 137.umask 077creates only~/.ssh(0700, which is what ssh wants) and~/.claude/.dw(0700); the volume root and the 0755 push are unaffected.== marker ==— verified by seeding stashes literally named== end ==and== unpushed ==. dw.sh is the report's only reader.--forcedoes not override it.docker inspect --formatbehaves correctly on an unlabelled container and on a missing one.tea0.15.1 in a real container, at mode 600, with no backup left behind — in the single-process case.preq.original.md:33.The seven code defects
CR-35rmchecks the volumes, each on its own, and refuses any it did not create. Volumes are labelled at creation; an unlabelled one is not adopted byupeither, since adopting it would make it unremovable ever after.--forcedoes not buy past this.CR-36set -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-37push_filewrites 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-38refresh-creds/repintake the per-issue lockupalready took.CR-39CR-40refs/stash; the listing is used only to describe what the ref finds, and a ref with no reflog behind it is named explicitly.CR-41The 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_fileactually 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 stopand 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:
docker run … bash -c '<a 30-line script>'is one argv element; recorded one-line-per-argument, ablank line inside it reads as a call boundary. Newlines are folded now.
cr_call_recorddoubled every record. awk'sexitruns 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
5f9d7f8bash scripts/lint-conventions.shandbash 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 areal volume holding real recoverable work.
Commits
Method
Three fresh-context reviewers at
5f9d7f8, scoped to78574f3..5f9d7f8and toldnot 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 exitthat bypasses the fail-closed design it completes) and
CR-62(the rename thatsilently misfiles a credential into a directory).
CR-51says why, and it is a design fact rather than a bug: "is there work herethat 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 thatloses 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:
== 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.--forcedoes not override ownership, in either direction of the unlabelled-volume pair.refs/stashwith no reflog is reported rather than read as empty.tea0.15.1 honoursXDG_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.DW_DESTandDW_MODEare 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 -cpropagates the inner exit code exactly (probed 0/1/2/7), which is what every[ "$rc" -eq 2 ]assertion rests on.The redesign
inspect_workspaceno longer looks for work. It tries to certify there isnone, and returns exactly one of
empty,checked(with everything thequestions turned up), or
undetermined(with a reason).rmproceeds on thefirst two only.
That one rule subsumes three of the round's blocking findings, and it reached
cases nobody had enumerated:
--allcovers tags and every ref namespace, theworktree 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.fsmonitorhook had writteninto 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, threeacceptances — 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
An unreadable directory looks empty. The first certification pass had the
unreadableshape certify asemptyand get destroyed. "I looked and therewas nothing" and "I could not look" produce identical output from
ls, sothe ability to look is now established before the emptiness question is asked.
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
flockinside thecontainer, where the resource is — a host lock cannot cover a
container-initrun, and two host verbs with differentTMPDIRtakedifferent host locks.
And one self-inflicted, caught by the criterion that already existed: the
/tmpsweep 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
CR-52–CR-55rmtakes 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-unmanagedis the deliberate override.CR-57,CR-60,CR-66/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-67push_filerejects an emptyHOME(the bound had degraded to the glob/*), refuses a directory at the destination (mvfiles the credential inside it and reports success), usesmktemprather than a pid-derived name, and bounds/tmpitself rather than the whole subtree.CR-63,CR-65CR-39gained the acceptance case its own rule demanded; the removal message names the session-state volume.CR-58,CR-64create_containerrefusing to adopt an unmanaged volume is now covered live, and the read-only mount replaces the per-setting neutralisation.cr_argv_counttakes every spelling of a flag — an extra mount passed as--volumewas invisible to a check counting only-v.Verification at
c4ae3f6bash scripts/lint-conventions.shandbash scripts/test-lint-conventions.sh— both clean.CR-68cases.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
feature/61-devcontainer-plugin-era-refresh, worktree.claude/worktrees/issue-61-devcontainer.c4ae3f6, clean tree.(
scripts/lint-conventions.sh,scripts/test-lint-conventions.sh) clean.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:rmguard specifically to break that trend.D-PO-61-6-1: stop when a round finds nothing that loses data orleaks a credential. Round 4 found several, so the rule says continue.
empty (the rule fires on evidence and #61 leaves QA), or it does not — and then the honest answer
is to ship
rmas an explicitly UNGUARDED verb with a loud warning, rather than one carrying asafety 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, theflockserialisation, andpush_file. Push hardest on the three defects found by BUILDING ratherthan reviewing — an unreadable directory certifying as
empty, the revert case, and the/tmpsweepthat deleted a live snapshot — because their neighbours have had no reader at all.
What round 4 changed (the design, not just fixes)
inspect_workspaceno longer looks for unsaved work. It tries to certify there is none, andreturns exactly one of:
rmempty— the volume holds nothingchecked— a repo, every question answered, plus what they turned upundetermined— something here cannot be certified, and whyWhy: "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.fsmonitorhook had writteninto the volume it was certifying); the forge login is serialised with
flockinside the container(a host lock cannot cover a
container-initrun);rmtakes the per-issue lock, refuses while asession runs, checks each volume separately, and gained
--discard-ignoredand--force-unmanaged.Round history
selftest.shitselfThe 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— theone rewritten after a real failure rather than drafted alongside its fix.
Key comment ids on issue #61
qa-report:v1 domain=code phase=validate(all 18 findings,CR-50..CR-67)phase=fixCR-*findings)Deferred, already homed
tea apiexits 0 on HTTP errorsOperational facts that cost time
sg docker -c '...'in a session whose process started before the group wasgranted.
selftest.shwraps its own calls.selftest.shrequires an argument (all, or named criteria). A bare call prints usage andexits 1, which reads exactly like a red run.
up after itself. The unit-style criteria (
CR-14,CR-37,CR-38,CR-39,RV-4) run in seconds..devcontainer/while a run is in flight. A runningbashkeeps reading the oldinode and executes the previous version to completion, silently.
in their transcript. Check
~/.claude/projects/<proj>/<session>/subagents/*.jsonlbefore asking.dw.sh, not the environment— check the first failure's reason before debugging anything else.
{
"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
checkedwith 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 statuscannot open a directory it lacks read/search permission on, so it printswarning: could not open directory .../: Permission deniedTO STDERR AND EXITS 0. set -eu sees nothing, no finding: line is emitted for that subtree, andverdict: checkedis 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 515002exited 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
checkedwith no findings","reasoning": "dw.sh:944-990. Every question is asked of exactly one repository.
git statusreports 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 --remoteswalks 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 verdictundetermined— 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 betweenrm --discard-ignoredand 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 addappears 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:roto 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
unreadableshape 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 --hardis 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.Sx7bKothen the loudthis 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 isdocker execwithout -t, where a host Ctrl-C is not forwarded anddocker stopsignals 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 -Sdates 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 throughdocker exec -iwith 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 thatflock 9blocks forever with no -w and no message, so anupmeeting 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"
]
}
}
Linked: this issue is sibling #353 (recorded by the devwork pipeline).
{
"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"
}
}