| 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 (per `--tracker`), ~10 min wall-clock. Two-terminal tandem runs (one per tracker) are supported. |
| argument-hint | [--tracker linear|jira] [--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 either the Linear path (default, --tracker linear) or the Jira path (--tracker jira --jira-project <KEY>) — the canonical chain (/setup → /spec-write → /implement → /gate-check → /spec-review → /simplify) is identical in both modes; 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.
Every per-run artifact is keyed on the resolved <tracker> (one of linear / jira): 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, the wrapped MCP config is /tmp/dpt-smoke-mcp-config-<tracker>.json, and the approval record is /tmp/dpt-smoke-<date>-<tracker>-approval.txt. This is what makes the two-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
Two /smoke-test invocations may run concurrently in two terminals, one per tracker, without filesystem collision or artifact-overwrite races. Per-tracker artifact isolation makes this safe by construction:
--tracker linear writes to ../dpt-test-project-linear and /tmp/dpt-smoke-*-linear.{md,log,json,txt}.
--tracker jira --jira-project <KEY> writes to ../dpt-test-project-jira and /tmp/dpt-smoke-*-jira.{md,log,json,txt}.
The two runs never touch the same path, never read the same MCP config, and never write the same findings file. Each invocation owns its own approval gate (Phase 0 — Pre-approval), its own teardown checklist, and its own trace. Phase 0.5 cleanup honors the same isolation 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. --tracker linear,jira does not exist; 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 two findings files side-by-side. 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 — pick the tracker mode (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 {linear, jira} ⇒ NFR-10 canonical refusal naming the unknown value and the supported set.
--jira-project <KEY> — required when --tracker jira is passed; ignored on the Linear path. 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 fire on the Jira path; Phase 1 step 4 + step 6 + Phase 2 setup answers + 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. No path runs both adapters in one invocation.
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 are Jira-only and fire only when --tracker jira is active. Pre-flights #1, #2, #4, #6, #10 always fire regardless of --tracker:
-
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 — ../dpt-test-project-linear for --tracker linear, ../dpt-test-project-jira for --tracker jira). 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. Name-prefix query=<TEAM_KEY> 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, STE is rejected as a name-prefix miss even though it's the canonical key).
-
Path-safety on the test-project location. Before spawning any child (see Phase 0), the driver MUST verify the resolved test-project path:
- Resolves with
realpath (no broken symlinks). On macOS realpath requires the path to exist; resolve via the parent dir + basename instead, since the test-project itself doesn't yet exist when this fires.
- Has the toolkit-repo path as its parent's parent (i.e. is a true sibling of
dev-process-toolkit, not an ancestor, child, or unrelated location).
- Basename matches one of the closed allow-list
{dpt-test-project-linear, dpt-test-project-jira} exactly — no other forms accepted (the bare dpt-test-project basename is intentionally rejected). Hard-coded by design; the cwd guard pins child spawns to two well-known throwaway paths, one per tracker.
- Is not a symlink, is not inside
$HOME directly (must be under a workspace/ ancestor), is not the toolkit repo itself.
Any failure refuses with NFR-10. This is the load-bearing cwd guard that pins the test-project path to one of two known throwaway directories — it bounds where the children operate, while the tracked permissions.allow allow-list (.claude/settings.json, STE-252) bounds what tool calls they may issue. The cwd guard no longer "justifies" any bypass posture; per-tool-call enforcement runs out of the tracked allow-list under default permission mode in Phase 2.
Reference implementation (originally verified 2026-04-27 against six adversarial cases — wrong-basename, not-sibling, symlink-decoy, is-toolkit, no-workspace-ancestor, canonical-good; M46 expanded the canonical-good case to two valid forms — dpt-test-project-linear and dpt-test-project-jira — and added three new negative cases — bare basename dpt-test-project, garbage-suffix dpt-test-project-foo, case-mismatch dpt-test-project-LINEAR):
TOOLKIT_REPO="$(pwd)"
TRACKER="${TRACKER:?--tracker must resolve to linear|jira before pre-flight #6}"
TEST_PATH="../dpt-test-project-${TRACKER}"
TOOLKIT_REAL="$(realpath "$TOOLKIT_REPO")"
TEST_DIR_REAL="$(realpath "$(dirname "$TEST_PATH")")" || exit 1
TEST_REAL="$TEST_DIR_REAL/$(basename "$TEST_PATH")"
case "$(basename "$TEST_REAL")" in
dpt-test-project-linear|dpt-test-project-jira) ;;
*) exit 1 ;;
esac
[ "$(dirname "$TEST_REAL")" = "$(dirname "$TOOLKIT_REAL")" ] || exit 1
[ ! -e "$TEST_REAL" ] || [ ! -L "$TEST_REAL" ] || exit 1
case "$TEST_REAL" in "$HOME"/workspace/*) ;; *) exit 1 ;; esac
[ "$TEST_REAL" != "$TOOLKIT_REAL" ] || exit 1
-
(Jira-only) Atlassian MCP not available in ~/.claude-st/ config. When --tracker jira is active, the chain calls Jira via mcp__atlassian__* tools through the child claude-st sessions; without the Atlassian Rovo MCP server registered AND OAuth-bound, those calls fail mid-run and leave half-created issues. Probe: call mcp__atlassian__atlassianUserInfo from the parent session before Phase 0 fires. Any error path — server not registered, OAuth token absent / expired, principal unauthenticated — refuses with NFR-10 canonical shape:
Atlassian Rovo MCP not loaded or not OAuth-bound.
Remedy: register the Atlassian Rovo MCP in ~/.claude-st/ and complete the one-time OAuth flow via mcp__atlassian__authenticate, then re-run /smoke-test --tracker jira.
Context: tracker=jira, probe=atlassianUserInfo, skill=smoke-test
Linear-mode invocations skip this probe entirely.
-
(Jira-only) Jira project (Space) not visible / --jira-project missing. When --tracker jira is active, --jira-project <KEY> is required and the configured key MUST appear in the response of mcp__atlassian__getVisibleJiraProjects(searchString=<KEY>) — i.e., the authenticated principal can see the Space. The probe inspects response.values[].key; refusal fires for the missing-flag case and the not-visible case across three message variants keyed on the response shape (smoke #9 / Jira run 1 driver-side caveat — STE-191):
| Response shape | Variant | Refusal message |
|---|
| flag missing | (a) | --jira-project <KEY> is required when --tracker jira is passed. |
values[] empty | (b) generic | Probe: mcp__atlassian__getVisibleJiraProjects(searchString=<X>) → response.values[].key did not contain '<X>'. |
values[] has exactly one entry whose name matched <X> and whose key did NOT match <X> | (c) single-hit name-match | '<X>' matched Space "<matched-name>" by display name, but the KEY is "<matched-key>". Pass --jira-project <matched-key> and retry. |
values[] has 2+ entries | (b) generic | (same as empty — surfacing one KEY out of N would mislead) |
--jira-project <KEY> is required when --tracker jira is passed.
Remedy: re-run /smoke-test --tracker jira --jira-project <KEY> with the Space key (e.g., DST).
Context: tracker=jira, flag=--jira-project, skill=smoke-test
Jira project '<KEY>' not visible to the authenticated principal.
Probe: mcp__atlassian__getVisibleJiraProjects(searchString=<KEY>) → response.values[].key did not contain '<KEY>'.
Remedy: create the Space in the Jira UI before running /smoke-test, or grant the OAuth principal membership; then re-run.
Context: tracker=jira, project=<KEY>, skill=smoke-test
Jira project '<KEY>' not visible — but '<KEY>' matched Space "<matched-name>" by display name, and the KEY is "<matched-key>".
Remedy: re-run /smoke-test --tracker jira --jira-project <matched-key>.
Context: tracker=jira, input=<KEY>, matched-name="<matched-name>", matched-key=<matched-key>, skill=smoke-test
Rationale for variant (c). Single-hit name-match is the unambiguous case where the operator clearly meant the matched Space — surfacing the resolved KEY is a cheap usability win that preserves the defensive refusal (the gate stays in place; the message just sharpens). Empty / multi-hit responses stay at the generic shape: surfacing one KEY out of zero or N would mislead the operator into rerunning with a wrong key. Silent auto-correction is rejected — refusal must fire so cases where the operator named the wrong Space entirely don't slip past.
Linear-mode invocations skip this probe entirely. The visibility result is cached for Phase 1 step 4 (which becomes a vacuous no-op when the probe already passed).
-
(Jira-only) Orphaned dpt-smoke ghost cluster. Optional warning probe — fires only when --tracker jira is active. JQLs the configured Space for unfinished dpt-smoke-labeled work items leftover from prior aborted / partial runs:
project = <flag-value> AND labels = "dpt-smoke" AND status != "Done"
Call mcp__atlassian__searchJiraIssuesUsingJql(cloudId=<resolved>, jql=<above>, fields=["summary","status","created","labels"]) and read response.issues.length. Warns (does not refuse) when the count exceeds a threshold (default 5; tunable inline). The run continues regardless — the operator decides when to run the manual sweep. Output line shape:
pre-flight #9: <N> orphaned dpt-smoke items in <flag-value> (status != Done) — consider one-time sweep before next run.
When the count is <= 5, emit a clean line pre-flight #9: 0 orphans (or count under threshold) and continue silently. Linear path skips the probe entirely — Linear's per-run project archival keeps no equivalent ghost cluster. Smoke #6 F5 motivated this; the cumulative count grows whenever Phase 5 teardown is interrupted before transitioning the just-created work items.
-
Child-spawn pattern present in the tracked allow-list (STE-351 AC-STE-351.1 — mirrors /conformance-loop pre-flight (f)). Always fires regardless of --tracker. Read the toolkit repo's tracked .claude/settings.json, JSON-parse it, and assert .permissions.allow contains the canonical child-spawn pattern literal Bash(claude:*) — a contains-check (jq -e '.permissions.allow | index("Bash(claude:*)")' .claude/settings.json), not merely a non-empty check. The Phase 1 step 6 scaffold snippet copies this allow-list into the test project's .claude/settings.json; if the pattern is absent, every nested claude -p spawn issued by the children is classifier-denied headless and the grandchildren die as 0-byte transcripts (the M94 false-green). Refuse with NFR-10 canonical shape:
permissions.allow lacks the child-spawn pattern "Bash(claude:*)" in .claude/settings.json.
Remedy: add "Bash(claude:*)" to the permissions.allow allow-list in the tracked .claude/settings.json (and keep the Phase 1 step 6 scaffold snippet in sync), then re-run /smoke-test.
Context: pre-flight=spawn_pattern_allow_check, file=.claude/settings.json, skill=smoke-test
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 cannot write them itself; the parent's Bash heredoc (shell I/O is not subject to that classification) is the only path. 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).
/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 {dpt-test-project-linear, dpt-test-project-jira}, under a
workspace/ ancestor, not a symlink, not the toolkit repo itself).
Child spawns are scoped to this one path; the operator's other projects
are unaffected. A concurrent run against the other tracker (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 value (linear / jira) — 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'
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.
-
Jira path (--tracker jira). No creation call — the Atlassian Rovo MCP exposes no createJiraProject tool, so the operator must have created the Space (Jira project) in the Jira UI manually before running /smoke-test. Pre-flight #8 has already verified visibility via mcp__atlassian__getVisibleJiraProjects; this step is a vacuous re-affirmation. Save the Space key (e.g., DST) and the Atlassian site URL to the findings file's header. Document: the Space is reused across runs (no per-run isolation); Phase 5 teardown closes only the work items this run created (matched by label dpt-smoke + creation-time window).
-
MCP config — branches on --tracker:
-
Linear path. Construct the wrapped Linear MCP config at /tmp/dpt-smoke-mcp-config-linear.json. Source: ~/.claude-st/plugins/marketplaces/claude-plugins-official/external_plugins/linear/.mcp.json (a bare server entry without the mcpServers: envelope). Wrap it as {"mcpServers": <source>} and write to /tmp. This is required because --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/.
-
Jira path. Construct the wrapped Atlassian Rovo MCP config at /tmp/dpt-smoke-mcp-config-jira.json directly (the Rovo MCP entry is a single-line http-transport URL with no auth material — child sessions inherit OAuth state from ~/.claude-st/):
{"mcpServers": {"atlassian": {"type": "http", "url": "https://mcp.atlassian.com/v1/mcp/authv2"}}}
The same --plugin-dir shadowing concern from the Linear path applies, so wrapping is required either way.
-
Pre-create the sensitive files from the parent's Bash heredoc. The child claude session — even in default permission mode (STE-252) — is still blocked from writing .claude/settings.json and .mcp.json: the harness's sensitive-path classification of those two files survives at the child's model layer regardless of permissions.allow content. The parent's Bash tool uses shell I/O (cat > file <<EOF), which is not subject to that classification, so the driver writes them directly. The child model layer denies ALL .claude/settings.json writes — full-file Write AND append-only Edit alike (iter-2 confirmed no child-side merge path exists) — so the parent's pre-creation must carry the FULL final allow-list; children can never extend it. The .claude/settings.json allow-list is identical in both tracker paths; .mcp.json branches on --tracker:
mkdir -p .claude
cat > .claude/settings.json <<'EOF'
{
"permissions": {
"allow": [
"Bash(bun *)", "Bash(bunx *)", "Bash(git *)", "Bash(gh *)",
"Bash(mkdir *)", "Bash(ls *)", "Bash(rm *)", "Bash(mv *)", "Bash(cp *)",
"Bash(claude:*)"
]
}
}
EOF
-
Linear path (--tracker linear, default):
cat > .mcp.json <<'EOF'
{
"mcpServers": {
"linear": { "type": "http", "url": "https://mcp.linear.app/mcp" }
}
}
EOF
-
Jira path (--tracker jira):
cat > .mcp.json <<'EOF'
{
"mcpServers": {
"atlassian": { "type": "http", "url": "https://mcp.atlassian.com/v1/mcp/authv2" }
}
}
EOF
OAuth state caveat. The driver writes only the mcpServers: envelope (URL + transport). No auth material lands on disk under the test project; OAuth tokens live in ~/.claude-st/ and are inherited by the child claude-st process at startup.
Additionally, on the Jira path, the driver pre-stages the ### Jira workspace-binding sub-section so the /setup child takes the idempotent-merge branch and emits the right CLAUDE.md ## Task Tracking shape on its own. The /setup child receives pre-baked answers (Phase 2 step 1) that resolve to:
## Task Tracking
mode: jira
mcp_server: atlassian
jira_ac_field: description
branch_template: {type}/{ticket-id}-{slug}
### Jira
project: <flag-value>
default_labels: [dpt-smoke]
jira_ac_field: description is the zero-config sentinel from STE-154 AC-STE-154.3 — ACs live as a bullet list under a ## Acceptance Criteria heading inside each Jira issue's description body; pull_acs / push_ac_toggle parse and rewrite that section atomically. default_labels: [dpt-smoke] is the free-form ### Jira sub-section field (per docs/patterns.md § Schema L Workspace binding sub-sections); the Jira adapter forwards every entry into mcp__atlassian__createJiraIssue.additional_fields.labels on every issue created during the run, which is what makes Phase 5 teardown's labels = "dpt-smoke" JQL find them.
Match the rest of the canonical content from plugins/dev-process-toolkit/skills/setup/SKILL.md step 6/7 (refresh as the plugin evolves). The /setup child detects existing files and takes the idempotent-merge branch.
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|jira>; <Linear project URL | Jira Space key + site URL>; MCP config: /tmp/dpt-smoke-mcp-config-.json; 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):
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
A SMOKE-CTX: headless (claude -p) banner is the common case for a /conformance-loop-spawned leg: 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). Only a SMOKE-CTX: interactive tty banner means a human is at the keyboard.
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 .claude/settings.json / .mcp.json writes — 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. 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 the other tracker from clobbering this run's config (operator-driven parallelism).
- Passes
--plugin-dir /Users/ns/workspace/dev-process-toolkit/plugins/dev-process-toolkit to load the in-tree plugin under test (not the cached version under ~/.claude-st/plugins/cache/).
- Receives a fully-pre-baked prompt where the slash command is the literal first line of the user message, not wrapped in natural language. Plugin skills carry
disable-model-invocation: true, so the child's model cannot call them via the Skill tool — only user-typed slash commands trigger; the prompt-pre-bake puts the slash command as the literal first line of the user message. Pre-baked answers go on the lines after.
- Has its stdout/stderr captured to
/tmp/dpt-smoke-<tracker>-<skill>.log (e.g., /tmp/dpt-smoke-jira-implement.log) as stream-json NDJSON — every spawn passes --output-format stream-json --verbose (STE-352; --verbose is required by claude -p for stream-json output). Default text mode emitted only the child's final result message, so mid-stream assistant tokens — per-probe capability rows, forked tdd-result fences — never reached the log (smoke F2, the blind spot that let the STE-350 0-byte-grandchild false-green survive). To read a capture, lift the assistant text via extractAssistantText (adapters/_shared/src/smoke_child_capture.ts; blocks are joined line-anchored so fences stay greppable), or project text/tool_use entries via the existing parseStreamJsonTranscript (adapters/_shared/src/socratic_first_turn_stream.ts) — the same parser Phase 8 already uses. Phase 2.X's substring greps keep working unchanged: literal tokens survive JSON string encoding.
- Is spawned detached (
& with its PID captured to /tmp/dpt-smoke-<tracker>-<skill>.pid) and awaited via the bounded poll-until-exit loop — never as a single foreground Bash call, which caps the grandchild at the harness's 10-minute per-call ceiling (STE-355; § Grandchild spawn lifecycle below).
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.
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.
Post-step master-merge (STE-295 AC.3). After the /setup child returns, the test project sits on the chore/setup-bootstrap branch with the toolkit scaffold (CLAUDE.md, specs/ tree, .claude/ config) committed there but NOT on master/main. Before spawning step 2 (/spec-write), the driver MUST merge chore/setup-bootstrap → master so the scaffold lands on the trunk:
git -C ../dpt-test-project-<tracker> checkout master \
&& git -C ../dpt-test-project-<tracker> merge --no-ff chore/setup-bootstrap -m "chore: merge setup-bootstrap → master"
This carries the /setup scaffold onto master so the universal branch gate (STE-228) fires correctly on the subsequent /spec-write spawn and takes the auto-apply branch_gate_default_applied path — gate detection reads CLAUDE.md from the current branch, and without the merge the child would re-enter on chore/setup-bootstrap with no trunk scaffold and a degenerate gate state. The merge is --no-ff so the bootstrap commit's subject + footer (asserted by gate-check probe #30) stays addressable on master's first-parent line.
-
/dev-process-toolkit:spec-write — feature stub (default greet): "Add a pure function greet(name?: string) returning 'Hello, !' (defaulting 'world' for undefined / empty / whitespace-only). File src/greet.ts; test src/greet.test.ts; 4 ACs."
-
/dev-process-toolkit:implement <feature-id> — full TDD + tracker writes (claim → release after archive). Pre-authorize the Phase 4 step 15 commit upfront. Do NOT push.
Post-step advisory (STE-181). After step 3 returns, log: "single-FR run complete — FR remains status: active, milestone remains status: active. Run /spec-archive M<N> to archive when ready." The smoke driver intentionally uses the <feature-id> form (per skills/implement/SKILL.md § Invocation forms — single-FR is the canonical "ship one FR" path), which silent-skips Phase 5. The end state is correct, not drift; gate-check probe #14 emits the STE-180 advisory if the plan is fully checked. Documentation prose only — no behavioral change to the smoke driver.
-
/dev-process-toolkit:gate-check — read-only verification.
-
/dev-process-toolkit:spec-review <feature-id> — read-only spec-vs-code audit.
-
/dev-process-toolkit:simplify — review changed code; safe refactors applied + gate re-verified.
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:
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:
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 stream-idle 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.
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). Before emitting any final message — success or failure — run the pidfile-liveness fence below over the run's pidfile glob (/tmp/dpt-smoke-*.pid). Any live pidfile means a spawned grandchild is still running: resume the bounded poll loop above; a live pidfile must never end the turn. Runtime validation ships deferred ([~]): the next conformance run must show this driver polling every spawned grandchild to completion.
setopt local_options null_glob 2>/dev/null || shopt -s nullglob 2>/dev/null || true
LIVE=""
for PIDFILE in /tmp/dpt-smoke-*.pid; do
[ -e "${PIDFILE}" ] || continue
kill -0 "$(cat "${PIDFILE}")" 2>/dev/null && LIVE="${LIVE} ${PIDFILE}"
done
if [ -n "${LIVE}" ]; then echo "LIVE:${LIVE} — resume the bounded poll loop"; else echo "no live pidfiles — final message may be emitted"; fi
Phase 2 child-spawn discipline (stdin partition)
Every Phase 2 spawn has explicit stdin handling — no spawn relies on the child's default stdin behavior. The spawn surface partitions into two classes by whether the child needs prompt-body input:
- Non-prompt-bearing children (
/spec-review, /simplify, /gate-check) — the slash command alone fully specifies the work; no extra prompt body is needed. Pipe < /dev/null immediately before the log redirect to skip claude -p's 3-second auto-stdin-detect wait (smoke #9 / Linear F5 — STE-188). The warning Warning: no stdin data received in 3s, proceeding without it. is the source signal; < /dev/null is the documented remediation.
- Prompt-bearing children (
/setup, /spec-write, /implement) — covered by STE-185's heredoc-on-stdin discipline (per-skill prompt body inlined; see § STE-185 below). Adding < /dev/null to those would close stdin before the heredoc body is read and break prompt delivery — the partition is deliberate.
⛔ FORBIDDEN at this spawn site. Do NOT await a grandchild with the Bash tool's run_in_background parameter, the Monitor tool, or by ending the turn "waiting for the completion notification" — under claude -p the notification never arrives (F3, 2026-07-04 conformance run: both legs fire-and-exited here). The ONLY sanctioned wait is the bounded kill -0 poll-until-exit loop (§ Grandchild spawn lifecycle above) run in the foreground.
Reference snippets — non-prompt-bearing children:
export CLAUDE_CONFIG_DIR=~/.claude-st
claude -p /dev-process-toolkit:gate-check \
--output-format stream-json --verbose \
--plugin-dir /Users/ns/workspace/dev-process-toolkit/plugins/dev-process-toolkit \
--mcp-config /tmp/dpt-smoke-mcp-config-<tracker>.json \
< /dev/null > /tmp/dpt-smoke-<tracker>-gate-check.log 2>&1 &
echo $! > /tmp/dpt-smoke-<tracker>-gate-check.pid
claude -p "/dev-process-toolkit:spec-review <feature-id>" \
--output-format stream-json --verbose \
--plugin-dir /Users/ns/workspace/dev-process-toolkit/plugins/dev-process-toolkit \
--mcp-config /tmp/dpt-smoke-mcp-config-<tracker>.json \
< /dev/null > /tmp/dpt-smoke-<tracker>-spec-review.log 2>&1 &
echo $! > /tmp/dpt-smoke-<tracker>-spec-review.pid
claude -p /dev-process-toolkit:simplify \
--output-format stream-json --verbose \
--plugin-dir /Users/ns/workspace/dev-process-toolkit/plugins/dev-process-toolkit \
--mcp-config /tmp/dpt-smoke-mcp-config-<tracker>.json \
< /dev/null > /tmp/dpt-smoke-<tracker>-simplify.log 2>&1 &
echo $! > /tmp/dpt-smoke-<tracker>-simplify.pid
Heredoc-on-stdin for prompt-bearing children (STE-185)
Prompt-bearing children (/setup, /spec-write, /implement) carry a per-skill prompt body — answers to /setup's pre-baked questions, the feature stub for /spec-write, the implementation arguments for /implement. The driver delivers the prompt body via a single-quoted bash heredoc on the child's stdin. The slash command stays the literal first line of the user message; the heredoc body provides the rest.
Threat model — content-swap attack surface (STE-185). Prompt files on disk are vulnerable to mid-run content swap by external processes — linters, file-mode-line auto-fixes, language servers, shared editor sessions. Smoke #9 / Jira run 2 hit this in the field: an external linter overwrote a Jira-flavored prompt file with a stale Linear-flavored stub between the parent's Write and the spawned claude -p child's read, causing silent cross-tracker corruption (the child built a Linear-mode CLAUDE.md on a Jira run). The heredoc-on-stdin discipline closes the window — there is no file on disk to swap. Single-quoted heredoc tag (<<'PROMPT_EOF') prevents shell expansion of $variable references in the body so prompt content passes through to Claude verbatim.
Reference snippets — prompt-bearing children, per-skill prompt body inlined as the heredoc body. Linear-path / Jira-path branching stays inside each heredoc body (the parent renders the per-tracker fragments before piping):
export CLAUDE_CONFIG_DIR=~/.claude-st
claude -p \
--output-format stream-json --verbose \
--plugin-dir /Users/ns/workspace/dev-process-toolkit/plugins/dev-process-toolkit \
--mcp-config /tmp/dpt-smoke-mcp-config-<tracker>.json \
> /tmp/dpt-smoke-<tracker>-setup.log 2>&1 <<'PROMPT_EOF' &
<dpt:auto-approve>v1</dpt:auto-approve>
/dev-process-toolkit:setup
stack=Bun+TS, tracker=<tracker>, mcp_server=<linear|atlassian>, ...
(Linear path) team=STE, project=<the smoke-test project from Phase 1>, jira_ac_field=blank, branch_template=default, docs flags=all-false; emit `### Linear` workspace binding.
(Jira path) project=<--jira-project flag value>, jira_ac_field=description, branch_template=default, docs flags=all-false, default_labels=[dpt-smoke]; emit `### Jira` workspace binding; skip discover_field.ts (zero-config sentinel path); skip Linear team probe.
The repo already contains .claude/settings.json and .mcp.json from the driver's pre-creation step; take the idempotent-merge branch — do not overwrite (model-layer block aborts the chain otherwise).
PROMPT_EOF
echo $! > /tmp/dpt-smoke-<tracker>-setup.pid
# /spec-write — heredoc body carries the feature stub. The marker
# `<dpt:auto-approve>v1</dpt:auto-approve>` on its own line is the
# byte-checkable pre-authorization handoff for /spec-write's draft + commit
claude -p \
--output-format stream-json --verbose \
--plugin-dir /Users/ns/workspace/dev-process-toolkit/plugins/dev-process-toolkit \
--mcp-config /tmp/dpt-smoke-mcp-config-<tracker>.json \
> /tmp/dpt-smoke-<tracker>-spec-write.log 2>&1 <<'PROMPT_EOF' &
<dpt:auto-approve>v1</dpt:auto-approve>
/dev-process-toolkit:spec-write
Add a pure function greet(name?: string) returning 'Hello, <name>!' (defaulting 'world' for undefined / empty / whitespace-only). File src/greet.ts; test src/greet.test.ts; 4 ACs.
PROMPT_EOF
echo $! > /tmp/dpt-smoke-<tracker>-spec-write.pid
claude -p \
--output-format stream-json --verbose \
--plugin-dir /Users/ns/workspace/dev-process-toolkit/plugins/dev-process-toolkit \
--mcp-config /tmp/dpt-smoke-mcp-config-<tracker>.json \
> /tmp/dpt-smoke-<tracker>-implement.log 2>&1 <<'PROMPT_EOF' &
<dpt:auto-approve>v1</dpt:auto-approve>
/dev-process-toolkit:implement <feature-id>
Pre-authorized: proceed through Phase 4 step 15 commit on success without prompting. Do NOT push. Stay on the current branch (skip worktree prompt).
PROMPT_EOF
echo $! > /tmp/dpt-smoke-<tracker>-implement.pid
Auto-approve marker contract (STE-226). Every prompt-bearing heredoc above carries the literal line <dpt:auto-approve>v1</dpt:auto-approve> as the first body line. The marker is a byte-checkable pre-authorization token that child skills (/spec-write, /implement) detect by literal string match — no <system-reminder> introspection, no claude -p non-interactive inference. Children whose gates depend on operator approval (/spec-write § 0b step 4 + § 7a draft/commit gates; /implement Phase 4 step 15 commit) auto-apply y when the marker is in the prompt body and gate interactively otherwise. Removing the marker line (deliberate or accidental) is the canonical way to flip a smoke-driver child into interactive-gating mode for diagnostic runs; the regression to watch for is the inverse — a child that auto-applies WITHOUT the marker (covered by Phase 2.X group 1 sub-fixture 1b below).
Stream-idle retry-with-rollback for prompt-bearing children (STE-195)
Anthropic's API stream occasionally idles mid-response on long-running prompt-bearing child spawns (/setup, /spec-write, /implement), exiting the child with the canonical signature API Error: Stream idle timeout - partial response received. The 2026-05-04 Jira smoke caught the failure mode on /setup's first attempt — the partial state created src/.placeholder.test.ts but no CLAUDE.md, no specs/ scaffold. The driver recovered manually with a deterministic rollback recipe and a re-spawn; STE-195 builds the recovery in so a single transient turns into a quiet retry instead of a smoke-blocker.
Detection signature. After each prompt-bearing child exits (the STE-355 poll loop reports exit; detection composes on top of the detached wrapper, unchanged), the driver inspects the child's exit reason / captured /tmp/dpt-smoke-<tracker>-<skill>.log for the substring API Error: Stream idle timeout. Match is substring (not exact); the trailing - partial response received and any minor wording drift in future Anthropic API versions still trigger the path. Non-prompt-bearing children (/gate-check, /spec-review, /simplify) are out of scope — they are short, idempotent, and the existing < /dev/null discipline already shields them from the stdin-detect race.
Rollback recipe (verbatim). When the signature is detected, the driver runs the following inside the test project's working directory (e.g., ../dpt-test-project-linear / ../dpt-test-project-jira) — NOT inside the dpt repo cwd. The driver's per-spawn cwd handling already isolates the test project; the rollback inherits that scope. The -e .claude -e .mcp.json excludes preserve the parent-pre-created sensitive files (Phase 1 step 6) so the second spawn finds the same .claude/settings.json + .mcp.json it would on the first attempt.
git clean -fdq -e .claude -e .mcp.json && git checkout -- .
git clean -fdq -e .claude -e .mcp.json — removes untracked files/directories EXCEPT .claude/ and .mcp.json.
git checkout -- . — reverts tracked-file modifications.
- Combined: returns the test project to its last-committed state plus the parent-pre-created sensitive files.
Retry budget. Exactly one retry per spawn (two attempts total). The third instance is genuine and surfaces as a smoke-test failure rather than looping indefinitely. Other exit modes (segfault, OOM kill, non-stream-idle Anthropic errors) do NOT retry — only the stream-idle signature has a known deterministic rollback; everything else warrants operator inspection.
Retry-success log row (AC-STE-195.3). After a successful retry, the driver appends a child_stream_idle_retried row to the Phase 2 per-skill log (/tmp/dpt-smoke-<tracker>-<skill>.log) carrying both attempt timestamps in UTC ISO-8601 form so the audit trail captures the transient. Template:
2026-05-04T06:42:11Z child_stream_idle_retried skill=/setup attempts=2
attempt_1_started=2026-05-04T06:40:07Z attempt_1_exit=stream_idle
attempt_2_started=2026-05-04T06:42:33Z attempt_2_exit=success
Double-timeout abort (AC-STE-195.4). When the second attempt also exits stream-idle, the driver aborts the smoke run with NFR-10 canonical refusal naming the skill that timed out twice, both attempt timestamps, and the rollback recipe operators can run manually if a third attempt is appropriate. The abort message is verbatim:
ABORT: /smoke-test Phase 2 spawn /<skill> stream-idle timeout twice
attempt_1_started=<ts1> attempt_1_exit=stream_idle
attempt_2_started=<ts2> attempt_2_exit=stream_idle
rollback recipe: git clean -fdq -e .claude -e .mcp.json && git checkout -- .
Worked example (Phase 2 /setup spawn, retry-success path). The driver wraps the existing detached heredoc-on-stdin spawn (above) in a two-attempt loop scoped to the prompt-bearing-children spawn surface only. Pseudocode spanning multiple driver Bash calls (the loop is sequential, not parallel; each [STE-355 …] comment marks where the bounded poll-until-exit calls run before the next line executes):
export CLAUDE_CONFIG_DIR=~/.claude-st
attempt_1_started=$(date -u +%Y-%m-%dT%H:%M:%SZ)
claude -p ... > /tmp/dpt-smoke-<tracker>-setup.log 2>&1 <<'PROMPT_EOF' &
<dpt:auto-approve>v1</dpt:auto-approve>
/dev-process-toolkit:setup
...prompt body...
PROMPT_EOF
echo $! > /tmp/dpt-smoke-<tracker>-setup.pid
if grep -q 'API Error: Stream idle timeout' /tmp/dpt-smoke-<tracker>-setup.log; then
attempt_1_exit=stream_idle
git clean -fdq -e .claude -e .mcp.json && git checkout -- .
attempt_2_started=$(date -u +%Y-%m-%dT%H:%M:%SZ)
claude -p ... > /tmp/dpt-smoke-<tracker>-setup.log 2>&1 <<'PROMPT_EOF' &
<dpt:auto-approve>v1</dpt:auto-approve>
/dev-process-toolkit:setup
...same prompt body...
PROMPT_EOF
echo $! > /tmp/dpt-smoke-<tracker>-setup.pid
if grep -q 'API Error: Stream idle timeout' /tmp/dpt-smoke-<tracker>-setup.log; then
cat <<EOF >> /tmp/dpt-smoke-<tracker>-setup.log
ABORT: /smoke-test Phase 2 spawn /setup stream-idle timeout twice
attempt_1_started=$attempt_1_started attempt_1_exit=stream_idle
attempt_2_started=$attempt_2_started attempt_2_exit=stream_idle
rollback recipe: git clean -fdq -e .claude -e .mcp.json && git checkout -- .
EOF
exit 1
fi
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
cat <<EOF >> /tmp/dpt-smoke-<tracker>-setup.log
$now child_stream_idle_retried skill=/setup attempts=2
attempt_1_started=$attempt_1_started attempt_1_exit=stream_idle
attempt_2_started=$attempt_2_started attempt_2_exit=success
EOF
fi
The same wrapper applies symmetrically to /spec-write and /implement — substitute the slash command, the per-skill log filename, and the heredoc body. Non-prompt-bearing spawns (/gate-check, /spec-review, /simplify) bypass the wrapper entirely; their < /dev/null snippet stays unchanged.
Post-return capture assertion — non-empty / non-denied (STE-352)
After each Phase 2 child exits (prompt-bearing and non-prompt-bearing alike; the STE-355 poll loop has reported exit and any stream-idle retry above has settled), the driver asserts the child actually produced output and no nested spawn was denied — the direct detector for the M94 0-byte-grandchild symptom, where a child whose nested claude -p spawn was blocked by the permission classifier still exited green over an empty log:
- Non-empty:
wc -c < /tmp/dpt-smoke-<tracker>-<skill>.log must be > 0 — a 0-byte capture is a hard finding, never a silent pass.
- Non-denied: no
result event in the capture carries a permission_denials[] entry whose tool_input.command head is the bare word claude (a denied nested spawn; a command merely mentioning claude -p mid-string does not count).
Both checks are implemented by checkChildSpawnCapture (adapters/_shared/src/smoke_child_capture.ts); the driver runs it over each capture as it lands. Either condition failing emits exactly one finding into the run's findings file with the canonical diagnostic:
STE-350 regression: nested claude -p spawn denied/empty — <child>
Severity: high. <child> is the per-skill spawn name (e.g., /implement). The finding is hard: the remaining Phase 2 steps still run (independent evidence beats an early abort), but the run can never report green while one is present.
- Allow-list effective (STE-356): the capture's raw text must not carry the workspace-trust warning —
Ignoring <N> permissions.allow entries from .claude/settings.json: this workspace has not been trusted. That warning means the tracked allow-list was inert for the spawn (the STE-252 policy artifact silently stopped enforcing — a policy breach, not a cosmetic nit), the exact 2026-07-02 F4 failure mode. Implemented by checkAllowlistInert (same module, adapters/_shared/src/smoke_child_capture.ts); the driver runs it alongside checkChildSpawnCapture over each capture's raw text as it lands (the warning is a stderr line interleaved into the 2>&1 log, or echoed inside an assistant text block when a child relays its grandchild's stderr — no NDJSON parsing required). Any hit emits exactly one finding with the canonical diagnostic:
STE-356 regression: allow-list inert — <child> (workspace untrusted)
Severity: high — same hardness as the STE-350 finding above: the remaining Phase 2 steps still run, but the run can never report green while one is present. Remedy lives in Phase 1 step 6b (workspace-trust seeding).
Comment-path probe (Jira-only)
After step 6 returns, on the Jira branch only, issue a stand-alone mcp__atlassian__addCommentToJiraIssue call against the run's freshly-created work item. The probe closes AC-STE-154.9 AC 6 — the canonical chain doesn't naturally exercise the comment endpoint, so a regression there would slip past every smoke run. Stand-alone probe (vs. side-effect-of-/implement narration) was chosen during M49 spec authoring: validates the MCP tool independent of /implement's narration policy, which can change without affecting the underlying contract.
mcp__atlassian__addCommentToJiraIssue \
cloudId=<resolved> \
issueIdOrKey=<latest work item key> \
comment="Smoke probe — AC-STE-154.9 AC 6 coverage. Run: <date> <tracker> v<plugin-version>."
Fires only when --tracker jira is active. The Linear branch skips this probe entirely — Linear's MCP comment surface is exercised by other paths and isn't part of AC-STE-154.9 scope. The comment surfaces in /tmp/dpt-smoke-jira-comment-probe.log and the run's findings file (Phase 3 capture appends "comment exercised" to the run notes).
Skills explicitly NOT run (with reasons logged in findings):
/dev-process-toolkit:brainstorm — multi-turn Socratic; not viable in -p.
/dev-process-toolkit:debug — needs a failing test to trigger.
/dev-process-toolkit:visual-check — no UI in test project.
/dev-process-toolkit:pr — no GitHub remote in test project.
/dev-process-toolkit:docs — would be no-op if /setup omitted ## Docs (until M29 STE-107 ships).
/dev-process-toolkit:ship-milestone — would dirty real Linear data further.
/dev-process-toolkit:tdd standalone — covered by /implement.
Phase 2.X — M56 runtime regression fixtures (STE-220 / STE-221 / STE-222)
Three fixture groups verify that the M55 cohort's SKILL.md-prose fixes (STE-213, STE-214, STE-215) actually fire at runtime — not just that the SKILL.md says they should. M55 archived these FRs [x] based on LLM self-confirm during /implement Phase 4; the 2026-05-04 v2.8.0 smoke runs (Linear F2-1 + Jira C-F1, both afternoon legs) proved STE-213's runtime had not changed despite green checkmarks. Phase 2.X is the fence: every smoke run captures real claude -p stdout for each system-under-test and asserts the documented contract.
Failure shape (canonical across all groups): STE-<sut> runtime regression: <fixture-name> where <sut> is the FR being regression-tested (STE-213 / STE-214 / STE-215), not the M56 FR carrying the fixture (STE-220 / STE-221 / STE-222). The diagnostic names the system-under-test so triage points to the broken implementation, not the test infrastructure. STE-220 is its own SUT (it carries both the fix and the regression test), so STE-220 runtime regression: … is the canonical diagnostic for fixture group 1.
Phase 2.X fires AFTER Phase 2 step 6 (/simplify) returns successfully and BEFORE Phase 3 (Capture). Fixture groups are independent; a failure in one does not abort the others.
Fixture group 1 — STE-226 spec-write marker carve-out (Linear + Jira)
Two sub-fixtures verify that the byte-checkable marker (<dpt:auto-approve>v1</dpt:auto-approve>) is the only trigger for /spec-write's draft + commit auto-apply path. STE-213 (M55) and STE-220 (M56) attempted the same carve-out via prose-only contracts and both falsified end-to-end across four smoke runs; STE-226 (M59) replaces the prose-only detection with this byte-checkable marker. The two sub-fixtures together close both directions of the failure surface — marker-present must auto-apply (1a), marker-absent must NOT auto-apply (1b).
Sub-fixture 1a — marker present (audit rows present)
Source: /tmp/dpt-smoke-<tracker>-spec-write.log (already captured during Phase 2 step 2 — no new spawn needed; the canonical Phase 2 /spec-write heredoc carries the marker on its first body line).
Assertions:
grep -F 'spec_write_draft_default_applied' /tmp/dpt-smoke-<tracker>-spec-write.log exit 0 (row present in stdout).
grep -F 'spec_write_commit_default_applied' /tmp/dpt-smoke-<tracker>-spec-write.log exit 0 (row present in stdout).
Diagnostic on failure:
STE-226 runtime regression: spec-write-marker-missing-audit-row
expected: spec_write_draft_default_applied row in stdout
actual: row absent
stdout excerpt (last 20 lines):
<tail -20 /tmp/dpt-smoke-<tracker>-spec-write.log>
Sub-fixture 1b — marker absent (gates fire interactively)
A new /spec-write spawn is fired with the marker line omitted from the heredoc body. The driver describes this spawn in prose so its snippet is NOT picked up by the /gate-check probe auto_approve_marker_in_canonical_spawns (which asserts the marker on every documented prompt-bearing spawn): the runtime spawn is constructed at smoke-driver runtime, not authored as a fenced reference snippet here. The driver writes the heredoc body with no marker, captures stdout to /tmp/dpt-smoke-<tracker>-spec-write-1b.log, and asserts the inverse — no audit rows appear because the gates fire interactively and the child halts at the prompt without ever reaching § 7's emit path.
Assertions:
grep -F 'spec_write_draft_default_applied' /tmp/dpt-smoke-<tracker>-spec-write-1b.log exit 1 (row absent — gate fired interactively, no auto-apply).
grep -F 'spec_write_commit_default_applied' /tmp/dpt-smoke-<tracker>-spec-write-1b.log exit 1 (row absent for the same reason).
- Stdout ends at the gate prompt without ever reaching § 7 emit.
- Post-TIGHTEN cross-tracker assertion (STE-294 AC.4): Linear-side AND Jira-side both raised
RequiresInputRefusedError (NFR-10 canonical shape — Verdict / Remedy / Context) under non-tty stdin when the marker is absent. The byte-checkable refusal is the ONLY acceptable outcome; an autonomous-mode reminder paraphrase, pre-baked <command-args> prose, or "standing instruction" inference MUST NOT flip the gate. Both legs of the tandem run produce the same refusal class — asymmetry between Linear-leg refusal and Jira-leg auto-apply (or vice-versa) is the M59→M65 regression shape this assertion fences.
Diagnostic on failure:
STE-226 runtime regression: spec-write marker-absent fixture 1b
expected: stdout halts at draft gate; no audit rows in output; both Linear-side AND Jira-side raise RequiresInputRefusedError under non-tty stdin
actual: spec_write_draft_default_applied row appeared without marker — child auto-applied via removed legacy detection path OR one tracker leg refused while the other auto-applied (cross-tracker asymmetry — see STE-294 § Notes)
stdout excerpt (last 20 lines):
<tail -20 /tmp/dpt-smoke-<tracker>-spec-write-1b.log>
If both sub-fixtures pass, append STE-226 runtime check: PASS to the run summary line. If only 1a passes, the marker contract is half-broken (auto-apply still fires regardless of the trigger) — surface as a high-severity finding so triage prioritizes the loose-trigger regression over the absent-trigger regression (the loose direction is the riskier one for unattended claude -p runs).
Fixture group 2 — STE-221 probe #26 ## Notes scanner (Linear-only)
Three sub-fixtures, each writing a temporary FR file under specs/frs/, invoking claude -p /dev-process-toolkit:gate-check, capturing stdout, then cleaning up. Linear leg only — probe #26 is vacuous on Jira (adapter Schema M project_milestone: false).
Sub-fixture 2a — positive (canonical capability key)
- Stage: write
specs/frs/<TKR>-FIX-A.md with ## Notes body containing the literal token milestone_attach_skipped_adapter_limit.
- Invoke + capture:
claude -p /dev-process-toolkit:gate-check → /tmp/dpt-smoke-<tracker>-ste221-positive.log.
- Assert:
grep -E 'ADVISORY.*probe.*26|probe.*26.*ADVISORY' /tmp/dpt-smoke-<tracker>-ste221-positive.log exit 0.
- Cleanup: remove the temporary FR file.
Sub-fixture 2b — control (no key)
- Stage: same FR but
## Notes body has no milestone-attach capability key.
- Invoke + capture as above →
/tmp/dpt-smoke-<tracker>-ste221-control.log.
- Assert: probe #26 surfaces
GATE FAILED (proves the scanner is the difference, not unrelated probe behavior).
- Cleanup.
Sub-fixture 2c — deprecated-alias (milestone_attach_unavailable)
- Stage: same FR but
## Notes body has milestone_attach_unavailable (the STE-198 deprecated alias).
- Invoke + capture as above →
/tmp/dpt-smoke-<tracker>-ste221-alias.log.
- Assert: probe #26 surfaces ADVISORY (deprecation window honored per STE-214 AC.5). When STE-198's rollover note flips, this fixture flips to expecting GATE FAILED.
- Cleanup.
Diagnostic on any sub-fixture failure:
STE-214 runtime regression: <sub-fixture-name>
expected: <ADVISORY | GATE FAILED>
actual: <observed>
stdout excerpt (last 20 lines):
<tail -20 of the relevant log>
If all three pass, append STE-214 runtime check: PASS to the run summary line.
Fixture group 3 — STE-222 cross-cutting drift propagation (Linear + Jira)
Three sub-fixtures. Both legs run — /implement's Phase 4b' propagation hook is adapter-agnostic.
Sub-fixture 3a — positive (deletion ⇒ propagation commit)
- Stage: pre-create
src/.placeholder.test.ts (committed); add stale references to that path in specs/technical-spec.md + specs/testing-spec.md directory-tree blocks; pre-create an FR whose implementation deletes the file.
- Invoke + capture:
claude -p /dev-process-toolkit:implement <FR> → /tmp/dpt-smoke-<tracker>-ste222-positive.log.
- Assert:
git log --grep 'propagate.*removal to cross-cutting specs' --since '<run-start>' | wc -l ≥ 1 (propagation commit landed).
- Assert: directory-tree blocks in both cross-cutting specs no longer reference
src/.placeholder.test.ts.
- Cleanup: revert the test project's run-window commits.
Sub-fixture 3b — control (no deletion)
- Stage: identical setup but FR's implementation does NOT delete any tracked file.