| name | smoke-test |
| description | Spawn a fresh Bun project under ../dpt-test-project-<tracker> and drive the dev-process-toolkit plugin's full skill chain (/setup → /spec-write → /implement → /gate-check → /spec-review → /simplify) via claude-st -p child sessions, capturing findings. Pre-release sanity check, not CI. Real Linear or Jira writes on the tracker legs (per `--tracker`), no tracker surface at all on the tracker-less leg, ~10 min wall-clock. Multi-terminal tandem runs (one per leg) are supported. |
| argument-hint | [--tracker linear|jira|none] [--jira-project KEY] [--keep] [--linear-team STE] [--feature-stub greet] |
| disable-model-invocation | true |
/smoke-test
Drive the dev-process-toolkit plugin end-to-end against a freshly-scaffolded Bun project, capturing functional gaps that only manifest at runtime in a fresh checkout. This is a project-local skill — it lives in .claude/skills/smoke-test.md of the dev-process-toolkit repo, not in the plugin itself. Downstream users never see it.
This is the autonomous variant: the parent claude session spawns claude-st -p children, captures their output, and writes findings + a teardown checklist. The skill drives exactly one leg per invocation: the Linear path (default, --tracker linear), the Jira path (--tracker jira --jira-project <KEY>), or the tracker-less path (--tracker none). The canonical chain (/setup → /spec-write → /implement → /gate-check → /spec-review → /simplify) is identical on every leg; only Phase 1 (project setup) and Phase 5 (teardown) branch on --tracker. Per-run findings live at /tmp/dpt-smoke-findings-<date>-<tracker>.md; that's the persistent audit trail.
The tracker-less leg is not a degraded tracker leg (STE-448). mode: none is the DEFAULT configuration for every project this toolkit bootstraps — /setup never writes a mode: none line, because ABSENCE of a ## Task Tracking section IS none — so it is the toolkit's most common downstream shape. It exercises a genuinely different set of code paths: identity is minted locally rather than allocated remotely, the milestone is an opaque M_<tail> token rather than a sequential number, and release is proved by the deletion of a lock file rather than by a ticket transition. What it skips is the tracker surface, not the chain: no MCP pre-flight, no MCP config, no tracker workspace, and a Phase 5 teardown that removes a directory and calls no tracker at all.
Every per-run artifact is keyed on the resolved <tracker> — which is one of the legs SMOKE_LEGS registers in adapters/_shared/src/smoke_fixture_groups.ts, never a set restated here: the test-project basename is ../dpt-test-project-<tracker>, the findings file is /tmp/dpt-smoke-findings-<date>-<tracker>.md, per-skill logs are /tmp/dpt-smoke-<tracker>-<skill>.log, grandchild pidfiles are /tmp/dpt-smoke-<tracker>-<skill>.pid (globbed as /tmp/dpt-smoke-<tracker>-*.pid), the rc / start / attempt markers are /tmp/dpt-smoke-<tracker>-<skill>.{rc,start,attempt<N>.log}, the verdict artifact is /tmp/dpt-smoke-verdict-<tracker>.json, the wrapped MCP config is /tmp/dpt-smoke-mcp-config-<tracker>.json (tracker legs only — the tracker-less leg constructs none), the approval record is /tmp/dpt-smoke-<date>-<tracker>-approval.txt, and Phase 8 transcript fixtures are tests/fixtures/socratic-first-turn/<skill>-<tracker>-<YYYY-MM-DD>.json. That shared <tracker> segment — not the calendar day, and not luck — is what makes the multi-terminal tandem run (§ Operator-driven parallelism, below) safe.
When to use
- Before
/ship-milestone M<N> runs, as a pre-release sanity check.
- After landing any FR that touches
skills/setup/SKILL.md, skills/spec-write/SKILL.md, skills/implement/SKILL.md, skills/gate-check/SKILL.md, skills/spec-archive/SKILL.md, or any of the templates/ files.
- Not for every commit, not in CI — this is slow (~10 minutes wall-clock per tracker; ~11–14 min wall-clock for a tandem run, see § Operator-driven parallelism) and produces real Linear/Jira writes.
Operator-driven parallelism
One /smoke-test invocation per registered leg may run concurrently, one per terminal, without filesystem collision or artifact-overwrite races. Per-tracker artifact isolation makes this safe by construction:
-
--tracker linear writes to ../dpt-test-project-linear, to /tmp/dpt-smoke-linear-*.{log,pid,rc,start,attempt}, /tmp/dpt-smoke-mcp-config-linear.json, /tmp/dpt-smoke-verdict-linear.json, /tmp/dpt-smoke-findings-<date>-linear.md and /tmp/dpt-smoke-<date>-linear-approval.txt, and to tests/fixtures/socratic-first-turn/<skill>-linear-<YYYY-MM-DD>.json.
-
--tracker jira --jira-project <KEY> writes to ../dpt-test-project-jira, to /tmp/dpt-smoke-jira-*.{log,pid,rc,start,attempt}, /tmp/dpt-smoke-mcp-config-jira.json, /tmp/dpt-smoke-verdict-jira.json, /tmp/dpt-smoke-findings-<date>-jira.md and /tmp/dpt-smoke-<date>-jira-approval.txt, and to tests/fixtures/socratic-first-turn/<skill>-jira-<YYYY-MM-DD>.json.
-
--tracker none writes to ../dpt-test-project-none and to exactly the same <tracker>-keyed classes as the two rows above — /tmp/ scratch, findings file, verdict artifact, approval record, Phase 8 fixtures — with its own segment substituted throughout. It writes no wrapped MCP config: there is no tracker MCP to wrap, so the leg has one fewer artifact class rather than an empty one (AC-STE-448.4).
Its per-leg paths are deliberately NOT spelled out here, and the omission is a contract rather than an oversight (STE-446 § AC.4 hazard). tests/m116-ste-423-tracker-scoped-artifacts.test.ts scans this document for every dpt-smoke- literal and requires each to carry a tracker segment matching its own closed <tracker>|${TRACKER}|$TRACKER|linear|jira alternation — an alternation that predates the third leg and that AC-STE-446.4 forbids editing. So writing out a per-leg scratch path for any leg that alternation does not recognize turns the meta-test red — including inside a paragraph explaining the hazard, which is how this sentence reached its third draft — and per-leg ENUMERATION therefore belongs in /conformance-loop, which STE-423 declares out of scope. The <tracker>-templated statement above is the whole claim; substituting none into it yields the paths, and § Output lists the classes.
What the isolation actually rests on — the tracker segment (STE-423). The safety claim is not "the two legs happen to differ"; it is that every path either leg writes, globs, or reaps carries the resolved <tracker> segment, so the two legs' path sets are disjoint by construction and no glob one leg expands can name a file the other leg owns. Four classes carry it: the test-project directory, the /tmp/ per-run scratch (logs, rc / start / attempt markers, wrapped MCP config, findings file, approval record), the Phase 8 transcript fixtures, and — the class that bites hardest when it is missed — the pidfile globs used for liveness detection and for reaping (/tmp/dpt-smoke-<tracker>-*.pid). The pidfile class is load-bearing in a way the others are not, because its glob feeds a kill, not merely a write: an unscoped pidfile glob would expand onto the partner leg's pidfiles, and the ps -p <pid> -o comm= identity check does not rescue the partner — the partner's grandchild is a genuine claude process and sails straight through that guard. So the claim extends exactly as far as the tracker segment does, and no further: any path that drops the segment drops the guarantee with it. Each invocation also owns its own approval gate (Phase 0 — Pre-approval), its own teardown checklist, and its own trace. Phase 0.5 cleanup honors the same invariant: it is per-tracker-scoped — each leg removes only its own stale scratch, including /tmp/dpt-smoke-mcp-config-<tracker>.json, so neither leg can delete the config the other leg's Phase 1 step 5 just wrote.
No combined-mode flag. A comma-separated --tracker value does not exist at this layer; there is no parent-side fan-out, no console-multiplexing, and no merged findings file — each terminal emits its own /tmp/dpt-smoke-findings-<date>-<tracker>.md. If a combined view is needed, read the per-leg findings files side-by-side, or run /conformance-loop, which owns the fan-out and its --legs selector. This was a deliberate brainstorm choice (2026-04-30, approach 1 selected over approaches 2 and 3) — minimum viable surface area, clean failure isolation per tracker, no merged-findings logic to maintain.
Rate-limit caveat. Both child chains bill against the same Anthropic API key concurrently, so the wall-clock win is below 2× — the API throttles the two streams against a shared budget. A typical tandem completes in ~11–14 min wall-clock vs. ~10 min solo, not 5 min. This is expected and acceptable; plan for ~70% of theoretical 2×. If the wall-clock win is consistently below 1.3× across multiple tandem runs, file a follow-up FR with measured traces — the next milestone may revisit (e.g., approach 2 — one driver, fan-out in Phase 2 only).
Failure isolation. A hang or abort in one terminal does not stall the other; each invocation is self-contained. The operator can Ctrl-C one and let the other complete. Phase 5 teardown runs independently per tracker.
Argument parsing
Parse $ARGUMENTS once, before any pre-flight runs:
-
--tracker linear|jira|none — pick the leg (canonical chain target). Default linear for back-compat — pre-M44 invocations (no flag) MUST behave byte-for-byte identically to the Linear path. Any value outside the alternation above ⇒ NFR-10 canonical refusal naming the unknown value and the supported set.
The alternation is DERIVED, not a hand-maintained literal (STE-448 AC.1). It restates SMOKE_LEGS from adapters/_shared/src/smoke_fixture_groups.ts, and adapters/_shared/src/leg_prose_surfaces.ts binds every --tracker alternation in this document to that enum, so a leg added to the authority turns this bullet RED until it catches up. none was admitted by widening the enum, not by typing a third value beside the other two — the whole point of STE-446 making the enum the sole authority was that the next leg would not be a hand edit. The supported set is stated exactly once per site and never re-derived in prose beside it: an earlier revision of this bullet carried a second copy as Any value outside {linear, jira}, which is precisely the drift shape that survives a widened enum.
-
--jira-project <KEY> — required when --tracker jira is passed; ignored on every other leg. Carries the Atlassian Space (Jira project) key (e.g., DST); pre-flight #8 verifies visibility before Phase 1 runs.
-
--reset — boolean, default off. When present, pre-flight #2's existing-test-project refusal is replaced by an auto rm -rf ../dpt-test-project-<tracker> so the run continues against a clean slate. Surfaces in the Phase 0 contract as a separate operator-visible line. Default behavior unchanged — without --reset, pre-flight #2 still refuses.
-
--keep, --linear-team, --feature-stub — unchanged.
Resolved values flow into the rest of the skill: pre-flights #3 / #5 fire on the Linear path, #7 / #8 / #9 fire on the Jira path; Phase 1 step 4 + step 5 + step 6 + Phase 2 setup answers + Phase 4 verify-on-disk + Phase 5 teardown all branch on --tracker. Linear-mode invocations skip every Jira-only step verbatim; Jira-mode invocations skip every Linear-only step verbatim. The tracker-less leg skips BOTH sets — every step above that is scoped to a named tracker is skipped on --tracker none, which is why that leg reaches no MCP pre-flight at all (AC-STE-448.4). No path runs two adapters in one invocation, and the tracker-less path runs none.
Pre-flight refusals
Each fires before any side effects, exits non-zero with an NFR-10-shape message. Pre-flights #3 / #5 are Linear-only and fire only when --tracker linear (default) is active; pre-flights #7 / #8 / #9 are Jira-only and fire only when --tracker jira is active. Pre-flights #1, #2, #4, #6, #10 always fire regardless of --tracker:
The tracker-less leg fires ZERO MCP pre-flights (STE-448 AC.4). Every MCP-touching probe in this list is scoped to a named tracker — #3 and #5 to Linear, #7, #8 and #9 to Jira — so on --tracker none the set that fires is exactly the tracker-agnostic five (#1, #2, #4, #6, #10), and not one of them calls an MCP tool. That is a consequence of the existing scoping rather than a new exemption written for this leg: no probe had to be taught to skip itself, because none of them was ever unscoped. The property is asserted rather than asserted-by-eye — a test enumerates the MCP-bearing probes and fails if any of them loses its tracker scope, which is the only way this could silently regress.
-
Not in the dev-process-toolkit repo. pwd must end in /dev-process-toolkit. The skill writes to ../dpt-test-project-<tracker> (a sibling of the repo); running it from elsewhere creates the test project in the wrong place.
-
../dpt-test-project-<tracker> already exists (per-tracker basename — one path per registered leg, e.g. ../dpt-test-project-linear for --tracker linear). Refuse unless --keep was passed at the previous invocation against the same tracker (in which case verify the dir is empty / matches the expected post-teardown shape). --reset escape hatch: when --reset is present, this refusal is suppressed and the driver runs rm -rf ../dpt-test-project-<tracker> before continuing — surface in the Phase 0 contract as a separate operator-visible line. Default behavior unchanged — without --reset (or --keep), pre-flight #2 still refuses on existing dir, and the operator must rm -rf manually. The refusal message names the per-tracker path so a Linear run does not refuse just because a concurrent Jira run owns ../dpt-test-project-jira (operator-driven parallelism, see § Operator-driven parallelism).
-
(Linear-only) Linear MCP not available in ~/.claude-st/ config. The skill calls Linear via mcp__linear__* tools through the child claude-st sessions; without the MCP server registered, those calls fail mid-run and leave half-created issues.
-
Uncommitted changes in the toolkit repo. The skill doesn't modify the toolkit repo, but a dirty tree means the operator may be mid-feature; surface this before tying up 10 minutes on a smoke run that may be against a moving target.
-
(Linear-only) Linear team key not resolvable. Default STE; override with --linear-team. Probe by key first — call mcp__linear__get_team with the team key (e.g., STE) directly, OR call mcp__linear__list_teams (no query=, large limit=) and filter the response on team.key == "<TEAM_KEY>". The key path is exact and resolves the canonical operator entry point on first try. matching is kept only as a fallback for legacy paths where the key probe misses (e.g., the operator passes a team display-name fragment instead of a key); fall back only after the key probe yields no hit. A bogus key fails with NFR-10 canonical refusal naming the unknown key and the supported keys (smoke #7 F1 — without this ordering, is rejected as a name-prefix miss even though it's the canonical key).
Flow
The flow is six phases. Each phase prints its name + status (RUN / PASS / FAIL / SKIP) so the operator can follow along. On any FAIL, the phase reports what happened and offers to continue or abort.
Phase 0 — Pre-approval gate
The skill spawns claude-st -p children in default permission mode and pre-creates .claude/settings.json + .mcp.json from the parent's Bash tool. The tracked .claude/settings.json carries a permissions.allow allow-list (STE-252) enumerating every tool surface the chain needs — Bash command patterns, Edit/Write/Read/Grep/Glob, mcp__linear__* / mcp__atlassian__*; children read it from the spawn cwd and run hands-off within that scope. The parent still pre-creates .claude/settings.json + .mcp.json because the harness's sensitive-path classification of those two files survives even default permission mode at the child's model layer, so a child can neither create either file nor overwrite one with a direct full-file Write; the parent's Bash heredoc (shell I/O is not subject to that classification) is the only path that brings them into being. A /setup child can still merge into a .claude/settings.json the parent already wrote — measured 2026-07-27, see Phase 1 step 6. See the Threat model section below for the residual-risk picture under the tracked-allow-list posture. The historical alternatives (acceptEdits + per-path Write, plain bypassPermissions without parent pre-creation) were both empirically falsified during early dogfooding (STE-185); the current default-mode + content-rich permissions.allow is neither.
Print this contract to the operator and prompt for y to proceed:
The "Real writes will occur" line branches on --tracker:
- Linear path:
Real Linear writes will occur (test project + ~6 issues).
- Jira path:
Real Jira writes will occur in Space <flag-value> (~6 work items, all carrying the dpt-smoke label so Phase 5 can transition them to Done).
- Tracker-less path:
No tracker writes of any kind will occur — this leg is mode: none end to end. Identity is minted locally, and release is proved by the deletion of .dpt/locks/<id> rather than by a ticket transition.
/smoke-test will:
1. Pre-create .claude/settings.json and .mcp.json from the driver process
(parent's Bash heredoc, not subject to the child's sensitive-path block).
2. Spawn claude-st child sessions in ../dpt-test-project-<tracker> in
default permission mode (per-tool-call enforcement via the tracked
permissions.allow allow-list in .claude/settings.json).
<rendered-tracker-line>
Path-safety pre-flights have verified the test-project path is a true sibling
of the toolkit repo (basename "dpt-test-project-<tracker>", one of the closed
allow-list holding exactly one entry per registered leg, under a
workspace/ ancestor, not a symlink, not the toolkit repo itself).
That check fixes the spawn working directory every child starts in. It does
NOT bound where a child's writes land once it is running: children inherit
your global Claude configuration, so a write outside the test project is
possible. Review your own global instructions before approving.
A concurrent run against another leg (see § Operator-driven
parallelism) writes to its own basename and never touches this one.
CAVEAT: smoke test exercises /setup's "files-already-exist, idempotent merge"
branch, NOT the fresh-create branch. Fresh-create coverage requires a separate
manual probe.
<rendered-reset-line>
Proceed? [y/n]
When --reset is present, substitute <rendered-reset-line> with the literal line:
RESET: existing ../dpt-test-project-<tracker> will be deleted before run.
When --reset is absent, drop the placeholder (no blank line emitted).
Substitute <rendered-tracker-line> with the per-tracker line above before printing — never present the literal <rendered-tracker-line> placeholder to the operator. Substitute <tracker> with the resolved leg — likewise never print the placeholder literal.
Refuse on n. On y, log the approval to /tmp/dpt-smoke-<date>-<tracker>-approval.txt and proceed.
requires-input: Phase 0 acceptance — STE-232 AC-STE-232.6. This Phase 0 acceptance gate carries the cross-skill requires-input: contract documented in docs/auto-mode-protocol.md. The three-branch resolution mirrors the protocol's Refusal Mechanism:
- Marker present (
<dpt:auto-approve>v1</dpt:auto-approve> observed in the parent prompt body) ⇒ default-applied: log the approval automatically and proceed without prompting. The Phase 2 driver heredoc-injects this marker as the first body line of every canonical claude -p child spawn — that injection is the canonical worked example of the default-apply mechanism (STE-226 cross-reference; see docs/auto-mode-protocol.md § Default-Apply Mechanism).
- Marker absent + non-interactive stdin (e.g., parent piped
< /dev/null) ⇒ refused: route through requireOrRefuse(...) so the failure surfaces as RequiresInputRefusedError with NFR-10 canonical shape — Verdict / Remedy / Context — rather than silent imputation. The smoke driver MUST NOT model-impute "y" because the operator described an unattended run; that is the v2.13.0 incident shape this FR closes.
- Marker absent + interactive stdin ⇒
user-supplied: prompt the operator and gate on their y/n answer as today.
Phase 2's heredoc-injected <dpt:auto-approve>v1</dpt:auto-approve> body line is the byte-checkable token children check for; the canonical injection sites in this driver are documented in docs/auto-mode-protocol.md § Default-Apply Mechanism so a future skill author has one place to look.
Phase 0.5 — Clear stale per-run scratch
After Phase 0 acceptance, before Phase 1.1, unconditionally clear stale per-run scratch from prior invocations, then verify the wipe on disk. Every per-run scratch class is wiped (widened per STE-358) — prompt-template scratch files, and every per-run artifact keyed on the resolved tracker: per-skill logs, pidfiles, rc files, start markers, attempt logs, and the resolved tracker's own wrapped MCP config from a prior run:
bash -c 'rm -f /tmp/dpt-smoke-prompt-*.txt /tmp/dpt-smoke-<tracker>-*.log /tmp/dpt-smoke-<tracker>-*.pid /tmp/dpt-smoke-<tracker>-*.rc /tmp/dpt-smoke-<tracker>-*.start /tmp/dpt-smoke-<tracker>-*.attempt* /tmp/dpt-smoke-mcp-config-<tracker>.json'
# Verify on disk — the wiped globs must yield zero survivors (no output expected):
bash -c 'ls /tmp/dpt-smoke-prompt-*.txt /tmp/dpt-smoke-<tracker>-*.log /tmp/dpt-smoke-<tracker>-*.pid /tmp/dpt-smoke-<tracker>-*.rc /tmp/dpt-smoke-<tracker>-*.start /tmp/dpt-smoke-<tracker>-*.attempt* /tmp/dpt-smoke-mcp-config-<tracker>.json 2>/dev/null'
Verified wipe (STE-358; iter-2 F2). The post-rm ls in the fence above is the pass condition: the wiped globs must yield zero survivors on disk (the ls prints nothing and exits non-zero). If any survivor is listed, refuse to proceed (NFR-10), naming the survivors in the refusal — do not continue to Phase 1.1 with stale scratch present. Self-reported "scratch cleared" without the on-disk assertion is forbidden: the iter-2 (2026-07-02) driver reported "Phase 0.5 — PASS (scratch cleared)" while the morning run's per-skill logs survived on disk, and a stale result-bearing log can false-pass downstream chain-completeness checks.
This closes smoke #6 F1 / smoke #7 F2 / smoke #7 F4 — stale prompt-template scratch files left over from prior runs caused Write-tool errors and stale-content reuse (a 2026-04-27 Linear-flavored prompt stub re-fired on a later Jira run). Clearing per-skill logs keyed on the resolved tracker prevents cross-run log smear when re-running against the same tracker. The /tmp/dpt-smoke-mcp-config-<tracker>.json path (smoke #9 / Linear F1; scoped per STE-354) removes only the resolved tracker's own wrapped config so Phase 1 step 5 always starts from a clean filesystem regardless of whether the operator uses the Write tool or a Bash heredoc to produce it. The STE-186 stale-cleanup intent is preserved and staleness coverage is unchanged — each leg cleans its own stale config, so every stale mcp-config file is still removed before the leg that owns it re-runs. The cross-tracker mcp-config glob was dropped (2026-07-02 F1) and must not be widened back: it races the concurrent tandem leg — under operator-driven parallelism, one leg's Phase 0.5 rm could delete the wrapped config the other leg's Phase 1 step 5 had just written.
Defense-in-depth annotation (STE-185). The dpt-smoke-prompt-*.txt glob in the rm above is now defense-in-depth, not load-bearing — post-STE-185, the driver no longer writes any prompt-template scratch files to disk (heredoc-on-stdin replaces them; see Phase 2 § STE-185 below). On post-STE-185 runs, the glob is expected to be a no-op. A non-empty match indicates either a pre-STE-185 (legacy) driver run on this machine or stale files left by an external process — keeping the cleanup line costs nothing and protects against transitional drift while older smoke driver versions could still be checked out elsewhere.
audit-trail invariant — do NOT delete /tmp/dpt-smoke-findings-*.md and /tmp/dpt-smoke-<date>-<tracker>-approval.txt. Those are audit-trail artifacts and are intentionally retained across runs (preserve them; never widen the rm to include the findings or approval prefix). The findings files accumulate across runs by design (one per tracker per date); the approval record is the operator's consent log and stays for forensics. Only the per-run scratch globs above are wiped — the findings file and approval record are explicitly excluded from cleanup (and from the post-rm ls verification).
Phase 1 — Setup
-
Create ../dpt-test-project-<tracker> and run bun init -y.
-
Remove bun init's stub CLAUDE.md (the plugin's /setup will overwrite it; cleaner to start blank).
-
cd ../dpt-test-project-<tracker> && git init -q && git add -A && git commit -q -m "chore: bun init scaffold".
-
Tracker workspace setup — branches on --tracker:
-
Linear path (--tracker linear, default). Create a Linear project under team STE via mcp__linear__save_project. The base name is DPT Smoke Test (<YYYY-MM-DD>).
Same-day collision auto-disambiguation (smoke #9 F4). Before save_project, call mcp__linear__list_projects(query="DPT Smoke Test (<YYYY-MM-DD>)") and filter the response on p.name.startsWith("DPT Smoke Test (<YYYY-MM-DD>") && p.name.endsWith(")") (precise equality vs. Linear's substring query semantics). On zero matches, save with the canonical name verbatim (no suffix). On one or more matches, parse the suffix integers from each match's name ((<YYYY-MM-DD>) ⇒ 1, (<YYYY-MM-DD>-v2) ⇒ 2, etc.), pick the smallest integer N ≥ 2 not present in the match set, and save as DPT Smoke Test (<YYYY-MM-DD>-v<N>). The scheme is deterministic and inspectable.
- Worked example. Smoke #8 lands first on a given day → name
DPT Smoke Test (2026-05-01). A same-day smoke #9 finds the prior project → name auto-resolves to DPT Smoke Test (2026-05-01-v2). A hypothetical same-day smoke #10 → DPT Smoke Test (2026-05-01-v3).
Save the project ID + URL to the findings file's header. The Phase 1 setup print line at step 7 (which prints the project URL) carries the resolved name implicitly — a suffixed name in the print indicates the same-day collision was auto-handled; an unsuffixed canonical-name print indicates no prior project existed.
-
Tracker-less path (--tracker none). SKIPPED ENTIRELY — no workspace is created, and no MCP tool is called (STE-448 AC.4/AC.6). There is no remote workspace for a mode: none project to bind to: identity is minted locally by and the milestone token is derived from it, so there is nothing to allocate and nothing to name. The findings file's header records and no workspace URL, because there is no URL to record — an empty or placeholder URL field would read as a failed lookup rather than as the absence it is. This is also the step that makes Phase 5's tracker-side teardown vacuous on this leg: teardown archives what setup created, and setup created nothing.
6b. Workspace trust is an operator PRECONDITION (STE-367 supersedes STE-356's self-seed). Grandchildren spawned in a fresh test-project cwd ignore the scaffolded .claude/settings.json allow-list until the workspace is trusted — captured logs open with "Ignoring N permissions.allow entries from .claude/settings.json: this workspace has not been trusted" (2026-07-02 conformance finding F4), leaving the STE-252 policy artifact inert at the grandchild layer. Workspace trust lives in the operator's live $CLAUDE_CONFIG_DIR/.claude.json under projects["<abs test-project path>"].hasTrustDialogAccepted (the absolute path is $TEST_REAL from pre-flight #6's realpath resolution — parent-dir realpath + hard-coded basename).
The driver does not write this entry. The harness auto-mode self-modification classifier reliably denies the programmatic hasTrustDialogAccepted write under claude -p ([Self-Modification] … not explicitly requested by the user; 2026-07-04 conformance finding F1), so a self-seed cannot run hands-off — and covertly retrying past that guard is out of bounds. Trust is therefore an operator precondition: it is asserted (not written) by the Workspace-trust spawn gate below, before the first Phase 2 spawn, and the gate's refusal hands the operator the one-line jq seed. Because the two test-project paths are fixed and operator-owned, a seeded entry persists across runs — there is no per-run re-seed, and Phase 5 teardown no longer removes it (STE-367). This removes the STE-356 backup + cross-leg spinlock + read-merge-write entirely: the driver never mutates the live config, so there is nothing to back up, lock, or roll back.
- Print: "Setup phase complete. Test project: ../dpt-test-project-; tracker: ; <Linear project URL | Jira Space key + site URL |
no tracker workspace (mode: none)>; MCP config: </tmp/dpt-smoke-mcp-config-<tracker>.json | none constructed (mode: none)>; sensitive files pre-created."
Phase 2 — Run the canonical chain
Phase-2-entry context probe — SMOKE-CTX (STE-365)
Before the first grandchild spawn, run this deterministic stdin-tty probe and read its banner line. It turns the driver's belief about its own execution context into an observed fact at the decision point — the fix for F3 (2026-07-04 conformance run: the driver misidentified its own claude -p context as interactive, fired the /setup grandchild via run_in_background, then ended the turn awaiting a completion notification that under claude -p never arrives, so /setup was torn down mid-run):
# Phase-2-entry context probe — run ONCE and read the banner before any spawn.
if [ -t 0 ]; then
echo "SMOKE-CTX: interactive tty"
else
echo "SMOKE-CTX: headless (claude -p) — background-task notifications will NOT arrive; the ONLY sanctioned wait is the bounded kill-0 poll. Do NOT run_in_background, do NOT Monitor, do NOT yield the turn to await a grandchild."
fi
HARD GATE — the banner is binding, not advisory (STE-414). The [ -t 0 ] result is the sole determinant of headless-vs-interactive for this leg: whatever the probe prints is the execution context. A SMOKE-CTX: headless (claude -p) banner is the common case for a /conformance-loop-spawned leg, and that classification is BINDING for the remainder of the run — it cannot be overridden by any later self-narration, reasoning, or belief to the contrary, no matter how confident. Only a SMOKE-CTX: interactive tty banner means a human is at the keyboard; the driver may not proceed on an "interactive" self-classification while stdin is non-tty.
The forbidden rationalization is byte-pinned. The driver MUST NOT self-narrate itself as an "interactive parent" — the verbatim 2026-07-24 Jira-leg wording — while stdin is non-tty. That self-narration is forbidden, carries no authority to override the banner, and does NOT re-open any background-wait or turn-yield path: under a headless banner every grandchild wait MUST use the bounded kill -0 poll-until-exit loop below — never run_in_background, never the Monitor tool, never ending the turn to await a completion notification (F3). Advisory prose was the escape hatch that let STE-355 → STE-357 → STE-365 each get narrated past; there is no discretion left here to exercise.
Headless-gate violation ⇒ abort with full teardown. If the driver finds it has violated this hard gate — acted on an "interactive" self-classification under a headless banner, spawned via run_in_background, reached for Monitor, or yielded the turn awaiting a grandchild — the leg is void. It MUST abort immediately and run ### Phase 5 — Teardown (archive/close the tracker project + rm -rf the test directory) before exiting, so a violated run never leaves orphaned tracker data or test directories behind (the 2026-07-24 failure mode on both legs). Reap first: before that teardown runs, kill every PID recorded in a still-answering pidfile (identity-checked exactly as the Final-message self-check clause's reap-first rule requires) and then bash -c 'rm -f /tmp/dpt-smoke-<tracker>-*.pid', because rm -rf-ing a directory a live grandchild is still writing into races it. Both halves of that removal are load-bearing (STE-423): the glob carries the resolved <tracker> so a tandem partner's pidfiles are never in the match set, and the bash -c wrapper is required because the operator's shell is zsh, where an unmatched glob is an error that would kill the abort itself instead of expanding to nothing.
Spawn one claude-st -p child per skill, sequentially. Each child:
- Has
cwd=../dpt-test-project-<tracker>.
- Is invoked as bare
claude -p ... with CLAUDE_CONFIG_DIR=~/.claude-st exported once at the top of the spawning Bash block (STE-350: exported rather than inlined so every spawn line begins with claude and the tracked Bash(claude:*) allow entry matches) — NOT claude-st -p, because the claude-st zsh alias does not expand inside the parent harness's Bash tool.
- Runs in default permission mode and reads the tracked
.claude/settings.json permissions.allow allow-list (STE-252) from the spawn cwd. The allow-list covers the chain's normal Bash + MCP operations at command-pattern granularity. NOT sufficient alone for creating either .claude/settings.json or .mcp.json, nor for a direct full-file Write of one — the harness's sensitive-path classification of those two files survives default permission mode at the child's model layer, which is why Phase 1 step 6 pre-creates them from the parent. It does not follow that a child cannot touch them at all: a /setup merge into an already-existing settings.json succeeded on 2026-07-27 (§ Phase 1 step 6 — Measured correction), so the classification bounds creation and whole-file replacement, not every write. Combined: tracked allow-list for the bulk of the chain + parent-pre-creation for the sensitive paths = end-to-end runnable.
- Passes
--mcp-config /tmp/dpt-smoke-mcp-config-<tracker>.json (built in Phase 1 step 5; linear entry on the Linear path, atlassian entry on the Jira path). --plugin-dir (used to load the in-tree plugin under test) shadows plugin-loaded MCPs, so the active tracker MCP must be passed via --mcp-config from a per-tracker wrapper file written to /tmp/. The per-tracker filename keeps a concurrent run against another leg from clobbering this run's config (operator-driven parallelism). On the tracker-less path the flag is OMITTED, not emptied (STE-448 AC.4): Phase 1 step 5 constructed no config, so every --mcp-config … occurrence in the reference snippets below is dropped from the spawn line on that leg rather than pointed at a file that does not exist. A --mcp-config naming a missing path is a startup error, and one naming an empty envelope is a false claim; omission is the only shape that is both true and runnable.
- Passes to load the in-tree plugin under test (not the cached version under ).
Skills to run, in order:
-
/dev-process-toolkit:setup — pre-baked answers branch on --tracker:
- Linear path:
stack=Bun+TS, tracker=linear, mcp_server=linear, team=STE, project=<the smoke-test project from Phase 1>, jira_ac_field=blank, branch_template=default, docs flags=all-false. The pre-baked workspace-binding sub-section emits ### Linear with team: + project: (and optionally default_labels: if downstream callers want labels — not used by the Linear smoke today).
- Jira path:
stack=Bun+TS, tracker=jira, mcp_server=atlassian, project=<--jira-project flag value>, jira_ac_field=description, branch_template=default, docs flags=all-false, default_labels=[dpt-smoke]. The pre-baked workspace-binding sub-section emits ### Jira with project: + default_labels: so the Jira adapter forwards dpt-smoke into every mcp__atlassian__createJiraIssue.additional_fields.labels call. Skip Jira AC custom-field discovery — the pre-baked jira_ac_field: description answer short-circuits /setup step 7b's discover_field.ts call (zero-config sentinel path). Skip the Linear team/project probe — the workspace binding is fully resolved from the flag.
- Tracker-less path:
stack=Bun+TS, tracker=none, docs flags=all-false. No mcp_server, no workspace binding, no jira_ac_field, and NO ## Task Tracking section in the emitted CLAUDE.md (STE-448 AC.5). Step 7b's option 1 is what the pre-baked tracker_mode: none answer selects, and its documented behavior is to emit no such section — absence is the canonical form, so the correct output is a CLAUDE.md with the section missing rather than one declaring mode: none. Both the Linear and Jira probes are skipped for the same reason there is nothing to probe. Phase 4's verify-on-disk row asserts the ABSENCE positively (§ Phase 4), because a section that was never written and a section nobody checked for produce the same file and different evidence.
In both modes, the prompt MUST acknowledge the pre-existing .claude/settings.json and .mcp.json (Phase 1 step 6) and instruct the child to take the idempotent-merge branch — do not blindly let it try to overwrite, since the sensitive-path classification block (see Phase 0 — Pre-approval gate) aborts the chain when the child attempts a fresh write. The canonical pre-baked prompt body is inlined into the Phase 2 child-spawn heredoc below (§ STE-185); do not write it to a file on disk.
Discretionary-halt guard — mid-run judgment calls (STE-414)
Scope. Under a SMOKE-CTX: headless classification, ANY mid-run judgment call the driver would otherwise resolve by asking the operator falls under this guard: a rate-limit / seven-day-usage warning, a cost pause, a reduced-run choice ("run one leg instead of two?"), and any new decision of the same shape that the auto-approve marker could not pre-authorize by name. There is no operator on the other end of a headless leg, so every such call MUST resolve deterministically off a single byte-checkable input — the presence of the auto-approve marker literal <dpt:auto-approve>v1</dpt:auto-approve> in the invoking prompt body. Two branches, no third.
Branch 1 — marker present ⇒ proceed. If the marker <dpt:auto-approve>v1</dpt:auto-approve> is present in the invoking prompt body, the judgment call is already pre-authorized: the driver MUST proceed with the FULL run — whole canonical chain, all fixtures, no self-imposed reduction — and log the decision in passing rather than pausing on it.
Branch 2 — marker absent ⇒ abort with full teardown. If the marker is absent, the leg holds no authority to decide for the operator and MUST abort immediately: run ### Phase 5 — Teardown (archive/close the tracker project + rm -rf the test directory), then exit non-zero. Abort-with-teardown is the ONLY sanctioned no-marker resolution; parking the leg mid-run is not one, because it strands exactly the tracker data and test directory that teardown exists to remove. Reap first: before that teardown runs, kill every PID recorded in a still-answering pidfile (identity-checked exactly as the Final-message self-check clause's reap-first rule requires) and then bash -c 'rm -f /tmp/dpt-smoke-<tracker>-*.pid', because rm -rf-ing a directory a live grandchild is still writing into races it. Both halves of that removal are load-bearing (STE-423): the glob carries the resolved <tracker> so a tandem partner's pidfiles are never in the match set, and the bash -c wrapper is required because the operator's shell is zsh, where an unmatched glob is an error that would kill the abort itself instead of expanding to nothing.
There is NO prose-ask-then-end-turn path under non-tty. Stating the question in prose and ending the turn is not a pause under a headless banner — it is a silent no-op: the leg exits rc=0, the canonical chain never runs, and the tracker project is left orphaned. That is the verbatim 2026-07-24 Linear-leg failure: the driver asked the operator a 3-option rate-limit question, ended its turn, and left the Linear project behind with the chain unrun. So under non-tty there is no prose-ask, no end-the-turn-and-await-an-answer, and nothing between Branch 1 and Branch 2 to exercise discretion over.
Phase 0's pre-approval [y/n] gate is UNAFFECTED. That gate fires before the run starts and is already marker/refusal-routed (§ Phase 0 — Pre-approval gate). This guard governs only judgment calls that surface after the run is under way.
Workspace-trust spawn gate (STE-356; STE-367)
Before the first Phase 2 spawn fires, assert that workspace trust is present for the test-project path — an operator precondition per step 6b (the driver never writes it). The scaffolded .claude/settings.json allow-list is enforcement-effective only when the spawn cwd's workspace is trusted; spawning without the entry re-creates the 2026-07-02 F4 inert-allow-list state, where every child ran on auto-mode classifier goodwill:
# Read-only probe; default the config dir (the Phase 2 export may not have run yet).
CFG="${CLAUDE_CONFIG_DIR:-$HOME/.claude-st}/.claude.json"
jq -e --arg p "$TEST_REAL" \
'.projects[$p].hasTrustDialogAccepted == true' "$CFG" > /dev/null
Miss (exit non-zero) ⇒ NFR-10 canonical refusal — do not spawn:
Verdict: workspace trust missing for <abs test-project path> in $CLAUDE_CONFIG_DIR/.claude.json — the scaffolded allow-list would be inert at the child/grandchild layer (2026-07-02 F4).
Remedy: seed workspace trust ONCE as the operator (the driver cannot — the harness self-modification classifier denies the write under `claude -p`):
CFG="${CLAUDE_CONFIG_DIR:-$HOME/.claude-st}/.claude.json"
jq --arg p "<abs test-project path>" '.projects[$p] |= (. // {} + {hasTrustDialogAccepted: true})' "$CFG" > "$CFG.tmp" && mv "$CFG.tmp" "$CFG"
Then re-run /smoke-test. The entry persists across runs (operator-owned; teardown no longer removes it — STE-367).
Context: skill=smoke-test, pre-flight=workspace_trust_check
Hit (exit 0) ⇒ log the byte-checkable capability token workspace_trust_present to the approval record /tmp/dpt-smoke-<date>-<tracker>-approval.txt (one literal line, no inference) and proceed to the first spawn. Same shape convention as /conformance-loop's spawn_pattern_allow_present token — byte-grep-checkable by downstream /gate-check probes and capability-row aggregators. (STE-367 renamed the former seed-time token: the driver asserts trust is present, it no longer seeds it.)
Grandchild spawn lifecycle — detached spawn + bounded poll-until-exit (STE-355)
A single foreground Bash call caps its child at the harness's 10-minute (600 s) per-call ceiling — the 2026-07-02 conformance run SIGTERM'd the /implement grandchild at exactly that ceiling (finding F2: RED→GREEN→REFACTOR completed; AUDIT and the commit never ran). Canonical-chain grandchildren routinely need longer, so no per-skill spawn may run in the foreground. Every canonical-chain spawn uses the detached-spawn + bounded-poll wrapper:
Detached spawn with PID capture (one Bash call). Background the claude -p invocation and capture its PID in the same call: claude -p … > /tmp/dpt-smoke-<tracker>-<skill>.log 2>&1 & echo $! > /tmp/dpt-smoke-<tracker>-<skill>.pid. Heredoc-on-stdin (§ STE-185 below) composes unchanged — the shell reads the heredoc body before the job detaches; the < /dev/null discipline for non-prompt-bearing children likewise composes. The reference snippets below carry the shape.
Bounded poll-until-exit (repeated bounded Bash calls). After the spawn call returns, poll until the PID exits. Each poll call is a bounded multi-iteration loop — up to 18 checks 30 s apart, ≈ ≤540 s (≈ 9 min) per call, safely under the harness's 600 s (10-minute) per-call ceiling. That is one Bash call per ~9 min instead of ~80 single-check calls across a 40-minute grandchild; the old single-check-then-end-call shape is not sanctioned. Never fold the whole wait into one unbounded call:
# One bounded poll call — up to 18 checks × 30 s ≈ 9 min (≤540 s), under the
# harness's 600 s per-call ceiling. Repeat this call until it reports "exited".
for i in $(seq 1 18); do
kill -0 "$(cat /tmp/dpt-smoke-<tracker>-<skill>.pid)" 2>/dev/null || break
sleep 30
done
if kill -0 "$(cat /tmp/dpt-smoke-<tracker>-<skill>.pid)" 2>/dev/null; then
echo "still running — poll again"
else
rm -f /tmp/dpt-smoke-<tracker>-<skill>.pid; echo "exited — proceed"
fi
Post-exit steps compose on top, unchanged. The STE-195 transient-failure detection, the STE-352 capture assertion, and the next sequential spawn all run only after the poll loop reports "exited" — detection runs after exit exactly as it did in the foreground form.
Residual risk — PID reuse. kill -0 answers for any live process with that PID, so a recycled PID could in principle keep the poll looping after the grandchild exited. The risk is negligible at a 30 s poll interval on macOS/Linux PID ranges, and the Phase 2.Y chain-integrity assertion is the corroborating signal (a truncated child's capture fails the result-event check regardless of what the poll believed) — noted so the wrapper isn't mistaken for a liveness proof. That negligible-risk reading covers the polling loop only — a false positive there merely keeps the poll running. It does not carry to the abort branch's reap below, which sends a real signal: a recycled PID there would terminate an unrelated process, which is exactly why the reap must confirm identity with ps before it signals anything.
Residual risk — orphan-vs-killed nondeterminism (STE-359; iter-2 F3). If this driver dies while a grandchild is still live, whether that grandchild dies with its parent or survives as an orphan is environment-nondeterministic — process-group inheritance varies with spawn nesting, and iter-2 observed both outcomes in a single run (the Linear /setup grandchild was killed with its driver while the Jira one survived and completed healthily on its own). Process-group discipline (setsid / PGID-wide kill) was considered and rejected as the primary mechanism: it is OS/shell-dependent and unverifiable from SKILL.md prose. The deterministic recovery lives one layer up — /conformance-loop's Phase A orphan-adoption block scans this driver's per-skill pidfiles post-exit and adopts any still-answering PID, polling it to exit so a surviving orphan's completed capture is recovered as evidence regardless of which way the environment broke.
Live-pidfile session rule. Ending the driver session — or reporting results — while any spawned grandchild is alive (a pidfile whose PID still answers kill -0) is forbidden; the bounded poll loop above is the only sanctioned wait. Do not substitute a single foreground Bash call (the 10-minute ceiling SIGTERMs the grandchild — F2), and do not fire the spawn then end the turn "waiting for its completion notification" (a -p session cannot resume on background-task notifications, so the rest of the run silently never executes — F3). The poll's exit branch removes the pidfile, so a clean session end leaves zero live pidfiles.
Red flag — the harness's foreground-sleep block hint is NOT license to background the wait. If a poll call leads with sleep, the harness blocks it with an error hint that reads roughly "Foreground sleep is blocked. To wait for a condition, use run_in_background or the Monitor tool." Do not follow that hint here: handing the wait to run_in_background/Monitor and then ending the turn IS the F3 fire-and-exit failure — a -p driver session never receives the completion notification, so the rest of the run silently never executes. The bounded poll loop above already avoids the block by gating each iteration on kill -0 before its sleep 30; keep waiting with that loop, in the foreground, until the pidfile dies.
Final-message self-check (STE-357, hardened by STE-414). Before emitting any final message — success or failure — run the pidfile-liveness fence below over the run's own tracker-scoped pidfile glob (/tmp/dpt-smoke-<tracker>-*.pid — never a cross-tracker one, which would walk a tandem partner's pidfiles; STE-423). Two triggers arm this check: (1) an incomplete grandchild chain — the canonical chain was not run to completion; (2) any live pidfile — a spawned grandchild is still running. On either trigger the driver MUST loudly abort — emit an explicit SMOKE-ABORT: <trigger> line as the first line of the final message. The abort MUST exit non-zero — a loud banner is not sufficient, because rc is one of several corroborating signals the parent reads — the per-skill log set is another — and a false green must never be reported in the exit code. Signal only what this run spawned: for each PID recorded in a still-answering pidfile, confirm its identity with ps -p <pid> -o comm= and reap it only when that reports a claude process, because a PID recycled since the kill -0 probe would otherwise take a real signal aimed at an unrelated process on the operator's machine. The abort MUST reap FIRST, before anything destructive runs: kill every PID recorded in a still-answering pidfile, then rm -f /tmp/dpt-smoke-<tracker>-*.pid, so the invariant closing this paragraph holds on the abort branch instead of being aspirational. Only once that reap is done may the driver run ### Phase 5 — Teardown in full (archive/close the tracker project, rm -rf the test directory) before the turn ends — quiesce the grandchild first, then destroy the state it was writing into, because tearing down around a live process races it: the grandchild can still be writing into the directory being removed and still posting to the project being archived. A live pidfile must never end the turn quietly: if the chain is still runnable, resume the bounded poll loop above and finish it; if it is not, take the abort-with-teardown path. The two branches are ordered, not discretionary — resume is available only while the chain can still be finished in this same turn, and taking it means no final message is emitted at all; the moment finishing is off the table (the session is ending, the chain is unrunnable, or the remaining work would be picked up in a later turn) the abort-with-teardown path above is the only move left. There is no third branch in which the turn ends while a pidfile still answers kill -0.
Exiting rc=0 is not proof the chain ran. This driver must NEVER exit rc=0 silently with an unfinished chain or a live grandchild — a silent rc=0 exit under either trigger IS the failure mode this clause exists to stop (2026-07-24: both conformance legs exited rc=0 in ~8 min without running the chain and left orphaned tracker data behind). A silent success exit is legal only when the canonical chain completed AND zero pidfiles still answer kill -0. Stated unqualified, with no adverb left to argue over: the driver must never exit rc=0 on the abort branch, under either trigger, loud or not.