@windyroad/itil 0.60.0 → 0.61.0-preview.1035
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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/wr-itil-migrate-story-status-mirror +51 -0
- package/hooks/hooks.json +153 -22
- package/hooks/itil-correction-detect.sh +18 -0
- package/hooks/itil-story-mirror-migration-nudge.sh +64 -0
- package/hooks/test/itil-correction-detect.bats +53 -0
- package/hooks/test/itil-story-mirror-migration-nudge.bats +122 -0
- package/package.json +1 -1
- package/scripts/migrate-story-status-mirror.sh +145 -0
- package/scripts/test/migrate-story-status-mirror.bats +167 -0
- package/skills/capture-story/SKILL.md +9 -1
- package/skills/capture-story/test/capture-story-behavioural.bats +69 -0
- package/skills/work-problems/SKILL.md +10 -0
|
@@ -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
|
-
{
|
|
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": [
|
|
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": [
|
|
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
|
-
{
|
|
16
|
-
|
|
17
|
-
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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": [
|
|
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
|
-
{
|
|
81
|
-
|
|
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
|
}
|
|
@@ -16,6 +16,24 @@
|
|
|
16
16
|
# Forward-compat: advertises /wr-itil:capture-problem (ADR-032 — not
|
|
17
17
|
# yet shipped) with /wr-itil:manage-problem as the today-target
|
|
18
18
|
# fallback. When capture-problem ships the wording stays valid.
|
|
19
|
+
#
|
|
20
|
+
# P430 / STORY-047 — WR_SUPPRESS_CORRECTION_DETECT is a DISTINCT guard
|
|
21
|
+
# class, not a split of WR_SUPPRESS_OVERSIGHT_NUDGE. That guard means
|
|
22
|
+
# "an absent user cannot ratify"; this one means "nobody authored a
|
|
23
|
+
# correction". A machine-authored iter prompt carrying an ordinary
|
|
24
|
+
# imperative ("DO NOT skip the gate") is not a correction signal, and
|
|
25
|
+
# conflating the two would couple this gate's suppression to the
|
|
26
|
+
# ADR-068 / ADR-047 oversight-nudge contract. Provenance is a property
|
|
27
|
+
# of the spawning process, so the dispatcher asserts it — the hook does
|
|
28
|
+
# not sniff content for it.
|
|
29
|
+
|
|
30
|
+
# Machine-authored prompt: no correction happened and no user is present to act
|
|
31
|
+
# on the nudge. Ahead of the jq parses so the suppressed path costs one string
|
|
32
|
+
# comparison, and before any marker write so the once-per-session budget is not
|
|
33
|
+
# burned by a prompt that produced no output.
|
|
34
|
+
if [ "${WR_SUPPRESS_CORRECTION_DETECT:-}" = "1" ]; then
|
|
35
|
+
exit 0
|
|
36
|
+
fi
|
|
19
37
|
|
|
20
38
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
21
39
|
# shellcheck source=lib/session-marker.sh
|
|
@@ -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
|
|
@@ -20,9 +20,15 @@ setup() {
|
|
|
20
20
|
REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../.." && pwd)"
|
|
21
21
|
HOOK="$REPO_ROOT/packages/itil/hooks/itil-correction-detect.sh"
|
|
22
22
|
SID="itil-correction-test-$$-$RANDOM"
|
|
23
|
+
# An AFK iter exports WR_SUPPRESS_CORRECTION_DETECT=1, which the hook
|
|
24
|
+
# self-suppresses on. Unset it so the positive-detection cases below do not
|
|
25
|
+
# false-RED when this suite runs inside such an iter (the P391 hermeticity
|
|
26
|
+
# class). Suppression is asserted per-invocation via `run env ...` instead.
|
|
27
|
+
unset WR_SUPPRESS_CORRECTION_DETECT
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
teardown() {
|
|
31
|
+
unset WR_SUPPRESS_CORRECTION_DETECT
|
|
26
32
|
rm -f "/tmp/itil-correction-detect-announced-${SID}"
|
|
27
33
|
rm -f "/tmp/itil-correction-detect-announced-${SID}-alt"
|
|
28
34
|
}
|
|
@@ -134,3 +140,50 @@ run_hook() {
|
|
|
134
140
|
[ -z "$output" ]
|
|
135
141
|
[ ! -f "/tmp/itil-correction-detect-announced-${SID}" ]
|
|
136
142
|
}
|
|
143
|
+
|
|
144
|
+
# --- P430 / STORY-047: prompt-authorship guard --------------------------------
|
|
145
|
+
# A framework-authored iter prompt carrying an ordinary imperative ("DO NOT
|
|
146
|
+
# skip the gate") is not a correction — nobody is correcting anything, and the
|
|
147
|
+
# absent user cannot act on the nudge. Provenance is a property of the spawning
|
|
148
|
+
# process, so the dispatcher asserts it via env var rather than the hook
|
|
149
|
+
# guessing from content.
|
|
150
|
+
|
|
151
|
+
run_hook_env() {
|
|
152
|
+
local guard="$1" sid="$2" prompt="$3"
|
|
153
|
+
echo "{\"session_id\":\"$sid\",\"prompt\":$(printf '%s' "$prompt" | jq -Rs .)}" \
|
|
154
|
+
| env WR_SUPPRESS_CORRECTION_DETECT="$guard" bash "$HOOK"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@test "correction-detect: WR_SUPPRESS_CORRECTION_DETECT=1 suppresses a correction prompt" {
|
|
158
|
+
run run_hook_env 1 "$SID" "DO NOT skip the architect gate"
|
|
159
|
+
[ "$status" -eq 0 ]
|
|
160
|
+
[ -z "$output" ]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@test "correction-detect: suppressed path writes no announcement marker" {
|
|
164
|
+
run run_hook_env 1 "$SID" "FFS, you didn't run the test"
|
|
165
|
+
[ "$status" -eq 0 ]
|
|
166
|
+
[ -z "$output" ]
|
|
167
|
+
# The once-per-session budget must survive a prompt that produced no output,
|
|
168
|
+
# or the first real correction in the orchestrator session emits only the
|
|
169
|
+
# terse reminder.
|
|
170
|
+
[ ! -f "/tmp/itil-correction-detect-announced-${SID}" ]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@test "correction-detect: same prompt still fires with the guard unset" {
|
|
174
|
+
run run_hook "$SID" "DO NOT skip the architect gate"
|
|
175
|
+
[ "$status" -eq 0 ]
|
|
176
|
+
[ "${#output}" -gt 300 ]
|
|
177
|
+
[[ "$output" == *"MANDATORY"* ]]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@test "correction-detect: only the literal value 1 suppresses" {
|
|
181
|
+
for v in 0 true yes ""; do
|
|
182
|
+
run run_hook_env "$v" "$SID-$RANDOM" "FFS, that's wrong"
|
|
183
|
+
[ "$status" -eq 0 ]
|
|
184
|
+
[ -n "$output" ] || {
|
|
185
|
+
echo "guard value '$v' wrongly suppressed"
|
|
186
|
+
false
|
|
187
|
+
}
|
|
188
|
+
done
|
|
189
|
+
}
|
|
@@ -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
|
@@ -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
|
+
}
|
|
@@ -582,6 +582,16 @@ export WR_SUPPRESS_PENDING_QUESTIONS=1
|
|
|
582
582
|
# pending-questions guard above (JTBD-006 friction guard).
|
|
583
583
|
export WR_SUPPRESS_OVERSIGHT_NUDGE=1
|
|
584
584
|
|
|
585
|
+
# AFK-iter correction-signal suppression (P430 / JTBD-006): the iteration prompt
|
|
586
|
+
# this loop writes is machine-authored, so an ordinary imperative in it ("DO NOT
|
|
587
|
+
# skip the gate") is not a user correcting anything, and no user is present to
|
|
588
|
+
# act on the capture nudge. itil-correction-detect.sh self-suppresses when this
|
|
589
|
+
# env var is set. A real correction is typed into the ORCHESTRATOR session,
|
|
590
|
+
# where this is unset, so P078 is preserved. Distinct guard class from the
|
|
591
|
+
# oversight nudge above — that one means "cannot ratify", this one means
|
|
592
|
+
# "nobody authored a correction".
|
|
593
|
+
export WR_SUPPRESS_CORRECTION_DETECT=1
|
|
594
|
+
|
|
585
595
|
# Project-scoped governance plugins are NOT loaded by headless `claude -p`
|
|
586
596
|
# (P382): it activates only USER-scoped enabledPlugins, and project activation
|
|
587
597
|
# is trust-gated (headless skips trust), so `--setting-sources user,project`
|