1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
|
#!/usr/bin/env bats
#
# Tests for claude-templates/.ai/scripts/spec-sort — the one-time docs-pile
# retrofit from the docs-lifecycle spec: classify docs/**/*.org outside
# docs/specs/ (spec candidate iff it carries BOTH a Decisions heading AND an
# Implementation phases heading), show an evidence panel, and on --apply
# move + rename confirmed candidates to docs/specs/*-spec.org, prepend the
# status heading (:ID:, dated history line), rewrite the keyword header to
# the two-sequence form, relink file: links across the rewritten roots,
# stamp :LAST_SPEC_SORT: in .ai/notes.org.
#
# Contract under test (docs/specs/2026-07-01-docs-lifecycle-spec.org,
# "The retrofit"):
# - dry-run report is the default; --apply writes
# - --apply refuses on a dirty worktree (exit 2) unless --allow-dirty
# - every candidate needs --confirm REL=KEYWORD or --skip REL (exit 1
# otherwise); terminal keywords need --reason REL=TEXT
# - plan validated before the first write; destination collisions block
# - bare-path mentions in rewritten roots block --apply until
# --acknowledge-bare waives them (reported, never rewritten)
# - mid-apply failure names applied/not-applied + git restore recovery
# - idempotent: a sorted project yields no candidates, no changes
#
# Strategy: each test builds a throwaway git project fixture and runs the
# real script against it. Mid-apply failure is forced via the test-only
# SPEC_SORT_INJECT_FAIL_AFTER env hook.
SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/spec-sort"
setup() {
TEST_DIR="$(mktemp -d -t spec-sort-bats.XXXXXX)"
PROJ="$TEST_DIR/proj"
mkdir -p "$PROJ"
}
teardown() {
rm -rf "$TEST_DIR"
}
# Standard fixture: one spec candidate, one note, a stray root spec with a
# spine, an anomaly (-spec.org name, no spine), inbound links from todo.org,
# a sibling note, a session archive (report-only surface), and .ai/notes.org
# with a Workflow State section.
make_project() {
cd "$PROJ"
git init -q
git config user.email test@test
git config user.name test
mkdir -p docs/design .ai/sessions
cat > docs/design/widget.org <<'EOF'
#+TITLE: Widget Feature
#+DATE: 2026-05-01
#+TODO: DRAFT REVIEW | SHIPPED
* Metadata
| Status | draft |
| Owner | Craig |
* Summary
The widget feature. See [[file:scratch-note.org][the note]].
* Decisions [1/2]
** DONE Pick the widget shape
** TODO Pick the color
* Implementation phases
** Phase 1 — build =src/widget.py=
EOF
cat > docs/design/scratch-note.org <<'EOF'
#+TITLE: Scratch Note
* Metadata
| Status | n/a |
* Thoughts
See [[file:widget.org][the widget spec]].
EOF
cat > docs/rooty-spec.org <<'EOF'
#+TITLE: Rooty
* Decisions
** DONE Only decision
* Implementation phases
** Phase 1 — nothing
EOF
cat > docs/lonely-spec.org <<'EOF'
#+TITLE: Lonely
Just prose, no spine.
EOF
cat > todo.org <<'EOF'
* Open Work
** DOING [#B] Widget feature
Spec: [[file:docs/design/widget.org][widget spec]].
Summary anchor: [[file:docs/design/widget.org::*Summary][the summary]].
EOF
cat > .ai/notes.org <<'EOF'
* Active Reminders
* Workflow State
:LAST_AUDIT: 2026-06-28
EOF
cat > .ai/sessions/2026-06-01-old.org <<'EOF'
Old log: [[file:../../docs/design/widget.org][widget]]
EOF
git add -A
git commit -qm init
}
# Confirm flags that satisfy the gate for the standard fixture's candidates.
CONFIRM_ALL=(--confirm docs/design/widget.org=DRAFT --confirm docs/rooty-spec.org=DRAFT)
# ---- Classification (dry-run) ----------------------------------------
@test "spec-sort: dry-run classifies the spine-carrying doc as a candidate" {
make_project
run "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" == *"CANDIDATE docs/design/widget.org -> docs/specs/widget-spec.org"* ]]
}
@test "spec-sort: a Metadata table alone does not qualify — note stays a note" {
make_project
run "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" == *"NOTE docs/design/scratch-note.org"* ]]
[[ "$output" != *"CANDIDATE docs/design/scratch-note.org"* ]]
}
@test "spec-sort: stray root spec with a spine is a candidate, suffix not doubled" {
make_project
run "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" == *"CANDIDATE docs/rooty-spec.org -> docs/specs/rooty-spec.org"* ]]
[[ "$output" != *"rooty-spec-spec.org"* ]]
}
@test "spec-sort: -spec.org name without a spine is an anomaly, never auto-moved" {
make_project
run "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" == *"ANOMALY docs/lonely-spec.org"* ]]
[[ "$output" != *"CANDIDATE docs/lonely-spec.org"* ]]
}
@test "spec-sort: docs/specs/ contents are excluded from classification" {
make_project
mkdir -p docs/specs
cp docs/design/widget.org docs/specs/sorted-spec.org
git add -A && git commit -qm more
run "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" != *"CANDIDATE docs/specs/sorted-spec.org"* ]]
}
@test "spec-sort: no docs/ directory is a silent no-op" {
cd "$PROJ"
git init -q
git config user.email test@test
git config user.name test
echo x > README.md
git add -A && git commit -qm init
run "$SCRIPT"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
# ---- Evidence panel ---------------------------------------------------
@test "spec-sort: evidence panel shows status field, cookies, and todo.org task" {
make_project
run "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" == *"status field: draft"* ]]
[[ "$output" == *"Decisions [1/2]"* ]]
[[ "$output" == *"todo.org:"*"DOING"*"Widget feature"* ]]
}
@test "spec-sort: keyword proposal follows the evidence — DOING from the linked DOING task" {
make_project
run "$SCRIPT"
[ "$status" -eq 0 ]
# status field says draft, but the linking todo.org task is DOING — the
# panel proposes the state the strongest evidence supports
[[ "$output" == *"proposed keyword: DOING"* ]]
}
@test "spec-sort: an 'incomplete' status field never proposes the terminal IMPLEMENTED" {
make_project
sed -i 's/| Status | draft |/| Status | incomplete |/' docs/design/widget.org
git add -A && git commit -qm status
run "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" != *"proposed keyword: IMPLEMENTED"* ]]
}
# ---- Confirm gate -----------------------------------------------------
@test "spec-sort --apply: refuses when a candidate is neither confirmed nor skipped" {
make_project
run "$SCRIPT" --apply --confirm docs/design/widget.org=DRAFT
[ "$status" -eq 1 ]
[[ "$output" == *"unconfirmed"* ]]
[[ "$output" == *"docs/rooty-spec.org"* ]]
[ -f docs/design/widget.org ] # nothing moved
}
@test "spec-sort --apply: a terminal keyword without --reason refuses" {
make_project
run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED --skip docs/rooty-spec.org
[ "$status" -eq 1 ]
[[ "$output" == *"--reason"* ]]
[ -f docs/design/widget.org ]
}
@test "spec-sort --apply: a terminal keyword with --reason records it in the history line" {
make_project
run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED \
--reason "docs/design/widget.org=shipped in v2, confirmed against src" \
--skip docs/rooty-spec.org
[ "$status" -eq 0 ]
grep -q '^\* IMPLEMENTED Widget Feature' docs/specs/widget-spec.org
grep -q 'shipped in v2, confirmed against src' docs/specs/widget-spec.org
}
@test "spec-sort --apply: --skip leaves the candidate in place and still stamps the marker" {
make_project
run "$SCRIPT" --apply --skip docs/design/widget.org --skip docs/rooty-spec.org
[ "$status" -eq 0 ]
[ -f docs/design/widget.org ]
grep -q ':LAST_SPEC_SORT:' .ai/notes.org
}
# ---- Preflight --------------------------------------------------------
@test "spec-sort --apply: refuses on a dirty worktree (exit 2)" {
make_project
echo "drift" >> todo.org
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 2 ]
[[ "$output" == *"dirty"* ]]
[ -f docs/design/widget.org ]
}
@test "spec-sort --apply --allow-dirty: proceeds and names what recovery loses" {
make_project
echo "drift" >> todo.org
git add todo.org && git commit -qm drift # keep the link intact; dirty a different file
echo "scratch" > untracked-note.txt
echo "local edit" >> .ai/notes.org
run "$SCRIPT" --apply --allow-dirty "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
[[ "$output" == *"pre-existing"* ]]
[[ "$output" == *".ai/notes.org"* ]]
[ -f docs/specs/widget-spec.org ]
}
# ---- Move + rename + rewrite ------------------------------------------
@test "spec-sort --apply: moves, renames to -spec.org, prepends status heading with :ID: and history" {
make_project
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
[ -f docs/specs/widget-spec.org ]
[ ! -f docs/design/widget.org ]
grep -q '^\* DRAFT Widget Feature' docs/specs/widget-spec.org
grep -q ':ID:' docs/specs/widget-spec.org
grep -q 'retrofitted by spec-sort' docs/specs/widget-spec.org
}
@test "spec-sort --apply: keyword header rewritten to the two-sequence form" {
make_project
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
grep -q '^#+TODO: TODO | DONE$' docs/specs/widget-spec.org
grep -q '^#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED$' docs/specs/widget-spec.org
! grep -q 'DRAFT REVIEW | SHIPPED' docs/specs/widget-spec.org
}
@test "spec-sort --apply: Metadata Status field mirrors the confirmed keyword in lowercase" {
make_project
run "$SCRIPT" --apply --confirm docs/design/widget.org=READY --skip docs/rooty-spec.org
[ "$status" -eq 0 ]
grep -q '^\* READY Widget Feature' docs/specs/widget-spec.org
grep -Eq '^\| Status[[:space:]]*\|[[:space:]]*ready' docs/specs/widget-spec.org
}
# ---- Relink -----------------------------------------------------------
@test "spec-sort --apply: rewrites the todo.org link, preserving the description" {
make_project
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
grep -q '\[\[file:docs/specs/widget-spec.org\]\[widget spec\]\]' todo.org
! grep -q 'docs/design/widget.org' todo.org
}
@test "spec-sort --apply: preserves a ::anchor suffix through the rewrite" {
make_project
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
grep -q '\[\[file:docs/specs/widget-spec.org::\*Summary\]\[the summary\]\]' todo.org
}
@test "spec-sort --apply: recomputes a sibling note's relative link to the moved spec" {
make_project
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
grep -q '\[\[file:../specs/widget-spec.org\]\[the widget spec\]\]' docs/design/scratch-note.org
}
@test "spec-sort --apply: recomputes the moved spec's own outbound link to an unmoved note" {
make_project
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
grep -q '\[\[file:../design/scratch-note.org\]\[the note\]\]' docs/specs/widget-spec.org
}
@test "spec-sort: session archives are reported, never rewritten" {
make_project
run "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" == *"REPORT .ai/sessions/2026-06-01-old.org"* ]]
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
grep -q 'docs/design/widget.org' .ai/sessions/2026-06-01-old.org
}
@test "spec-sort: a synced template path report names the canonical rulesets file" {
make_project
mkdir -p .ai/workflows
echo 'See [[file:../../docs/design/widget.org][widget]]' > .ai/workflows/startup.org
git add -A && git commit -qm wf
run "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" == *"REPORT .ai/workflows/startup.org"* ]]
[[ "$output" == *"claude-templates/.ai/workflows/startup.org"* ]]
}
# ---- Bare-path mentions -----------------------------------------------
@test "spec-sort --apply: a bare-path mention in a rewritten root blocks until acknowledged" {
make_project
echo "raw mention: docs/design/widget.org needs review" >> todo.org
git add -A && git commit -qm bare
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 1 ]
[[ "$output" == *"BARE"* ]]
[ -f docs/design/widget.org ] # nothing moved
run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
grep -q 'raw mention: docs/design/widget.org' todo.org # reported, never rewritten
}
@test "spec-sort --apply: a moving doc's bare mention of its own old path is acknowledgeable, not post-apply residue" {
make_project
echo "History: docs/design/widget.org was drafted in May." >> docs/design/widget.org
git add -A && git commit -qm selfmention
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 1 ]
[[ "$output" == *"BARE"* ]]
run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ] # the acknowledged mention rides along to docs/specs/; not residue
grep -q ':LAST_SPEC_SORT:' .ai/notes.org
}
# ---- Plan validation ---------------------------------------------------
@test "spec-sort --apply: a destination collision blocks validation, nothing moved" {
make_project
mkdir -p docs/specs
echo "occupied" > docs/specs/widget-spec.org
git add -A && git commit -qm occupy
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 1 ]
[[ "$output" == *"destination exists"* ]]
[ -f docs/design/widget.org ]
[ "$(cat docs/specs/widget-spec.org)" = "occupied" ]
}
@test "spec-sort --apply: writes the plan file before executing" {
make_project
run "$SCRIPT" --apply --plan-file "$TEST_DIR/plan.json" "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
[ -f "$TEST_DIR/plan.json" ]
grep -q 'widget-spec.org' "$TEST_DIR/plan.json"
}
# ---- Mid-apply failure recovery ----------------------------------------
@test "spec-sort --apply: forced mid-apply failure yields named recovery, not a half-migrated shrug" {
make_project
run env SPEC_SORT_INJECT_FAIL_AFTER=1 "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 1 ]
[[ "$output" == *"RECOVERY"* ]]
[[ "$output" == *"git restore"* ]]
[[ "$output" == *"applied"* ]]
[[ "$output" == *"not applied"* ]]
! grep -q ':LAST_SPEC_SORT:' .ai/notes.org # no stamp on a failed apply
}
# ---- Idempotence + marker ----------------------------------------------
@test "spec-sort --apply: stamps :LAST_SPEC_SORT: in the Workflow State section" {
make_project
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
grep -q ':LAST_SPEC_SORT: ' .ai/notes.org
# lands inside the Workflow State section, alongside the existing marker
awk '/^\* Workflow State/{ws=1} ws && /:LAST_SPEC_SORT:/{found=1} END{exit !found}' .ai/notes.org
}
@test "spec-sort --apply: creates the Workflow State section when notes.org lacks it" {
make_project
printf '* Active Reminders\n' > .ai/notes.org
git add -A && git commit -qm notes
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
grep -q '^\* Workflow State' .ai/notes.org
grep -q ':LAST_SPEC_SORT: ' .ai/notes.org
}
@test "spec-sort --apply: zero candidates still stamps the marker (clears the nudge)" {
make_project
rm docs/design/widget.org docs/rooty-spec.org docs/lonely-spec.org
git add -A && git commit -qm notes-only
run "$SCRIPT" --apply
[ "$status" -eq 0 ]
grep -q ':LAST_SPEC_SORT:' .ai/notes.org
}
@test "spec-sort: a second run after a successful apply finds nothing to do" {
make_project
run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
[ "$status" -eq 0 ]
git add -A && git commit -qm sorted
run "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" != *"CANDIDATE"* ]]
run "$SCRIPT" --apply
[ "$status" -eq 0 ]
run git status --porcelain
# only the re-stamped marker (same date) may differ — tree stays clean
[ -z "$(git status --porcelain -- docs todo.org)" ]
}
|