@windyroad/itil 0.61.0-preview.1033 → 0.61.0

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.
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ # Generated by scripts/sync-shim-wrappers.sh from
3
+ # packages/shared/lib/shim-wrapper-template.sh. DO NOT EDIT individual
4
+ # shim files in packages/*/bin/wr-* directly; edit the template + run
5
+ # `npm run sync:shim-wrappers` to regenerate.
6
+ #
7
+ # Resolution (ADR-080):
8
+ # 1. If the wrapper's parent dir is semver-shaped, treat as installed-
9
+ # cache execution and resolve to the highest-version sibling's
10
+ # scripts/ entry below.
11
+ # 2. Otherwise (parent dir is e.g. `architect`), treat as source-
12
+ # monorepo execution and dispatch to own scripts/. The source-repo-
13
+ # guard `exec` is the anchor parsed by
14
+ # packages/retrospective/scripts/check-tarball-shipped-shims.sh.
15
+ # 3. If the cache parent contains zero semver-shaped siblings, exit
16
+ # 127 with a stderr message naming the cache parent (per SQ-080-2).
17
+ #
18
+ # @adr ADR-080 (highest-version-wins shim wrapper plugin scaffold)
19
+ # @adr ADR-049 (plugin-bundled scripts resolve via bin/ on $PATH — amended)
20
+ # @problem P343 (mid-session staleness window)
21
+
22
+ set -euo pipefail
23
+
24
+ SHIM_DIR="$(cd "$(dirname "$0")" && pwd)"
25
+ OWN_VERSION_DIR="$(dirname "$SHIM_DIR")"
26
+ OWN_VERSION_NAME="$(basename "$OWN_VERSION_DIR")"
27
+ CACHE_PARENT="$(dirname "$OWN_VERSION_DIR")"
28
+
29
+ SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'
30
+
31
+ # Source-repo guard: own parent dir is NOT semver → dispatch to own scripts/.
32
+ if ! [[ "$OWN_VERSION_NAME" =~ $SEMVER_RE ]]; then
33
+ exec "$SHIM_DIR/../scripts/migrate-story-status-mirror.sh" "$@"
34
+ fi
35
+
36
+ # Cache execution: pick the highest-semver sibling under CACHE_PARENT.
37
+ HIGHEST=""
38
+ while IFS= read -r dir; do
39
+ name="$(basename "$dir")"
40
+ [[ "$name" =~ $SEMVER_RE ]] || continue
41
+ if [[ -z "$HIGHEST" ]] || [[ "$(printf '%s\n%s\n' "$HIGHEST" "$name" | sort -V | tail -1)" == "$name" ]]; then
42
+ HIGHEST="$name"
43
+ fi
44
+ done < <(find "$CACHE_PARENT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
45
+
46
+ if [[ -z "$HIGHEST" ]]; then
47
+ printf 'wr-shim: no cached versions in %s\n' "$CACHE_PARENT" >&2
48
+ exit 127
49
+ fi
50
+
51
+ exec "$CACHE_PARENT/$HIGHEST/scripts/migrate-story-status-mirror.sh" "$@"
package/hooks/hooks.json CHANGED
@@ -1,84 +1,215 @@
1
1
  {
2
2
  "hooks": {
3
3
  "SessionStart": [
4
- { "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/bin/check-deps.sh wr-itil wr-risk-scorer" }] },
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "${CLAUDE_PLUGIN_ROOT}/bin/check-deps.sh wr-itil wr-risk-scorer"
9
+ }
10
+ ]
11
+ },
12
+ {
13
+ "matcher": "startup",
14
+ "hooks": [
15
+ {
16
+ "type": "command",
17
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-pending-questions-surface.sh"
18
+ }
19
+ ]
20
+ },
5
21
  {
6
22
  "matcher": "startup",
7
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-pending-questions-surface.sh" }]
23
+ "hooks": [
24
+ {
25
+ "type": "command",
26
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-rfc-oversight-nudge.sh"
27
+ }
28
+ ]
8
29
  },
9
30
  {
10
31
  "matcher": "startup",
11
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-rfc-oversight-nudge.sh" }]
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-story-mirror-migration-nudge.sh"
36
+ }
37
+ ]
12
38
  }
13
39
  ],
14
40
  "UserPromptSubmit": [
15
- { "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-assistant-output-gate.sh" }] },
16
- { "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-correction-detect.sh" }] },
17
- { "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/staleness-check.sh" }] }
41
+ {
42
+ "hooks": [
43
+ {
44
+ "type": "command",
45
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-assistant-output-gate.sh"
46
+ }
47
+ ]
48
+ },
49
+ {
50
+ "hooks": [
51
+ {
52
+ "type": "command",
53
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-correction-detect.sh"
54
+ }
55
+ ]
56
+ },
57
+ {
58
+ "hooks": [
59
+ {
60
+ "type": "command",
61
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/staleness-check.sh"
62
+ }
63
+ ]
64
+ }
18
65
  ],
19
66
  "PreToolUse": [
20
67
  {
21
68
  "matcher": "Bash|Write|Edit|Read",
22
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-runtime-sid-marker.sh" }]
69
+ "hooks": [
70
+ {
71
+ "type": "command",
72
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-runtime-sid-marker.sh"
73
+ }
74
+ ]
23
75
  },
24
76
  {
25
77
  "matcher": "Write",
26
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/manage-problem-enforce-create.sh" }]
78
+ "hooks": [
79
+ {
80
+ "type": "command",
81
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/manage-problem-enforce-create.sh"
82
+ }
83
+ ]
27
84
  },
28
85
  {
29
86
  "matcher": "Write|Edit",
30
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-claude-space-protection.sh" }]
87
+ "hooks": [
88
+ {
89
+ "type": "command",
90
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-claude-space-protection.sh"
91
+ }
92
+ ]
31
93
  },
32
94
  {
33
95
  "matcher": "Bash",
34
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/p057-staging-trap-detect.sh" }]
96
+ "hooks": [
97
+ {
98
+ "type": "command",
99
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/p057-staging-trap-detect.sh"
100
+ }
101
+ ]
35
102
  },
36
103
  {
37
104
  "matcher": "Bash",
38
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-no-implement-draft-gate.sh" }]
105
+ "hooks": [
106
+ {
107
+ "type": "command",
108
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-no-implement-draft-gate.sh"
109
+ }
110
+ ]
39
111
  },
40
112
  {
41
113
  "matcher": "Bash",
42
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-bash-polling-antipattern-detect.sh" }]
114
+ "hooks": [
115
+ {
116
+ "type": "command",
117
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-bash-polling-antipattern-detect.sh"
118
+ }
119
+ ]
43
120
  },
44
121
  {
45
122
  "matcher": "Bash",
46
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/pre-publish-intake-gate.sh" }]
123
+ "hooks": [
124
+ {
125
+ "type": "command",
126
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/pre-publish-intake-gate.sh"
127
+ }
128
+ ]
47
129
  },
48
130
  {
49
131
  "matcher": "Bash",
50
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-changeset-discipline.sh" }]
132
+ "hooks": [
133
+ {
134
+ "type": "command",
135
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-changeset-discipline.sh"
136
+ }
137
+ ]
51
138
  },
52
139
  {
53
140
  "matcher": "Bash",
54
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-readme-refresh-discipline.sh" }]
141
+ "hooks": [
142
+ {
143
+ "type": "command",
144
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-readme-refresh-discipline.sh"
145
+ }
146
+ ]
55
147
  }
56
148
  ],
57
149
  "PostToolUse": [
58
150
  {
59
151
  "matcher": "Bash",
60
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-rfc-trailer-advisory.sh" }]
152
+ "hooks": [
153
+ {
154
+ "type": "command",
155
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-rfc-trailer-advisory.sh"
156
+ }
157
+ ]
61
158
  },
62
159
  {
63
160
  "matcher": "Bash",
64
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-commit-trailer-transition-advisory.sh" }]
161
+ "hooks": [
162
+ {
163
+ "type": "command",
164
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-commit-trailer-transition-advisory.sh"
165
+ }
166
+ ]
65
167
  },
66
168
  {
67
169
  "matcher": "Bash",
68
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-fix-title-lifecycle-advisory.sh" }]
170
+ "hooks": [
171
+ {
172
+ "type": "command",
173
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-fix-title-lifecycle-advisory.sh"
174
+ }
175
+ ]
69
176
  },
70
177
  {
71
178
  "matcher": "Write|Edit|MultiEdit",
72
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-fictional-defer-detect.sh" }]
179
+ "hooks": [
180
+ {
181
+ "type": "command",
182
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-fictional-defer-detect.sh"
183
+ }
184
+ ]
73
185
  },
74
186
  {
75
187
  "matcher": "Write|Edit|MultiEdit",
76
- "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-deferral-cadence-gate.sh" }]
188
+ "hooks": [
189
+ {
190
+ "type": "command",
191
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-deferral-cadence-gate.sh"
192
+ }
193
+ ]
77
194
  }
78
195
  ],
79
196
  "Stop": [
80
- { "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-assistant-output-review.sh" }] },
81
- { "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-mid-loop-ask-detect.sh" }] }
197
+ {
198
+ "hooks": [
199
+ {
200
+ "type": "command",
201
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-assistant-output-review.sh"
202
+ }
203
+ ]
204
+ },
205
+ {
206
+ "hooks": [
207
+ {
208
+ "type": "command",
209
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-mid-loop-ask-detect.sh"
210
+ }
211
+ ]
212
+ }
82
213
  ]
83
214
  }
84
215
  }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ # wr-itil — SessionStart hook (P474 / RFC-059 / STORY-054).
3
+ #
4
+ # Surfaces a one-line nudge when any story still carries the `**Status**:` body
5
+ # line that duplicates its frontmatter `status:`. That duplicate is why an accept
6
+ # transition used to drift a story's own oversight fingerprint — the story read
7
+ # as unratified moments after being ratified, and the no-implement gate then
8
+ # denied its own implementing commit.
9
+ #
10
+ # Why a hook and not just a release note. The migration ships as a PATH shim, so
11
+ # it never appears in `/` autocomplete, and a release note cannot fire inside an
12
+ # adopter's repo — as a control it depends entirely on someone remembering. A
13
+ # maintenance action with no automatic trigger does not happen, so artefacts
14
+ # predating the fix would keep the defect indefinitely while newly captured
15
+ # stories were immune. JTBD-009 already sanctions this shape: "Idempotency is
16
+ # what makes the speculative invocation safe — and what makes the skill safe to
17
+ # wire into bootstrap / install flows."
18
+ #
19
+ # NUDGE ONLY — this hook MUST NOT run the migration. JTBD-009's ratified
20
+ # "Foreground-synchronous + self-committed" outcome is explicit that the
21
+ # developer reviews the rewritten artefacts before they ship, so migrating
22
+ # unattended would breach a ratified outcome rather than serve it.
23
+ #
24
+ # Names the ADR-049 PATH shim, never a repo-relative `packages/...` path — the
25
+ # latter resolves only in the source monorepo, which is exactly the blind spot
26
+ # source-repo dogfooding hides (P151/P153/P219/P317).
27
+ #
28
+ # Detection is token-cheap: one grep over story filenames, no body reads beyond
29
+ # the match, no LLM call. Silent when clean (the steady state after migrating),
30
+ # fail-open, one line, within the ADR-040 ≤2KB budget.
31
+ #
32
+ # AFK self-suppress: shares the suite-wide WR_SUPPRESS_OVERSIGHT_NUDGE guard
33
+ # with the architect / jtbd / itil-rfc oversight nudges, so an orchestrator
34
+ # exports it once and every nudge self-suppresses rather than firing at an
35
+ # absent user (JTBD-006 friction guard). Only the literal "1" suppresses.
36
+
37
+ set -uo pipefail
38
+
39
+ if [ "${WR_SUPPRESS_OVERSIGHT_NUDGE:-}" = "1" ]; then
40
+ exit 0
41
+ fi
42
+
43
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
44
+ STORIES_DIR="$PROJECT_DIR/docs/stories"
45
+
46
+ [ -d "$STORIES_DIR" ] || exit 0
47
+
48
+ # STORY-*.md only. README.md documents the template and legitimately shows the
49
+ # field, so scanning it would make this nudge fire forever on a clean corpus.
50
+ COUNT=0
51
+ while IFS= read -r f; do
52
+ [ -f "$f" ] || continue
53
+ if grep -qE '^\*\*Status\*\*:' "$f" 2>/dev/null; then
54
+ COUNT=$((COUNT + 1))
55
+ fi
56
+ done < <(find "$STORIES_DIR" -mindepth 1 -maxdepth 2 -name 'STORY-*.md' -type f 2>/dev/null)
57
+
58
+ [ "$COUNT" -gt 0 ] 2>/dev/null || exit 0
59
+
60
+ if [ "$COUNT" -eq 1 ]; then
61
+ echo "[wr-itil] 1 story still duplicates its status in a \`**Status**:\` body line, so accepting it will drop its ratification — run \`wr-itil-migrate-story-status-mirror docs/stories\` once to remove the duplicate and carry each ratification forward."
62
+ else
63
+ echo "[wr-itil] $COUNT stories still duplicate their status in a \`**Status**:\` body line, so accepting them will drop their ratification — run \`wr-itil-migrate-story-status-mirror docs/stories\` once to remove the duplicates and carry each ratification forward."
64
+ fi
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env bats
2
+ # Behavioural tests for itil-story-mirror-migration-nudge.sh — the P474 / RFC-059
3
+ # self-firing cadence for the story `**Status**:` mirror migration.
4
+ #
5
+ # Why this exists: the migration is a PATH shim, so it does not surface in
6
+ # autocomplete, and its only announcement was the release note. Release notes
7
+ # cannot fire in an adopter's repo — prose counts as zero control paths — so
8
+ # artefacts predating the fix would keep the defect indefinitely while newly
9
+ # captured stories were immune. A maintenance action with no automatic trigger
10
+ # does not happen.
11
+ #
12
+ # Three contract conditions this suite locks, per the JTBD-009 review:
13
+ # 1. Nudge-only, never auto-run — JTBD-009's "I review the rewritten artefacts
14
+ # before they ship" is a ratified outcome, so the hook must not migrate.
15
+ # 2. Names the PATH shim, never a repo-relative `packages/...` path, which
16
+ # would only resolve in the source monorepo (P151/P153/P219/P317).
17
+ # 3. Silent when clean, so steady state costs nothing.
18
+ #
19
+ # @problem P474 @adr ADR-049 ADR-090 @story STORY-054
20
+
21
+ setup() {
22
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../.." && pwd)"
23
+ HOOK="$REPO_ROOT/packages/itil/hooks/itil-story-mirror-migration-nudge.sh"
24
+ TMPD="$(mktemp -d)"
25
+ mkdir -p "$TMPD/docs/stories/draft" "$TMPD/docs/stories/accepted"
26
+ # AFK iters export the shared oversight guard; unset so the positive cases do
27
+ # not false-RED inside one (the P391 hermeticity class).
28
+ unset WR_SUPPRESS_OVERSIGHT_NUDGE
29
+ }
30
+ teardown() { unset WR_SUPPRESS_OVERSIGHT_NUDGE; rm -rf "$TMPD"; }
31
+
32
+ seed_with_mirror() {
33
+ printf -- '---\nstatus: draft\n---\n\n# STORY-901: x\n\n**Status**: draft\n**Reported**: 2026-07-30\n' \
34
+ > "$TMPD/docs/stories/draft/STORY-901-x.md"
35
+ }
36
+ seed_clean() {
37
+ printf -- '---\nstatus: draft\n---\n\n# STORY-902: y\n\n**Reported**: 2026-07-30\n' \
38
+ > "$TMPD/docs/stories/draft/STORY-902-y.md"
39
+ }
40
+
41
+ @test "fires when a story still carries the **Status** mirror" {
42
+ seed_with_mirror
43
+ run env CLAUDE_PROJECT_DIR="$TMPD" bash "$HOOK"
44
+ [ "$status" -eq 0 ]
45
+ [ -n "$output" ]
46
+ [[ "$output" == *"[wr-itil]"* ]]
47
+ }
48
+
49
+ @test "names the PATH shim so it resolves in an adopter install" {
50
+ seed_with_mirror
51
+ run env CLAUDE_PROJECT_DIR="$TMPD" bash "$HOOK"
52
+ [[ "$output" == *"wr-itil-migrate-story-status-mirror"* ]]
53
+ }
54
+
55
+ # The recurring adopter-portability class: a repo-relative path resolves only in
56
+ # the source monorepo, so source-repo dogfooding hides the breakage.
57
+ @test "never emits a repo-relative packages/ path" {
58
+ seed_with_mirror
59
+ run env CLAUDE_PROJECT_DIR="$TMPD" bash "$HOOK"
60
+ [[ "$output" != *"packages/itil"* ]]
61
+ [[ "$output" != *"scripts/migrate-story-status-mirror.sh"* ]]
62
+ }
63
+
64
+ @test "silent when no story carries the mirror" {
65
+ seed_clean
66
+ run env CLAUDE_PROJECT_DIR="$TMPD" bash "$HOOK"
67
+ [ "$status" -eq 0 ]
68
+ [ -z "$output" ]
69
+ }
70
+
71
+ @test "silent when there is no stories directory at all" {
72
+ run env CLAUDE_PROJECT_DIR="$(mktemp -d)" bash "$HOOK"
73
+ [ "$status" -eq 0 ]
74
+ [ -z "$output" ]
75
+ }
76
+
77
+ @test "counts affected stories rather than reporting a bare flag" {
78
+ seed_with_mirror
79
+ printf -- '---\nstatus: draft\n---\n\n# STORY-903: z\n\n**Status**: draft\n' \
80
+ > "$TMPD/docs/stories/accepted/STORY-903-z.md"
81
+ run env CLAUDE_PROJECT_DIR="$TMPD" bash "$HOOK"
82
+ [[ "$output" == *"2"* ]]
83
+ }
84
+
85
+ # Ratified JTBD-009 outcome: the developer reviews the rewrite before it ships.
86
+ # A nudge that migrated on its own would breach that, so assert the file is
87
+ # untouched — this is the condition that keeps the hook inside the job.
88
+ @test "NEVER migrates — the story file is left exactly as found" {
89
+ seed_with_mirror
90
+ local before; before="$(cat "$TMPD/docs/stories/draft/STORY-901-x.md")"
91
+ run env CLAUDE_PROJECT_DIR="$TMPD" bash "$HOOK"
92
+ [ "$status" -eq 0 ]
93
+ [ "$before" = "$(cat "$TMPD/docs/stories/draft/STORY-901-x.md")" ]
94
+ grep -qE '^\*\*Status\*\*: draft' "$TMPD/docs/stories/draft/STORY-901-x.md"
95
+ }
96
+
97
+ @test "self-suppresses under the shared AFK oversight guard" {
98
+ seed_with_mirror
99
+ run env WR_SUPPRESS_OVERSIGHT_NUDGE=1 CLAUDE_PROJECT_DIR="$TMPD" bash "$HOOK"
100
+ [ "$status" -eq 0 ]
101
+ [ -z "$output" ]
102
+ }
103
+
104
+ @test "only the literal value 1 suppresses" {
105
+ seed_with_mirror
106
+ run env WR_SUPPRESS_OVERSIGHT_NUDGE=yes CLAUDE_PROJECT_DIR="$TMPD" bash "$HOOK"
107
+ [ -n "$output" ]
108
+ }
109
+
110
+ # README documents the template and legitimately shows the field; scanning it
111
+ # would make the nudge fire forever on a clean corpus.
112
+ @test "ignores docs/stories/README.md" {
113
+ printf -- '# Stories\n\n**Status**: <status>\n' > "$TMPD/docs/stories/README.md"
114
+ run env CLAUDE_PROJECT_DIR="$TMPD" bash "$HOOK"
115
+ [ "$status" -eq 0 ]
116
+ [ -z "$output" ]
117
+ }
118
+
119
+ @test "registered on SessionStart startup in hooks.json" {
120
+ run grep -c 'itil-story-mirror-migration-nudge.sh' "$REPO_ROOT/packages/itil/hooks/hooks.json"
121
+ [ "$output" -ge 1 ]
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.61.0-preview.1033",
3
+ "version": "0.61.0",
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"
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env bash
2
+ # migrate-story-status-mirror.sh — P474 one-time migration.
3
+ #
4
+ # Removes the redundant `**Status**:` body line from story artefacts and carries
5
+ # each artefact's existing ratification forward.
6
+ #
7
+ # WHY the mirror is removed rather than normalised out of the oversight hash:
8
+ # the body line only repeats the frontmatter `status:` key that
9
+ # `oversight_content_hash` already excludes. Left in place, advancing a story
10
+ # from draft to accepted changed hashed content, so a story the maintainer had
11
+ # just ratified read as unratified and the no-implement gate denied its own
12
+ # implementing commit. Normalising the line out of the hash would have worked
13
+ # too, but it was the FOURTH such lifecycle mirror in a family of three and a
14
+ # fifth was already anticipated — one normaliser rule per mirror, forever.
15
+ # Maintainer direction 2026-07-29: kill the class, not the instance.
16
+ #
17
+ # RE-FINGERPRINT, NOT RE-RATIFY — the safety property this script rests on.
18
+ # `human-oversight: confirmed` asserts that a human confirmed something; this
19
+ # script NEVER writes it. `oversight-hash` asserts no event at all — it only
20
+ # identifies WHICH content the confirmation covered. Recomputing that pointer,
21
+ # over content whose sole delta is a mechanical mirror of an already-excluded
22
+ # field, removes zero ratified substance from coverage. That argument is what
23
+ # distinguishes this from the P348 hollow-marker class, and it holds ONLY
24
+ # because of two guards:
25
+ #
26
+ # 1. Per-artefact validity gate — re-fingerprint only when the stored hash
27
+ # still matches the content as it stands. An artefact that had already
28
+ # drifted stays drifted; nothing is silently revived. Without this the
29
+ # migration is a blanket re-bless, which IS P348.
30
+ # 2. Mirror-agreement precondition — if the body line disagrees with the
31
+ # frontmatter, it is carrying independent information (the real corpus had
32
+ # `superseded (was: draft)` and `in-progress (2026-07-23)`), so deleting it
33
+ # would lose content rather than de-duplicate it. Those are SKIPPED and
34
+ # REPORTED for a human to resolve, never rewritten.
35
+ #
36
+ # Idempotent: an artefact with no body mirror is untouched, so re-running after
37
+ # the fix ships is a no-op. Reports every artefact it touches, so the
38
+ # re-fingerprint set is auditable from the commit that ran it.
39
+ #
40
+ # Usage: migrate-story-status-mirror.sh [<stories-dir>...] (default: docs/stories)
41
+ # Exit: 0 = completed (including "nothing to do"); 2 = usage / missing lib.
42
+ #
43
+ # Authority: ADR-090 (oversight fingerprint), ADR-101 (oversight-basis).
44
+ # Driver: P474. Test: migrate-story-status-mirror.bats.
45
+
46
+ set -euo pipefail
47
+
48
+ LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" 2>/dev/null && pwd)" || {
49
+ echo "migrate-story-status-mirror: cannot locate lib dir" >&2; exit 2; }
50
+ # shellcheck source=/dev/null
51
+ source "$LIB/story-oversight.sh"
52
+
53
+ dirs=("$@")
54
+ [ "${#dirs[@]}" -eq 0 ] && dirs=("docs/stories")
55
+
56
+ # Octal mode of a file, portably. Three traps here, all of them hit in practice:
57
+ # 1. `chmod --reference` is GNU-only, so BSD hosts silently fell through to a
58
+ # hardcoded 644 while the call site claimed to preserve the mode.
59
+ # 2. The two `stat` flavours spell the mode differently — GNU `-c %a`, BSD
60
+ # `-f %Lp` — and a mac with coreutils on PATH has the GNU one.
61
+ # 3. Crucially, the wrong flavour does not fail cleanly. GNU `stat -f` means
62
+ # "filesystem information" and prints a multi-line block about the volume
63
+ # with exit 0, so a plain `A || B` chain accepts that blob as a mode.
64
+ # Hence: validate the shape rather than trusting the exit status, and only then
65
+ # accept the answer. Falls back to 644, the correct mode for these files.
66
+ file_mode() {
67
+ local m
68
+ for m in "$(stat -c %a "$1" 2>/dev/null)" "$(stat -f %Lp "$1" 2>/dev/null)"; do
69
+ case "$m" in
70
+ [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) printf '%s' "$m"; return 0 ;;
71
+ esac
72
+ done
73
+ printf '644'
74
+ }
75
+
76
+ frontmatter_status() {
77
+ awk '/^---$/{c++; next} c==1 && /^status:/{ sub(/^status:[[:space:]]*/, ""); gsub(/"/, ""); print; exit }' "$1"
78
+ }
79
+ body_status() {
80
+ # `|| true` is load-bearing: no match makes grep exit 1, which under
81
+ # `set -euo pipefail` would kill the script on the second (no-op) run and
82
+ # silently destroy idempotence.
83
+ grep -m1 -E '^\*\*Status\*\*:' "$1" 2>/dev/null | sed -E 's/^\*\*Status\*\*:[[:space:]]*//' || true
84
+ }
85
+
86
+ removed=0 refingerprinted=0 skipped=0 untouched=0
87
+
88
+ for d in "${dirs[@]}"; do
89
+ [ -d "$d" ] || continue
90
+ # Story artefacts live one level down in per-state subdirs.
91
+ while IFS= read -r f; do
92
+ [ -f "$f" ] || continue
93
+ body="$(body_status "$f")"
94
+ if [ -z "$body" ]; then
95
+ untouched=$((untouched + 1))
96
+ continue
97
+ fi
98
+ fm="$(frontmatter_status "$f")"
99
+ if [ "$fm" != "$body" ]; then
100
+ printf 'SKIP %s — body Status disagrees with frontmatter (body=%s frontmatter=%s); it carries information the frontmatter does not, so resolve by hand before removing\n' \
101
+ "$f" "$body" "${fm:-<none>}" >&2
102
+ skipped=$((skipped + 1))
103
+ continue
104
+ fi
105
+
106
+ # Was this artefact ratified-and-valid BEFORE we touch it? Decide now: once
107
+ # the line is gone the pre-migration hash is unreproducible.
108
+ was_valid=0
109
+ if is_story_map_ratified "$f"; then was_valid=1; fi
110
+
111
+ # Drop the FIRST matching mirror line and nothing else — surrounding blank
112
+ # lines are left exactly as they are. The template puts the mirror in a run
113
+ # of adjacent `**Field**:` lines, so removing it leaves no double blank to
114
+ # collapse; a later prose line that merely starts with the same token is not
115
+ # reached, because `done` latches on the first match.
116
+ tmp="$(mktemp)"
117
+ awk '!(/^\*\*Status\*\*:/ && !done) { print; next } { done=1 }' "$f" > "$tmp"
118
+ # Preserve the original mode — mktemp creates 0600, which would otherwise
119
+ # strip the group/other read bit from every migrated story.
120
+ chmod "$(file_mode "$f")" "$tmp"
121
+ mv "$tmp" "$f"
122
+ removed=$((removed + 1))
123
+
124
+ if [ "$was_valid" -eq 1 ]; then
125
+ # Re-point the fingerprint at the same ratified substance. Never touches
126
+ # human-oversight, and never adds an oversight-basis it did not have.
127
+ new_hash="$(oversight_content_hash "$f")"
128
+ tmp="$(mktemp)"
129
+ sed -E "s/^oversight-hash:[[:space:]]*[a-f0-9]{64}.*$/oversight-hash: ${new_hash}/" "$f" > "$tmp"
130
+ chmod "$(file_mode "$f")" "$tmp"
131
+ mv "$tmp" "$f"
132
+ refingerprinted=$((refingerprinted + 1))
133
+ printf 'MIGRATE %s — mirror removed, fingerprint re-pointed (ratification preserved)\n' "$f"
134
+ else
135
+ printf 'MIGRATE %s — mirror removed (was not ratified-and-valid; left unratified)\n' "$f"
136
+ fi
137
+ # STORY-*.md only. `README.md` documents the template and legitimately
138
+ # contains a `**Status**: <status>` example; scanning it would report a
139
+ # spurious disagreement and, worse, a laxer matcher would rewrite the doc.
140
+ done < <(find "$d" -mindepth 1 -maxdepth 2 -name 'STORY-*.md' -type f 2>/dev/null | sort)
141
+ done
142
+
143
+ printf '\nmigrate-story-status-mirror: %d mirror(s) removed, %d re-fingerprinted, %d skipped (disagreement), %d already clean\n' \
144
+ "$removed" "$refingerprinted" "$skipped" "$untouched"
145
+ exit 0
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env bats
2
+ # Behavioural tests for migrate-story-status-mirror.sh — the P474 one-time
3
+ # migration that removes the redundant `**Status**:` body mirror from story
4
+ # artefacts and carries their existing ratification forward.
5
+ #
6
+ # Why the mirror goes rather than getting normalised out of the hash: the body
7
+ # line only repeats the frontmatter `status:` the fingerprint already excludes,
8
+ # and every mirror discovered otherwise costs another normaliser rule (this was
9
+ # the fourth in a family of three). Maintainer direction 2026-07-29 — kill the
10
+ # class, not the instance.
11
+ #
12
+ # The load-bearing safety property is that this RE-FINGERPRINTS, never
13
+ # RE-RATIFIES. `human-oversight: confirmed` records that a human confirmed
14
+ # something and is never written here; `oversight-hash` only identifies WHICH
15
+ # content that confirmation covered. Recomputing it over content whose only
16
+ # delta is an excluded mechanical mirror removes zero ratified substance. The
17
+ # `stored == hash(before)` gate is what makes that provable per artefact — drop
18
+ # the gate and this becomes a blanket re-bless, i.e. the P348 hollow-marker
19
+ # class.
20
+ #
21
+ # @problem P474 @adr ADR-090 ADR-101
22
+
23
+ setup() {
24
+ SCRIPTS="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
25
+ MIGRATE="$SCRIPTS/migrate-story-status-mirror.sh"
26
+ # shellcheck source=/dev/null
27
+ source "$SCRIPTS/../lib/story-oversight.sh"
28
+ TMPD="$(mktemp -d)"; cd "$TMPD"
29
+ mkdir -p docs/stories/draft docs/stories/accepted
30
+ }
31
+ teardown() { cd /; rm -rf "$TMPD"; }
32
+
33
+ # Independent mode probe for assertions. Shape-validated for the same reason the
34
+ # script's own helper is: GNU `stat -f` prints a filesystem blob with exit 0, so
35
+ # an exit-status-only chain silently accepts garbage as a mode.
36
+ mode_of() {
37
+ local m
38
+ for m in "$(stat -c %a "$1" 2>/dev/null)" "$(stat -f %Lp "$1" 2>/dev/null)"; do
39
+ case "$m" in [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) printf '%s' "$m"; return 0 ;; esac
40
+ done
41
+ printf 'UNKNOWN'
42
+ }
43
+
44
+ # Write a story whose stored hash is valid for its current content.
45
+ seed_ratified() {
46
+ local path="$1" fm_status="$2" body_status="$3"
47
+ printf -- '---\nstatus: %s\nstory-id: x\nhuman-oversight: confirmed\noversight-hash: PLACEHOLDER\n---\n\n# STORY-901: a story\n\n**Status**: %s\n**Reported**: 2026-07-29\n\n## User value\n\nvalue prose.\n\n## Acceptance criteria\n\n- [ ] a criterion\n' \
48
+ "$fm_status" "$body_status" > "$path"
49
+ local h; h="$(oversight_content_hash "$path")"
50
+ sed -i.bak "s/oversight-hash: PLACEHOLDER/oversight-hash: $h/" "$path" && rm -f "$path.bak"
51
+ }
52
+
53
+ @test "removes the body Status mirror when it agrees with frontmatter" {
54
+ seed_ratified docs/stories/draft/STORY-901-x.md draft draft
55
+ run bash "$MIGRATE" docs/stories
56
+ [ "$status" -eq 0 ]
57
+ ! grep -qE '^\*\*Status\*\*:' docs/stories/draft/STORY-901-x.md
58
+ # Everything else survives.
59
+ grep -q '^\*\*Reported\*\*: 2026-07-29' docs/stories/draft/STORY-901-x.md
60
+ grep -q 'value prose.' docs/stories/draft/STORY-901-x.md
61
+ }
62
+
63
+ @test "a currently-ratified story is still ratified after migration" {
64
+ seed_ratified docs/stories/draft/STORY-901-x.md draft draft
65
+ run is_story_map_ratified docs/stories/draft/STORY-901-x.md
66
+ [ "$status" -eq 0 ]
67
+ run bash "$MIGRATE" docs/stories
68
+ [ "$status" -eq 0 ]
69
+ run is_story_map_ratified docs/stories/draft/STORY-901-x.md
70
+ [ "$status" -eq 0 ]
71
+ }
72
+
73
+ # The whole point: after migration, the accept transition must stop drifting.
74
+ @test "after migration an accept transition does NOT drift the hash" {
75
+ seed_ratified docs/stories/draft/STORY-901-x.md draft draft
76
+ bash "$MIGRATE" docs/stories
77
+ # Advance status the way manage-story does: frontmatter + a criterion tick.
78
+ sed -i.bak 's/^status: draft/status: accepted/; s/- \[ \] a criterion/- [x] a criterion/' \
79
+ docs/stories/draft/STORY-901-x.md && rm -f docs/stories/draft/STORY-901-x.md.bak
80
+ run is_story_map_ratified docs/stories/draft/STORY-901-x.md
81
+ [ "$status" -eq 0 ]
82
+ }
83
+
84
+ @test "SKIPS and REPORTS a story whose body Status carries extra information" {
85
+ # `superseded (was: draft)` and `in-progress (2026-07-23)` both exist in the
86
+ # real corpus — the line is carrying provenance the frontmatter does not, so
87
+ # deleting it would lose content rather than de-duplicate it.
88
+ seed_ratified docs/stories/draft/STORY-902-y.md superseded 'superseded (was: draft)'
89
+ run bash "$MIGRATE" docs/stories
90
+ [ "$status" -eq 0 ]
91
+ grep -qE '^\*\*Status\*\*: superseded \(was: draft\)' docs/stories/draft/STORY-902-y.md
92
+ [[ "$output" == *"STORY-902"* ]]
93
+ [[ "$output" == *"disagree"* ]] || [[ "$output" == *"SKIP"* ]]
94
+ }
95
+
96
+ @test "does NOT revive an already-drifted story" {
97
+ seed_ratified docs/stories/draft/STORY-903-z.md draft draft
98
+ # Real substance edit after ratification → drifted before migration runs.
99
+ printf '\nAn edit made after ratification.\n' >> docs/stories/draft/STORY-903-z.md
100
+ run is_story_map_ratified docs/stories/draft/STORY-903-z.md
101
+ [ "$status" -ne 0 ]
102
+ run bash "$MIGRATE" docs/stories
103
+ [ "$status" -eq 0 ]
104
+ run is_story_map_ratified docs/stories/draft/STORY-903-z.md
105
+ [ "$status" -ne 0 ]
106
+ }
107
+
108
+ @test "never writes human-oversight — an unconfirmed story stays unconfirmed" {
109
+ printf -- '---\nstatus: draft\nhuman-oversight: unconfirmed\n---\n\n# STORY-904\n\n**Status**: draft\n\n## User value\n\nv.\n' \
110
+ > docs/stories/draft/STORY-904-w.md
111
+ run bash "$MIGRATE" docs/stories
112
+ [ "$status" -eq 0 ]
113
+ grep -q '^human-oversight: unconfirmed' docs/stories/draft/STORY-904-w.md
114
+ ! grep -q '^human-oversight: confirmed' docs/stories/draft/STORY-904-w.md
115
+ }
116
+
117
+ # ADR-101: the post-hoc drain finds AFK-accepted stories via oversight-basis.
118
+ # Migration must not strip it, or those stories stop surfacing for review.
119
+ @test "preserves oversight-basis so the ADR-101 post-hoc drain still finds it" {
120
+ seed_ratified docs/stories/accepted/STORY-905-v.md accepted accepted
121
+ printf -- '%s\n' "$(sed 's/^human-oversight: confirmed/human-oversight: confirmed\noversight-basis: pure-decomposition/' docs/stories/accepted/STORY-905-v.md)" \
122
+ > docs/stories/accepted/STORY-905-v.md
123
+ run bash "$MIGRATE" docs/stories
124
+ [ "$status" -eq 0 ]
125
+ grep -q '^oversight-basis: pure-decomposition' docs/stories/accepted/STORY-905-v.md
126
+ }
127
+
128
+ @test "is idempotent — a second run changes nothing" {
129
+ seed_ratified docs/stories/draft/STORY-901-x.md draft draft
130
+ bash "$MIGRATE" docs/stories
131
+ cp docs/stories/draft/STORY-901-x.md /tmp/after-first-$$.md
132
+ run bash "$MIGRATE" docs/stories
133
+ [ "$status" -eq 0 ]
134
+ diff /tmp/after-first-$$.md docs/stories/draft/STORY-901-x.md
135
+ rm -f /tmp/after-first-$$.md
136
+ }
137
+
138
+ @test "leaves README.md alone — it documents the template, it is not a story" {
139
+ printf -- '# Stories\n\n**Status**: <status>\n\nTemplate docs.\n' > docs/stories/README.md
140
+ run bash "$MIGRATE" docs/stories
141
+ [ "$status" -eq 0 ]
142
+ grep -qE '^\*\*Status\*\*: <status>' docs/stories/README.md
143
+ [[ "$output" != *"README.md"* ]]
144
+ }
145
+
146
+ @test "reports the touched set so the re-fingerprint is auditable" {
147
+ seed_ratified docs/stories/draft/STORY-901-x.md draft draft
148
+ run bash "$MIGRATE" docs/stories
149
+ [ "$status" -eq 0 ]
150
+ [[ "$output" == *"STORY-901"* ]]
151
+ }
152
+
153
+ # The migration writes via mktemp+mv, and mktemp creates 0600. Without an
154
+ # explicit mode carry-over every migrated story loses its group/other read bit.
155
+ # `chmod --reference` is GNU-only, so the portable stat read is what makes this
156
+ # hold on darwin — where the earlier --reference form silently fell through to a
157
+ # hardcoded 644 while the call site claimed to preserve the mode.
158
+ @test "preserves the story file's mode across the rewrite" {
159
+ seed_ratified docs/stories/draft/STORY-906-mode.md draft draft
160
+ chmod 640 docs/stories/draft/STORY-906-mode.md
161
+ before="$(mode_of docs/stories/draft/STORY-906-mode.md)"
162
+ [ "$before" = "640" ]
163
+ run bash "$MIGRATE" docs/stories
164
+ [ "$status" -eq 0 ]
165
+ after="$(mode_of docs/stories/draft/STORY-906-mode.md)"
166
+ [ "$after" = "640" ]
167
+ }
@@ -209,9 +209,17 @@ estimated-effort: <S|M|L|XL — derived at capture per ADR-067 (real best-effort
209
209
  human-oversight: unconfirmed
210
210
  ---
211
211
 
212
+ <!-- Frontmatter `status:` is the ONLY place a story's lifecycle state lives.
213
+ Do NOT add a `**Status**:` body line here. It used to mirror the
214
+ frontmatter, and because the oversight fingerprint excludes the frontmatter
215
+ key but hashed the body copy, every accept transition drifted the story's
216
+ own hash — so a story the maintainer had just ratified read as unratified
217
+ and the no-implement gate denied its own implementing commit. Maintainer
218
+ direction 2026-07-29 removed the mirror rather than adding a fourth
219
+ normaliser rule for it (P474 / ADR-090 amendment). -->
220
+
212
221
  # STORY-<NNN>: <Title>
213
222
 
214
- **Status**: draft
215
223
  **Reported**: <YYYY-MM-DD>
216
224
  **Problems**: <P<NNN> [, P<NNN>, ...]>
217
225
  **JTBD**: <JTBD-<NNN> [, ...]>
@@ -246,3 +246,72 @@ EOF
246
246
  [ ! -f docs/stories/in-progress/STORY-001-test.md ]
247
247
  [ ! -f docs/stories/done/STORY-001-test.md ]
248
248
  }
249
+
250
+ # --- P474 / STORY-054: the lifecycle mirror must stay gone ---------------------
251
+ # A `**Status**:` body line duplicating the frontmatter `status:` used to sit in
252
+ # this template. The oversight fingerprint excludes the frontmatter key but
253
+ # hashed the body copy, so advancing a story un-ratified it and the no-implement
254
+ # gate then denied its own implementing commit. This was the FOURTH such
255
+ # lifecycle mirror in a family of three, so the removal needs a lock: without
256
+ # one, the next template edit reintroduces it and nothing fails.
257
+
258
+ @test "P474: the capture-story template emits no **Status** body mirror" {
259
+ # Behavioural on the artefact the skill prescribes, not on prose ABOUT it:
260
+ # extract the template's own story scaffold and assert the shape it produces.
261
+ # The SKILL may still MENTION the forbidden line in its do-not-reintroduce
262
+ # note, so match only a line-start emission inside a fenced block.
263
+ run bash -c "awk '/^# STORY-<NNN>/,/^## Dependencies/' '$SKILL_FILE' | grep -cE '^\*\*Status\*\*:'"
264
+ [ "$output" = "0" ]
265
+ # Anti-vacuity: if a heading rename ever empties the awk range, the grep -c
266
+ # above returns 0 and passes while the mirror is present. Assert the range
267
+ # actually contains the sibling field that DID survive.
268
+ run bash -c "awk '/^# STORY-<NNN>/,/^## Dependencies/' '$SKILL_FILE' | grep -cE '^\*\*Reported\*\*:'"
269
+ [ "$output" -ge 1 ]
270
+ }
271
+
272
+ @test "P474: the documented story body shape emits no **Status** body mirror" {
273
+ # The second carrier. docs/stories/README.md is the ADOPTER-facing one —
274
+ # reconcile-stories.sh reads the adopter's own copy — so a stale shape here
275
+ # has them hand-author the mirror straight back in.
276
+ local readme="${REPO_ROOT}/docs/stories/README.md"
277
+ [ -f "$readme" ]
278
+ run bash -c "awk '/^# STORY-<NNN>/,/^## /' '$readme' | grep -cE '^\*\*Status\*\*:'"
279
+ [ "$output" = "0" ]
280
+ # Same anti-vacuity guard as the template carrier above.
281
+ run bash -c "awk '/^# STORY-<NNN>/,/^## /' '$readme' | grep -cE '^\*\*Reported\*\*:'"
282
+ [ "$output" -ge 1 ]
283
+ }
284
+
285
+ @test "P474: a template-shaped story keeps its ratification across an accept transition" {
286
+ # The invariant the prose serves. Seeds a story in the shape the template now
287
+ # produces, ratifies it, advances status + ticks a criterion, and asserts the
288
+ # fingerprint still matches — the exact sequence that used to drift.
289
+ # shellcheck source=/dev/null
290
+ source "${REPO_ROOT}/packages/itil/lib/story-oversight.sh"
291
+ mkdir -p docs/stories/draft
292
+ local f=docs/stories/draft/STORY-902-shape.md
293
+ printf -- '---\nstatus: draft\nstory-id: shape\nhuman-oversight: confirmed\noversight-hash: P\n---\n\n# STORY-902: a story\n\n**Reported**: 2026-07-30\n\n## User value\n\nvalue.\n\n## Acceptance criteria\n\n- [ ] a criterion\n' > "$f"
294
+ local h; h="$(oversight_content_hash "$f")"
295
+ sed -i.bak "s/oversight-hash: P/oversight-hash: $h/" "$f" && rm -f "$f.bak"
296
+ run is_story_map_ratified "$f"
297
+ [ "$status" -eq 0 ]
298
+
299
+ sed -i.bak 's/^status: draft/status: accepted/; s/- \[ \] a criterion/- [x] a criterion/' "$f" && rm -f "$f.bak"
300
+ run is_story_map_ratified "$f"
301
+ [ "$status" -eq 0 ]
302
+ }
303
+
304
+ @test "P474: reintroducing the mirror DOES drift a ratified story (the lock has teeth)" {
305
+ # Proves the invariant test above is not vacuous: put the mirror back and the
306
+ # accept transition drifts again, which is the defect.
307
+ # shellcheck source=/dev/null
308
+ source "${REPO_ROOT}/packages/itil/lib/story-oversight.sh"
309
+ mkdir -p docs/stories/draft
310
+ local f=docs/stories/draft/STORY-903-regress.md
311
+ printf -- '---\nstatus: draft\nstory-id: regress\nhuman-oversight: confirmed\noversight-hash: P\n---\n\n# STORY-903: a story\n\n**Status**: draft\n**Reported**: 2026-07-30\n\n## User value\n\nvalue.\n' > "$f"
312
+ local h; h="$(oversight_content_hash "$f")"
313
+ sed -i.bak "s/oversight-hash: P/oversight-hash: $h/" "$f" && rm -f "$f.bak"
314
+ sed -i.bak 's/^status: draft/status: accepted/; s/^\*\*Status\*\*: draft/**Status**: accepted/' "$f" && rm -f "$f.bak"
315
+ run is_story_map_ratified "$f"
316
+ [ "$status" -ne 0 ]
317
+ }