@windyroad/itil 0.50.1-preview.709 → 0.50.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -497,5 +497,5 @@
497
497
  }
498
498
  },
499
499
  "name": "wr-itil",
500
- "version": "0.50.1"
500
+ "version": "0.50.2"
501
501
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.50.1-preview.709",
3
+ "version": "0.50.2",
4
4
  "description": "ITIL-aligned IT service management for Claude Code (problem, and future incident/change skills)",
5
5
  "bin": {
6
6
  "windyroad-itil": "./bin/install.mjs"
@@ -24,11 +24,23 @@
24
24
  # merge-commit: <SHA>
25
25
  # release-date: <YYYY-MM-DD>
26
26
  #
27
+ # De-facto-released variant (P361, exit 0) when the changeset is present in
28
+ # .changeset/ but its code already shipped with a sibling release:
29
+ # RELEASE_VEHICLE:
30
+ # changeset: .changeset/<name>.md
31
+ # status: de-facto-released (attribution pending)
32
+ # fix-commit: <SHA>
33
+ # shipped-with-version-packages-commit: <SHA>
34
+ # release-date: <YYYY-MM-DD>
35
+ # note: ...
36
+ #
27
37
  # Exit codes:
28
- # 0 = OK (full citation emitted)
38
+ # 0 = OK (full citation emitted, OR de-facto-released graduated-holding
39
+ # changeset whose code already shipped with a sibling release — P361)
29
40
  # 1 = ticket file not found
30
41
  # 2 = no changeset reference in ticket body
31
- # 3 = changeset still present in working tree (unreleased)
42
+ # 3 = changeset present in working tree AND not de-facto-released
43
+ # (genuinely unreleased)
32
44
  # 4 = deletion commit found but no merge PR / merge commit resolvable
33
45
  #
34
46
  # @problem P267 — Codify derive-release-vehicle.sh helper for K→V release-
@@ -36,6 +48,14 @@
36
48
  # fragile to wrong-release-cited errors when sessions
37
49
  # pre-apply transitions across sibling tickets (observed
38
50
  # 2026-05-18 P250 K→V cited P247's release refs).
51
+ # @problem P361 — exit-3 "unreleased" false positive on ADR-061 graduated
52
+ # holding changesets; helper now distinguishes "attribution
53
+ # pending" (de-facto-released, exit 0) from "code unreleased"
54
+ # (exit 3) via an add-commit ancestry test against the latest
55
+ # version-packages commit.
56
+ # @adr ADR-061 (dogfood graduation criteria — held changeset reinstated to
57
+ # .changeset/ awaiting attribution after its code shipped; the
58
+ # de-facto-released exit-0 path)
39
59
  # @adr ADR-049 (bin/ on PATH shim — adopter-safe script resolution; helper
40
60
  # is invoked as `wr-itil-derive-release-vehicle`)
41
61
  # @adr ADR-022 (Verifying lifecycle — citation supports the K→V transition's
@@ -58,10 +78,10 @@ USAGE: derive-release-vehicle.sh <ticket-id> [<problems-dir>]
58
78
  <problems-dir> — defaults to ./docs/problems
59
79
 
60
80
  Exit codes:
61
- 0 ok (full citation emitted)
81
+ 0 ok (full citation, OR de-facto-released graduated-holding changeset — P361)
62
82
  1 ticket file not found
63
83
  2 no changeset reference in ticket body
64
- 3 changeset still present in working tree (unreleased)
84
+ 3 changeset present in working tree AND not de-facto-released (unreleased)
65
85
  4 deletion commit found but no merge PR / merge commit resolvable
66
86
  EOF
67
87
  }
@@ -112,7 +132,55 @@ fi
112
132
 
113
133
  # ── Released? Changeset must be ABSENT from working tree (deleted by
114
134
  # chore: version packages) AND have a deletion commit in git history. ────
135
+ # Exception (P361 / ADR-061 Rule 5 + P359): a changeset can be reinstated to
136
+ # .changeset/ awaiting changelog attribution AFTER its code already de-facto
137
+ # shipped with a sibling release (held code ships with any sibling release —
138
+ # P359). Present-in-tree therefore does NOT always mean unreleased. Before
139
+ # exiting 3, test whether the commit that originally ADDED this changeset is
140
+ # an ancestor of the latest published "chore: version packages" commit; if so
141
+ # the fix code shipped → emit a de-facto-released citation (exit 0). A
142
+ # genuinely-unreleased fresh changeset has its add-commit NEWER than the last
143
+ # bump, so the is-ancestor test is false and it correctly stays exit 3.
115
144
  if [ -f "$CHANGESET_PATH" ]; then
145
+ # Oldest Add of the path = the original fix commit. Robust to the
146
+ # hold→graduate `git mv`, which (without rename detection) records a later
147
+ # Add at the same path; `tail -1` selects the original.
148
+ ADD_SHA="$(
149
+ git log --diff-filter=A --format='%H' -- "$CHANGESET_PATH" 2>/dev/null \
150
+ | tail -1
151
+ )"
152
+
153
+ # Resolve the published-history ref (same ladder used for merge resolution).
154
+ DEFACTO_REF=""
155
+ for ref in origin/main main HEAD; do
156
+ if git rev-parse --verify "$ref" >/dev/null 2>&1; then
157
+ DEFACTO_REF="$ref"
158
+ break
159
+ fi
160
+ done
161
+
162
+ LATEST_VERSION_BUMP=""
163
+ if [ -n "$DEFACTO_REF" ]; then
164
+ LATEST_VERSION_BUMP="$(
165
+ git log --grep='^chore: version packages' --format='%H' -1 "$DEFACTO_REF" 2>/dev/null
166
+ )"
167
+ fi
168
+
169
+ if [ -n "$ADD_SHA" ] && [ -n "$LATEST_VERSION_BUMP" ] \
170
+ && git merge-base --is-ancestor "$ADD_SHA" "$LATEST_VERSION_BUMP" 2>/dev/null; then
171
+ RELEASE_DATE="$(git log -1 --format='%cs' "$LATEST_VERSION_BUMP" 2>/dev/null)"
172
+ cat <<EOF
173
+ RELEASE_VEHICLE:
174
+ changeset: $CHANGESET_PATH
175
+ status: de-facto-released (attribution pending)
176
+ fix-commit: $ADD_SHA
177
+ shipped-with-version-packages-commit: $LATEST_VERSION_BUMP
178
+ release-date: $RELEASE_DATE
179
+ note: changeset present in .changeset/ awaiting changelog attribution (ADR-061 holding-graduation); code already shipped with a sibling release (P359).
180
+ EOF
181
+ exit 0
182
+ fi
183
+
116
184
  echo "ERROR: changeset $CHANGESET_PATH still present in working tree (unreleased)" >&2
117
185
  exit 3
118
186
  fi
@@ -21,10 +21,11 @@
21
21
  # release-date: <YYYY-MM-DD>
22
22
  #
23
23
  # Exit codes:
24
- # 0 = OK (full citation emitted)
24
+ # 0 = OK (full citation emitted, OR de-facto-released graduated-holding
25
+ # changeset whose code already shipped with a sibling release — P361)
25
26
  # 1 = ticket file not found
26
27
  # 2 = no changeset reference in ticket body
27
- # 3 = changeset not yet deleted (unreleased)
28
+ # 3 = changeset still present AND not de-facto-released (genuinely unreleased)
28
29
  # 4 = deletion commit found but no merge PR / merge commit resolvable
29
30
  #
30
31
  # @adr ADR-049 (bin/ on PATH shim — adopter-safe script resolution)
@@ -124,6 +125,102 @@ EOF
124
125
  echo "$output" | grep -qi "unreleased\|not.*delet"
125
126
  }
126
127
 
128
+ # ── Exit 0: de-facto-released graduated-holding changeset (P361) ─────────────
129
+
130
+ @test "derive-release-vehicle: graduated holding changeset whose code shipped with a sibling release → exit 0 de-facto-released" {
131
+ # P361 / ADR-061 Rule 5 + P359: a changeset can be reinstated to
132
+ # .changeset/ awaiting changelog attribution AFTER its code already shipped
133
+ # with a sibling release. Present-in-tree must NOT read as unreleased.
134
+ cat > .changeset/p211-graduated.md <<'EOF'
135
+ ---
136
+ '@windyroad/itil': patch
137
+ ---
138
+
139
+ P211 fix — held then graduated.
140
+ EOF
141
+ cat > docs/problems/verifying/211-graduated.md <<'EOF'
142
+ # Problem 211: Graduated
143
+
144
+ **Status**: Known Error
145
+
146
+ ## Fix Strategy
147
+
148
+ Ship via `.changeset/p211-graduated.md`.
149
+ EOF
150
+ git add .
151
+ git commit -q -m "feat(itil): P211 fix + changeset" # commit A — the fix
152
+
153
+ # Hold it out of the active release queue (ADR-042 Rule 7).
154
+ mkdir -p docs/changesets-holding
155
+ git mv .changeset/p211-graduated.md docs/changesets-holding/p211-graduated.md
156
+ git commit -q -m "chore(itil): hold P211 changeset (above-appetite)"
157
+
158
+ # A sibling release ships AFTER the fix landed (P359: code ships regardless).
159
+ cat > .changeset/p999-sibling.md <<'EOF'
160
+ ---
161
+ '@windyroad/itil': patch
162
+ ---
163
+
164
+ Sibling fix.
165
+ EOF
166
+ git add .changeset/p999-sibling.md
167
+ git commit -q -m "feat(itil): sibling fix + changeset"
168
+ git rm -q .changeset/p999-sibling.md
169
+ git commit -q -m "chore: version packages" # the published release bump
170
+
171
+ # Graduate the held changeset back to .changeset/ awaiting attribution.
172
+ mkdir -p .changeset
173
+ git mv docs/changesets-holding/p211-graduated.md .changeset/p211-graduated.md
174
+ git commit -q -m "chore(itil): graduate P211 changeset"
175
+
176
+ run "$SCRIPT" P211 docs/problems
177
+ [ "$status" -eq 0 ]
178
+ echo "$output" | grep -qi "de-facto-released"
179
+ echo "$output" | grep -q "changeset: .changeset/p211-graduated.md"
180
+ }
181
+
182
+ @test "derive-release-vehicle: fresh changeset added AFTER the last release → still exit 3 (not a false de-facto-released)" {
183
+ # Guard: a prior release exists, but THIS changeset was added afterwards —
184
+ # its code has NOT shipped. Its add-commit is a DESCENDANT of the last
185
+ # version bump, so the is-ancestor discriminator must keep it at exit 3.
186
+ cat > .changeset/p888-old.md <<'EOF'
187
+ ---
188
+ '@windyroad/itil': patch
189
+ ---
190
+
191
+ Old fix that did ship.
192
+ EOF
193
+ git add .changeset/p888-old.md
194
+ git commit -q -m "feat(itil): old fix + changeset"
195
+ git rm -q .changeset/p888-old.md
196
+ git commit -q -m "chore: version packages" # prior release
197
+
198
+ # Now add a NEW changeset AFTER the release — genuinely unreleased.
199
+ mkdir -p .changeset
200
+ cat > .changeset/p889-fresh.md <<'EOF'
201
+ ---
202
+ '@windyroad/itil': patch
203
+ ---
204
+
205
+ Fresh fix, not yet released.
206
+ EOF
207
+ cat > docs/problems/known-error/889-fresh.md <<'EOF'
208
+ # Problem 889: Fresh
209
+
210
+ **Status**: Known Error
211
+
212
+ ## Fix Strategy
213
+
214
+ Ship via `.changeset/p889-fresh.md`.
215
+ EOF
216
+ git add .
217
+ git commit -q -m "feat(itil): fresh fix + changeset"
218
+
219
+ run "$SCRIPT" P889 docs/problems
220
+ [ "$status" -eq 3 ]
221
+ echo "$output" | grep -qi "unreleased"
222
+ }
223
+
127
224
  # ── Exit 0: happy path — full citation emitted ──────────────────────────────
128
225
 
129
226
  @test "derive-release-vehicle: happy path — full citation block on stdout" {
@@ -176,7 +176,7 @@ The helper returns one of five outcomes (contract documented at `packages/itil/l
176
176
  | `ttl-expiry age=<N>s ttl=<M>s` | Dispatch `/wr-itil:review-problems` as a pre-flight iter. Cache is stale; review-problems' Step 4.5b's TTL-expiry auto-recheck branch fires inside the dispatched subprocess and refreshes the cache + audit-log + README. |
177
177
  | `fresh-within-ttl` | Silent-pass per ADR-013 Rule 5 + P132 mechanical-stage carve-out. Proceed to Step 1. |
178
178
 
179
- **Pre-flight dispatch shape**: when promoted, dispatch a single `claude -p --permission-mode bypassPermissions --output-format json` subprocess that invokes `/wr-itil:review-problems` (per P084 + ADR-032 subprocess isolation). The subprocess runs the full Step 4.5 inbound-discovery + assessment pipeline; the cache + `docs/audits/inbound-discovery-log.md` + `docs/problems/README.md` are refreshed in its own commit per ADR-014 (review-problems' Slice E commit grain). After the subprocess completes, the orchestrator reads the freshly-refreshed README at Step 1.
179
+ **Pre-flight dispatch shape**: when promoted, dispatch a single `claude -p --permission-mode bypassPermissions --output-format json` subprocess that invokes `/wr-itil:review-problems` (per P084 + ADR-032 subprocess isolation). The subprocess runs the full Step 4.5 inbound-discovery + assessment pipeline; the cache + `docs/audits/inbound-discovery-log.md` + `docs/problems/README.md` are refreshed in its own commit per ADR-014 (review-problems' Slice E commit grain). After the subprocess completes, the orchestrator reads the freshly-refreshed README at Step 1. **If the pre-flight subprocess exits non-zero OR returns `is_error: true`**, apply the non-blocking revert-and-proceed contract in "Step 0 pre-flight subprocess failure handling (P358)" below — do NOT halt the loop (a failed pre-flight is a non-load-bearing cache-refresh dependency, NOT an iter).
180
180
 
181
181
  **Iter-summary annotation**:
182
182
 
@@ -222,7 +222,7 @@ The helper returns one of five outcomes (contract documented at `packages/itil/l
222
222
 
223
223
  The intersection is the actual signal: "there is work to do AND the cadence has slipped".
224
224
 
225
- **Pre-flight dispatch shape**: when promoted (`no-readme` or `stale-readme`), dispatch a single `claude -p --permission-mode bypassPermissions --output-format json` subprocess that invokes `/wr-itil:review-problems` (per P084 + ADR-032 subprocess isolation). Reuse the Step 5 subprocess wrapper verbatim — same flag set, same idle-timeout SIGTERM poll loop, same retro-on-exit contract. The subprocess runs the full Step 2 + Step 2.5 + Step 4 + Step 5 re-rate + README refresh + commit; the orchestrator reads the freshly-refreshed README at Step 1.
225
+ **Pre-flight dispatch shape**: when promoted (`no-readme` or `stale-readme`), dispatch a single `claude -p --permission-mode bypassPermissions --output-format json` subprocess that invokes `/wr-itil:review-problems` (per P084 + ADR-032 subprocess isolation). Reuse the Step 5 subprocess wrapper verbatim — same flag set, same idle-timeout SIGTERM poll loop, same retro-on-exit contract. The subprocess runs the full Step 2 + Step 2.5 + Step 4 + Step 5 re-rate + README refresh + commit; the orchestrator reads the freshly-refreshed README at Step 1. **If the pre-flight subprocess exits non-zero OR returns `is_error: true`**, apply the non-blocking revert-and-proceed contract in "Step 0 pre-flight subprocess failure handling (P358)" below — do NOT halt the loop (a failed pre-flight is a non-load-bearing cache-refresh dependency, NOT an iter).
226
226
 
227
227
  **ADR-079 composition note**: Step 0c dispatches `/wr-itil:review-problems` which includes Step 4.6 relevance-close per ADR-079 — relevance-close fires as a side-effect of the auto-dispatch. This is desirable: relevance closes accumulate the same way deferred placeholders do, and the AND-trigger reasonably gates both pieces of work.
228
228
 
@@ -266,7 +266,7 @@ The helper returns one of five outcomes (contract documented at `packages/itil/l
266
266
  | `ttl-expiry age=<N>s ttl=<M>s` | Dispatch `/wr-itil:check-upstream-responses` as a pre-flight iter. Cache stale; the skill polls each back-linked upstream URL, diffs against the cache, and emits STATE / NEW / LABEL / NONE / FAIL per back-link ticket. |
267
267
  | `fresh-within-ttl` | Silent-pass per ADR-013 Rule 5 + P132 mechanical-stage carve-out. Proceed to Step 1. |
268
268
 
269
- **Pre-flight dispatch shape**: when promoted, dispatch a single `claude -p --permission-mode bypassPermissions --output-format json` subprocess that invokes `/wr-itil:check-upstream-responses` (per P084 + ADR-032 subprocess isolation). Reuse the Step 5 subprocess wrapper verbatim — same flag set, same idle-timeout SIGTERM poll loop. The subprocess runs the full check-upstream-responses Step 1 + Step 2 + Step 3 pipeline; the cache file `docs/problems/.outbound-responses-cache.json` + audit-log `docs/audits/outbound-responses-log.md` are refreshed in its own commit per ADR-014 (check-upstream-responses' SKILL.md Step 3 commit grain). After the subprocess completes, the orchestrator proceeds to Step 1.
269
+ **Pre-flight dispatch shape**: when promoted, dispatch a single `claude -p --permission-mode bypassPermissions --output-format json` subprocess that invokes `/wr-itil:check-upstream-responses` (per P084 + ADR-032 subprocess isolation). Reuse the Step 5 subprocess wrapper verbatim — same flag set, same idle-timeout SIGTERM poll loop. The subprocess runs the full check-upstream-responses Step 1 + Step 2 + Step 3 pipeline; the cache file `docs/problems/.outbound-responses-cache.json` + audit-log `docs/audits/outbound-responses-log.md` are refreshed in its own commit per ADR-014 (check-upstream-responses' SKILL.md Step 3 commit grain). After the subprocess completes, the orchestrator proceeds to Step 1. **If the pre-flight subprocess exits non-zero OR returns `is_error: true`**, apply the non-blocking revert-and-proceed contract in "Step 0 pre-flight subprocess failure handling (P358)" below — do NOT halt the loop (a failed pre-flight is a non-load-bearing cache-refresh dependency, NOT an iter).
270
270
 
271
271
  **Iter-summary annotation**:
272
272
 
@@ -285,7 +285,23 @@ The annotation pre-empts the "surprise heavy iter" perception JTBD-006 expects a
285
285
  <!-- @jtbd JTBD-006 (Progress the Backlog While I'm Away — AFK orchestrator pre-flights check-upstream-responses so outbound STATE/NEW deltas surface without manual polling) -->
286
286
  <!-- @jtbd JTBD-004 (Connect Agents Across Repos to Collaborate — closes the outbound symmetric feedback loop) -->
287
287
 
288
- After Step 0d completes (whether dispatched or silent-passed), proceed to Step 1.
288
+ After Step 0d completes (whether dispatched or silent-passed), proceed to the shared pre-flight failure-handling contract below, then to Step 1.
289
+
290
+ ### Step 0 pre-flight subprocess failure handling (P358 — non-blocking revert-and-proceed)
291
+
292
+ Step 0b / Step 0c / Step 0d (and **any future Step 0x pre-flight** that reuses the Step 5 `claude -p` subprocess wrapper) dispatch a `/wr-itil:review-problems` or `/wr-itil:check-upstream-responses` **pre-flight subprocess** "same shape as Step 5". That phrase imports the Step 5 *dispatch mechanism* (the `claude -p --output-format json` wrapper + the idle-timeout SIGTERM poll loop), but the **failure semantics are NOT shared** — and the prior prose left this implicit, which P358 surfaced. Step 5's exit-code semantics HALT the loop on non-zero exit / `is_error: true` because **the iter IS the loop body unit** — its failure is the loop's failure. A **pre-flight is a non-load-bearing cache-refresh dependency**, not an iteration of the loop body: Step 1's backlog scan reads whatever `docs/problems/README.md` already exists (freshly-refreshed or slightly-stale), so a failed pre-flight degrades to "cache not refreshed this pass" — never to "halt the loop".
293
+
294
+ **Contract — a pre-flight subprocess that exits non-zero OR returns `is_error: true` is NON-BLOCKING** (general rule; every Step 0x pre-flight inherits it):
295
+
296
+ 1. **Revert any dirty working-tree state the failed pre-flight left.** The dispatched skill commits its own refresh per ADR-014 (review-problems' Slice E grain / check-upstream-responses' Step 3 grain) — it commits end-to-end or not at all. A subprocess that died mid-refresh may leave an **UNSTAGED** partial write across any path the dispatched skill is contractually allowed to touch: the staleness cache (`docs/problems/.upstream-cache.json` for 0b, `docs/problems/.outbound-responses-cache.json` for 0d), the audit log (`docs/audits/inbound-discovery-log.md` for 0b, `docs/audits/outbound-responses-log.md` for 0d), AND `docs/problems/README.md` + re-rated ticket bodies (0c). Revert the whole contractually-touchable set — not just the cache JSON — so a half-written README or audit-log is also restored. Revert each path **independently** (`git checkout -- docs/problems/ 2>/dev/null; git checkout -- docs/audits/ 2>/dev/null`) rather than as a combined `git checkout -- docs/problems/ docs/audits/` pathspec: the combined form errors and reverts NOTHING when `docs/audits/` is absent (a fresh adopter repo that has never run inbound/outbound discovery), whereas the per-path form tolerates the missing directory and still reverts the dirty `docs/problems/` write. Do NOT commit a partial write: a half-refreshed cache/README is worse than a stale-but-coherent one. If the dead pre-flight somehow left **STAGED** residue (it should not — the pre-flight owns its commit end-to-end), `git reset` (unstage) it first, then revert, so the orchestrator's own subsequent Step 1+ gate flow is not contaminated by a dead subprocess's index (mirrors the ADR-009 no-trust-window-extension reasoning — a dead `is_error: true` subprocess MUST NOT seed the parent's commit).
297
+ 2. **Log a one-line iter-summary annotation** naming the failed pre-flight + the failure class: `Step 0<b|c|d> pre-flight FAILED (<exit-code | is_error class>) — reverted partial cache write, proceeding to Step 1 with existing README`. Preserves the JTBD-006 audit-trail outcome (the silent degradation becomes observable rather than invisible).
298
+ 3. **Proceed to Step 1.** The pre-flight failure does NOT halt the loop and does NOT count against the Step 0 prior-session-state Branch 3 detection (step 1 above restored a clean tree, so the iter dispatches that follow start from a clean state).
299
+
300
+ **`is_error: true` sub-class note (reconciles P358 with the Step 5 taxonomy).** A pre-flight subprocess failure is the SAME `is_error: true` family the Step 5 exit-code semantics taxonomise (P261 SALVAGE / P214 HALT) — **including** the `socket connection was closed unexpectedly` variant (an `is_error: true` shape that routes to the Step 5 catch-all advisory). The load-bearing distinction P358 surfaces is **orthogonal to the SALVAGE-vs-HALT axis**: that axis is scoped to **iters** (the loop body); pre-flights have their own non-blocking failure contract. The Step 5 SALVAGE branch does **NOT** apply to a pre-flight even when the pre-flight left staged work — a pre-flight is not an iteration whose work the orchestrator salvages-and-commits; its job is a cache refresh the dispatched skill owns end-to-end. Pre-flight failure is therefore ALWAYS the revert-and-proceed branch above, never SALVAGE.
301
+
302
+ **AFK authorisation per ADR-013 Rule 6**: revert-and-proceed is a deterministic, non-interactive recovery — no `AskUserQuestion`. Reverting an unstaged partial write is fully reversible (the next loop pass re-attempts the refresh) and policy-authorised (ADR-019 preflight-reconciliation "leave the tree clean" precedent). Mirrors the P121 SIGTERM Rule-6 posture.
303
+
304
+ **Compose-with**: ADR-032 § "Pre-flight subprocess failure handling — non-blocking revert-and-proceed (P358 amendment)" (the architectural record + the iter-vs-pre-flight failure-semantics distinction), Step 5 exit-code semantics (the iter-failure HALT contract this is distinguished from), ADR-019 (preflight-reconciliation clean-tree surface), ADR-009 (no-trust-window-extension — the `git reset` of any staged residue), ADR-013 Rule 6 (non-interactive recovery), P358 (driver ticket). This is a fourth symmetric pre-flight surface alongside the three "Staleness contract drift" clauses (lines for Step 0b/0c/0d) — a future Step 0x pre-flight inherits this failure rule by construction; do NOT re-derive a step-specific copy.
289
305
 
290
306
  ### Step 1: Scan the backlog
291
307
 
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env bats
2
+ # tdd-review: structural-permitted (justification: the doc-lint slice below
3
+ # asserts SKILL.md / ADR-032 prose contract — SKILL.md is the contract
4
+ # document per ADR-037 Permitted Exception; these guards catch prose drift
5
+ # away from the behavioural revert-and-proceed contract exercised above. The
6
+ # load-bearing core of this fixture is behavioural per ADR-052. harness-gap P012)
7
+ #
8
+ # Behavioural test: work-problems Step 0 PRE-FLIGHT subprocess failure handling
9
+ # (P358). Step 0b / 0c / 0d dispatch a /wr-itil:review-problems (or
10
+ # check-upstream-responses) pre-flight subprocess "same shape as Step 5". But a
11
+ # pre-flight is a NON-load-bearing cache-refresh dependency, NOT an iter (the
12
+ # loop body). When a pre-flight subprocess exits non-zero OR returns
13
+ # `is_error: true` (e.g. `API Error: The socket connection was closed
14
+ # unexpectedly`), the orchestrator's contract is NON-BLOCKING:
15
+ # (1) revert any dirty (unstaged) partial cache/audit/README write the
16
+ # pre-flight left (`git checkout -- docs/problems/ docs/audits/`);
17
+ # (1b) if a dead pre-flight left STAGED residue, `git reset` then revert
18
+ # (ADR-009 no-trust-window-extension — a dead is_error:true subprocess
19
+ # must not seed the parent's commit);
20
+ # (2) log a one-line annotation;
21
+ # (3) proceed to Step 1 with the existing README.
22
+ # This is ORTHOGONAL to the Step 5 iter SALVAGE-vs-HALT axis (P261 / P214):
23
+ # that axis classifies an *iter's* is_error:true; this fixture classifies the
24
+ # *pre-flight role* — which NEVER salvages and NEVER halts the loop.
25
+ #
26
+ # The fake `claude` shim below re-creates the production shape: it dirties an
27
+ # unstaged cache file in the repo, then emits an is_error:true socket-closed
28
+ # JSON envelope (no commit). The harness re-implements the orchestrator's
29
+ # pre-flight failure contract (faithful to SKILL.md § "Step 0 pre-flight
30
+ # subprocess failure handling (P358)") and asserts the revert-and-proceed
31
+ # outcome across the input shapes. Adopters who copy the SKILL.md pre-flight
32
+ # block into their orchestrator should observe the same outcomes.
33
+ #
34
+ # @problem P358
35
+ # @rfc RFC-024
36
+ # @jtbd JTBD-006
37
+ #
38
+ # Cross-reference:
39
+ # P358 (claude -p subprocess dispatch socket-closed; pre-flight is_error
40
+ # handling gap) — driver ticket
41
+ # RFC-024 (work-problems pre-flight subprocess failure handling) — fix vehicle
42
+ # ADR-032 (governance skill invocation patterns — § "Pre-flight subprocess
43
+ # failure handling — non-blocking revert-and-proceed (P358 amendment)") —
44
+ # the iter-vs-pre-flight failure-semantics distinction this fixture pins
45
+ # ADR-009 (gate-marker / no-trust-window-extension — staged residue git reset)
46
+ # ADR-019 (preflight clean-tree reconciliation surface)
47
+ # P261 / P214 (the iter is_error:true SALVAGE/HALT axis this is orthogonal to)
48
+ # ADR-037 / ADR-052 (skill testing strategy — behavioural default; doc-lint
49
+ # contract assertion is the Permitted Exception, marked above)
50
+
51
+ setup() {
52
+ TEST_TMP="$(mktemp -d)"
53
+ FAKE_BIN="${TEST_TMP}/bin"
54
+ mkdir -p "$FAKE_BIN"
55
+
56
+ # Fake `claude` binary simulating a pre-flight subprocess of the socket-closed
57
+ # class: it leaves a dirty (UNSTAGED by default) partial cache write in the
58
+ # CWD git repo, then emits an is_error:true JSON envelope carrying the
59
+ # socket-closed error string in `.result`. No commit. This matches the
60
+ # 2026-06-10 P358 shape: a /wr-itil:review-problems pre-flight that partially
61
+ # refreshed docs/problems/.upstream-cache.json then died with
62
+ # `API Error: The socket connection was closed unexpectedly`.
63
+ cat > "$FAKE_BIN/claude" <<'FAKE_EOF'
64
+ #!/usr/bin/env bash
65
+ # Test fake for work-problems Step 0 pre-flight failure-handling fixture.
66
+ # Dirties a partial cache write, then emits is_error:true socket-closed JSON.
67
+ mkdir -p docs/problems
68
+ printf 'partial-refresh-mid-die\n' >> docs/problems/.upstream-cache.json
69
+ if [ "${FAKE_STAGE_RESIDUE:-0}" = "1" ]; then
70
+ git add docs/problems/.upstream-cache.json 2>/dev/null || true
71
+ fi
72
+ if [ "${FAKE_EXIT:-0}" != "0" ]; then
73
+ printf '%s\n' '{"is_error":true,"result":"subprocess crashed"}'
74
+ exit "${FAKE_EXIT}"
75
+ fi
76
+ printf '%s\n' '{"is_error":true,"result":"API Error: The socket connection was closed unexpectedly. For more information, pass `verbose: true`","total_cost_usd":0.0,"duration_ms":727000}'
77
+ FAKE_EOF
78
+ chmod +x "$FAKE_BIN/claude"
79
+ export PATH="$FAKE_BIN:$PATH"
80
+
81
+ # A throwaway git repo so dirty-detection + the revert are real. Seed a
82
+ # committed clean cache so the partial write is observably a dirty delta.
83
+ REPO="${TEST_TMP}/repo"
84
+ mkdir -p "$REPO/docs/problems"
85
+ git -C "$REPO" init -q
86
+ git -C "$REPO" config user.email "test@example.com"
87
+ git -C "$REPO" config user.name "Test"
88
+ printf 'clean-cache\n' > "$REPO/docs/problems/.upstream-cache.json"
89
+ git -C "$REPO" add docs/problems/.upstream-cache.json
90
+ git -C "$REPO" commit -q -m "root: clean cache"
91
+
92
+ SKILL_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
93
+ SKILL_FILE="${SKILL_DIR}/SKILL.md"
94
+ ADR_FILE="$(cd "${SKILL_DIR}/../../../.." && pwd)/docs/decisions/032-governance-skill-invocation-patterns.proposed.md"
95
+ }
96
+
97
+ teardown() {
98
+ if [ -n "${TEST_TMP:-}" ] && [ -d "$TEST_TMP" ]; then
99
+ rm -rf "$TEST_TMP"
100
+ fi
101
+ }
102
+
103
+ # Faithful re-implementation of SKILL.md § "Step 0 pre-flight subprocess failure
104
+ # handling (P358)". Consumes the pre-flight JSON envelope + the pre-flight exit
105
+ # code + the repo working-tree state, applies the non-blocking revert-and-proceed
106
+ # contract, and returns the orchestrator's decision. KEY PROPERTY: a pre-flight
107
+ # NEVER halts the loop and NEVER salvages — it reverts and proceeds.
108
+ preflight_failure_outcome() {
109
+ local json="$1"
110
+ local exit_code="$2"
111
+ local repo="$3"
112
+
113
+ local is_error
114
+ is_error=$(printf '%s' "$json" | python3 -c 'import json,sys; print(str(json.load(sys.stdin).get("is_error")).lower())' 2>/dev/null || echo unknown)
115
+
116
+ # Pre-flight succeeded (exit 0 AND is_error:false) → cache refreshed; proceed.
117
+ if [ "$exit_code" = "0" ] && [ "$is_error" = "false" ]; then
118
+ printf 'DECISION=PROCEED reason=preflight-ok\n'
119
+ return 0
120
+ fi
121
+
122
+ # Pre-flight FAILED (non-zero exit OR is_error:true) → NON-BLOCKING contract.
123
+ # Step 1b: if the dead pre-flight left STAGED residue, unstage it first
124
+ # (ADR-009 — a dead is_error:true subprocess must not seed the parent index).
125
+ if [ -n "$(git -C "$repo" diff --cached --name-only)" ]; then
126
+ git -C "$repo" reset -q
127
+ fi
128
+ # Step 1: revert the whole contractually-touchable path set (not just cache).
129
+ # Per-path tolerant — a COMBINED `git checkout -- A B` errors and reverts
130
+ # NOTHING when B is absent (e.g. docs/audits/ on a fresh adopter repo), so
131
+ # revert each path independently.
132
+ git -C "$repo" checkout -- docs/problems/ 2>/dev/null || true
133
+ git -C "$repo" checkout -- docs/audits/ 2>/dev/null || true
134
+ # Step 3: proceed to Step 1 (never halt).
135
+ printf 'DECISION=PROCEED reason=preflight-failed-reverted\n'
136
+ return 0
137
+ }
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Behavioural cases (the load-bearing core per ADR-052).
141
+ # ---------------------------------------------------------------------------
142
+
143
+ @test "P358: pre-flight is_error:true (socket-closed) + unstaged partial write -> revert + PROCEED (never halt)" {
144
+ export FAKE_STAGE_RESIDUE=0 FAKE_EXIT=0
145
+ local json
146
+ json=$( cd "$REPO" && claude -p --output-format json "PREFLIGHT" < /dev/null )
147
+ # The fake left a dirty partial cache write.
148
+ run git -C "$REPO" status --porcelain docs/problems/.upstream-cache.json
149
+ [ -n "$output" ]
150
+ run preflight_failure_outcome "$json" 0 "$REPO"
151
+ [ "$status" -eq 0 ]
152
+ [[ "$output" == *"DECISION=PROCEED"* ]]
153
+ [[ "$output" == *"preflight-failed-reverted"* ]]
154
+ # The dirty partial write was reverted — tree is clean again.
155
+ run git -C "$REPO" status --porcelain
156
+ [ -z "$output" ]
157
+ # The committed clean cache content survived (revert restored it).
158
+ run cat "$REPO/docs/problems/.upstream-cache.json"
159
+ [[ "$output" == "clean-cache" ]]
160
+ }
161
+
162
+ @test "P358: pre-flight non-zero exit -> revert + PROCEED (non-blocking; not a loop halt)" {
163
+ export FAKE_STAGE_RESIDUE=0 FAKE_EXIT=1
164
+ local json
165
+ json=$( cd "$REPO" && claude -p --output-format json "PREFLIGHT" < /dev/null || true )
166
+ run preflight_failure_outcome "$json" 1 "$REPO"
167
+ [ "$status" -eq 0 ]
168
+ [[ "$output" == *"DECISION=PROCEED"* ]]
169
+ # Crucially NOT a HALT — a pre-flight failure does not stop the loop.
170
+ [[ "$output" != *"HALT"* ]]
171
+ run git -C "$REPO" status --porcelain
172
+ [ -z "$output" ]
173
+ }
174
+
175
+ @test "P358: pre-flight left STAGED residue -> git reset then revert (ADR-009 no-seed-parent-index)" {
176
+ export FAKE_STAGE_RESIDUE=1 FAKE_EXIT=0
177
+ local json
178
+ json=$( cd "$REPO" && claude -p --output-format json "PREFLIGHT" < /dev/null )
179
+ # The fake STAGED the partial write.
180
+ run git -C "$REPO" diff --cached --name-only
181
+ [[ "$output" == *".upstream-cache.json"* ]]
182
+ run preflight_failure_outcome "$json" 0 "$REPO"
183
+ [ "$status" -eq 0 ]
184
+ [[ "$output" == *"DECISION=PROCEED"* ]]
185
+ # Staged residue was unstaged AND reverted — index + tree both clean.
186
+ run git -C "$REPO" diff --cached --name-only
187
+ [ -z "$output" ]
188
+ run git -C "$REPO" status --porcelain
189
+ [ -z "$output" ]
190
+ }
191
+
192
+ @test "P358: pre-flight success (exit 0 + is_error:false) -> PROCEED without revert" {
193
+ # Hand-build a clean success envelope (the fake always fails by design).
194
+ local json='{"is_error":false,"result":"refreshed"}'
195
+ run preflight_failure_outcome "$json" 0 "$REPO"
196
+ [ "$status" -eq 0 ]
197
+ [[ "$output" == *"DECISION=PROCEED"* ]]
198
+ [[ "$output" == *"preflight-ok"* ]]
199
+ }
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Doc-lint contract assertions (Permitted Exception per ADR-037; structural
203
+ # slice marked at top of file per ADR-052 Surface 2). These guard the SKILL.md
204
+ # / ADR-032 prose against drift away from the behavioural contract above.
205
+ # ---------------------------------------------------------------------------
206
+
207
+ @test "P358: SKILL.md documents the Step 0 pre-flight failure-handling subsection" {
208
+ run grep -niE "pre-flight subprocess failure handling.{0,40}P358|Step 0 pre-flight subprocess failure" "$SKILL_FILE"
209
+ [ "$status" -eq 0 ]
210
+ }
211
+
212
+ @test "P358: SKILL.md pre-flight contract names the non-blocking revert-and-proceed rule" {
213
+ run grep -niE "non-?blocking" "$SKILL_FILE"
214
+ [ "$status" -eq 0 ]
215
+ run grep -niE "revert.{0,40}(proceed|partial)|proceed to Step 1 with the existing" "$SKILL_FILE"
216
+ [ "$status" -eq 0 ]
217
+ }
218
+
219
+ @test "P358: SKILL.md distinguishes pre-flight (cache-refresh dependency) from iter (loop body)" {
220
+ run grep -niE "non-load-bearing cache-refresh dependency" "$SKILL_FILE"
221
+ [ "$status" -eq 0 ]
222
+ run grep -niE "iter IS the loop body|the iter is the loop body" "$SKILL_FILE"
223
+ [ "$status" -eq 0 ]
224
+ }
225
+
226
+ @test "P358: SKILL.md pre-flight forward-pointer fires in the 0b/0c/0d dispatch paragraphs" {
227
+ # At least three "do NOT halt the loop (a failed pre-flight ...)" pointers.
228
+ run bash -c "grep -ciE 'do NOT halt the loop .a failed pre-flight' '$SKILL_FILE'"
229
+ [ "$status" -eq 0 ]
230
+ [ "$output" -ge 3 ]
231
+ }
232
+
233
+ @test "P358: SKILL.md pre-flight contract cites P358" {
234
+ run grep -nE "P358" "$SKILL_FILE"
235
+ [ "$status" -eq 0 ]
236
+ }
237
+
238
+ @test "P358: ADR-032 carries the pre-flight failure-handling P358 amendment" {
239
+ run grep -niE "Pre-flight subprocess failure handling.{0,60}P358 amendment" "$ADR_FILE"
240
+ [ "$status" -eq 0 ]
241
+ }
242
+
243
+ @test "P358: ADR-032 amendment names the iter-vs-pre-flight failure-semantics axis as orthogonal to SALVAGE/HALT" {
244
+ run grep -niE "orthogonal" "$ADR_FILE"
245
+ [ "$status" -eq 0 ]
246
+ run grep -niE "No SALVAGE for a pre-flight|SALVAGE branch does NOT apply to a pre-flight" "$ADR_FILE"
247
+ [ "$status" -eq 0 ]
248
+ }