@house365/comate 0.1.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,1681 @@
1
+ #!/bin/bash
2
+ # comate.sh — single entry dispatcher for comate.
3
+ #
4
+ # Subcommands:
5
+ # state <name> init <workflow> — create .comate.yaml with workflow defaults
6
+ # state <name> get <field> — read a field
7
+ # state <name> set <field> <value> — write a field (atomic, write-back checked)
8
+ # state <name> transition <event> — guarded state-machine transition
9
+ # state <name> check <phase> [--json] — entry-precondition check for a phase
10
+ # state <name> recover <phase> — structured compaction-resume context
11
+ # state <name> scale — assess and set verify_mode
12
+ # state <name> validate — schema + flat-yaml validation
13
+ # guard <name> <phase> [--apply] [--json] — exit-evidence check, optionally transition
14
+ # handoff <name> design --write [--full] — design->build handoff package
15
+ # handoff <name> build --write — build->verify handoff package
16
+ # sources <name> track <file...> [--note] — record knowledge-base source files
17
+ # sources <name> check — drift check tracked source files
18
+ # sources <name> list — print the source manifest
19
+ # sources find <file> — reverse lookup: changes citing a file
20
+ # archive <name> [--dry-run] — sync delta specs + annotate + move + mark archived
21
+ # status [path] — list active changes + next command
22
+ # doctor — prerequisite self-check
23
+ #
24
+ # Exit codes: 0 OK | 1 BLOCK | 2 USAGE | 3 ENV | 4 CORRUPT
25
+
26
+ set -euo pipefail
27
+
28
+ SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")" && pwd)"
29
+ # shellcheck source=comate-lib.sh
30
+ . "$SCRIPT_DIR/comate-lib.sh"
31
+
32
+ usage_top() {
33
+ cat >&2 <<'EOF'
34
+ Usage: comate.sh <command> ...
35
+
36
+ state <name> init <workflow> full | hotfix | tweak
37
+ state <name> get <field>
38
+ state <name> set <field> <value>
39
+ state <name> transition <event> open-complete|design-complete|build-complete|verify-pass|verify-fail|archived
40
+ state <name> check <phase> [--json]
41
+ state <name> recover <phase>
42
+ state <name> scale
43
+ state <name> validate
44
+ guard <name> <phase> [--apply] [--json]
45
+ handoff <name> design --write [--full]
46
+ handoff <name> build --write
47
+ sources <name> track <file...> [--note <text>]
48
+ sources <name> check
49
+ sources <name> list
50
+ sources find <file>
51
+ archive <name> [--dry-run]
52
+ status [path]
53
+ doctor
54
+ EOF
55
+ }
56
+
57
+ require_yaml() {
58
+ # $1 = change name; ensures .comate.yaml exists, echoes its path.
59
+ local name="$1" yaml
60
+ yaml=$(comate_yaml_for "$name")
61
+ [ -f "$yaml" ] || die "$COMATE_CORRUPT" "ERROR: .comate.yaml not found at $yaml"
62
+ echo "$yaml"
63
+ }
64
+
65
+ today() { date +%Y-%m-%d; }
66
+
67
+ # =====================================================================
68
+ # state
69
+ # =====================================================================
70
+
71
+ state_init() {
72
+ local name="$1" workflow="${2:-}"
73
+ validate_change_name "$name"
74
+ validate_enum workflow "$workflow"
75
+ [ -n "$workflow" ] || die "$COMATE_USAGE" "Usage: comate.sh state <name> init <full|hotfix|tweak>"
76
+
77
+ local dir yaml
78
+ dir=$(comate_change_dir "$name")
79
+ yaml="$dir/.comate.yaml"
80
+ [ -f "$yaml" ] && die "$COMATE_USAGE" "ERROR: .comate.yaml already exists at $yaml"
81
+ mkdir -p "$dir"
82
+
83
+ # base_ref = current HEAD.
84
+ local base_ref="null"
85
+ if git rev-parse --verify HEAD >/dev/null 2>&1; then
86
+ base_ref=$(git rev-parse HEAD 2>/dev/null || echo "null")
87
+ fi
88
+ # base_branch = detected default branch, fallback main/master.
89
+ local base_branch="null" ref
90
+ if ref=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null); then
91
+ base_branch="${ref#refs/remotes/origin/}"
92
+ elif git show-ref --verify --quiet refs/heads/main 2>/dev/null; then
93
+ base_branch="main"
94
+ elif git show-ref --verify --quiet refs/heads/master 2>/dev/null; then
95
+ base_branch="master"
96
+ fi
97
+
98
+ # Workflow defaults: full leaves decisions null; presets prefill.
99
+ local build_mode="null" tdd_mode="null" isolation="null" verify_mode="null"
100
+ case "$workflow" in
101
+ hotfix|tweak)
102
+ build_mode="direct"; tdd_mode="direct"; isolation="branch"; verify_mode="light" ;;
103
+ esac
104
+
105
+ # Write full canonical file directly (FIELD_ORDER kept in sync with lib).
106
+ cat > "$yaml" <<EOF
107
+ # comate state — flat key:value only, no nesting
108
+ schema_version: $COMATE_SCHEMA_VERSION
109
+ workflow: $workflow
110
+ phase: open
111
+ build_mode: $build_mode
112
+ tdd_mode: $tdd_mode
113
+ isolation: $isolation
114
+ verify_mode: $verify_mode
115
+ verify_result: pending
116
+ design_doc: null
117
+ plan: null
118
+ verification_report: null
119
+ branch_status: pending
120
+ handoff_context: null
121
+ handoff_hash: null
122
+ impl_context: null
123
+ impl_hash: null
124
+ direct_override: false
125
+ base_ref: $base_ref
126
+ base_branch: $base_branch
127
+ source_kind: conversation
128
+ source_manifest: null
129
+ build_command: null
130
+ verify_command: null
131
+ created_at: $(today)
132
+ verified_at: null
133
+ archived: false
134
+ EOF
135
+
136
+ validate_flat_yaml "$yaml"
137
+ log_pass "[INIT] $yaml (workflow=$workflow, base_branch=$base_branch)"
138
+ }
139
+
140
+ state_get() {
141
+ local name="$1" field="${2:-}"
142
+ validate_change_name "$name"
143
+ [ -n "$field" ] || die "$COMATE_USAGE" "Usage: comate.sh state <name> get <field>"
144
+ local yaml; yaml=$(require_yaml "$name")
145
+ yaml_get "$yaml" "$field"
146
+ }
147
+
148
+ state_set() {
149
+ local name="$1" field="${2:-}" value="${3:-}"
150
+ validate_change_name "$name"
151
+ [ -n "$field" ] || die "$COMATE_USAGE" "Usage: comate.sh state <name> set <field> <value>"
152
+ if [ "$field" = "phase" ]; then
153
+ log_warn "WARNING: setting 'phase' directly bypasses the state machine; prefer 'state transition'."
154
+ fi
155
+ local yaml; yaml=$(require_yaml "$name")
156
+ yaml_set "$yaml" "$field" "$value"
157
+ log_pass "[SET] $field=$value"
158
+ }
159
+
160
+ # --- transition guards (see IMPLEMENTATION.md §3 guard table) ---
161
+
162
+ _require_phase() {
163
+ local yaml="$1" expected="$2" actual
164
+ actual=$(yaml_get "$yaml" phase)
165
+ [ "$actual" = "$expected" ] || \
166
+ die "$COMATE_BLOCK" "ERROR: expected phase '$expected', got '${actual:-null}'"
167
+ }
168
+
169
+ _require_build_decisions() {
170
+ local yaml="$1"
171
+ local workflow build_mode tdd_mode isolation direct_override
172
+ workflow=$(yaml_get "$yaml" workflow)
173
+ build_mode=$(yaml_get "$yaml" build_mode)
174
+ tdd_mode=$(yaml_get "$yaml" tdd_mode)
175
+ isolation=$(yaml_get "$yaml" isolation)
176
+ direct_override=$(yaml_get "$yaml" direct_override)
177
+ case "$isolation" in
178
+ branch|worktree) ;;
179
+ *) die "$COMATE_BLOCK" "ERROR: isolation must be branch or worktree, got '${isolation:-null}'" ;;
180
+ esac
181
+ case "$build_mode" in
182
+ subagent-driven-development|executing-plans|direct) ;;
183
+ *) die "$COMATE_BLOCK" "ERROR: build_mode must be selected, got '${build_mode:-null}'" ;;
184
+ esac
185
+ if [ "$build_mode" = "direct" ] && [ "$workflow" != "hotfix" ] && [ "$workflow" != "tweak" ] && [ "$direct_override" != "true" ]; then
186
+ die "$COMATE_BLOCK" "ERROR: build_mode=direct is only allowed for hotfix/tweak unless direct_override=true"
187
+ fi
188
+ case "$tdd_mode" in
189
+ tdd|direct) ;;
190
+ *) die "$COMATE_BLOCK" "ERROR: tdd_mode must be tdd or direct, got '${tdd_mode:-null}'" ;;
191
+ esac
192
+ }
193
+
194
+ _require_verify_evidence() {
195
+ local yaml="$1" report branch_status
196
+ report=$(yaml_get "$yaml" verification_report)
197
+ branch_status=$(yaml_get "$yaml" branch_status)
198
+ if [ -z "$report" ] || [ "$report" = "null" ] || [ ! -f "$report" ]; then
199
+ die "$COMATE_BLOCK" "ERROR: verification_report must point to an existing file (got '${report:-null}')"
200
+ fi
201
+ [ "$branch_status" = "handled" ] || \
202
+ die "$COMATE_BLOCK" "ERROR: branch_status must be 'handled', got '${branch_status:-null}'"
203
+ }
204
+
205
+ state_transition() {
206
+ local name="$1" event="${2:-}"
207
+ validate_change_name "$name"
208
+ validate_enum_event() {
209
+ case "$1" in
210
+ open-complete|design-complete|build-complete|verify-pass|verify-fail|archived) return 0 ;;
211
+ *) die "$COMATE_USAGE" "ERROR: unknown event '$1'" ;;
212
+ esac
213
+ }
214
+ [ -n "$event" ] || die "$COMATE_USAGE" "Usage: comate.sh state <name> transition <event>"
215
+ validate_enum_event "$event"
216
+ local yaml; yaml=$(require_yaml "$name")
217
+ validate_flat_yaml "$yaml"
218
+
219
+ case "$event" in
220
+ open-complete)
221
+ _require_phase "$yaml" open
222
+ local workflow; workflow=$(yaml_get "$yaml" workflow)
223
+ if [ "$workflow" = "full" ]; then
224
+ yaml_set "$yaml" phase design
225
+ else
226
+ yaml_set "$yaml" phase build
227
+ fi
228
+ ;;
229
+ design-complete)
230
+ _require_phase "$yaml" design
231
+ yaml_set "$yaml" phase build
232
+ ;;
233
+ build-complete)
234
+ _require_phase "$yaml" build
235
+ _require_build_decisions "$yaml"
236
+ yaml_set "$yaml" phase verify
237
+ yaml_set "$yaml" verify_result pending
238
+ yaml_set "$yaml" verification_report null
239
+ yaml_set "$yaml" branch_status pending
240
+ ;;
241
+ verify-pass)
242
+ _require_phase "$yaml" verify
243
+ _require_verify_evidence "$yaml"
244
+ yaml_set "$yaml" verify_result pass
245
+ yaml_set "$yaml" phase archive
246
+ yaml_set "$yaml" verified_at "$(today)"
247
+ ;;
248
+ verify-fail)
249
+ _require_phase "$yaml" verify
250
+ yaml_set "$yaml" verify_result fail
251
+ yaml_set "$yaml" phase build
252
+ yaml_set "$yaml" branch_status pending
253
+ ;;
254
+ archived)
255
+ _require_phase "$yaml" archive
256
+ yaml_set "$yaml" archived true
257
+ ;;
258
+ esac
259
+ log_pass "[TRANSITION] $event"
260
+ }
261
+
262
+ # --- validate (schema + flat-yaml) ---
263
+
264
+ state_validate() {
265
+ local name="$1"
266
+ validate_change_name "$name"
267
+ local yaml; yaml=$(require_yaml "$name")
268
+ log_info "[VALIDATE] $yaml"
269
+
270
+ validate_flat_yaml "$yaml" # dies code 4 on nesting/lists/block scalars
271
+
272
+ local errors=0 warnings=0
273
+ # schema_version present and known.
274
+ local sv; sv=$(yaml_get "$yaml" schema_version)
275
+ if [ -z "$sv" ] || [ "$sv" = "null" ]; then
276
+ log_warn " WARN: schema_version missing"; warnings=$((warnings+1))
277
+ elif [ "$sv" != "$COMATE_SCHEMA_VERSION" ]; then
278
+ log_warn " WARN: schema_version=$sv (this tool writes $COMATE_SCHEMA_VERSION)"; warnings=$((warnings+1))
279
+ fi
280
+
281
+ # Required fields present.
282
+ local required="workflow phase verify_result archived"
283
+ local f
284
+ for f in $required; do
285
+ if ! yaml_keys "$yaml" | grep -qx "$f"; then
286
+ log_fail " FAIL: missing required field '$f'"; errors=$((errors+1))
287
+ fi
288
+ done
289
+
290
+ # Enum validity (null/empty allowed by validate_enum, but it dies on bad — so check inline).
291
+ local key legal v ok w
292
+ for key in "${!ENUMS[@]}"; do
293
+ v=$(yaml_get "$yaml" "$key")
294
+ [ -z "$v" ] && continue
295
+ [ "$v" = "null" ] && continue
296
+ legal="${ENUMS[$key]}"
297
+ ok=0
298
+ for w in $legal; do
299
+ if [ "$v" = "$w" ]; then ok=1; break; fi
300
+ done
301
+ if [ "$ok" -eq 0 ]; then
302
+ log_fail " FAIL: $key='$v' invalid (expected: $legal)"; errors=$((errors+1))
303
+ fi
304
+ done
305
+
306
+ # Path fields point to existing files when set.
307
+ for f in design_doc plan verification_report handoff_context impl_context; do
308
+ v=$(yaml_get "$yaml" "$f")
309
+ if [ -n "$v" ] && [ "$v" != "null" ] && [ ! -f "$v" ]; then
310
+ log_fail " FAIL: $f='$v' does not exist on disk"; errors=$((errors+1))
311
+ fi
312
+ done
313
+
314
+ # Hash fields are 64 hex when set.
315
+ for f in handoff_hash impl_hash; do
316
+ v=$(yaml_get "$yaml" "$f")
317
+ if [ -n "$v" ] && [ "$v" != "null" ] && [[ ! "$v" =~ ^[a-f0-9]{64}$ ]]; then
318
+ log_fail " FAIL: $f='$v' is not a sha256 hex digest"; errors=$((errors+1))
319
+ fi
320
+ done
321
+
322
+ # Unknown keys -> warning.
323
+ local known=" ${FIELD_ORDER[*]} "
324
+ while IFS= read -r key; do
325
+ [ -z "$key" ] && continue
326
+ case "$known" in
327
+ *" $key "*) ;;
328
+ *) log_warn " WARN: unknown field '$key'"; warnings=$((warnings+1)) ;;
329
+ esac
330
+ done < <(yaml_keys "$yaml")
331
+
332
+ if [ "$errors" -gt 0 ]; then
333
+ die "$COMATE_CORRUPT" "$errors error(s), $warnings warning(s) — validation FAILED"
334
+ fi
335
+ log_pass "0 errors, $warnings warning(s) — validation PASSED"
336
+ }
337
+
338
+ # --- check (entry preconditions for a phase) ---
339
+
340
+ CHECK_JSON=0
341
+ declare -a CHECK_RESULTS=() # "name|status"
342
+ CHECK_BLOCK=0
343
+
344
+ _chk() {
345
+ # $1 = name, $2 = 0(pass)/1(fail). Always returns 0 (set -e safe).
346
+ local name="$1" rc="$2"
347
+ if [ "$rc" -eq 0 ]; then
348
+ CHECK_RESULTS+=("$name|pass")
349
+ if [ "$CHECK_JSON" -eq 0 ]; then log_pass " [PASS] $name"; fi
350
+ else
351
+ CHECK_RESULTS+=("$name|fail")
352
+ CHECK_BLOCK=1
353
+ if [ "$CHECK_JSON" -eq 0 ]; then log_fail " [FAIL] $name"; fi
354
+ fi
355
+ return 0
356
+ }
357
+
358
+ _chk_yaml_is() {
359
+ local yaml="$1" field="$2" expected="$3" actual rc=0
360
+ actual=$(yaml_get "$yaml" "$field")
361
+ [ "$actual" = "$expected" ] || rc=1
362
+ _chk "$field=$expected (got ${actual:-null})" "$rc"
363
+ }
364
+
365
+ _chk_yaml_empty() {
366
+ local yaml="$1" field="$2" v rc=0
367
+ v=$(yaml_get "$yaml" "$field")
368
+ { [ -z "$v" ] || [ "$v" = "null" ]; } || rc=1
369
+ _chk "$field empty/null" "$rc"
370
+ }
371
+
372
+ _chk_nonempty() {
373
+ local rc=0
374
+ file_nonempty "$2" || rc=1
375
+ _chk "$1 non-empty" "$rc"
376
+ }
377
+
378
+ state_check() {
379
+ local name="$1" phase="${2:-}"
380
+ validate_change_name "$name"
381
+ validate_enum phase "$phase"
382
+ [ -n "$phase" ] || die "$COMATE_USAGE" "Usage: comate.sh state <name> check <phase> [--json]"
383
+ local dir yaml
384
+ dir=$(comate_change_dir "$name")
385
+ yaml="$dir/.comate.yaml"
386
+ [ -f "$yaml" ] || die "$COMATE_CORRUPT" "ERROR: .comate.yaml not found at $yaml"
387
+
388
+ case "$phase" in
389
+ open)
390
+ _chk ".comate.yaml exists" 0
391
+ _chk_yaml_is "$yaml" phase open
392
+ ;;
393
+ design)
394
+ _chk_yaml_is "$yaml" phase design
395
+ _chk_yaml_is "$yaml" workflow full
396
+ _chk_yaml_empty "$yaml" design_doc
397
+ _chk_nonempty "proposal.md" "$dir/proposal.md"
398
+ _chk_nonempty "design.md" "$dir/design.md"
399
+ _chk_nonempty "tasks.md" "$dir/tasks.md"
400
+ ;;
401
+ build)
402
+ _chk_yaml_is "$yaml" phase build
403
+ local workflow; workflow=$(yaml_get "$yaml" workflow)
404
+ if [ "$workflow" = "full" ]; then
405
+ local dd; dd=$(yaml_get "$yaml" design_doc)
406
+ { [ -n "$dd" ] && [ "$dd" != "null" ] && [ -f "$dd" ]; }
407
+ _chk "design_doc exists ($dd)" $?
408
+ fi
409
+ _chk_nonempty "proposal.md" "$dir/proposal.md"
410
+ _chk_nonempty "tasks.md" "$dir/tasks.md"
411
+ ;;
412
+ verify)
413
+ _chk_yaml_is "$yaml" phase verify
414
+ local vr; vr=$(yaml_get "$yaml" verify_result)
415
+ { [ "$vr" = "pending" ] || [ -z "$vr" ] || [ "$vr" = "null" ]; }
416
+ _chk "verify_result pending/null (got $vr)" $?
417
+ ;;
418
+ archive)
419
+ _chk_yaml_is "$yaml" phase archive
420
+ _chk_yaml_is "$yaml" verify_result pass
421
+ local ar; ar=$(yaml_get "$yaml" archived)
422
+ [ "$ar" != "true" ]; _chk "archived not yet true (got $ar)" $?
423
+ ;;
424
+ esac
425
+
426
+ if [ "$CHECK_JSON" -eq 1 ]; then
427
+ local out='{"phase":"'"$phase"'","checks":['
428
+ local first=1 r cname cstatus
429
+ for r in "${CHECK_RESULTS[@]}"; do
430
+ cname="${r%|*}"; cstatus="${r#*|}"
431
+ [ "$first" -eq 0 ] && out+=','
432
+ first=0
433
+ out+='{"name":"'"$(json_escape "$cname")"'","status":"'"$cstatus"'"}'
434
+ done
435
+ out+='],"block":'"$([ "$CHECK_BLOCK" -eq 1 ] && echo true || echo false)"'}'
436
+ printf '%s\n' "$out"
437
+ else
438
+ echo "" >&2
439
+ if [ "$CHECK_BLOCK" -eq 1 ]; then
440
+ log_fail "BLOCKED — fix failing checks before proceeding"
441
+ else
442
+ log_pass "ALL CHECKS PASSED — ready to proceed"
443
+ fi
444
+ fi
445
+ if [ "$CHECK_BLOCK" -eq 1 ]; then exit "$COMATE_BLOCK"; fi
446
+ exit "$COMATE_OK"
447
+ }
448
+
449
+ # --- recover (compaction-resume structured context) ---
450
+
451
+ _field_status() {
452
+ # $1 name, $2 value, [$3 path]
453
+ local field="$1" value="$2" path="${3:-}"
454
+ if [ -z "$value" ] || [ "$value" = "null" ]; then
455
+ echo " - $field: PENDING"
456
+ elif [ -n "$path" ] && [ ! -f "$path" ]; then
457
+ echo " - $field: BROKEN (path $value does not exist)"
458
+ else
459
+ echo " - $field: DONE ($value)"
460
+ fi
461
+ }
462
+
463
+ state_recover() {
464
+ local name="$1" phase_arg="${2:-}"
465
+ validate_change_name "$name"
466
+ local dir yaml
467
+ dir=$(comate_change_dir "$name")
468
+ yaml="$dir/.comate.yaml"
469
+ [ -f "$yaml" ] || die "$COMATE_CORRUPT" "ERROR: .comate.yaml not found at $yaml"
470
+
471
+ local phase workflow
472
+ phase=$(yaml_get "$yaml" phase)
473
+ workflow=$(yaml_get "$yaml" workflow)
474
+ # If a phase arg was passed, honor it for reporting; else use stored phase.
475
+ [ -n "$phase_arg" ] && phase="$phase_arg"
476
+
477
+ echo "=== Recovery Context: $name ==="
478
+ echo "Phase: $phase"
479
+ echo "Workflow: $workflow"
480
+ echo ""
481
+
482
+ local design_doc plan verify_result verify_mode verification_report
483
+ local branch_status handoff_context handoff_hash impl_context impl_hash
484
+ local isolation build_mode tdd_mode direct_override
485
+ design_doc=$(yaml_get "$yaml" design_doc)
486
+ plan=$(yaml_get "$yaml" plan)
487
+ verify_result=$(yaml_get "$yaml" verify_result)
488
+ verify_mode=$(yaml_get "$yaml" verify_mode)
489
+ verification_report=$(yaml_get "$yaml" verification_report)
490
+ branch_status=$(yaml_get "$yaml" branch_status)
491
+ handoff_context=$(yaml_get "$yaml" handoff_context)
492
+ handoff_hash=$(yaml_get "$yaml" handoff_hash)
493
+ impl_context=$(yaml_get "$yaml" impl_context)
494
+ impl_hash=$(yaml_get "$yaml" impl_hash)
495
+ isolation=$(yaml_get "$yaml" isolation)
496
+ build_mode=$(yaml_get "$yaml" build_mode)
497
+ tdd_mode=$(yaml_get "$yaml" tdd_mode)
498
+ direct_override=$(yaml_get "$yaml" direct_override)
499
+
500
+ case "$phase" in
501
+ open)
502
+ echo "Artifacts:"
503
+ local fdone
504
+ for f in proposal.md design.md tasks.md; do
505
+ if file_nonempty "$dir/$f"; then echo " - $f: DONE"; else echo " - $f: PENDING"; fi
506
+ done
507
+ echo ""
508
+ echo "Recovery action: Create or complete missing artifacts, then use AskUserQuestion for user confirmation."
509
+ ;;
510
+ design)
511
+ echo "Artifacts:"
512
+ for f in proposal.md design.md tasks.md; do
513
+ if file_nonempty "$dir/$f"; then echo " - $f: DONE"; else echo " - $f: MISSING (unexpected in design)"; fi
514
+ done
515
+ echo ""
516
+ echo "Design progress:"
517
+ _field_status "handoff_context" "$handoff_context" "$handoff_context"
518
+ _field_status "handoff_hash" "$handoff_hash"
519
+ _field_status "design_doc" "$design_doc" "$design_doc"
520
+ echo ""
521
+ if [ -n "$design_doc" ] && [ "$design_doc" != "null" ] && [ -f "$design_doc" ]; then
522
+ echo "Recovery action: Design Doc linked. Run guard to transition to build."
523
+ elif [ -n "$handoff_context" ] && [ "$handoff_context" != "null" ] && [ -f "$handoff_context" ]; then
524
+ echo "Recovery action: Handoff generated but Design Doc not created. Resume from brainstorming confirmation (Step 1c)."
525
+ else
526
+ echo "Recovery action: No handoff yet. Start from Step 1a (generate handoff package)."
527
+ fi
528
+ ;;
529
+ build)
530
+ echo "Build decisions:"
531
+ _field_status "isolation" "$isolation"
532
+ _field_status "build_mode" "$build_mode"
533
+ _field_status "tdd_mode" "$tdd_mode"
534
+ if [ "$build_mode" = "direct" ] && [ "$workflow" != "hotfix" ] && [ "$workflow" != "tweak" ]; then
535
+ _field_status "direct_override" "$direct_override"
536
+ fi
537
+ echo ""
538
+ _field_status "plan" "$plan" "$plan"
539
+ _field_status "impl_context" "$impl_context" "$impl_context"
540
+ echo ""
541
+ local tasks_file="$dir/tasks.md" total=0 done=0 pending=0
542
+ if [ -f "$tasks_file" ]; then
543
+ total=$(grep -c '^- \[' "$tasks_file" 2>/dev/null || echo 0)
544
+ done=$(grep -c '^- \[x\]' "$tasks_file" 2>/dev/null || echo 0)
545
+ pending=$((total - done))
546
+ echo "Tasks: $done/$total done, $pending pending"
547
+ else
548
+ echo "Tasks: tasks.md MISSING"
549
+ fi
550
+ echo ""
551
+ if [ "$isolation" = "null" ] || [ -z "$isolation" ]; then
552
+ echo "Recovery action: Isolation not selected. AskUserQuestion for branch/worktree."
553
+ elif [ "$build_mode" = "null" ] || [ -z "$build_mode" ]; then
554
+ echo "Recovery action: Build mode not selected. AskUserQuestion for execution method."
555
+ elif [ "$tdd_mode" = "null" ] || [ -z "$tdd_mode" ]; then
556
+ echo "Recovery action: TDD mode not selected. AskUserQuestion for tdd/direct."
557
+ elif [ ! -f "$tasks_file" ]; then
558
+ echo "Recovery action: tasks.md missing. Verify change directory integrity."
559
+ elif [ "$pending" -gt 0 ]; then
560
+ echo "Recovery action: Read tasks.md and continue from first unchecked task."
561
+ else
562
+ echo "Recovery action: All tasks done. Run guard to transition to verify."
563
+ fi
564
+ ;;
565
+ verify)
566
+ echo "Verification:"
567
+ _field_status "verify_result" "$verify_result"
568
+ _field_status "verify_mode" "$verify_mode"
569
+ _field_status "verification_report" "$verification_report" "$verification_report"
570
+ _field_status "branch_status" "$branch_status"
571
+ _field_status "impl_hash" "$impl_hash"
572
+ echo ""
573
+ if [ "$verify_result" = "pass" ] && [ "$branch_status" = "handled" ]; then
574
+ echo "Recovery action: Verification complete. Run guard to transition to archive."
575
+ elif [ "$verify_result" = "pass" ]; then
576
+ echo "Recovery action: Verified but branch not handled. Complete branch handling, set branch_status=handled."
577
+ elif [ "$verify_result" = "fail" ]; then
578
+ echo "Recovery action: Verification failed, rolled back to build. Resume from /comate-build."
579
+ else
580
+ echo "Recovery action: Verification not started. Run scale assessment then verify."
581
+ fi
582
+ ;;
583
+ archive)
584
+ echo "Archive:"
585
+ _field_status "verify_result" "$verify_result"
586
+ _field_status "archived" "$(yaml_get "$yaml" archived)"
587
+ echo ""
588
+ echo "Recovery action: Run /comate-archive to complete archiving."
589
+ ;;
590
+ *)
591
+ die "$COMATE_USAGE" "ERROR: unknown phase '$phase'"
592
+ ;;
593
+ esac
594
+ echo ""
595
+ echo "=== End Recovery Context ==="
596
+ }
597
+
598
+ # --- scale (assess verify_mode, base-ref annotation) ---
599
+
600
+ state_scale() {
601
+ local name="$1"
602
+ validate_change_name "$name"
603
+ local dir yaml
604
+ dir=$(comate_change_dir "$name")
605
+ yaml="$dir/.comate.yaml"
606
+ [ -f "$yaml" ] || die "$COMATE_CORRUPT" "ERROR: .comate.yaml not found at $yaml"
607
+
608
+ local tasks_file="$dir/tasks.md" task_count=0
609
+ [ -f "$tasks_file" ] && task_count=$(grep -c '^- \[' "$tasks_file" 2>/dev/null || echo 0)
610
+
611
+ local delta_spec_count=0
612
+ if [ -d "$dir/specs" ]; then
613
+ delta_spec_count=$(find "$dir/specs" -name spec.md -type f 2>/dev/null | wc -l | tr -d ' ')
614
+ fi
615
+
616
+ # Changed files: plan base-ref > yaml base_ref > worktree diff. Annotate source.
617
+ local changed_files=0 basis="worktree-diff" base_ref=""
618
+ if git rev-parse --git-dir >/dev/null 2>&1; then
619
+ local plan_file
620
+ plan_file=$(yaml_get "$yaml" plan)
621
+ if [ -n "$plan_file" ] && [ "$plan_file" != "null" ] && [ -f "$plan_file" ]; then
622
+ base_ref=$(grep '^base-ref:' "$plan_file" 2>/dev/null | head -1 | sed 's/^base-ref:[[:space:]]*//')
623
+ [ -n "$base_ref" ] && [ "$base_ref" != "null" ] && basis="plan base-ref"
624
+ fi
625
+ if [ -z "$base_ref" ] || [ "$base_ref" = "null" ]; then
626
+ base_ref=$(yaml_get "$yaml" base_ref)
627
+ [ -n "$base_ref" ] && [ "$base_ref" != "null" ] && basis="state base_ref"
628
+ fi
629
+ if [ -n "$base_ref" ] && [ "$base_ref" != "null" ] && git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
630
+ changed_files=$(git diff --name-only "$base_ref"...HEAD 2>/dev/null | wc -l | tr -d ' ')
631
+ else
632
+ changed_files=$(git diff --name-only HEAD 2>/dev/null | wc -l | tr -d ' ')
633
+ basis="worktree-diff"
634
+ fi
635
+ fi
636
+
637
+ local result="light"
638
+ if [ "$task_count" -gt 3 ] || [ "$delta_spec_count" -gt 1 ] || [ "$changed_files" -gt 4 ]; then
639
+ result="full"
640
+ fi
641
+
642
+ {
643
+ echo "=== Scale Assessment: $name ==="
644
+ echo " Tasks: $task_count (threshold: 3)"
645
+ echo " Delta specs: $delta_spec_count capabilities (threshold: 1)"
646
+ echo " Changed files: $changed_files (threshold: 4) [basis: $basis]"
647
+ echo " -> Result: $result"
648
+ } >&2
649
+
650
+ yaml_set "$yaml" verify_mode "$result"
651
+ log_pass "[SCALE] verify_mode=$result"
652
+ }
653
+
654
+ # =====================================================================
655
+ # guard — phase EXIT evidence checks (table in IMPLEMENTATION.md §4.3)
656
+ # =====================================================================
657
+
658
+ GUARD_BLOCK=0
659
+ declare -a GUARD_RESULTS=() # "name|status"
660
+
661
+ # _guard_check <desc> <command...> — run command; capture output; record result.
662
+ # Always returns 0 so a recorded FAIL never trips the caller's set -e.
663
+ _guard_check() {
664
+ local desc="$1"; shift
665
+ local output rc
666
+ if output=$("$@" 2>&1); then rc=0; else rc=1; fi
667
+ if [ "$rc" -eq 0 ]; then
668
+ GUARD_RESULTS+=("$desc|pass")
669
+ if [ "$GUARD_JSON" -eq 0 ]; then log_pass " [PASS] $desc"; fi
670
+ else
671
+ GUARD_RESULTS+=("$desc|fail")
672
+ GUARD_BLOCK=1
673
+ if [ "$GUARD_JSON" -eq 0 ]; then
674
+ log_fail " [FAIL] $desc"
675
+ if [ -n "$output" ]; then while IFS= read -r line; do log_fail " $line"; done <<< "$output"; fi
676
+ fi
677
+ fi
678
+ return 0
679
+ }
680
+
681
+ # --- evidence predicates (use $G_DIR / $G_YAML / $G_CHANGE set by guard()) ---
682
+
683
+ _g_tasks_all_done() {
684
+ local tasks="$G_DIR/tasks.md"
685
+ [ -f "$tasks" ] || { echo "tasks.md missing at $tasks"; return 1; }
686
+ grep -q '^- \[x\]' "$tasks" || { echo "tasks.md has no completed tasks"; return 1; }
687
+ if grep -q '^- \[ \]' "$tasks"; then
688
+ echo "Unfinished tasks:"; grep -n '^- \[ \]' "$tasks" || true; return 1
689
+ fi
690
+ return 0
691
+ }
692
+ _g_tasks_has_any() {
693
+ local tasks="$G_DIR/tasks.md"
694
+ [ -f "$tasks" ] && grep -q '^- \[' "$tasks"
695
+ }
696
+
697
+ # Resolve a project command: change yaml first, then repo-root config files.
698
+ _g_project_command() {
699
+ local field="$1" value
700
+ value=$(yaml_get "$G_YAML" "$field")
701
+ if [ -n "$value" ] && [ "$value" != "null" ]; then echo "$value"; return 0; fi
702
+ local cfg
703
+ for cfg in ".comate.yaml" "comate.yaml" ".comate.yml" "comate.yml"; do
704
+ if [ -f "$cfg" ]; then
705
+ value=$(yaml_get "$cfg" "$field")
706
+ if [ -n "$value" ] && [ "$value" != "null" ]; then echo "$value"; return 0; fi
707
+ fi
708
+ done
709
+ }
710
+
711
+ _g_is_windows_bash() {
712
+ case "$(uname -s 2>/dev/null || true)" in
713
+ MINGW*|MSYS*|CYGWIN*) return 0 ;;
714
+ *) return 1 ;;
715
+ esac
716
+ }
717
+
718
+ _g_run_cmd() { echo "+ $1" >&2; bash -lc "$1"; }
719
+
720
+ _g_build_passes() {
721
+ [ "${COMATE_SKIP_BUILD:-0}" = "1" ] && return 0
722
+ local cmd; cmd=$(_g_project_command build_command)
723
+ if [ -n "$cmd" ]; then _g_run_cmd "$cmd"; return $?; fi
724
+ if [ -f package.json ] && grep -q '"build"' package.json; then npm run build; return $?; fi
725
+ if [ -f pom.xml ]; then
726
+ if [ -x ./mvnw ]; then ./mvnw compile -q
727
+ elif _g_is_windows_bash && command -v mvn.cmd >/dev/null 2>&1; then mvn.cmd compile -q
728
+ else mvn compile -q; fi
729
+ return $?
730
+ fi
731
+ if [ -f Cargo.toml ]; then cargo build; return $?; fi
732
+ echo "no build_command configured and no known build tool detected"
733
+ echo "Next: set build_command in .comate.yaml, or skip with COMATE_SKIP_BUILD=1 (not recommended)."
734
+ return 1
735
+ }
736
+
737
+ _g_verify_passes() {
738
+ [ "${COMATE_SKIP_BUILD:-0}" = "1" ] && return 0
739
+ local cmd; cmd=$(_g_project_command verify_command)
740
+ if [ -n "$cmd" ]; then _g_run_cmd "$cmd"; return $?; fi
741
+ _g_build_passes
742
+ }
743
+
744
+ _g_isolation_selected() {
745
+ local v; v=$(yaml_get "$G_YAML" isolation)
746
+ case "$v" in branch|worktree) return 0 ;; esac
747
+ echo "isolation must be branch or worktree, got '${v:-null}'"
748
+ echo "Next: comate.sh state $G_CHANGE set isolation <branch|worktree>"
749
+ return 1
750
+ }
751
+ _g_build_mode_selected() {
752
+ local v; v=$(yaml_get "$G_YAML" build_mode)
753
+ case "$v" in subagent-driven-development|executing-plans|direct) return 0 ;; esac
754
+ echo "build_mode must be selected, got '${v:-null}'"
755
+ return 1
756
+ }
757
+ _g_build_mode_allowed() {
758
+ local workflow build_mode direct_override
759
+ workflow=$(yaml_get "$G_YAML" workflow)
760
+ build_mode=$(yaml_get "$G_YAML" build_mode)
761
+ direct_override=$(yaml_get "$G_YAML" direct_override)
762
+ [ "$build_mode" != "direct" ] && return 0
763
+ case "$workflow" in hotfix|tweak) return 0 ;; esac
764
+ [ "$direct_override" = "true" ] && return 0
765
+ echo "build_mode=direct only allowed for hotfix/tweak unless direct_override=true"
766
+ return 1
767
+ }
768
+ _g_tdd_mode_selected() {
769
+ local v; v=$(yaml_get "$G_YAML" tdd_mode)
770
+ case "$v" in tdd|direct) return 0 ;; esac
771
+ echo "tdd_mode must be tdd or direct, got '${v:-null}'"
772
+ echo "Next: comate.sh state $G_CHANGE set tdd_mode <tdd|direct>"
773
+ return 1
774
+ }
775
+ _g_verification_report_exists() {
776
+ local v; v=$(yaml_get "$G_YAML" verification_report)
777
+ [ -n "$v" ] && [ "$v" != "null" ] && [ -f "$v" ]
778
+ }
779
+ _g_branch_status_handled() {
780
+ [ "$(yaml_get "$G_YAML" branch_status)" = "handled" ]
781
+ }
782
+ _g_archived_is_true() {
783
+ [ "$(yaml_get "$G_YAML" archived)" = "true" ]
784
+ }
785
+
786
+ # source-tracking drift: knowledge-base files unchanged since they were pinned.
787
+ # conversation sources (no manifest) pass trivially.
788
+ _g_sources_not_drifted() {
789
+ local kind; kind=$(yaml_get "$G_YAML" source_kind)
790
+ [ "$kind" = "file" ] || return 0
791
+ local manifest; manifest=$(yaml_get "$G_YAML" source_manifest)
792
+ if [ -z "$manifest" ] || [ "$manifest" = "null" ]; then
793
+ echo "source_kind=file but source_manifest is null"; return 1
794
+ fi
795
+ local out rc
796
+ out=$(sources_drift "$manifest"); rc=$?
797
+ if [ "$rc" -ne 0 ]; then
798
+ [ -n "$out" ] && echo "$out"
799
+ echo "Next: review the knowledge-base change; rerun comate.sh sources $G_CHANGE track <file> if intended."
800
+ return 1
801
+ fi
802
+ return 0
803
+ }
804
+
805
+ # Handoff source set (design phase) — proposal/design/tasks + delta specs.
806
+ _g_handoff_sources() {
807
+ printf '%s\n' "$G_DIR/proposal.md" "$G_DIR/design.md" "$G_DIR/tasks.md"
808
+ [ -d "$G_DIR/specs" ] && find "$G_DIR/specs" -path '*/spec.md' -type f 2>/dev/null | sort
809
+ }
810
+
811
+ _g_design_handoff_valid() {
812
+ local context recorded actual md
813
+ context=$(yaml_get "$G_YAML" handoff_context)
814
+ recorded=$(yaml_get "$G_YAML" handoff_hash)
815
+ if [ -z "$context" ] || [ "$context" = "null" ]; then
816
+ echo "handoff_context missing; run: comate.sh handoff $G_CHANGE design --write"; return 1
817
+ fi
818
+ [ -s "$context" ] || { echo "handoff_context not a non-empty file: $context"; return 1; }
819
+ if [[ ! "$recorded" =~ ^[a-f0-9]{64}$ ]]; then
820
+ echo "handoff_hash missing or invalid: ${recorded:-null}"; return 1
821
+ fi
822
+ # Recompute over current sources; flag drift.
823
+ local files=()
824
+ while IFS= read -r f; do files+=("$f"); done < <(_g_handoff_sources)
825
+ actual=$(hash_files_manifest "${files[@]}")
826
+ if [ "$actual" != "$recorded" ]; then
827
+ echo "OpenSpec artifacts changed after handoff was generated."
828
+ echo " recorded: $recorded"
829
+ echo " actual: $actual"
830
+ echo "Next: rerun comate.sh handoff $G_CHANGE design --write"
831
+ return 1
832
+ fi
833
+ md="${context%.json}.md"
834
+ [ -s "$md" ] || { echo "design handoff markdown missing/empty: $md"; return 1; }
835
+ return 0
836
+ }
837
+
838
+ _g_design_handoff_traceable() {
839
+ local context md
840
+ context=$(yaml_get "$G_YAML" handoff_context)
841
+ [ -n "$context" ] && [ "$context" != "null" ] || { echo "handoff_context missing"; return 1; }
842
+ md="${context%.json}.md"
843
+ [ -s "$md" ] || { echo "handoff markdown missing/empty: $md"; return 1; }
844
+ grep -q '^Generated-by: comate$' "$md" || { echo "missing Generated-by marker"; return 1; }
845
+ grep -Eq '^- Mode: (compact|full)$' "$md" || { echo "missing Mode marker"; return 1; }
846
+ local f rc=0
847
+ while IFS= read -r f; do
848
+ [ -f "$f" ] || continue
849
+ grep -q "^- Source: $f$" "$md" || { echo "missing source reference: $f"; rc=1; }
850
+ grep -q "^- SHA256: $(hash_file "$f")$" "$md" || { echo "missing current sha256 for: $f"; rc=1; }
851
+ done < <(_g_handoff_sources)
852
+ return $rc
853
+ }
854
+
855
+ # Design Doc frontmatter helper.
856
+ _g_frontmatter_has() {
857
+ local file="$1" field="$2" expected="$3"
858
+ awk '
859
+ { line=$0; sub(/^\357\273\277/, "", line) }
860
+ !in_fm && line == "---" { in_fm=1; next }
861
+ in_fm && line == "---" { exit }
862
+ in_fm { print line }
863
+ ' "$file" | grep -Eq "^${field}: ['\"]?${expected}['\"]?[[:space:]]*$"
864
+ }
865
+ _g_design_doc_links_change() {
866
+ local dd; dd=$(yaml_get "$G_YAML" design_doc)
867
+ [ -n "$dd" ] && [ "$dd" != "null" ] && [ -s "$dd" ] || { echo "design_doc must point to an existing Design Doc"; return 1; }
868
+ _g_frontmatter_has "$dd" comate_change "$G_CHANGE" || { echo "Design Doc frontmatter missing comate_change: $G_CHANGE"; return 1; }
869
+ }
870
+ _g_design_doc_role() {
871
+ local dd; dd=$(yaml_get "$G_YAML" design_doc)
872
+ [ -n "$dd" ] && [ "$dd" != "null" ] && [ -s "$dd" ] && _g_frontmatter_has "$dd" role technical-design
873
+ }
874
+ _g_design_doc_canonical() {
875
+ local dd; dd=$(yaml_get "$G_YAML" design_doc)
876
+ [ -n "$dd" ] && [ "$dd" != "null" ] && [ -s "$dd" ] && _g_frontmatter_has "$dd" canonical_spec openspec
877
+ }
878
+
879
+ # impl-context drift: verify entry plan+tasks match build exit.
880
+ _g_impl_context_written() {
881
+ local ctx hash
882
+ ctx=$(yaml_get "$G_YAML" impl_context)
883
+ hash=$(yaml_get "$G_YAML" impl_hash)
884
+ if [ -z "$ctx" ] || [ "$ctx" = "null" ] || [ ! -f "$ctx" ]; then
885
+ echo "impl_context not written; run: comate.sh handoff $G_CHANGE build --write"
886
+ return 1
887
+ fi
888
+ if [[ ! "$hash" =~ ^[a-f0-9]{64}$ ]]; then
889
+ echo "impl_hash missing or invalid: ${hash:-null}"; return 1
890
+ fi
891
+ return 0
892
+ }
893
+ _g_impl_hash_matches() {
894
+ local recorded plan actual
895
+ recorded=$(yaml_get "$G_YAML" impl_hash)
896
+ if [ -z "$recorded" ] || [ "$recorded" = "null" ]; then
897
+ echo "impl_hash missing; run: comate.sh handoff $G_CHANGE build --write at end of build"; return 1
898
+ fi
899
+ plan=$(yaml_get "$G_YAML" plan)
900
+ local files=("$G_DIR/tasks.md")
901
+ [ -n "$plan" ] && [ "$plan" != "null" ] && [ -f "$plan" ] && files=("$plan" "$G_DIR/tasks.md")
902
+ actual=$(hash_files_manifest "${files[@]}")
903
+ if [ "$actual" != "$recorded" ]; then
904
+ echo "plan/tasks changed between build exit and verify entry."
905
+ echo " recorded: $recorded"
906
+ echo " actual: $actual"
907
+ echo "Next: investigate the edit; rerun build handoff only if the change is intended."
908
+ return 1
909
+ fi
910
+ return 0
911
+ }
912
+
913
+ guard() {
914
+ local name="$1" phase="${2:-}"
915
+ validate_change_name "$name"
916
+ validate_enum phase "$phase"
917
+ [ -n "$phase" ] || die "$COMATE_USAGE" "Usage: comate.sh guard <name> <phase> [--apply] [--json]"
918
+
919
+ G_CHANGE="$name"
920
+ G_DIR=$(comate_change_dir "$name")
921
+ G_YAML="$G_DIR/.comate.yaml"
922
+
923
+ # preflight: directory + state file + flat-yaml schema.
924
+ [ -d "$G_DIR" ] || die "$COMATE_CORRUPT" "FATAL: change directory not found: $G_DIR"
925
+ [ -f "$G_YAML" ] || die "$COMATE_CORRUPT" "FATAL: .comate.yaml not found in $G_DIR"
926
+ validate_flat_yaml "$G_YAML"
927
+
928
+ [ "$GUARD_JSON" -eq 0 ] && log_info "=== Guard: $phase ==="
929
+
930
+ case "$phase" in
931
+ open)
932
+ _guard_check "proposal.md non-empty" file_nonempty "$G_DIR/proposal.md"
933
+ _guard_check "design.md non-empty" file_nonempty "$G_DIR/design.md"
934
+ _guard_check "tasks.md non-empty" file_nonempty "$G_DIR/tasks.md"
935
+ _guard_check "tasks.md has at least one task" _g_tasks_has_any
936
+ ;;
937
+ design)
938
+ _guard_check "proposal.md non-empty" file_nonempty "$G_DIR/proposal.md"
939
+ _guard_check "design.md non-empty" file_nonempty "$G_DIR/design.md"
940
+ _guard_check "tasks.md non-empty" file_nonempty "$G_DIR/tasks.md"
941
+ _guard_check "design handoff context valid (hash not drifted)" _g_design_handoff_valid
942
+ _guard_check "design handoff markdown traceable" _g_design_handoff_traceable
943
+ _guard_check "source files not drifted" _g_sources_not_drifted
944
+ local dd; dd=$(yaml_get "$G_YAML" design_doc)
945
+ if [ -n "$dd" ] && [ "$dd" != "null" ]; then
946
+ _guard_check "Design Doc exists ($dd)" file_nonempty "$dd"
947
+ _guard_check "Design Doc frontmatter links change" _g_design_doc_links_change
948
+ _guard_check "Design Doc declares technical-design role" _g_design_doc_role
949
+ _guard_check "Design Doc declares canonical_spec: openspec" _g_design_doc_canonical
950
+ else
951
+ [ "$GUARD_JSON" -eq 0 ] && log_warn " [WARN] no design_doc recorded"
952
+ fi
953
+ ;;
954
+ build)
955
+ _guard_check "isolation selected" _g_isolation_selected
956
+ _guard_check "build_mode selected" _g_build_mode_selected
957
+ _guard_check "build_mode allowed for workflow" _g_build_mode_allowed
958
+ _guard_check "tdd_mode selected" _g_tdd_mode_selected
959
+ _guard_check "tasks.md all tasks checked" _g_tasks_all_done
960
+ _guard_check "proposal.md non-empty" file_nonempty "$G_DIR/proposal.md"
961
+ _guard_check "impl-context written" _g_impl_context_written
962
+ _guard_check "source files not drifted" _g_sources_not_drifted
963
+ _guard_check "build passes" _g_build_passes
964
+ ;;
965
+ verify)
966
+ _guard_check "tasks.md all tasks checked" _g_tasks_all_done
967
+ _guard_check "verify command passes" _g_verify_passes
968
+ _guard_check "verification_report exists" _g_verification_report_exists
969
+ _guard_check "branch_status=handled" _g_branch_status_handled
970
+ _guard_check "impl_hash matches current plan+tasks" _g_impl_hash_matches
971
+ ;;
972
+ archive)
973
+ _guard_check "archived is true" _g_archived_is_true
974
+ _guard_check "proposal.md non-empty" file_nonempty "$G_DIR/proposal.md"
975
+ _guard_check "tasks.md all tasks checked" _g_tasks_all_done
976
+ ;;
977
+ esac
978
+
979
+ if [ "$GUARD_JSON" -eq 1 ]; then
980
+ local out='{"phase":"'"$phase"'","checks":['
981
+ local first=1 r cname cstatus
982
+ for r in "${GUARD_RESULTS[@]}"; do
983
+ cname="${r%|*}"; cstatus="${r#*|}"
984
+ [ "$first" -eq 0 ] && out+=','
985
+ first=0
986
+ out+='{"name":"'"$(json_escape "$cname")"'","status":"'"$cstatus"'"}'
987
+ done
988
+ out+='],"block":'"$([ "$GUARD_BLOCK" -eq 1 ] && echo true || echo false)"
989
+ out+=',"applied":false}'
990
+ printf '%s\n' "$out"
991
+ if [ "$GUARD_BLOCK" -eq 1 ]; then exit "$COMATE_BLOCK"; fi
992
+ # In JSON mode, still apply if requested (silent).
993
+ if [ "$GUARD_APPLY" -eq 1 ]; then _guard_apply "$phase" >/dev/null 2>&1 || true; fi
994
+ exit "$COMATE_OK"
995
+ fi
996
+
997
+ echo "" >&2
998
+ if [ "$GUARD_BLOCK" -eq 1 ]; then
999
+ log_fail "BLOCKED — fix failing checks before proceeding to next phase"
1000
+ exit "$COMATE_BLOCK"
1001
+ fi
1002
+ log_pass "ALL CHECKS PASSED — ready for next phase"
1003
+ if [ "$GUARD_APPLY" -eq 1 ]; then
1004
+ _guard_apply "$phase"
1005
+ fi
1006
+ exit "$COMATE_OK"
1007
+ }
1008
+
1009
+ # _guard_apply <phase> — delegate to the matching transition.
1010
+ _guard_apply() {
1011
+ case "$1" in
1012
+ open) state_transition "$G_CHANGE" open-complete ;;
1013
+ design) state_transition "$G_CHANGE" design-complete ;;
1014
+ build) state_transition "$G_CHANGE" build-complete ;;
1015
+ verify) state_transition "$G_CHANGE" verify-pass ;;
1016
+ archive) : ;; # archive completion handled by the archive command
1017
+ esac
1018
+ local np; np=$(yaml_get "$G_YAML" phase)
1019
+ log_pass " [APPLY] .comate.yaml updated: phase=$np"
1020
+ }
1021
+
1022
+ # =====================================================================
1023
+ # handoff — machine-owned context packages between phases
1024
+ # =====================================================================
1025
+
1026
+ # Shared: emit a traceable markdown excerpt for one file.
1027
+ _handoff_excerpt() {
1028
+ local file="$1" max_lines="$2" mode="$3" total
1029
+ total=$(wc -l < "$file" | tr -d ' ')
1030
+ echo "## $file"
1031
+ echo ""
1032
+ echo "- Source: $file"
1033
+ echo "- Lines: 1-$total"
1034
+ echo "- SHA256: $(hash_file "$file")"
1035
+ echo ""
1036
+ if [ "$mode" = "full" ] || [ "$total" -le "$max_lines" ]; then
1037
+ echo '```md'; cat "$file"; echo '```'
1038
+ else
1039
+ echo "[TRUNCATED]"; echo ""
1040
+ echo '```md'; sed -n "1,${max_lines}p" "$file"; echo '```'
1041
+ echo ""; echo "Full source: $file"
1042
+ fi
1043
+ echo ""
1044
+ }
1045
+
1046
+ handoff() {
1047
+ local name="$1" phase="${2:-}" mode="${3:-}" full="${4:-}"
1048
+ validate_change_name "$name"
1049
+ local dir yaml
1050
+ dir=$(comate_change_dir "$name")
1051
+ yaml="$dir/.comate.yaml"
1052
+ [ -d "$dir" ] || die "$COMATE_CORRUPT" "ERROR: change directory not found: $dir"
1053
+ [ -f "$yaml" ] || die "$COMATE_CORRUPT" "ERROR: .comate.yaml not found at $yaml"
1054
+ [ "$mode" = "--write" ] || die "$COMATE_USAGE" "Usage: comate.sh handoff <name> <design|build> --write [--full]"
1055
+
1056
+ case "$phase" in
1057
+ design) _handoff_design "$name" "$dir" "$yaml" "$full" ;;
1058
+ build) _handoff_build "$name" "$dir" "$yaml" ;;
1059
+ *) die "$COMATE_USAGE" "Usage: comate.sh handoff <name> <design|build> --write [--full]" ;;
1060
+ esac
1061
+ }
1062
+
1063
+ _handoff_design() {
1064
+ local name="$1" dir="$2" yaml="$3" full="$4"
1065
+ local hmode="compact"
1066
+ case "$full" in
1067
+ "") hmode="compact" ;;
1068
+ --full) hmode="full" ;;
1069
+ *) die "$COMATE_USAGE" "Usage: comate.sh handoff <name> design --write [--full]" ;;
1070
+ esac
1071
+ [ "$(yaml_get "$yaml" phase)" = "design" ] || die "$COMATE_BLOCK" "ERROR: design handoff requires phase: design"
1072
+
1073
+ local f
1074
+ for f in proposal.md design.md tasks.md; do
1075
+ [ -s "$dir/$f" ] || die "$COMATE_BLOCK" "ERROR: required artifact missing/empty: $dir/$f"
1076
+ done
1077
+
1078
+ # Source set: proposal/design/tasks + delta specs.
1079
+ local files=("$dir/proposal.md" "$dir/design.md" "$dir/tasks.md")
1080
+ if [ -d "$dir/specs" ]; then
1081
+ while IFS= read -r s; do files+=("$s"); done < <(find "$dir/specs" -path '*/spec.md' -type f 2>/dev/null | sort)
1082
+ fi
1083
+
1084
+ local hdir="$dir/.comate/handoff"
1085
+ mkdir -p "$hdir"
1086
+ local cjson="$hdir/design-context.json" cmd="$hdir/design-context.md"
1087
+ local chash; chash=$(hash_files_manifest "${files[@]}")
1088
+
1089
+ # markdown
1090
+ {
1091
+ echo "# comate Design Handoff"
1092
+ echo ""
1093
+ echo "- Change: $name"
1094
+ echo "- Phase: design"
1095
+ echo "- Mode: $hmode"
1096
+ echo "- Context hash: $chash"
1097
+ echo ""
1098
+ echo "Generated-by: comate"
1099
+ echo ""
1100
+ echo "OpenSpec remains the canonical capability spec. This handoff is a deterministic, source-traceable context pack, not an agent-authored summary."
1101
+ echo ""
1102
+ for f in "${files[@]}"; do
1103
+ [ -f "$f" ] || continue
1104
+ _handoff_excerpt "$f" 80 "$hmode"
1105
+ done
1106
+ } > "$cmd"
1107
+
1108
+ # json
1109
+ {
1110
+ echo "{"
1111
+ echo " \"change\": \"$(json_escape "$name")\","
1112
+ echo " \"phase\": \"design\","
1113
+ echo " \"mode\": \"$hmode\","
1114
+ echo " \"canonical_spec\": \"openspec\","
1115
+ echo " \"generated_by\": \"comate\","
1116
+ echo " \"context_hash\": \"$chash\","
1117
+ echo " \"files\": ["
1118
+ local first=1
1119
+ for f in "${files[@]}"; do
1120
+ [ -f "$f" ] || continue
1121
+ [ "$first" -eq 0 ] && echo ","
1122
+ first=0
1123
+ printf ' { "path": "%s", "sha256": "%s" }' "$(json_escape "$f")" "$(hash_file "$f")"
1124
+ done
1125
+ echo ""
1126
+ echo " ]"
1127
+ echo "}"
1128
+ } > "$cjson"
1129
+
1130
+ yaml_set "$yaml" handoff_context "$cjson"
1131
+ yaml_set "$yaml" handoff_hash "$chash"
1132
+ log_pass "[HANDOFF] wrote $cjson"
1133
+ log_pass "[HANDOFF] wrote $cmd"
1134
+ log_pass "[HANDOFF] handoff_hash=$chash"
1135
+ }
1136
+
1137
+ # build->verify handoff: hash plan + tasks.
1138
+ _handoff_build() {
1139
+ local name="$1" dir="$2" yaml="$3"
1140
+ [ "$(yaml_get "$yaml" phase)" = "build" ] || die "$COMATE_BLOCK" "ERROR: build handoff requires phase: build"
1141
+ [ -s "$dir/tasks.md" ] || die "$COMATE_BLOCK" "ERROR: tasks.md missing/empty: $dir/tasks.md"
1142
+
1143
+ local plan; plan=$(yaml_get "$yaml" plan)
1144
+ local files=("$dir/tasks.md")
1145
+ if [ -n "$plan" ] && [ "$plan" != "null" ] && [ -f "$plan" ]; then
1146
+ files=("$plan" "$dir/tasks.md")
1147
+ fi
1148
+
1149
+ local hdir="$dir/.comate/handoff"
1150
+ mkdir -p "$hdir"
1151
+ local cjson="$hdir/impl-context.json"
1152
+ local chash; chash=$(hash_files_manifest "${files[@]}")
1153
+
1154
+ {
1155
+ echo "{"
1156
+ echo " \"change\": \"$(json_escape "$name")\","
1157
+ echo " \"phase\": \"build\","
1158
+ echo " \"generated_by\": \"comate\","
1159
+ echo " \"impl_hash\": \"$chash\","
1160
+ echo " \"files\": ["
1161
+ local first=1 f
1162
+ for f in "${files[@]}"; do
1163
+ [ -f "$f" ] || continue
1164
+ [ "$first" -eq 0 ] && echo ","
1165
+ first=0
1166
+ printf ' { "path": "%s", "sha256": "%s" }' "$(json_escape "$f")" "$(hash_file "$f")"
1167
+ done
1168
+ echo ""
1169
+ echo " ]"
1170
+ echo "}"
1171
+ } > "$cjson"
1172
+
1173
+ yaml_set "$yaml" impl_context "$cjson"
1174
+ yaml_set "$yaml" impl_hash "$chash"
1175
+ log_pass "[HANDOFF] wrote $cjson"
1176
+ log_pass "[HANDOFF] impl_hash=$chash"
1177
+ }
1178
+
1179
+ # =====================================================================
1180
+ # sources — knowledge-base provenance for a change
1181
+ # =====================================================================
1182
+
1183
+ # _sources_pin_ref <yaml> — echo the commit ref to pin a tracked file to:
1184
+ # the change's base_ref, falling back to current HEAD, else "null".
1185
+ _sources_pin_ref() {
1186
+ local yaml="$1" ref
1187
+ ref=$(yaml_get "$yaml" base_ref)
1188
+ if [ -z "$ref" ] || [ "$ref" = "null" ]; then
1189
+ ref=$(git rev-parse --short HEAD 2>/dev/null || echo "null")
1190
+ elif git rev-parse --git-dir >/dev/null 2>&1; then
1191
+ ref=$(git rev-parse --short "$ref" 2>/dev/null || echo "$ref")
1192
+ fi
1193
+ echo "$ref"
1194
+ }
1195
+
1196
+ # sources <name> track <file...> [--note <text>] — record knowledge-base files
1197
+ # as the source of this change. Out-of-range files are skipped (caller stays on
1198
+ # conversation). Re-tracking a path replaces its prior record.
1199
+ sources_track() {
1200
+ local name="$1"; shift
1201
+ validate_change_name "$name"
1202
+ local yaml; yaml=$(require_yaml "$name")
1203
+ local manifest; manifest=$(sources_manifest_for "$name")
1204
+
1205
+ local note="" files=()
1206
+ while [ $# -gt 0 ]; do
1207
+ case "$1" in
1208
+ --note) note="${2:-}"; shift 2 || shift ;;
1209
+ *) files+=("$1"); shift ;;
1210
+ esac
1211
+ done
1212
+ [ "${#files[@]}" -gt 0 ] || die "$COMATE_USAGE" "Usage: comate.sh sources <name> track <file...> [--note <text>]"
1213
+
1214
+ local ref; ref=$(_sources_pin_ref "$yaml")
1215
+ mkdir -p "$(dirname "$manifest")"
1216
+ [ -f "$manifest" ] || : > "$manifest"
1217
+
1218
+ local tracked=0 skipped=0 f rel
1219
+ for f in "${files[@]}"; do
1220
+ if rel=$(validate_kb_path "$f"); then
1221
+ _sources_remove_path "$manifest" "$rel"
1222
+ {
1223
+ [ -s "$manifest" ] && printf -- '---\n'
1224
+ printf 'path:%s\n' "$rel"
1225
+ printf 'sha256:%s\n' "$(hash_file "$rel")"
1226
+ printf 'ref:%s\n' "$ref"
1227
+ [ -n "$note" ] && printf 'note:%s\n' "$note"
1228
+ } >> "$manifest"
1229
+ log_pass "[SOURCES] tracked $rel"
1230
+ tracked=$((tracked + 1))
1231
+ else
1232
+ log_warn "[SOURCES] not in $(comate_kb_root)/ or unreadable, skipped: $f"
1233
+ skipped=$((skipped + 1))
1234
+ fi
1235
+ done
1236
+
1237
+ if [ "$tracked" -gt 0 ]; then
1238
+ yaml_set "$yaml" source_kind file
1239
+ yaml_set "$yaml" source_manifest "$manifest"
1240
+ log_pass "[SOURCES] source_kind=file, source_manifest=$manifest"
1241
+ else
1242
+ log_warn "[SOURCES] no file tracked; source_kind stays conversation"
1243
+ fi
1244
+ [ "$skipped" -eq 0 ] || return "$COMATE_OK"
1245
+ }
1246
+
1247
+ # _sources_remove_path <manifest> <path> — drop the record block for one path.
1248
+ _sources_remove_path() {
1249
+ local manifest="$1" target="$2" tmp
1250
+ [ -f "$manifest" ] || return 0
1251
+ tmp=$(mktemp "${TMPDIR:-/tmp}/comate.XXXXXX") || die "$COMATE_ENV" "ERROR: mktemp failed"
1252
+ awk -v target="$target" '
1253
+ function flush() {
1254
+ if (block != "" && keep) printf "%s", block
1255
+ block=""; keep=1
1256
+ }
1257
+ BEGIN { keep=1 }
1258
+ /^---$/ { flush(); next }
1259
+ /^path:/ { if ($0 == "path:" target) keep=0 }
1260
+ { block = block $0 "\n" }
1261
+ END { flush() }
1262
+ ' "$manifest" > "$tmp"
1263
+ # Collapse any leading blank lines, then normalise record separators.
1264
+ awk 'NF || NR>1 {print}' "$tmp" > "$manifest"
1265
+ rm -f "$tmp"
1266
+ }
1267
+
1268
+ # sources <name> check — drift check; conversation skips. Mirrors guard codes.
1269
+ sources_check() {
1270
+ local name="$1"
1271
+ validate_change_name "$name"
1272
+ local yaml; yaml=$(require_yaml "$name")
1273
+ local kind; kind=$(yaml_get "$yaml" source_kind)
1274
+ if [ "$kind" != "file" ]; then
1275
+ log_info "[SOURCES] source_kind=$kind — no source files to check"
1276
+ exit "$COMATE_OK"
1277
+ fi
1278
+ local manifest; manifest=$(yaml_get "$yaml" source_manifest)
1279
+ if [ -z "$manifest" ] || [ "$manifest" = "null" ]; then
1280
+ die "$COMATE_CORRUPT" "ERROR: source_kind=file but source_manifest is null"
1281
+ fi
1282
+ local out rc=0
1283
+ out=$(sources_drift "$manifest") || rc=$?
1284
+ [ -n "$out" ] && printf '%s\n' "$out" >&2
1285
+ if [ "$rc" -eq 0 ]; then
1286
+ log_pass "[SOURCES] no drift ($manifest)"
1287
+ fi
1288
+ exit "$rc"
1289
+ }
1290
+
1291
+ # sources <name> list — print the manifest.
1292
+ sources_list() {
1293
+ local name="$1"
1294
+ validate_change_name "$name"
1295
+ local manifest; manifest=$(sources_manifest_for "$name")
1296
+ if [ -f "$manifest" ]; then cat "$manifest"; else echo "no source manifest for $name"; fi
1297
+ }
1298
+
1299
+ # sources find <file> — reverse lookup: which changes reference this file.
1300
+ sources_find() {
1301
+ local target="$1"
1302
+ [ -n "$target" ] || die "$COMATE_USAGE" "Usage: comate.sh sources find <file>"
1303
+ local rel="$target"
1304
+ if r=$(validate_kb_path "$target" 2>/dev/null); then rel="$r"; fi
1305
+ local any=0 m chg
1306
+ while IFS= read -r m; do
1307
+ if grep -q "^path:$rel$" "$m" 2>/dev/null; then
1308
+ chg=$(basename "$(dirname "$(dirname "$m")")")
1309
+ echo "$chg ($m)"
1310
+ any=1
1311
+ fi
1312
+ done < <(find openspec/changes -path '*/.comate/sources.manifest' -type f 2>/dev/null | sort)
1313
+ [ "$any" -eq 0 ] && echo "no change references: $rel"
1314
+ return 0
1315
+ }
1316
+
1317
+ # =====================================================================
1318
+ # archive
1319
+ # =====================================================================
1320
+
1321
+ archive() {
1322
+ local name="$1" dry="${2:-}"
1323
+ validate_change_name "$name"
1324
+ local dry_run=0
1325
+ [ "$dry" = "--dry-run" ] && dry_run=1
1326
+
1327
+ local dir="openspec/changes/$name"
1328
+ local yaml="$dir/.comate.yaml"
1329
+ local today archive_name archive_dir
1330
+ today=$(today)
1331
+ archive_name="${today}-${name}"
1332
+ archive_dir="openspec/changes/archive/${archive_name}"
1333
+
1334
+ [ -f "$yaml" ] || die "$COMATE_CORRUPT" "FATAL: .comate.yaml not found in $dir"
1335
+ validate_flat_yaml "$yaml"
1336
+
1337
+ local phase verify_result archived design_doc plan
1338
+ phase=$(yaml_get "$yaml" phase)
1339
+ verify_result=$(yaml_get "$yaml" verify_result)
1340
+ archived=$(yaml_get "$yaml" archived)
1341
+ design_doc=$(yaml_get "$yaml" design_doc)
1342
+ plan=$(yaml_get "$yaml" plan)
1343
+
1344
+ [ "$phase" = "archive" ] || die "$COMATE_BLOCK" "FATAL: phase is '$phase', expected 'archive'"
1345
+ [ "$verify_result" = "pass" ] || die "$COMATE_BLOCK" "FATAL: verify_result is '$verify_result', expected 'pass'"
1346
+ [ "$archived" != "true" ] || die "$COMATE_BLOCK" "FATAL: change already archived"
1347
+ [ ! -d "$archive_dir" ] || die "$COMATE_BLOCK" "FATAL: archive target already exists: $archive_dir"
1348
+
1349
+ local steps_ok=0 steps_total=0
1350
+ _step_ok() { log_pass " [OK] $1"; steps_ok=$((steps_ok+1)); steps_total=$((steps_total+1)); }
1351
+ _step_dry() { log_warn " [DRY-RUN] $1"; steps_ok=$((steps_ok+1)); steps_total=$((steps_total+1)); }
1352
+ _step_fail(){ log_fail " [FAIL] $1"; steps_total=$((steps_total+1)); }
1353
+
1354
+ log_info "=== comate Archive: $name ==="
1355
+ _step_ok "Entry state verified (phase=archive, verify_result=pass)"
1356
+
1357
+ # OpenSpec owns delta-spec merge semantics. Do not copy change specs over
1358
+ # main specs directly; delta specs may contain ADDED/MODIFIED sections rather
1359
+ # than a complete canonical spec.
1360
+ local has_delta_specs=0
1361
+ if [ -d "$dir/specs" ] && find "$dir/specs" -path '*/spec.md' -type f -print -quit 2>/dev/null | grep -q .; then
1362
+ has_delta_specs=1
1363
+ fi
1364
+
1365
+ # Annotate frontmatter (design doc + plan), and rewrite any body references to
1366
+ # the change's pre-archive OpenSpec path so they survive the move (class B).
1367
+ # NOTE: design_doc/plan live under docs/superpowers and do NOT move with the
1368
+ # change. Annotation runs AFTER openspec has moved the change, using the REAL
1369
+ # archive name discovered from disk — comate must not guess the dir name, as a
1370
+ # locally-computed date can skew vs openspec's across a UTC/local midnight.
1371
+ local old_prefix="openspec/changes/$name/"
1372
+ local new_prefix="" # set to real archive path after the move
1373
+ local ann_archive_name="" # set to real archive name after the move
1374
+ _annotate() {
1375
+ local file="$1" extra="$2"
1376
+ [ -f "$file" ] || return 0
1377
+ if [ "$dry_run" -eq 1 ]; then _step_dry "Would annotate: $file"; return 0; fi
1378
+ local tmp; tmp=$(mktemp)
1379
+ if head -1 "$file" | grep -q '^---'; then
1380
+ awk -v archive="$ann_archive_name" -v extra="$extra" \
1381
+ -v oldp="$old_prefix" -v newp="$new_prefix" '
1382
+ /^archived-with:/ { next }
1383
+ NR==1 && /^---/ { print; next }
1384
+ /^---/ && NR>1 && fm==0 { print "archived-with: " archive; if (extra != "") print extra; fm=1; print; next }
1385
+ fm==1 { gsub(oldp, newp) } 1
1386
+ ' "$file" > "$tmp"
1387
+ else
1388
+ {
1389
+ echo "---"; echo "archived-with: $ann_archive_name"; [ -n "$extra" ] && echo "$extra"; echo "status: final"; echo "---"
1390
+ sed "s#$old_prefix#$new_prefix#g" "$file"
1391
+ } > "$tmp"
1392
+ fi
1393
+ mv "$tmp" "$file"
1394
+ _step_ok "Annotated: $file"
1395
+ }
1396
+ # Dry-run previews annotation up front (real name not needed for the message).
1397
+ if [ "$dry_run" -eq 1 ]; then
1398
+ [ -n "$design_doc" ] && [ "$design_doc" != "null" ] && _annotate "$design_doc" "status: final"
1399
+ [ -n "$plan" ] && [ "$plan" != "null" ] && _annotate "$plan" ""
1400
+ fi
1401
+
1402
+ # Delegate spec updates and change movement to OpenSpec.
1403
+ if [ "$dry_run" -eq 1 ]; then
1404
+ if [ "$has_delta_specs" -eq 1 ]; then
1405
+ _step_dry "Would run: openspec archive $name -y"
1406
+ else
1407
+ _step_dry "Would run: openspec archive $name -y --skip-specs"
1408
+ fi
1409
+ else
1410
+ command -v openspec >/dev/null 2>&1 || die "$COMATE_ENV" "FATAL: openspec CLI not found; install OpenSpec before archiving"
1411
+ if [ "$has_delta_specs" -eq 1 ]; then
1412
+ openspec archive "$name" -y
1413
+ _step_ok "OpenSpec archived change and updated main specs"
1414
+ else
1415
+ openspec archive "$name" -y --skip-specs
1416
+ _step_ok "OpenSpec archived change (no delta specs)"
1417
+ fi
1418
+ fi
1419
+
1420
+ # Mark archived via transition. OpenSpec normally uses YYYY-MM-DD-<name>,
1421
+ # but fall back to locating the moved state file if that ever changes.
1422
+ if [ "$dry_run" -eq 1 ]; then
1423
+ _step_dry "Would set archived: true"
1424
+ else
1425
+ local moved_archive_name="$archive_name"
1426
+ if [ ! -f "$archive_dir/.comate.yaml" ]; then
1427
+ local found
1428
+ found=$(find "openspec/changes/archive" -maxdepth 2 -path "*/.comate.yaml" -type f 2>/dev/null | sed 's#^\./##' | grep "/${name}/.comate.yaml$\|-${name}/.comate.yaml$" | sort | tail -1 || true)
1429
+ if [ -n "$found" ]; then
1430
+ moved_archive_name=$(basename "$(dirname "$found")")
1431
+ archive_dir=$(dirname "$found")
1432
+ fi
1433
+ fi
1434
+ if [ -f "$archive_dir/.comate.yaml" ]; then
1435
+ state_transition "$moved_archive_name" archived >/dev/null
1436
+ _step_ok "archived: true"
1437
+ # Now that the change has moved and the REAL archive name is known, annotate
1438
+ # design doc + plan (they stay under docs/superpowers; openspec never moved
1439
+ # them). Using the discovered name avoids any local-vs-openspec date skew.
1440
+ ann_archive_name="$moved_archive_name"
1441
+ new_prefix="$archive_dir/"
1442
+ [ -n "$design_doc" ] && [ "$design_doc" != "null" ] && _annotate "$design_doc" "status: final"
1443
+ [ -n "$plan" ] && [ "$plan" != "null" ] && _annotate "$plan" ""
1444
+ # Rewrite self-pointers that moved with the change (class A): fields under
1445
+ # the change's own .comate/ dir become stale after the move. design_doc /
1446
+ # plan live under docs/superpowers and do not move, so leave them alone.
1447
+ local moved_yaml="$archive_dir/.comate.yaml" k v
1448
+ local actual_old="openspec/changes/$name/"
1449
+ local actual_new="$archive_dir/"
1450
+ for k in source_manifest handoff_context impl_context; do
1451
+ v=$(yaml_get "$moved_yaml" "$k")
1452
+ case "$v" in
1453
+ "$actual_old"*)
1454
+ yaml_set "$moved_yaml" "$k" "$actual_new${v#"$actual_old"}"
1455
+ _step_ok "Repointed $k -> archive path"
1456
+ ;;
1457
+ esac
1458
+ done
1459
+ else
1460
+ _step_fail "archived: true (.comate.yaml not found after move)"
1461
+ fi
1462
+ fi
1463
+
1464
+ echo "" >&2
1465
+ if [ "$dry_run" -eq 1 ]; then
1466
+ log_warn "Dry run complete. $steps_ok/$steps_total steps would succeed."
1467
+ else
1468
+ log_pass "Archive complete. $steps_ok/$steps_total steps succeeded."
1469
+ fi
1470
+ if [ "$steps_ok" -lt "$steps_total" ]; then exit "$COMATE_BLOCK"; fi
1471
+ exit "$COMATE_OK"
1472
+ }
1473
+
1474
+ # =====================================================================
1475
+ # status — list active changes + next command
1476
+ # =====================================================================
1477
+
1478
+ _next_command() {
1479
+ case "$1" in
1480
+ open) echo "/comate-open" ;;
1481
+ design) echo "/comate-design" ;;
1482
+ build) echo "/comate-build" ;;
1483
+ verify) echo "/comate-verify" ;;
1484
+ archive) echo "/comate-archive" ;;
1485
+ *) echo "" ;;
1486
+ esac
1487
+ }
1488
+
1489
+ status() {
1490
+ local base="${1:-.}"
1491
+ local changes_dir="$base/openspec/changes"
1492
+ if [ ! -d "$changes_dir" ]; then
1493
+ echo "No active changes."
1494
+ return 0
1495
+ fi
1496
+ local any=0 d name yaml phase workflow build_mode vr dd plan total done tasks_file
1497
+ for d in "$changes_dir"/*/; do
1498
+ [ -d "$d" ] || continue
1499
+ name=$(basename "$d")
1500
+ [ "$name" = "archive" ] && continue
1501
+ yaml="$d/.comate.yaml"
1502
+ [ -f "$yaml" ] || continue
1503
+ [ "$(yaml_get "$yaml" archived)" = "true" ] && continue
1504
+ any=1
1505
+ phase=$(yaml_get "$yaml" phase)
1506
+ workflow=$(yaml_get "$yaml" workflow)
1507
+ build_mode=$(yaml_get "$yaml" build_mode)
1508
+ vr=$(yaml_get "$yaml" verify_result)
1509
+ dd=$(yaml_get "$yaml" design_doc)
1510
+ plan=$(yaml_get "$yaml" plan)
1511
+ tasks_file="$d/tasks.md"
1512
+ total=0; done=0
1513
+ if [ -f "$tasks_file" ]; then
1514
+ total=$(grep -c '^- \[' "$tasks_file" 2>/dev/null || echo 0)
1515
+ done=$(grep -c '^- \[x\]' "$tasks_file" 2>/dev/null || echo 0)
1516
+ fi
1517
+ echo "• $name [phase: $phase, $done/$total tasks]"
1518
+ echo " workflow: $workflow | build_mode: $build_mode"
1519
+ [ -n "$dd" ] && [ "$dd" != "null" ] && echo " design: $dd"
1520
+ [ -n "$plan" ] && [ "$plan" != "null" ] && echo " plan: $plan"
1521
+ [ "$phase" = "verify" ] && echo " verify_result: $vr"
1522
+ echo " next: $(_next_command "$phase")"
1523
+ echo ""
1524
+ done
1525
+ [ "$any" -eq 0 ] && echo "No active changes."
1526
+ return 0
1527
+ }
1528
+
1529
+ # =====================================================================
1530
+ # doctor — prerequisite self-check
1531
+ # =====================================================================
1532
+
1533
+ doctor() {
1534
+ local fail=0 warn=0
1535
+ _d_pass() { log_pass " ✓ $1"; }
1536
+ _d_warn() { log_warn " ⚠ $1"; warn=$((warn+1)); }
1537
+ _d_fail() { log_fail " ✗ $1"; fail=$((fail+1)); }
1538
+
1539
+ log_info "comate doctor"
1540
+
1541
+ # bash version >= 4 (need associative arrays).
1542
+ if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
1543
+ _d_pass "bash ${BASH_VERSION} (>= 4)"
1544
+ else
1545
+ _d_fail "bash ${BASH_VERSION:-unknown} — comate needs bash >= 4 (associative arrays)"
1546
+ fi
1547
+
1548
+ # git.
1549
+ if command -v git >/dev/null 2>&1; then _d_pass "git present"; else _d_fail "git not found — install git"; fi
1550
+
1551
+ # sha256 tool.
1552
+ if command -v sha256sum >/dev/null 2>&1; then _d_pass "sha256sum present"
1553
+ elif command -v shasum >/dev/null 2>&1; then _d_pass "shasum present"
1554
+ else _d_fail "neither sha256sum nor shasum found — install coreutils or perl shasum"; fi
1555
+
1556
+ # openspec CLI.
1557
+ if command -v openspec >/dev/null 2>&1; then
1558
+ _d_pass "openspec CLI present ($(openspec --version 2>/dev/null | head -1 || echo installed))"
1559
+ else
1560
+ _d_warn "openspec CLI not found — install: npm install -g @fission-ai/openspec@latest"
1561
+ fi
1562
+
1563
+ # Superpowers skill directory — check known skill roots only (bounded; an
1564
+ # unbounded $HOME scan is too slow on large home directories).
1565
+ # Plugin roots matter too: Claude Code installs superpowers as a plugin under
1566
+ # plugins/{cache,data}/, not under skills/. maxdepth 3 keeps deeper
1567
+ # marketplace docs dirs (e.g. marketplaces/*/docs/superpowers) from matching.
1568
+ local sp_found=0 root
1569
+ for root in \
1570
+ "./.claude/skills" "./.cursor/skills" "./.codex/skills" \
1571
+ "$HOME/.claude/skills" "$HOME/.config/skills" "$HOME/.cursor/skills" "$HOME/.codex/skills" "$HOME/.gemini/skills" \
1572
+ "./.claude/plugins" "$HOME/.claude/plugins"; do
1573
+ if [ -d "$root" ] && find "$root" -maxdepth 3 -type d -name 'superpowers*' -print -quit 2>/dev/null | grep -q .; then
1574
+ sp_found=1; break
1575
+ fi
1576
+ done
1577
+ if [ "$sp_found" -eq 1 ]; then
1578
+ _d_pass "Superpowers skills found"
1579
+ else
1580
+ _d_warn "Superpowers skills not found in known roots — install manually (see reference/prerequisites.md)"
1581
+ fi
1582
+
1583
+ # comate's own files present.
1584
+ local missing=0 f
1585
+ for f in comate-lib.sh comate.sh; do
1586
+ [ -f "$SCRIPT_DIR/$f" ] || { _d_fail "missing comate file: scripts/$f"; missing=1; }
1587
+ done
1588
+ [ "$missing" -eq 0 ] && _d_pass "comate scripts complete"
1589
+
1590
+ # LF line endings on .sh (Windows-critical).
1591
+ local crlf=0
1592
+ for f in "$SCRIPT_DIR"/*.sh; do
1593
+ if grep -lq $'\r' "$f" 2>/dev/null; then _d_warn "CRLF line endings in $(basename "$f") — convert to LF"; crlf=1; fi
1594
+ done
1595
+ [ "$crlf" -eq 0 ] && _d_pass ".sh files use LF line endings"
1596
+
1597
+ echo "" >&2
1598
+ if [ "$fail" -gt 0 ]; then
1599
+ die "$COMATE_ENV" "$fail failure(s), $warn warning(s) — fix failures before using comate"
1600
+ fi
1601
+ log_pass "0 failures, $warn warning(s)"
1602
+ exit "$COMATE_OK"
1603
+ }
1604
+
1605
+ # =====================================================================
1606
+ # main router
1607
+ # =====================================================================
1608
+
1609
+ CMD="${1:-}"
1610
+ shift || true
1611
+
1612
+ case "$CMD" in
1613
+ state)
1614
+ NAME="${1:-}"; SUB="${2:-}"
1615
+ [ -n "$NAME" ] && [ -n "$SUB" ] || { usage_top; exit "$COMATE_USAGE"; }
1616
+ shift 2 || true
1617
+ case "$SUB" in
1618
+ init) state_init "$NAME" "${1:-}" ;;
1619
+ get) state_get "$NAME" "${1:-}" ;;
1620
+ set) state_set "$NAME" "${1:-}" "${2:-}" ;;
1621
+ transition) state_transition "$NAME" "${1:-}" ;;
1622
+ check)
1623
+ CHECK_JSON=0
1624
+ [ "${2:-}" = "--json" ] && CHECK_JSON=1
1625
+ state_check "$NAME" "${1:-}"
1626
+ ;;
1627
+ recover) state_recover "$NAME" "${1:-}" ;;
1628
+ scale) state_scale "$NAME" ;;
1629
+ validate) state_validate "$NAME" ;;
1630
+ *) die "$COMATE_USAGE" "Unknown state subcommand: $SUB" ;;
1631
+ esac
1632
+ ;;
1633
+ guard)
1634
+ NAME="${1:-}"; PHASE="${2:-}"
1635
+ [ -n "$NAME" ] && [ -n "$PHASE" ] || { usage_top; exit "$COMATE_USAGE"; }
1636
+ GUARD_APPLY=0; GUARD_JSON=0
1637
+ for arg in "${@:3}"; do
1638
+ case "$arg" in
1639
+ --apply) GUARD_APPLY=1 ;;
1640
+ --json) GUARD_JSON=1 ;;
1641
+ esac
1642
+ done
1643
+ guard "$NAME" "$PHASE"
1644
+ ;;
1645
+ handoff)
1646
+ handoff "${1:-}" "${2:-}" "${3:-}" "${4:-}"
1647
+ ;;
1648
+ sources)
1649
+ NAME="${1:-}"; SUB="${2:-}"
1650
+ if [ "$NAME" = "find" ]; then
1651
+ sources_find "${2:-}"
1652
+ else
1653
+ [ -n "$NAME" ] && [ -n "$SUB" ] || { usage_top; exit "$COMATE_USAGE"; }
1654
+ shift 2 || true
1655
+ case "$SUB" in
1656
+ track) sources_track "$NAME" "$@" ;;
1657
+ check) sources_check "$NAME" ;;
1658
+ list) sources_list "$NAME" ;;
1659
+ *) die "$COMATE_USAGE" "Unknown sources subcommand: $SUB" ;;
1660
+ esac
1661
+ fi
1662
+ ;;
1663
+ archive)
1664
+ [ -n "${1:-}" ] || { usage_top; exit "$COMATE_USAGE"; }
1665
+ archive "$1" "${2:-}"
1666
+ ;;
1667
+ status)
1668
+ status "${1:-.}"
1669
+ ;;
1670
+ doctor)
1671
+ doctor
1672
+ ;;
1673
+ ""|-h|--help|help)
1674
+ usage_top
1675
+ exit "$COMATE_OK"
1676
+ ;;
1677
+ *)
1678
+ usage_top
1679
+ exit "$COMATE_USAGE"
1680
+ ;;
1681
+ esac