@house365/comate 0.10.2 → 1.0.2-next.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.
Files changed (74) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +104 -64
  3. package/bin/cli.mjs +197 -190
  4. package/bin/doctor.mjs +29 -41
  5. package/bin/init-run.mjs +310 -368
  6. package/bin/init.mjs +53 -71
  7. package/bin/postinstall.mjs +56 -56
  8. package/docs/CHANGELOG-fix-simplification.md +145 -0
  9. package/docs/COMATE_BETA.md +90 -0
  10. package/docs/COMATE_MANUAL.md +239 -665
  11. package/docs/superpowers/plans/2026-10-14-review-fixes.md +48 -0
  12. package/docs/superpowers/plans/2026-10-14-workflow-quality.md +48 -0
  13. package/package.json +54 -55
  14. package/skills/comate/SKILL.md +42 -325
  15. package/skills/comate/reference/archive.md +50 -0
  16. package/skills/comate/reference/build.md +67 -0
  17. package/skills/comate/reference/fix.md +150 -0
  18. package/skills/comate/reference/open.md +162 -0
  19. package/skills/comate/reference/verify.md +123 -0
  20. package/skills/comate/scaffold/comate/README.md +18 -0
  21. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/architecture/README.md +5 -6
  22. package/skills/comate/scaffold/comate/comate/config.json +10 -0
  23. package/skills/comate/scaffold/comate/knowledge-base/README.md +42 -0
  24. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/_TEMPLATE/README.md +2 -2
  25. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/_TEMPLATE/api-snapshot.md +47 -47
  26. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/requirements/_templates/PRD.md +4 -4
  27. package/skills/comate/scripts/comate.mjs +147 -0
  28. package/skills/comate/scripts/lib/acceptance.mjs +112 -0
  29. package/skills/comate/scripts/lib/brief.mjs +50 -0
  30. package/skills/comate/scripts/lib/checks.mjs +178 -0
  31. package/skills/comate/scripts/lib/cmd-archive.mjs +152 -0
  32. package/skills/comate/scripts/lib/cmd-decide.mjs +116 -0
  33. package/skills/comate/scripts/lib/cmd-init.mjs +75 -0
  34. package/skills/comate/scripts/lib/cmd-loop.mjs +273 -0
  35. package/skills/comate/scripts/lib/cmd-open.mjs +161 -0
  36. package/skills/comate/scripts/lib/cmd-status.mjs +121 -0
  37. package/skills/comate/scripts/lib/common.mjs +85 -0
  38. package/skills/comate/scripts/lib/docs.mjs +45 -0
  39. package/skills/comate/scripts/lib/finish.mjs +108 -0
  40. package/skills/comate/scripts/lib/git.mjs +112 -0
  41. package/skills/comate/scripts/lib/md.mjs +85 -0
  42. package/skills/comate/scripts/lib/next.mjs +217 -0
  43. package/skills/comate/scripts/lib/paths.mjs +96 -0
  44. package/skills/comate/scripts/lib/render.mjs +46 -0
  45. package/skills/comate/scripts/lib/spec.mjs +106 -0
  46. package/skills/comate/scripts/lib/state.mjs +128 -0
  47. package/skills/comate/scripts/lib/tasks.mjs +53 -0
  48. package/skills/comate/scripts/lib/templates.mjs +58 -0
  49. package/skills/comate/scripts/lib/util.mjs +108 -0
  50. package/skills/comate-kb-integration/SKILL.md +128 -128
  51. package/skills/comate-kb-integration/scripts/fetch_rendered_doc.py +84 -84
  52. package/bin/cleanup-duplicate-superpowers.mjs +0 -119
  53. package/docs/CLEANUP_SUPERPOWERS.md +0 -128
  54. package/docs/COMATE_BEGINNER_GUIDE.md +0 -188
  55. package/docs/COMATE_INIT_DIRECTORY_GUIDE.md +0 -201
  56. package/docs/SUPERPOWERS_FIX_SUMMARY.md +0 -114
  57. package/skills/comate/reference/decision-points.md +0 -103
  58. package/skills/comate/reference/dirty-worktree.md +0 -58
  59. package/skills/comate/reference/prerequisites.md +0 -89
  60. package/skills/comate/scripts/comate-lib.sh +0 -559
  61. package/skills/comate/scripts/comate.sh +0 -2147
  62. package/skills/comate/tests/comate.bats +0 -1019
  63. package/skills/comate/tests/run.sh +0 -101
  64. package/skills/comate-archive/SKILL.md +0 -63
  65. package/skills/comate-build/SKILL.md +0 -278
  66. package/skills/comate-design/SKILL.md +0 -178
  67. package/skills/comate-fix/SKILL.md +0 -195
  68. package/skills/comate-gate/SKILL.md +0 -316
  69. package/skills/comate-gate/scaffold/docs/README.md +0 -36
  70. package/skills/comate-gate/scaffold/docs/knowledge-base/README.md +0 -47
  71. package/skills/comate-grill/SKILL.md +0 -185
  72. package/skills/comate-open/SKILL.md +0 -230
  73. package/skills/comate-verify/SKILL.md +0 -212
  74. /package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/README.md +0 -0
@@ -1,2147 +0,0 @@
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
- # gate <prd> review — create/refresh gate.yaml next to the PRD, pin hash
21
- # gate <prd> set <field> <value> — write score | blocking_open | gate_status | …
22
- # gate <prd> pass [--tbd] — pass clean gate or explicitly accepted TBD blockers
23
- # gate <prd> spawned <change> — record a change fanned out from this PRD
24
- # gate <prd> fanout-status [--json] — report mapped/spawned fanout progress
25
- # gate <prd> status [--json] — print gate status (computes stale on hash drift)
26
- # gate <prd> check — precondition guard before spawning a change
27
- # archive <name> [--dry-run] — sync delta specs + annotate + move + mark archived
28
- # status [path] — list active changes + next command
29
- # doctor — prerequisite self-check
30
- #
31
- # Exit codes: 0 OK | 1 BLOCK | 2 USAGE | 3 ENV | 4 CORRUPT
32
-
33
- set -euo pipefail
34
-
35
- # The scripts intentionally stay compatible with macOS's system Bash 3.2.
36
- # Do not add Bash 4-only features (notably associative arrays) here.
37
-
38
- SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")" && pwd)"
39
- # shellcheck source=comate-lib.sh
40
- . "$SCRIPT_DIR/comate-lib.sh"
41
-
42
- usage_top() {
43
- cat >&2 <<'EOF'
44
- Usage: comate.sh <command> ...
45
-
46
- state <name> init <workflow> full | fix
47
- state <name> get <field>
48
- state <name> set <field> <value>
49
- state <name> transition <event> open-complete|design-complete|build-complete|verify-pass|verify-fail|fix-upgrade|archived
50
- state <name> check <phase> [--json]
51
- state <name> recover <phase>
52
- state <name> scale
53
- state <name> validate
54
- guard <name> <phase> [--apply] [--json]
55
- handoff <name> design --write [--full]
56
- handoff <name> build --write
57
- sources <name> track <file...> [--note <text>]
58
- sources <name> check
59
- sources <name> list
60
- sources find <file>
61
- dependency <name> archived verify an exact archived change exists
62
- gate <prd> review create/refresh gate.yaml next to PRD, pin hash
63
- gate <prd> set <field> <value> score | blocking_open | gate_status | …
64
- gate <prd> pass [--tbd] pass: zero blockers; --tbd: accepted blockers
65
- gate <prd> spawned <change> record a change fanned out from this PRD
66
- gate <prd> fanout-status [--json] report mapped/spawned fanout progress
67
- gate <prd> status [--json]
68
- gate <prd> check precondition guard before spawning a change
69
- archive <name> [--dry-run]
70
- status [path]
71
- doctor
72
- EOF
73
- }
74
-
75
- require_yaml() {
76
- # $1 = change name; ensures .comate.yaml exists, echoes its path.
77
- local name="$1" yaml
78
- yaml=$(comate_yaml_for "$name")
79
- [ -f "$yaml" ] || die "$COMATE_CORRUPT" "ERROR: .comate.yaml not found at $yaml"
80
- echo "$yaml"
81
- }
82
-
83
- today() { date +%Y-%m-%d; }
84
-
85
- # =====================================================================
86
- # state
87
- # =====================================================================
88
-
89
- state_init() {
90
- local name="$1" workflow="${2:-}"
91
- validate_change_name "$name"
92
- validate_enum workflow "$workflow"
93
- [ -n "$workflow" ] || die "$COMATE_USAGE" "Usage: comate.sh state <name> init <full|fix>"
94
-
95
- local dir yaml
96
- dir=$(comate_change_dir "$name")
97
- yaml="$dir/.comate.yaml"
98
- [ -f "$yaml" ] && die "$COMATE_USAGE" "ERROR: .comate.yaml already exists at $yaml"
99
- mkdir -p "$dir"
100
-
101
- # base_ref = current HEAD.
102
- local base_ref="null"
103
- if git rev-parse --verify HEAD >/dev/null 2>&1; then
104
- base_ref=$(git rev-parse HEAD 2>/dev/null || echo "null")
105
- fi
106
- # base_branch = detected default branch, fallback main/master.
107
- local base_branch="null" ref
108
- if ref=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null); then
109
- base_branch="${ref#refs/remotes/origin/}"
110
- elif git show-ref --verify --quiet refs/heads/main 2>/dev/null; then
111
- base_branch="main"
112
- elif git show-ref --verify --quiet refs/heads/master 2>/dev/null; then
113
- base_branch="master"
114
- fi
115
-
116
- # Workflow defaults: full leaves decisions null; presets prefill.
117
- local build_mode="null" tdd_mode="null" isolation="null" verify_mode="null"
118
- case "$workflow" in
119
- fix)
120
- build_mode="direct"; tdd_mode="direct"; isolation="branch"; verify_mode="light" ;;
121
- esac
122
-
123
- # Write full canonical file directly (FIELD_ORDER kept in sync with lib).
124
- cat > "$yaml" <<EOF
125
- # comate state — flat key:value only, no nesting
126
- schema_version: $COMATE_SCHEMA_VERSION
127
- workflow: $workflow
128
- phase: open
129
- phase_step: null
130
- phase_step_data: null
131
- build_mode: $build_mode
132
- tdd_mode: $tdd_mode
133
- isolation: $isolation
134
- verify_mode: $verify_mode
135
- verify_result: pending
136
- design_doc: null
137
- plan: null
138
- verification_report: null
139
- branch_status: pending
140
- handoff_context: null
141
- handoff_hash: null
142
- impl_context: null
143
- impl_hash: null
144
- direct_override: false
145
- base_ref: $base_ref
146
- base_branch: $base_branch
147
- source_kind: conversation
148
- source_manifest: null
149
- build_command: null
150
- verify_command: null
151
- created_at: $(today)
152
- verified_at: null
153
- archived: false
154
- EOF
155
-
156
- validate_flat_yaml "$yaml"
157
- log_pass "[INIT] $yaml (workflow=$workflow, base_branch=$base_branch)"
158
- }
159
-
160
- state_get() {
161
- local name="$1" field="${2:-}"
162
- validate_change_name "$name"
163
- [ -n "$field" ] || die "$COMATE_USAGE" "Usage: comate.sh state <name> get <field>"
164
- local yaml; yaml=$(require_yaml "$name")
165
- yaml_get "$yaml" "$field"
166
- }
167
-
168
- state_set() {
169
- local name="$1" field="${2:-}" value="${3:-}"
170
- validate_change_name "$name"
171
- [ -n "$field" ] || die "$COMATE_USAGE" "Usage: comate.sh state <name> set <field> <value>"
172
- if [ "$field" = "phase" ]; then
173
- log_warn "WARNING: setting 'phase' directly bypasses the state machine; prefer 'state transition'."
174
- fi
175
- local yaml; yaml=$(require_yaml "$name")
176
- yaml_set "$yaml" "$field" "$value"
177
- log_pass "[SET] $field=$value"
178
- }
179
-
180
- # --- transition guards (see IMPLEMENTATION.md §3 guard table) ---
181
-
182
- _require_phase() {
183
- local yaml="$1" expected="$2" actual
184
- actual=$(yaml_get "$yaml" phase)
185
- [ "$actual" = "$expected" ] || \
186
- die "$COMATE_BLOCK" "ERROR: expected phase '$expected', got '${actual:-null}'"
187
- }
188
-
189
- _require_build_decisions() {
190
- local yaml="$1"
191
- local workflow build_mode tdd_mode isolation direct_override
192
- workflow=$(yaml_get "$yaml" workflow)
193
- build_mode=$(yaml_get "$yaml" build_mode)
194
- tdd_mode=$(yaml_get "$yaml" tdd_mode)
195
- isolation=$(yaml_get "$yaml" isolation)
196
- direct_override=$(yaml_get "$yaml" direct_override)
197
- case "$isolation" in
198
- branch|worktree) ;;
199
- *) die "$COMATE_BLOCK" "ERROR: isolation must be branch or worktree, got '${isolation:-null}'" ;;
200
- esac
201
- case "$build_mode" in
202
- subagent-driven-development|executing-plans|direct) ;;
203
- *) die "$COMATE_BLOCK" "ERROR: build_mode must be selected, got '${build_mode:-null}'" ;;
204
- esac
205
- if [ "$build_mode" = "direct" ] && [ "$workflow" != "fix" ] && [ "$direct_override" != "true" ]; then
206
- die "$COMATE_BLOCK" "ERROR: build_mode=direct is only allowed for fix unless direct_override=true"
207
- fi
208
- case "$tdd_mode" in
209
- tdd|direct) ;;
210
- *) die "$COMATE_BLOCK" "ERROR: tdd_mode must be tdd or direct, got '${tdd_mode:-null}'" ;;
211
- esac
212
- }
213
-
214
- _require_verify_evidence() {
215
- local yaml="$1" report branch_status
216
- report=$(yaml_get "$yaml" verification_report)
217
- branch_status=$(yaml_get "$yaml" branch_status)
218
- if [ -z "$report" ] || [ "$report" = "null" ] || [ ! -f "$report" ]; then
219
- die "$COMATE_BLOCK" "ERROR: verification_report must point to an existing file (got '${report:-null}')"
220
- fi
221
- [ "$branch_status" = "handled" ] || \
222
- die "$COMATE_BLOCK" "ERROR: branch_status must be 'handled', got '${branch_status:-null}'"
223
- }
224
-
225
- state_transition() {
226
- local name="$1" event="${2:-}"
227
- validate_change_name "$name"
228
- validate_enum_event() {
229
- case "$1" in
230
- open-complete|design-complete|build-complete|verify-pass|verify-fail|fix-upgrade|archived) return 0 ;;
231
- *) die "$COMATE_USAGE" "ERROR: unknown event '$1'" ;;
232
- esac
233
- }
234
- [ -n "$event" ] || die "$COMATE_USAGE" "Usage: comate.sh state <name> transition <event>"
235
- validate_enum_event "$event"
236
- local yaml; yaml=$(require_yaml "$name")
237
- validate_flat_yaml "$yaml"
238
-
239
- # phase_step is local to the current phase. Carrying it into the next phase
240
- # makes identical step numbers ambiguous and can skip mandatory work.
241
- _clear_phase_progress() {
242
- yaml_set "$yaml" phase_step null
243
- yaml_set "$yaml" phase_step_data null
244
- }
245
-
246
- case "$event" in
247
- open-complete)
248
- _require_phase "$yaml" open
249
- local workflow; workflow=$(yaml_get "$yaml" workflow)
250
- if [ "$workflow" = "full" ]; then
251
- yaml_set "$yaml" phase design
252
- else
253
- yaml_set "$yaml" phase build
254
- fi
255
- _clear_phase_progress
256
- ;;
257
- design-complete)
258
- _require_phase "$yaml" design
259
- yaml_set "$yaml" phase build
260
- _clear_phase_progress
261
- ;;
262
- build-complete)
263
- _require_phase "$yaml" build
264
- _require_build_decisions "$yaml"
265
- yaml_set "$yaml" phase verify
266
- yaml_set "$yaml" verify_result pending
267
- yaml_set "$yaml" verification_report null
268
- yaml_set "$yaml" branch_status pending
269
- _clear_phase_progress
270
- ;;
271
- verify-pass)
272
- _require_phase "$yaml" verify
273
- _require_verify_evidence "$yaml"
274
- yaml_set "$yaml" verify_result pass
275
- yaml_set "$yaml" phase archive
276
- yaml_set "$yaml" verified_at "$(today)"
277
- _clear_phase_progress
278
- ;;
279
- verify-fail)
280
- _require_phase "$yaml" verify
281
- # The failure is captured in the report; once routed back to build the
282
- # active verification state must be pending or the orchestrator loops.
283
- yaml_set "$yaml" verify_result pending
284
- yaml_set "$yaml" phase build
285
- yaml_set "$yaml" verification_report null
286
- yaml_set "$yaml" branch_status pending
287
- _clear_phase_progress
288
- ;;
289
- fix-upgrade)
290
- [ "$(yaml_get "$yaml" workflow)" = "fix" ] || \
291
- die "$COMATE_BLOCK" "ERROR: fix-upgrade requires workflow=fix"
292
- case "$(yaml_get "$yaml" phase)" in
293
- open|build) ;;
294
- *) die "$COMATE_BLOCK" "ERROR: fix-upgrade is only valid from open or build" ;;
295
- esac
296
- yaml_set "$yaml" workflow full
297
- yaml_set "$yaml" phase design
298
- yaml_set "$yaml" build_mode null
299
- yaml_set "$yaml" tdd_mode null
300
- yaml_set "$yaml" isolation null
301
- yaml_set "$yaml" verify_mode null
302
- yaml_set "$yaml" direct_override false
303
- yaml_set "$yaml" verify_result pending
304
- yaml_set "$yaml" verification_report null
305
- yaml_set "$yaml" branch_status pending
306
- _clear_phase_progress
307
- ;;
308
- archived)
309
- _require_phase "$yaml" archive
310
- yaml_set "$yaml" archived true
311
- ;;
312
- esac
313
- log_pass "[TRANSITION] $event"
314
- }
315
-
316
- # --- validate (schema + flat-yaml) ---
317
-
318
- state_validate() {
319
- local name="$1"
320
- validate_change_name "$name"
321
- local yaml; yaml=$(require_yaml "$name")
322
- log_info "[VALIDATE] $yaml"
323
-
324
- validate_flat_yaml "$yaml" # dies code 4 on nesting/lists/block scalars
325
-
326
- local errors=0 warnings=0
327
- # schema_version present and known.
328
- local sv; sv=$(yaml_get "$yaml" schema_version)
329
- if [ -z "$sv" ] || [ "$sv" = "null" ]; then
330
- log_warn " WARN: schema_version missing"; warnings=$((warnings+1))
331
- elif [ "$sv" != "$COMATE_SCHEMA_VERSION" ]; then
332
- log_warn " WARN: schema_version=$sv (this tool writes $COMATE_SCHEMA_VERSION)"; warnings=$((warnings+1))
333
- fi
334
-
335
- # Required fields present.
336
- local required="workflow phase verify_result archived"
337
- local f
338
- for f in $required; do
339
- if ! yaml_keys "$yaml" | grep -qx "$f"; then
340
- log_fail " FAIL: missing required field '$f'"; errors=$((errors+1))
341
- fi
342
- done
343
-
344
- # Enum validity (null/empty allowed by validate_enum, but it dies on bad — so check inline).
345
- local key legal v ok w
346
- for key in $(enum_keys); do
347
- v=$(yaml_get "$yaml" "$key")
348
- [ -z "$v" ] && continue
349
- [ "$v" = "null" ] && continue
350
- legal=$(enum_values "$key")
351
- ok=0
352
- for w in $legal; do
353
- if [ "$v" = "$w" ]; then ok=1; break; fi
354
- done
355
- if [ "$ok" -eq 0 ]; then
356
- log_fail " FAIL: $key='$v' invalid (expected: $legal)"; errors=$((errors+1))
357
- fi
358
- done
359
-
360
- # Path fields point to existing files when set.
361
- for f in design_doc plan verification_report handoff_context impl_context; do
362
- v=$(yaml_get "$yaml" "$f")
363
- if [ -n "$v" ] && [ "$v" != "null" ] && [ ! -f "$v" ]; then
364
- log_fail " FAIL: $f='$v' does not exist on disk"; errors=$((errors+1))
365
- fi
366
- done
367
-
368
- # Hash fields are 64 hex when set.
369
- for f in handoff_hash impl_hash; do
370
- v=$(yaml_get "$yaml" "$f")
371
- if [ -n "$v" ] && [ "$v" != "null" ] && [[ ! "$v" =~ ^[a-f0-9]{64}$ ]]; then
372
- log_fail " FAIL: $f='$v' is not a sha256 hex digest"; errors=$((errors+1))
373
- fi
374
- done
375
-
376
- # Unknown keys -> warning.
377
- local known=" ${FIELD_ORDER[*]} "
378
- while IFS= read -r key; do
379
- [ -z "$key" ] && continue
380
- case "$known" in
381
- *" $key "*) ;;
382
- *) log_warn " WARN: unknown field '$key'"; warnings=$((warnings+1)) ;;
383
- esac
384
- done < <(yaml_keys "$yaml")
385
-
386
- if [ "$errors" -gt 0 ]; then
387
- die "$COMATE_CORRUPT" "$errors error(s), $warnings warning(s) — validation FAILED"
388
- fi
389
- log_pass "0 errors, $warnings warning(s) — validation PASSED"
390
- }
391
-
392
- # --- check (entry preconditions for a phase) ---
393
-
394
- CHECK_JSON=0
395
- declare -a CHECK_RESULTS=() # "name|status"
396
- CHECK_BLOCK=0
397
-
398
- _chk() {
399
- # $1 = name, $2 = 0(pass)/1(fail). Always returns 0 (set -e safe).
400
- local name="$1" rc="$2"
401
- if [ "$rc" -eq 0 ]; then
402
- CHECK_RESULTS+=("$name|pass")
403
- if [ "$CHECK_JSON" -eq 0 ]; then log_pass " [PASS] $name"; fi
404
- else
405
- CHECK_RESULTS+=("$name|fail")
406
- CHECK_BLOCK=1
407
- if [ "$CHECK_JSON" -eq 0 ]; then log_fail " [FAIL] $name"; fi
408
- fi
409
- return 0
410
- }
411
-
412
- _chk_yaml_is() {
413
- local yaml="$1" field="$2" expected="$3" actual rc=0
414
- actual=$(yaml_get "$yaml" "$field")
415
- [ "$actual" = "$expected" ] || rc=1
416
- _chk "$field=$expected (got ${actual:-null})" "$rc"
417
- }
418
-
419
- _chk_yaml_empty() {
420
- local yaml="$1" field="$2" v rc=0
421
- v=$(yaml_get "$yaml" "$field")
422
- { [ -z "$v" ] || [ "$v" = "null" ]; } || rc=1
423
- _chk "$field empty/null" "$rc"
424
- }
425
-
426
- _chk_nonempty() {
427
- local rc=0
428
- file_nonempty "$2" || rc=1
429
- _chk "$1 non-empty" "$rc"
430
- }
431
-
432
- state_check() {
433
- local name="$1" phase="${2:-}"
434
- validate_change_name "$name"
435
- validate_enum phase "$phase"
436
- [ -n "$phase" ] || die "$COMATE_USAGE" "Usage: comate.sh state <name> check <phase> [--json]"
437
- local dir yaml
438
- dir=$(comate_change_dir "$name")
439
- yaml="$dir/.comate.yaml"
440
- [ -f "$yaml" ] || die "$COMATE_CORRUPT" "ERROR: .comate.yaml not found at $yaml"
441
-
442
- case "$phase" in
443
- open)
444
- _chk ".comate.yaml exists" 0
445
- _chk_yaml_is "$yaml" phase open
446
- ;;
447
- design)
448
- _chk_yaml_is "$yaml" phase design
449
- _chk_yaml_is "$yaml" workflow full
450
- _chk_yaml_empty "$yaml" design_doc
451
- _chk_nonempty "proposal.md" "$dir/proposal.md"
452
- _chk_nonempty "design.md" "$dir/design.md"
453
- _chk_nonempty "tasks.md" "$dir/tasks.md"
454
- ;;
455
- build)
456
- _chk_yaml_is "$yaml" phase build
457
- local workflow; workflow=$(yaml_get "$yaml" workflow)
458
- if [ "$workflow" = "full" ]; then
459
- local dd; dd=$(yaml_get "$yaml" design_doc)
460
- { [ -n "$dd" ] && [ "$dd" != "null" ] && [ -f "$dd" ]; }
461
- _chk "design_doc exists ($dd)" $?
462
- fi
463
- _chk_nonempty "proposal.md" "$dir/proposal.md"
464
- _chk_nonempty "tasks.md" "$dir/tasks.md"
465
- ;;
466
- verify)
467
- _chk_yaml_is "$yaml" phase verify
468
- local vr; vr=$(yaml_get "$yaml" verify_result)
469
- { [ "$vr" = "pending" ] || [ -z "$vr" ] || [ "$vr" = "null" ]; }
470
- _chk "verify_result pending/null (got $vr)" $?
471
- ;;
472
- archive)
473
- _chk_yaml_is "$yaml" phase archive
474
- _chk_yaml_is "$yaml" verify_result pass
475
- local ar; ar=$(yaml_get "$yaml" archived)
476
- [ "$ar" != "true" ]; _chk "archived not yet true (got $ar)" $?
477
- ;;
478
- esac
479
-
480
- if [ "$CHECK_JSON" -eq 1 ]; then
481
- local out='{"phase":"'"$phase"'","checks":['
482
- local first=1 r cname cstatus
483
- for r in "${CHECK_RESULTS[@]}"; do
484
- cname="${r%|*}"; cstatus="${r#*|}"
485
- [ "$first" -eq 0 ] && out+=','
486
- first=0
487
- out+='{"name":"'"$(json_escape "$cname")"'","status":"'"$cstatus"'"}'
488
- done
489
- out+='],"block":'"$([ "$CHECK_BLOCK" -eq 1 ] && echo true || echo false)"'}'
490
- printf '%s\n' "$out"
491
- else
492
- echo "" >&2
493
- if [ "$CHECK_BLOCK" -eq 1 ]; then
494
- log_fail "BLOCKED — fix failing checks before proceeding"
495
- else
496
- log_pass "ALL CHECKS PASSED — ready to proceed"
497
- fi
498
- fi
499
- if [ "$CHECK_BLOCK" -eq 1 ]; then exit "$COMATE_BLOCK"; fi
500
- exit "$COMATE_OK"
501
- }
502
-
503
- # --- recover (compaction-resume structured context) ---
504
-
505
- _field_status() {
506
- # $1 name, $2 value, [$3 path]
507
- local field="$1" value="$2" path="${3:-}"
508
- if [ -z "$value" ] || [ "$value" = "null" ]; then
509
- echo " - $field: PENDING"
510
- elif [ -n "$path" ] && [ ! -f "$path" ]; then
511
- echo " - $field: BROKEN (path $value does not exist)"
512
- else
513
- echo " - $field: DONE ($value)"
514
- fi
515
- }
516
-
517
- state_recover() {
518
- local name="$1" phase_arg="${2:-}"
519
- validate_change_name "$name"
520
- local dir yaml
521
- dir=$(comate_change_dir "$name")
522
- yaml="$dir/.comate.yaml"
523
- [ -f "$yaml" ] || die "$COMATE_CORRUPT" "ERROR: .comate.yaml not found at $yaml"
524
-
525
- local phase workflow phase_step phase_step_data
526
- phase=$(yaml_get "$yaml" phase)
527
- workflow=$(yaml_get "$yaml" workflow)
528
- phase_step=$(yaml_get "$yaml" phase_step)
529
- phase_step_data=$(yaml_get "$yaml" phase_step_data)
530
- # If a phase arg was passed, honor it for reporting; else use stored phase.
531
- [ -n "$phase_arg" ] && phase="$phase_arg"
532
-
533
- echo "=== Recovery Context: $name ==="
534
- echo "Phase: $phase"
535
- echo "Workflow: $workflow"
536
- echo "Phase step: ${phase_step:-null}"
537
- echo "Phase step data: ${phase_step_data:-null}"
538
- echo ""
539
-
540
- local design_doc plan verify_result verify_mode verification_report
541
- local branch_status handoff_context handoff_hash impl_context impl_hash
542
- local isolation build_mode tdd_mode direct_override
543
- design_doc=$(yaml_get "$yaml" design_doc)
544
- plan=$(yaml_get "$yaml" plan)
545
- verify_result=$(yaml_get "$yaml" verify_result)
546
- verify_mode=$(yaml_get "$yaml" verify_mode)
547
- verification_report=$(yaml_get "$yaml" verification_report)
548
- branch_status=$(yaml_get "$yaml" branch_status)
549
- handoff_context=$(yaml_get "$yaml" handoff_context)
550
- handoff_hash=$(yaml_get "$yaml" handoff_hash)
551
- impl_context=$(yaml_get "$yaml" impl_context)
552
- impl_hash=$(yaml_get "$yaml" impl_hash)
553
- isolation=$(yaml_get "$yaml" isolation)
554
- build_mode=$(yaml_get "$yaml" build_mode)
555
- tdd_mode=$(yaml_get "$yaml" tdd_mode)
556
- direct_override=$(yaml_get "$yaml" direct_override)
557
-
558
- case "$phase" in
559
- open)
560
- echo "Artifacts:"
561
- local fdone
562
- for f in proposal.md design.md tasks.md; do
563
- if file_nonempty "$dir/$f"; then echo " - $f: DONE"; else echo " - $f: PENDING"; fi
564
- done
565
- echo ""
566
- echo "Recovery action: Create or complete missing artifacts, then use AskUserQuestion for user confirmation."
567
- ;;
568
- design)
569
- echo "Artifacts:"
570
- for f in proposal.md design.md tasks.md; do
571
- if file_nonempty "$dir/$f"; then echo " - $f: DONE"; else echo " - $f: MISSING (unexpected in design)"; fi
572
- done
573
- echo ""
574
- echo "Design progress:"
575
- _field_status "handoff_context" "$handoff_context" "$handoff_context"
576
- _field_status "handoff_hash" "$handoff_hash"
577
- _field_status "design_doc" "$design_doc" "$design_doc"
578
- echo ""
579
- if [ -n "$design_doc" ] && [ "$design_doc" != "null" ] && [ -f "$design_doc" ]; then
580
- echo "Recovery action: Design Doc linked. Run guard to transition to build."
581
- elif [ -n "$handoff_context" ] && [ "$handoff_context" != "null" ] && [ -f "$handoff_context" ]; then
582
- echo "Recovery action: Handoff generated but Design Doc not created. Resume from brainstorming confirmation (Step 1c)."
583
- else
584
- echo "Recovery action: No handoff yet. Start from Step 1a (generate handoff package)."
585
- fi
586
- ;;
587
- build)
588
- echo "Build decisions:"
589
- _field_status "isolation" "$isolation"
590
- _field_status "build_mode" "$build_mode"
591
- _field_status "tdd_mode" "$tdd_mode"
592
- if [ "$build_mode" = "direct" ] && [ "$workflow" != "fix" ]; then
593
- _field_status "direct_override" "$direct_override"
594
- fi
595
- echo ""
596
- _field_status "plan" "$plan" "$plan"
597
- _field_status "impl_context" "$impl_context" "$impl_context"
598
- echo ""
599
- local tasks_file="$dir/tasks.md" total=0 done=0 pending=0
600
- if [ -f "$tasks_file" ]; then
601
- total=$(grep -c '^- \[' "$tasks_file" 2>/dev/null || echo 0)
602
- done=$(grep -c '^- \[x\]' "$tasks_file" 2>/dev/null || echo 0)
603
- pending=$((total - done))
604
- echo "Tasks: $done/$total done, $pending pending"
605
- else
606
- echo "Tasks: tasks.md MISSING"
607
- fi
608
- echo ""
609
- if [[ "$phase_step" == build.blocked-by-* ]]; then
610
- local dep="$phase_step_data"
611
- if [ -z "$dep" ] || [ "$dep" = "null" ]; then dep="${phase_step#build.blocked-by-}"; fi
612
- echo "Recovery action: Blocked by $dep. Run: comate.sh dependency $dep archived"
613
- elif [ "$isolation" = "null" ] || [ -z "$isolation" ]; then
614
- echo "Recovery action: Isolation not selected. AskUserQuestion for branch/worktree."
615
- elif [ "$build_mode" = "null" ] || [ -z "$build_mode" ]; then
616
- echo "Recovery action: Build mode not selected. AskUserQuestion for execution method."
617
- elif [ "$tdd_mode" = "null" ] || [ -z "$tdd_mode" ]; then
618
- echo "Recovery action: TDD mode not selected. AskUserQuestion for tdd/direct."
619
- elif [ ! -f "$tasks_file" ]; then
620
- echo "Recovery action: tasks.md missing. Verify change directory integrity."
621
- elif [ "$pending" -gt 0 ]; then
622
- echo "Recovery action: Read tasks.md and continue from first unchecked task."
623
- else
624
- echo "Recovery action: All tasks done. Run guard to transition to verify."
625
- fi
626
- ;;
627
- verify)
628
- echo "Verification:"
629
- _field_status "verify_result" "$verify_result"
630
- _field_status "verify_mode" "$verify_mode"
631
- _field_status "verification_report" "$verification_report" "$verification_report"
632
- _field_status "branch_status" "$branch_status"
633
- _field_status "impl_hash" "$impl_hash"
634
- echo ""
635
- if [ "$branch_status" = "discarded" ]; then
636
- echo "Recovery action: Work was discarded. Run verify-fail to return to build before reimplementing, or explicitly abandon the change."
637
- elif [ "$verify_result" = "pass" ] && [ "$branch_status" = "handled" ]; then
638
- echo "Recovery action: Verification complete. Run guard to transition to archive."
639
- elif [ "$verify_result" = "pass" ]; then
640
- echo "Recovery action: Verified but branch not handled. Complete branch handling, set branch_status=handled."
641
- elif [ "$verify_result" = "fail" ]; then
642
- echo "Recovery action: Verification failed, rolled back to build. Resume from /comate-build."
643
- else
644
- echo "Recovery action: Verification not started. Run scale assessment then verify."
645
- fi
646
- ;;
647
- archive)
648
- echo "Archive:"
649
- _field_status "verify_result" "$verify_result"
650
- _field_status "archived" "$(yaml_get "$yaml" archived)"
651
- echo ""
652
- echo "Recovery action: Run /comate-archive to complete archiving."
653
- ;;
654
- *)
655
- die "$COMATE_USAGE" "ERROR: unknown phase '$phase'"
656
- ;;
657
- esac
658
- echo ""
659
- echo "=== End Recovery Context ==="
660
- }
661
-
662
- # --- scale (assess verify_mode, base-ref annotation) ---
663
-
664
- state_scale() {
665
- local name="$1"
666
- validate_change_name "$name"
667
- local dir yaml
668
- dir=$(comate_change_dir "$name")
669
- yaml="$dir/.comate.yaml"
670
- [ -f "$yaml" ] || die "$COMATE_CORRUPT" "ERROR: .comate.yaml not found at $yaml"
671
-
672
- local tasks_file="$dir/tasks.md" task_count=0
673
- [ -f "$tasks_file" ] && task_count=$(grep -c '^- \[' "$tasks_file" 2>/dev/null || echo 0)
674
-
675
- local delta_spec_count=0
676
- if [ -d "$dir/specs" ]; then
677
- delta_spec_count=$(find "$dir/specs" -name spec.md -type f 2>/dev/null | wc -l | tr -d ' ')
678
- fi
679
-
680
- # Changed files: plan base-ref > yaml base_ref > worktree diff. Annotate source.
681
- local changed_files=0 basis="worktree-diff" base_ref=""
682
- if git rev-parse --git-dir >/dev/null 2>&1; then
683
- local plan_file
684
- plan_file=$(yaml_get "$yaml" plan)
685
- if [ -n "$plan_file" ] && [ "$plan_file" != "null" ] && [ -f "$plan_file" ]; then
686
- base_ref=$(grep '^base-ref:' "$plan_file" 2>/dev/null | head -1 | sed 's/^base-ref:[[:space:]]*//')
687
- [ -n "$base_ref" ] && [ "$base_ref" != "null" ] && basis="plan base-ref"
688
- fi
689
- if [ -z "$base_ref" ] || [ "$base_ref" = "null" ]; then
690
- base_ref=$(yaml_get "$yaml" base_ref)
691
- [ -n "$base_ref" ] && [ "$base_ref" != "null" ] && basis="state base_ref"
692
- fi
693
- if [ -n "$base_ref" ] && [ "$base_ref" != "null" ] && git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
694
- changed_files=$(git diff --name-only "$base_ref"...HEAD 2>/dev/null | wc -l | tr -d ' ')
695
- else
696
- changed_files=$(git diff --name-only HEAD 2>/dev/null | wc -l | tr -d ' ')
697
- basis="worktree-diff"
698
- fi
699
- fi
700
-
701
- local result="light"
702
- if [ "$task_count" -gt 3 ] || [ "$delta_spec_count" -gt 1 ] || [ "$changed_files" -gt 4 ]; then
703
- result="full"
704
- fi
705
-
706
- {
707
- echo "=== Scale Assessment: $name ==="
708
- echo " Tasks: $task_count (threshold: 3)"
709
- echo " Delta specs: $delta_spec_count capabilities (threshold: 1)"
710
- echo " Changed files: $changed_files (threshold: 4) [basis: $basis]"
711
- echo " -> Result: $result"
712
- } >&2
713
-
714
- yaml_set "$yaml" verify_mode "$result"
715
- log_pass "[SCALE] verify_mode=$result"
716
- }
717
-
718
- # =====================================================================
719
- # guard — phase EXIT evidence checks (table in IMPLEMENTATION.md §4.3)
720
- # =====================================================================
721
-
722
- GUARD_BLOCK=0
723
- declare -a GUARD_RESULTS=() # "name|status"
724
-
725
- # _guard_check <desc> <command...> — run command; capture output; record result.
726
- # Always returns 0 so a recorded FAIL never trips the caller's set -e.
727
- _guard_check() {
728
- local desc="$1"; shift
729
- local output rc
730
- if output=$("$@" 2>&1); then rc=0; else rc=1; fi
731
- if [ "$rc" -eq 0 ]; then
732
- GUARD_RESULTS+=("$desc|pass")
733
- if [ "$GUARD_JSON" -eq 0 ]; then log_pass " [PASS] $desc"; fi
734
- else
735
- GUARD_RESULTS+=("$desc|fail")
736
- GUARD_BLOCK=1
737
- if [ "$GUARD_JSON" -eq 0 ]; then
738
- log_fail " [FAIL] $desc"
739
- if [ -n "$output" ]; then while IFS= read -r line; do log_fail " $line"; done <<< "$output"; fi
740
- fi
741
- fi
742
- return 0
743
- }
744
-
745
- # --- evidence predicates (use $G_DIR / $G_YAML / $G_CHANGE set by guard()) ---
746
-
747
- _g_tasks_all_done() {
748
- local tasks="$G_DIR/tasks.md"
749
- [ -f "$tasks" ] || { echo "tasks.md missing at $tasks"; return 1; }
750
- grep -q '^- \[x\]' "$tasks" || { echo "tasks.md has no completed tasks"; return 1; }
751
- if grep -q '^- \[ \]' "$tasks"; then
752
- echo "Unfinished tasks:"; grep -n '^- \[ \]' "$tasks" || true; return 1
753
- fi
754
- return 0
755
- }
756
- _g_tasks_has_any() {
757
- local tasks="$G_DIR/tasks.md"
758
- [ -f "$tasks" ] && grep -q '^- \[' "$tasks"
759
- }
760
-
761
- # Resolve a project command: change yaml first, then repo-root config files.
762
- _g_project_command() {
763
- local field="$1" value
764
- value=$(yaml_get "$G_YAML" "$field")
765
- if [ -n "$value" ] && [ "$value" != "null" ]; then echo "$value"; return 0; fi
766
- local cfg
767
- for cfg in ".comate.yaml" "comate.yaml" ".comate.yml" "comate.yml"; do
768
- if [ -f "$cfg" ]; then
769
- value=$(yaml_get "$cfg" "$field")
770
- if [ -n "$value" ] && [ "$value" != "null" ]; then echo "$value"; return 0; fi
771
- fi
772
- done
773
- }
774
-
775
- _g_is_windows_bash() {
776
- case "$(uname -s 2>/dev/null || true)" in
777
- MINGW*|MSYS*|CYGWIN*) return 0 ;;
778
- *) return 1 ;;
779
- esac
780
- }
781
-
782
- _g_run_cmd() { echo "+ $1" >&2; bash -lc "$1"; }
783
-
784
- _g_build_passes() {
785
- [ "${COMATE_SKIP_BUILD:-0}" = "1" ] && return 0
786
- local cmd; cmd=$(_g_project_command build_command)
787
- if [ -n "$cmd" ]; then _g_run_cmd "$cmd"; return $?; fi
788
- if [ -f package.json ] && grep -q '"build"' package.json; then npm run build; return $?; fi
789
- if [ -f pom.xml ]; then
790
- if [ -x ./mvnw ]; then ./mvnw compile -q
791
- elif _g_is_windows_bash && command -v mvn.cmd >/dev/null 2>&1; then mvn.cmd compile -q
792
- else mvn compile -q; fi
793
- return $?
794
- fi
795
- if [ -f Cargo.toml ]; then cargo build; return $?; fi
796
- echo "no build_command configured and no known build tool detected"
797
- echo "Next: set build_command in .comate.yaml, or skip with COMATE_SKIP_BUILD=1 (not recommended)."
798
- return 1
799
- }
800
-
801
- _g_verify_passes() {
802
- [ "${COMATE_SKIP_BUILD:-0}" = "1" ] && return 0
803
- local cmd; cmd=$(_g_project_command verify_command)
804
- if [ -n "$cmd" ]; then _g_run_cmd "$cmd"; return $?; fi
805
- _g_build_passes
806
- }
807
-
808
- _g_isolation_selected() {
809
- local v; v=$(yaml_get "$G_YAML" isolation)
810
- case "$v" in branch|worktree) return 0 ;; esac
811
- echo "isolation must be branch or worktree, got '${v:-null}'"
812
- echo "Next: comate.sh state $G_CHANGE set isolation <branch|worktree>"
813
- return 1
814
- }
815
- _g_build_mode_selected() {
816
- local v; v=$(yaml_get "$G_YAML" build_mode)
817
- case "$v" in subagent-driven-development|executing-plans|direct) return 0 ;; esac
818
- echo "build_mode must be selected, got '${v:-null}'"
819
- return 1
820
- }
821
- _g_build_mode_allowed() {
822
- local workflow build_mode direct_override
823
- workflow=$(yaml_get "$G_YAML" workflow)
824
- build_mode=$(yaml_get "$G_YAML" build_mode)
825
- direct_override=$(yaml_get "$G_YAML" direct_override)
826
- [ "$build_mode" != "direct" ] && return 0
827
- case "$workflow" in fix) return 0 ;; esac
828
- [ "$direct_override" = "true" ] && return 0
829
- echo "build_mode=direct only allowed for fix unless direct_override=true"
830
- return 1
831
- }
832
- _g_tdd_mode_selected() {
833
- local v; v=$(yaml_get "$G_YAML" tdd_mode)
834
- case "$v" in tdd|direct) return 0 ;; esac
835
- echo "tdd_mode must be tdd or direct, got '${v:-null}'"
836
- echo "Next: comate.sh state $G_CHANGE set tdd_mode <tdd|direct>"
837
- return 1
838
- }
839
- _g_verification_report_exists() {
840
- local v; v=$(yaml_get "$G_YAML" verification_report)
841
- [ -n "$v" ] && [ "$v" != "null" ] && [ -f "$v" ]
842
- }
843
- _g_branch_status_handled() {
844
- [ "$(yaml_get "$G_YAML" branch_status)" = "handled" ]
845
- }
846
- _g_archived_is_true() {
847
- [ "$(yaml_get "$G_YAML" archived)" = "true" ]
848
- }
849
-
850
- # source-tracking drift: knowledge-base files unchanged since they were pinned.
851
- # conversation sources (no manifest) pass trivially.
852
- _g_sources_not_drifted() {
853
- local kind; kind=$(yaml_get "$G_YAML" source_kind)
854
- [ "$kind" = "file" ] || return 0
855
- local manifest; manifest=$(yaml_get "$G_YAML" source_manifest)
856
- if [ -z "$manifest" ] || [ "$manifest" = "null" ]; then
857
- echo "source_kind=file but source_manifest is null"; return 1
858
- fi
859
- local out rc
860
- out=$(sources_drift "$manifest"); rc=$?
861
- if [ "$rc" -ne 0 ]; then
862
- [ -n "$out" ] && echo "$out"
863
- echo "Next: review the knowledge-base change; rerun comate.sh sources $G_CHANGE track <file> if intended."
864
- return 1
865
- fi
866
- return 0
867
- }
868
-
869
- # Handoff source set (design phase) — proposal/design/tasks + delta specs.
870
- _g_handoff_sources() {
871
- printf '%s\n' "$G_DIR/proposal.md" "$G_DIR/design.md" "$G_DIR/tasks.md"
872
- [ -d "$G_DIR/specs" ] && find "$G_DIR/specs" -path '*/spec.md' -type f 2>/dev/null | sort
873
- }
874
-
875
- _g_design_handoff_valid() {
876
- local context recorded actual md
877
- context=$(yaml_get "$G_YAML" handoff_context)
878
- recorded=$(yaml_get "$G_YAML" handoff_hash)
879
- if [ -z "$context" ] || [ "$context" = "null" ]; then
880
- echo "handoff_context missing; run: comate.sh handoff $G_CHANGE design --write"; return 1
881
- fi
882
- [ -s "$context" ] || { echo "handoff_context not a non-empty file: $context"; return 1; }
883
- if [[ ! "$recorded" =~ ^[a-f0-9]{64}$ ]]; then
884
- echo "handoff_hash missing or invalid: ${recorded:-null}"; return 1
885
- fi
886
- # Recompute over current sources; flag drift.
887
- local files=()
888
- while IFS= read -r f; do files+=("$f"); done < <(_g_handoff_sources)
889
- actual=$(hash_files_manifest "${files[@]}")
890
- if [ "$actual" != "$recorded" ]; then
891
- echo "OpenSpec artifacts changed after handoff was generated."
892
- echo " recorded: $recorded"
893
- echo " actual: $actual"
894
- echo "Next: rerun comate.sh handoff $G_CHANGE design --write"
895
- return 1
896
- fi
897
- md="${context%.json}.md"
898
- [ -s "$md" ] || { echo "design handoff markdown missing/empty: $md"; return 1; }
899
- return 0
900
- }
901
-
902
- _g_design_handoff_traceable() {
903
- local context md
904
- context=$(yaml_get "$G_YAML" handoff_context)
905
- [ -n "$context" ] && [ "$context" != "null" ] || { echo "handoff_context missing"; return 1; }
906
- md="${context%.json}.md"
907
- [ -s "$md" ] || { echo "handoff markdown missing/empty: $md"; return 1; }
908
- grep -q '^Generated-by: comate$' "$md" || { echo "missing Generated-by marker"; return 1; }
909
- grep -Eq '^- Mode: (compact|full)$' "$md" || { echo "missing Mode marker"; return 1; }
910
- local f rc=0
911
- while IFS= read -r f; do
912
- [ -f "$f" ] || continue
913
- grep -q "^- Source: $f$" "$md" || { echo "missing source reference: $f"; rc=1; }
914
- grep -q "^- SHA256: $(hash_file "$f")$" "$md" || { echo "missing current sha256 for: $f"; rc=1; }
915
- done < <(_g_handoff_sources)
916
- return $rc
917
- }
918
-
919
- # Design Doc frontmatter helper.
920
- _g_frontmatter_has() {
921
- local file="$1" field="$2" expected="$3"
922
- awk '
923
- { line=$0; sub(/^\357\273\277/, "", line) }
924
- !in_fm && line == "---" { in_fm=1; next }
925
- in_fm && line == "---" { exit }
926
- in_fm { print line }
927
- ' "$file" | grep -Eq "^${field}: ['\"]?${expected}['\"]?[[:space:]]*$"
928
- }
929
- _g_design_doc_links_change() {
930
- local dd; dd=$(yaml_get "$G_YAML" design_doc)
931
- [ -n "$dd" ] && [ "$dd" != "null" ] && [ -s "$dd" ] || { echo "design_doc must point to an existing Design Doc"; return 1; }
932
- _g_frontmatter_has "$dd" comate_change "$G_CHANGE" || { echo "Design Doc frontmatter missing comate_change: $G_CHANGE"; return 1; }
933
- }
934
- _g_design_doc_role() {
935
- local dd; dd=$(yaml_get "$G_YAML" design_doc)
936
- [ -n "$dd" ] && [ "$dd" != "null" ] && [ -s "$dd" ] && _g_frontmatter_has "$dd" role technical-design
937
- }
938
- _g_design_doc_canonical() {
939
- local dd; dd=$(yaml_get "$G_YAML" design_doc)
940
- [ -n "$dd" ] && [ "$dd" != "null" ] && [ -s "$dd" ] && _g_frontmatter_has "$dd" canonical_spec openspec
941
- }
942
-
943
- # impl-context drift: verify entry plan+tasks match build exit.
944
- _g_impl_context_written() {
945
- local ctx hash
946
- ctx=$(yaml_get "$G_YAML" impl_context)
947
- hash=$(yaml_get "$G_YAML" impl_hash)
948
- if [ -z "$ctx" ] || [ "$ctx" = "null" ] || [ ! -f "$ctx" ]; then
949
- echo "impl_context not written; run: comate.sh handoff $G_CHANGE build --write"
950
- return 1
951
- fi
952
- if [[ ! "$hash" =~ ^[a-f0-9]{64}$ ]]; then
953
- echo "impl_hash missing or invalid: ${hash:-null}"; return 1
954
- fi
955
- return 0
956
- }
957
- _g_impl_hash_matches() {
958
- local recorded plan actual
959
- recorded=$(yaml_get "$G_YAML" impl_hash)
960
- if [ -z "$recorded" ] || [ "$recorded" = "null" ]; then
961
- echo "impl_hash missing; run: comate.sh handoff $G_CHANGE build --write at end of build"; return 1
962
- fi
963
- plan=$(yaml_get "$G_YAML" plan)
964
- local files=("$G_DIR/tasks.md")
965
- [ -n "$plan" ] && [ "$plan" != "null" ] && [ -f "$plan" ] && files=("$plan" "$G_DIR/tasks.md")
966
- actual=$(hash_files_manifest "${files[@]}")
967
- if [ "$actual" != "$recorded" ]; then
968
- echo "plan/tasks changed between build exit and verify entry."
969
- echo " recorded: $recorded"
970
- echo " actual: $actual"
971
- echo "Next: investigate the edit; rerun build handoff only if the change is intended."
972
- return 1
973
- fi
974
- return 0
975
- }
976
-
977
- guard() {
978
- local name="$1" phase="${2:-}"
979
- validate_change_name "$name"
980
- validate_enum phase "$phase"
981
- [ -n "$phase" ] || die "$COMATE_USAGE" "Usage: comate.sh guard <name> <phase> [--apply] [--json]"
982
-
983
- G_CHANGE="$name"
984
- G_DIR=$(comate_change_dir "$name")
985
- G_YAML="$G_DIR/.comate.yaml"
986
-
987
- # preflight: directory + state file + flat-yaml schema.
988
- [ -d "$G_DIR" ] || die "$COMATE_CORRUPT" "FATAL: change directory not found: $G_DIR"
989
- [ -f "$G_YAML" ] || die "$COMATE_CORRUPT" "FATAL: .comate.yaml not found in $G_DIR"
990
- validate_flat_yaml "$G_YAML"
991
-
992
- [ "$GUARD_JSON" -eq 0 ] && log_info "=== Guard: $phase ==="
993
-
994
- case "$phase" in
995
- open)
996
- _guard_check "proposal.md non-empty" file_nonempty "$G_DIR/proposal.md"
997
- _guard_check "design.md non-empty" file_nonempty "$G_DIR/design.md"
998
- _guard_check "tasks.md non-empty" file_nonempty "$G_DIR/tasks.md"
999
- _guard_check "tasks.md has at least one task" _g_tasks_has_any
1000
- ;;
1001
- design)
1002
- _guard_check "proposal.md non-empty" file_nonempty "$G_DIR/proposal.md"
1003
- _guard_check "design.md non-empty" file_nonempty "$G_DIR/design.md"
1004
- _guard_check "tasks.md non-empty" file_nonempty "$G_DIR/tasks.md"
1005
- _guard_check "design handoff context valid (hash not drifted)" _g_design_handoff_valid
1006
- _guard_check "design handoff markdown traceable" _g_design_handoff_traceable
1007
- _guard_check "source files not drifted" _g_sources_not_drifted
1008
- local dd; dd=$(yaml_get "$G_YAML" design_doc)
1009
- if [ -n "$dd" ] && [ "$dd" != "null" ]; then
1010
- _guard_check "Design Doc exists ($dd)" file_nonempty "$dd"
1011
- _guard_check "Design Doc frontmatter links change" _g_design_doc_links_change
1012
- _guard_check "Design Doc declares technical-design role" _g_design_doc_role
1013
- _guard_check "Design Doc declares canonical_spec: openspec" _g_design_doc_canonical
1014
- else
1015
- # Design Doc is required for full workflow moving to build
1016
- _guard_check "Design Doc recorded (design_doc field)" false
1017
- fi
1018
- ;;
1019
- build)
1020
- _guard_check "isolation selected" _g_isolation_selected
1021
- _guard_check "build_mode selected" _g_build_mode_selected
1022
- _guard_check "build_mode allowed for workflow" _g_build_mode_allowed
1023
- _guard_check "tdd_mode selected" _g_tdd_mode_selected
1024
- _guard_check "tasks.md all tasks checked" _g_tasks_all_done
1025
- _guard_check "proposal.md non-empty" file_nonempty "$G_DIR/proposal.md"
1026
- _guard_check "impl-context written" _g_impl_context_written
1027
- _guard_check "source files not drifted" _g_sources_not_drifted
1028
- _guard_check "build passes" _g_build_passes
1029
- ;;
1030
- verify)
1031
- _guard_check "tasks.md all tasks checked" _g_tasks_all_done
1032
- _guard_check "verify command passes" _g_verify_passes
1033
- _guard_check "verification_report exists" _g_verification_report_exists
1034
- _guard_check "branch_status=handled" _g_branch_status_handled
1035
- _guard_check "impl_hash matches current plan+tasks" _g_impl_hash_matches
1036
- ;;
1037
- archive)
1038
- _guard_check "archived is true" _g_archived_is_true
1039
- _guard_check "proposal.md non-empty" file_nonempty "$G_DIR/proposal.md"
1040
- _guard_check "tasks.md all tasks checked" _g_tasks_all_done
1041
- ;;
1042
- esac
1043
-
1044
- if [ "$GUARD_JSON" -eq 1 ]; then
1045
- local out='{"phase":"'"$phase"'","checks":['
1046
- local first=1 r cname cstatus
1047
- for r in "${GUARD_RESULTS[@]}"; do
1048
- cname="${r%|*}"; cstatus="${r#*|}"
1049
- [ "$first" -eq 0 ] && out+=','
1050
- first=0
1051
- out+='{"name":"'"$(json_escape "$cname")"'","status":"'"$cstatus"'"}'
1052
- done
1053
- out+='],"block":'"$([ "$GUARD_BLOCK" -eq 1 ] && echo true || echo false)"
1054
- out+=',"applied":false}'
1055
- printf '%s\n' "$out"
1056
- if [ "$GUARD_BLOCK" -eq 1 ]; then exit "$COMATE_BLOCK"; fi
1057
- # In JSON mode, still apply if requested (silent).
1058
- if [ "$GUARD_APPLY" -eq 1 ]; then _guard_apply "$phase" >/dev/null 2>&1 || true; fi
1059
- exit "$COMATE_OK"
1060
- fi
1061
-
1062
- echo "" >&2
1063
- if [ "$GUARD_BLOCK" -eq 1 ]; then
1064
- log_fail "BLOCKED — fix failing checks before proceeding to next phase"
1065
- exit "$COMATE_BLOCK"
1066
- fi
1067
- log_pass "ALL CHECKS PASSED — ready for next phase"
1068
- if [ "$GUARD_APPLY" -eq 1 ]; then
1069
- _guard_apply "$phase"
1070
- fi
1071
- exit "$COMATE_OK"
1072
- }
1073
-
1074
- # _guard_apply <phase> — delegate to the matching transition.
1075
- _guard_apply() {
1076
- case "$1" in
1077
- open) state_transition "$G_CHANGE" open-complete ;;
1078
- design) state_transition "$G_CHANGE" design-complete ;;
1079
- build) state_transition "$G_CHANGE" build-complete ;;
1080
- verify) state_transition "$G_CHANGE" verify-pass ;;
1081
- archive) : ;; # archive completion handled by the archive command
1082
- esac
1083
- local np; np=$(yaml_get "$G_YAML" phase)
1084
- log_pass " [APPLY] .comate.yaml updated: phase=$np"
1085
- }
1086
-
1087
- # =====================================================================
1088
- # handoff — machine-owned context packages between phases
1089
- # =====================================================================
1090
-
1091
- # Shared: emit a traceable markdown excerpt for one file.
1092
- _handoff_excerpt() {
1093
- local file="$1" max_lines="$2" mode="$3" total
1094
- total=$(wc -l < "$file" | tr -d ' ')
1095
- echo "## $file"
1096
- echo ""
1097
- echo "- Source: $file"
1098
- echo "- Lines: 1-$total"
1099
- echo "- SHA256: $(hash_file "$file")"
1100
- echo ""
1101
- if [ "$mode" = "full" ] || [ "$total" -le "$max_lines" ]; then
1102
- echo '```md'; cat "$file"; echo '```'
1103
- else
1104
- echo "[TRUNCATED]"; echo ""
1105
- echo '```md'; sed -n "1,${max_lines}p" "$file"; echo '```'
1106
- echo ""; echo "Full source: $file"
1107
- fi
1108
- echo ""
1109
- }
1110
-
1111
- handoff() {
1112
- local name="$1" phase="${2:-}" mode="${3:-}" full="${4:-}"
1113
- validate_change_name "$name"
1114
- local dir yaml
1115
- dir=$(comate_change_dir "$name")
1116
- yaml="$dir/.comate.yaml"
1117
- [ -d "$dir" ] || die "$COMATE_CORRUPT" "ERROR: change directory not found: $dir"
1118
- [ -f "$yaml" ] || die "$COMATE_CORRUPT" "ERROR: .comate.yaml not found at $yaml"
1119
- [ "$mode" = "--write" ] || die "$COMATE_USAGE" "Usage: comate.sh handoff <name> <design|build> --write [--full]"
1120
-
1121
- case "$phase" in
1122
- design) _handoff_design "$name" "$dir" "$yaml" "$full" ;;
1123
- build) _handoff_build "$name" "$dir" "$yaml" ;;
1124
- *) die "$COMATE_USAGE" "Usage: comate.sh handoff <name> <design|build> --write [--full]" ;;
1125
- esac
1126
- }
1127
-
1128
- _handoff_design() {
1129
- local name="$1" dir="$2" yaml="$3" full="$4"
1130
- local hmode="compact"
1131
- case "$full" in
1132
- "") hmode="compact" ;;
1133
- --full) hmode="full" ;;
1134
- *) die "$COMATE_USAGE" "Usage: comate.sh handoff <name> design --write [--full]" ;;
1135
- esac
1136
- [ "$(yaml_get "$yaml" phase)" = "design" ] || die "$COMATE_BLOCK" "ERROR: design handoff requires phase: design"
1137
-
1138
- local f
1139
- for f in proposal.md design.md tasks.md; do
1140
- [ -s "$dir/$f" ] || die "$COMATE_BLOCK" "ERROR: required artifact missing/empty: $dir/$f"
1141
- done
1142
-
1143
- # Source set: proposal/design/tasks + delta specs.
1144
- local files=("$dir/proposal.md" "$dir/design.md" "$dir/tasks.md")
1145
- if [ -d "$dir/specs" ]; then
1146
- while IFS= read -r s; do files+=("$s"); done < <(find "$dir/specs" -path '*/spec.md' -type f 2>/dev/null | sort)
1147
- fi
1148
-
1149
- local hdir="$dir/.comate/handoff"
1150
- mkdir -p "$hdir"
1151
- local cjson="$hdir/design-context.json" cmd="$hdir/design-context.md"
1152
- local chash; chash=$(hash_files_manifest "${files[@]}")
1153
-
1154
- # markdown
1155
- {
1156
- echo "# comate Design Handoff"
1157
- echo ""
1158
- echo "- Change: $name"
1159
- echo "- Phase: design"
1160
- echo "- Mode: $hmode"
1161
- echo "- Context hash: $chash"
1162
- echo ""
1163
- echo "Generated-by: comate"
1164
- echo ""
1165
- echo "OpenSpec remains the canonical capability spec. This handoff is a deterministic, source-traceable context pack, not an agent-authored summary."
1166
- echo ""
1167
- for f in "${files[@]}"; do
1168
- [ -f "$f" ] || continue
1169
- _handoff_excerpt "$f" 80 "$hmode"
1170
- done
1171
- } > "$cmd"
1172
-
1173
- # json
1174
- {
1175
- echo "{"
1176
- echo " \"change\": \"$(json_escape "$name")\","
1177
- echo " \"phase\": \"design\","
1178
- echo " \"mode\": \"$hmode\","
1179
- echo " \"canonical_spec\": \"openspec\","
1180
- echo " \"generated_by\": \"comate\","
1181
- echo " \"context_hash\": \"$chash\","
1182
- echo " \"files\": ["
1183
- local first=1
1184
- for f in "${files[@]}"; do
1185
- [ -f "$f" ] || continue
1186
- [ "$first" -eq 0 ] && echo ","
1187
- first=0
1188
- printf ' { "path": "%s", "sha256": "%s" }' "$(json_escape "$f")" "$(hash_file "$f")"
1189
- done
1190
- echo ""
1191
- echo " ]"
1192
- echo "}"
1193
- } > "$cjson"
1194
-
1195
- yaml_set "$yaml" handoff_context "$cjson"
1196
- yaml_set "$yaml" handoff_hash "$chash"
1197
- log_pass "[HANDOFF] wrote $cjson"
1198
- log_pass "[HANDOFF] wrote $cmd"
1199
- log_pass "[HANDOFF] handoff_hash=$chash"
1200
- }
1201
-
1202
- # build->verify handoff: hash plan + tasks.
1203
- _handoff_build() {
1204
- local name="$1" dir="$2" yaml="$3"
1205
- [ "$(yaml_get "$yaml" phase)" = "build" ] || die "$COMATE_BLOCK" "ERROR: build handoff requires phase: build"
1206
- [ -s "$dir/tasks.md" ] || die "$COMATE_BLOCK" "ERROR: tasks.md missing/empty: $dir/tasks.md"
1207
-
1208
- local plan; plan=$(yaml_get "$yaml" plan)
1209
- local files=("$dir/tasks.md")
1210
- if [ -n "$plan" ] && [ "$plan" != "null" ] && [ -f "$plan" ]; then
1211
- files=("$plan" "$dir/tasks.md")
1212
- fi
1213
-
1214
- local hdir="$dir/.comate/handoff"
1215
- mkdir -p "$hdir"
1216
- local cjson="$hdir/impl-context.json"
1217
- local chash; chash=$(hash_files_manifest "${files[@]}")
1218
-
1219
- {
1220
- echo "{"
1221
- echo " \"change\": \"$(json_escape "$name")\","
1222
- echo " \"phase\": \"build\","
1223
- echo " \"generated_by\": \"comate\","
1224
- echo " \"impl_hash\": \"$chash\","
1225
- echo " \"files\": ["
1226
- local first=1 f
1227
- for f in "${files[@]}"; do
1228
- [ -f "$f" ] || continue
1229
- [ "$first" -eq 0 ] && echo ","
1230
- first=0
1231
- printf ' { "path": "%s", "sha256": "%s" }' "$(json_escape "$f")" "$(hash_file "$f")"
1232
- done
1233
- echo ""
1234
- echo " ]"
1235
- echo "}"
1236
- } > "$cjson"
1237
-
1238
- yaml_set "$yaml" impl_context "$cjson"
1239
- yaml_set "$yaml" impl_hash "$chash"
1240
- log_pass "[HANDOFF] wrote $cjson"
1241
- log_pass "[HANDOFF] impl_hash=$chash"
1242
- }
1243
-
1244
- # =====================================================================
1245
- # sources — knowledge-base provenance for a change
1246
- # =====================================================================
1247
-
1248
- # _sources_pin_ref <yaml> — echo the commit ref to pin a tracked file to:
1249
- # the change's base_ref, falling back to current HEAD, else "null".
1250
- _sources_pin_ref() {
1251
- local yaml="$1" ref
1252
- ref=$(yaml_get "$yaml" base_ref)
1253
- if [ -z "$ref" ] || [ "$ref" = "null" ]; then
1254
- ref=$(git rev-parse --short HEAD 2>/dev/null || echo "null")
1255
- elif git rev-parse --git-dir >/dev/null 2>&1; then
1256
- ref=$(git rev-parse --short "$ref" 2>/dev/null || echo "$ref")
1257
- fi
1258
- echo "$ref"
1259
- }
1260
-
1261
- # sources <name> track <file...> [--note <text>] — record knowledge-base files
1262
- # as the source of this change. Out-of-range files are skipped (caller stays on
1263
- # conversation). Re-tracking a path replaces its prior record.
1264
- sources_track() {
1265
- local name="$1"; shift
1266
- validate_change_name "$name"
1267
- local yaml; yaml=$(require_yaml "$name")
1268
- local manifest; manifest=$(sources_manifest_for "$name")
1269
-
1270
- local note="" files=()
1271
- while [ $# -gt 0 ]; do
1272
- case "$1" in
1273
- --note) note="${2:-}"; shift 2 || shift ;;
1274
- *) files+=("$1"); shift ;;
1275
- esac
1276
- done
1277
- [ "${#files[@]}" -gt 0 ] || die "$COMATE_USAGE" "Usage: comate.sh sources <name> track <file...> [--note <text>]"
1278
-
1279
- local ref; ref=$(_sources_pin_ref "$yaml")
1280
- mkdir -p "$(dirname "$manifest")"
1281
- [ -f "$manifest" ] || : > "$manifest"
1282
-
1283
- local tracked=0 skipped=0 f rel
1284
- for f in "${files[@]}"; do
1285
- if rel=$(validate_kb_path "$f"); then
1286
- _sources_remove_path "$manifest" "$rel"
1287
- {
1288
- [ -s "$manifest" ] && printf -- '---\n'
1289
- printf 'path:%s\n' "$rel"
1290
- printf 'sha256:%s\n' "$(hash_file "$rel")"
1291
- printf 'ref:%s\n' "$ref"
1292
- [ -n "$note" ] && printf 'note:%s\n' "$note"
1293
- } >> "$manifest"
1294
- log_pass "[SOURCES] tracked $rel"
1295
- tracked=$((tracked + 1))
1296
- else
1297
- log_warn "[SOURCES] not in $(comate_kb_root)/ or unreadable, skipped: $f"
1298
- skipped=$((skipped + 1))
1299
- fi
1300
- done
1301
-
1302
- if [ "$tracked" -gt 0 ]; then
1303
- yaml_set "$yaml" source_kind file
1304
- yaml_set "$yaml" source_manifest "$manifest"
1305
- log_pass "[SOURCES] source_kind=file, source_manifest=$manifest"
1306
- else
1307
- log_warn "[SOURCES] no file tracked; source_kind stays conversation"
1308
- fi
1309
- [ "$skipped" -eq 0 ] || return "$COMATE_OK"
1310
- }
1311
-
1312
- # _sources_remove_path <manifest> <path> — drop the record block for one path.
1313
- _sources_remove_path() {
1314
- local manifest="$1" target="$2" tmp
1315
- [ -f "$manifest" ] || return 0
1316
- tmp=$(mktemp "${TMPDIR:-/tmp}/comate.XXXXXX") || die "$COMATE_ENV" "ERROR: mktemp failed"
1317
- awk -v target="$target" '
1318
- function flush() {
1319
- if (block != "" && keep) printf "%s", block
1320
- block=""; keep=1
1321
- }
1322
- BEGIN { keep=1 }
1323
- /^---$/ { flush(); next }
1324
- /^path:/ { if ($0 == "path:" target) keep=0 }
1325
- { block = block $0 "\n" }
1326
- END { flush() }
1327
- ' "$manifest" > "$tmp"
1328
- # Collapse any leading blank lines, then normalise record separators.
1329
- awk 'NF || NR>1 {print}' "$tmp" > "$manifest"
1330
- rm -f "$tmp"
1331
- }
1332
-
1333
- # sources <name> check — drift check; conversation skips. Mirrors guard codes.
1334
- sources_check() {
1335
- local name="$1"
1336
- validate_change_name "$name"
1337
- local yaml; yaml=$(require_yaml "$name")
1338
- local kind; kind=$(yaml_get "$yaml" source_kind)
1339
- if [ "$kind" != "file" ]; then
1340
- log_info "[SOURCES] source_kind=$kind — no source files to check"
1341
- exit "$COMATE_OK"
1342
- fi
1343
- local manifest; manifest=$(yaml_get "$yaml" source_manifest)
1344
- if [ -z "$manifest" ] || [ "$manifest" = "null" ]; then
1345
- die "$COMATE_CORRUPT" "ERROR: source_kind=file but source_manifest is null"
1346
- fi
1347
- local out rc=0
1348
- out=$(sources_drift "$manifest") || rc=$?
1349
- [ -n "$out" ] && printf '%s\n' "$out" >&2
1350
- if [ "$rc" -eq 0 ]; then
1351
- log_pass "[SOURCES] no drift ($manifest)"
1352
- fi
1353
- exit "$rc"
1354
- }
1355
-
1356
- # sources <name> list — print the manifest.
1357
- sources_list() {
1358
- local name="$1"
1359
- validate_change_name "$name"
1360
- local manifest; manifest=$(sources_manifest_for "$name")
1361
- if [ -f "$manifest" ]; then cat "$manifest"; else echo "no source manifest for $name"; fi
1362
- }
1363
-
1364
- # sources find <file> — reverse lookup: which changes reference this file.
1365
- sources_find() {
1366
- local target="$1"
1367
- [ -n "$target" ] || die "$COMATE_USAGE" "Usage: comate.sh sources find <file>"
1368
- local rel="$target"
1369
- if r=$(validate_kb_path "$target" 2>/dev/null); then rel="$r"; fi
1370
- local any=0 m chg
1371
- while IFS= read -r m; do
1372
- if grep -q "^path:$rel$" "$m" 2>/dev/null; then
1373
- chg=$(basename "$(dirname "$(dirname "$m")")")
1374
- echo "$chg ($m)"
1375
- any=1
1376
- fi
1377
- done < <(find openspec/changes -path '*/.comate/sources.manifest' -type f 2>/dev/null | sort)
1378
- [ "$any" -eq 0 ] && echo "no change references: $rel"
1379
- return 0
1380
- }
1381
-
1382
- # dependency <name> archived — prove completion from archived state, rather
1383
- # than inferring it from absence in `openspec list`.
1384
- dependency_archived() {
1385
- local name="$1"
1386
- validate_change_name "$name"
1387
- [ ! -d "openspec/changes/$name" ] || \
1388
- die "$COMATE_BLOCK" "BLOCK: dependency '$name' is still active"
1389
- local found="" yaml
1390
- while IFS= read -r yaml; do
1391
- [ "$(yaml_get "$yaml" archived)" = "true" ] && found="$yaml"
1392
- done < <(find openspec/changes/archive -mindepth 2 -maxdepth 2 \
1393
- -path "*/????-??-??-${name}/.comate.yaml" -type f 2>/dev/null | sort)
1394
- [ -n "$found" ] || \
1395
- die "$COMATE_BLOCK" "BLOCK: no archived=true record found for dependency '$name'"
1396
- log_pass "[DEPENDENCY] archived: $name ($found)"
1397
- }
1398
-
1399
- # =====================================================================
1400
- # gate — phase 0: PRD quality gate + split precondition
1401
- # =====================================================================
1402
-
1403
- # require_prd <prd> — ensure the PRD path is real, non-empty, and conventionally named.
1404
- validate_prd_filename() {
1405
- local prd="$1" base
1406
- base=$(basename "$prd")
1407
- case "$base" in
1408
- PRD-[0-9][0-9][0-9]-?*.md) return 0 ;;
1409
- *) die "$COMATE_USAGE" "ERROR: PRD filename must match PRD-NNN-title.md: $base" ;;
1410
- esac
1411
- }
1412
-
1413
- require_prd() {
1414
- local prd="$1"
1415
- [ -n "$prd" ] || die "$COMATE_USAGE" "ERROR: PRD path required"
1416
- case "$prd" in *..*) die "$COMATE_USAGE" "ERROR: PRD path cannot contain '..': $prd" ;; esac
1417
- [ -f "$prd" ] || die "$COMATE_CORRUPT" "ERROR: PRD not found: $prd"
1418
- file_nonempty "$prd" || die "$COMATE_CORRUPT" "ERROR: PRD is empty: $prd"
1419
- validate_prd_filename "$prd"
1420
- echo "$prd"
1421
- }
1422
-
1423
- # gate <prd> review — create or refresh gate.yaml next to the PRD and pin the
1424
- # current PRD hash. Existing passed gates are re-opened to `reviewing` only when
1425
- # the PRD changed (hash drift); otherwise the review report path is (re)emitted
1426
- # so the skill knows where to write its human-readable audit. The skill does the
1427
- # semantic scoring and writes score/blocking_open/gate_status via `gate set`.
1428
- gate_review() {
1429
- local prd; prd=$(require_prd "$1")
1430
- local gate report cur_hash
1431
- gate=$(gate_path_for "$prd")
1432
- report=$(gate_report_for "$prd")
1433
- cur_hash=$(hash_file "$prd")
1434
-
1435
- if [ ! -f "$gate" ]; then
1436
- cat > "$gate" <<EOF
1437
- # comate PRD gate — flat key:value only, no nesting
1438
- schema_version: $GATE_SCHEMA_VERSION
1439
- prd_path: $prd
1440
- prd_hash: $cur_hash
1441
- gate_status: reviewing
1442
- score: null
1443
- blocking_open: null
1444
- review_report: $report
1445
- spawned_changes: null
1446
- changes_map: null
1447
- gated_at: null
1448
- created_at: $(today)
1449
- EOF
1450
- log_pass "[GATE] created $gate (status=reviewing)"
1451
- else
1452
- local old_hash old_status
1453
- old_hash=$(yaml_get "$gate" prd_hash)
1454
- old_status=$(yaml_get "$gate" gate_status)
1455
- gate_yaml_set "$gate" prd_path "$prd"
1456
- gate_yaml_set "$gate" review_report "$report"
1457
- if [ "$old_hash" != "$cur_hash" ]; then
1458
- gate_yaml_set "$gate" prd_hash "$cur_hash"
1459
- gate_yaml_set "$gate" gate_status reviewing
1460
- log_warn "[GATE] PRD changed since last review — status reset to reviewing (was $old_status)"
1461
- else
1462
- log_info "[GATE] $gate (status=$old_status, unchanged PRD)"
1463
- fi
1464
- fi
1465
- echo "review_report: $report"
1466
- }
1467
-
1468
- # gate <prd> set <field> <value> — write a gate field (score/blocking_open/
1469
- # gate_status/…) with the same validation as gate_yaml_set.
1470
- gate_set() {
1471
- local prd; prd=$(require_prd "$1")
1472
- local field="${2:-}" value="${3:-}"
1473
- [ -n "$field" ] || die "$COMATE_USAGE" "Usage: comate.sh gate <prd> set <field> <value>"
1474
- local gate; gate=$(gate_path_for "$prd")
1475
- [ -f "$gate" ] || die "$COMATE_CORRUPT" "ERROR: gate not found (run 'gate review' first): $gate"
1476
- if [ "$field" = "changes_map" ]; then
1477
- gate_validate_changes_map "$value"
1478
- local current_map current_status
1479
- current_map=$(yaml_get "$gate" changes_map)
1480
- current_status=$(yaml_get "$gate" gate_status)
1481
- case "$current_status" in
1482
- passed|passed-with-tbd)
1483
- [ "$value" = "$current_map" ] || \
1484
- die "$COMATE_BLOCK" "BLOCK: changes_map is immutable after gate pass; re-run gate review before remapping"
1485
- ;;
1486
- esac
1487
- fi
1488
- gate_yaml_set "$gate" "$field" "$value"
1489
- log_pass "[GATE] set $field=$value"
1490
- }
1491
-
1492
- # gate_validate_changes_map <changes_map> — reject malformed entries and
1493
- # duplicate names when the user-confirmed mapping is first persisted.
1494
- gate_validate_changes_map() {
1495
- local map="$1" rest entry name items seen=""
1496
- [ -n "$map" ] && [ "$map" != "null" ] || \
1497
- die "$COMATE_USAGE" "ERROR: changes_map cannot be empty"
1498
- case "$map" in
1499
- \;*|*\;\;*|*\;) die "$COMATE_USAGE" "ERROR: changes_map contains an empty entry" ;;
1500
- esac
1501
- rest="$map"
1502
- while [ -n "$rest" ]; do
1503
- case "$rest" in
1504
- *\;*) entry="${rest%%;*}"; rest="${rest#*;}" ;;
1505
- *) entry="$rest"; rest="" ;;
1506
- esac
1507
- name="${entry%%:*}"
1508
- items="${entry#*:}"
1509
- [ -n "$name" ] && [ "$name" != "$entry" ] || \
1510
- die "$COMATE_USAGE" "ERROR: malformed changes_map entry '$entry'"
1511
- [ -n "$(printf '%s' "$items" | tr -d '[:space:]')" ] || \
1512
- die "$COMATE_USAGE" "ERROR: empty function mapping for change '$name'"
1513
- validate_change_name "$name"
1514
- case ",$seen," in
1515
- *",$name,"*) die "$COMATE_USAGE" "ERROR: duplicate change '$name' in changes_map" ;;
1516
- esac
1517
- [ -n "$seen" ] && seen="$seen,"
1518
- seen="$seen$name"
1519
- done
1520
- }
1521
-
1522
- # gate <prd> pass [--tbd] — flip to passed/passed-with-tbd. Normal pass requires
1523
- # zero blockers; --tbd requires at least one explicitly accepted blocker.
1524
- gate_pass() {
1525
- local prd; prd=$(require_prd "$1")
1526
- local tbd=0
1527
- [ "${2:-}" = "--tbd" ] && tbd=1
1528
- local gate; gate=$(gate_path_for "$prd")
1529
- [ -f "$gate" ] || die "$COMATE_CORRUPT" "ERROR: gate not found (run 'gate review' first): $gate"
1530
-
1531
- local score blocking changes_map
1532
- score=$(yaml_get "$gate" score)
1533
- blocking=$(yaml_get "$gate" blocking_open)
1534
- changes_map=$(yaml_get "$gate" changes_map)
1535
- [[ "$score" =~ ^[0-9]+$ ]] && [ "$score" -ge 0 ] && [ "$score" -le 100 ] || \
1536
- die "$COMATE_BLOCK" "BLOCK: score must be an integer from 0 to 100 (got '$score')"
1537
- [[ "$blocking" =~ ^[0-9]+$ ]] || \
1538
- die "$COMATE_BLOCK" "BLOCK: blocking_open must be a non-negative integer (got '$blocking')"
1539
- if [ "$tbd" -eq 1 ]; then
1540
- [ "$blocking" -gt 0 ] || \
1541
- die "$COMATE_BLOCK" "BLOCK: --tbd requires at least one recorded unresolved blocker"
1542
- elif [ "$blocking" -ne 0 ]; then
1543
- die "$COMATE_BLOCK" "BLOCK: blocking_open='$blocking' — resolve all blocking issues or use --tbd after explicit user authorization"
1544
- fi
1545
- if [ -z "$changes_map" ] || [ "$changes_map" = "null" ]; then
1546
- die "$COMATE_BLOCK" "BLOCK: changes_map is required before passing the gate"
1547
- fi
1548
- gate_validate_changes_map "$changes_map"
1549
- local report; report=$(yaml_get "$gate" review_report)
1550
- if [ -z "$report" ] || [ "$report" = "null" ] || ! file_nonempty "$report"; then
1551
- die "$COMATE_BLOCK" "BLOCK: review report missing or empty ($report) — the gate report is mandatory"
1552
- fi
1553
-
1554
- gate_yaml_set "$gate" prd_hash "$(hash_file "$prd")"
1555
- if [ "$tbd" -eq 1 ]; then
1556
- gate_yaml_set "$gate" gate_status passed-with-tbd
1557
- else
1558
- gate_yaml_set "$gate" gate_status passed
1559
- fi
1560
- gate_yaml_set "$gate" gated_at "$(today)"
1561
- log_pass "[GATE] $(yaml_get "$gate" gate_status) — changes may now be spawned from $prd"
1562
- }
1563
-
1564
- # gate <prd> spawned <change> — append a change name to spawned_changes
1565
- # (comma-joined), recording which changes this PRD fanned out into.
1566
- gate_spawned() {
1567
- local prd; prd=$(require_prd "$1")
1568
- local change="${2:-}"
1569
- validate_change_name "$change"
1570
- local gate; gate=$(gate_path_for "$prd")
1571
- [ -f "$gate" ] || die "$COMATE_CORRUPT" "ERROR: gate not found: $gate"
1572
- local changes_map; changes_map=$(yaml_get "$gate" changes_map)
1573
- gate_change_is_mapped "$changes_map" "$change" || \
1574
- die "$COMATE_BLOCK" "BLOCK: change '$change' is not present in changes_map"
1575
- local cur; cur=$(yaml_get "$gate" spawned_changes)
1576
- case ",$cur," in
1577
- *",$change,"*) log_info "[GATE] $change already recorded" ; return 0 ;;
1578
- esac
1579
- gate_check "$prd" >/dev/null
1580
- gate_require_skeleton "$prd" "$change"
1581
- if [ -z "$cur" ] || [ "$cur" = "null" ]; then
1582
- gate_yaml_set "$gate" spawned_changes "$change"
1583
- else
1584
- gate_yaml_set "$gate" spawned_changes "$cur,$change"
1585
- fi
1586
- log_pass "[GATE] recorded spawned change: $change"
1587
- }
1588
-
1589
- # gate_require_skeleton <prd> <change> — prove that comate-open finished the
1590
- # gate skeleton before it is counted as spawned.
1591
- gate_require_skeleton() {
1592
- local prd="$1" change="$2" dir yaml manifest tracked_prd f
1593
- dir=$(comate_change_dir "$change")
1594
- for f in .openspec.yaml proposal.md design.md tasks.md .comate.yaml; do
1595
- file_nonempty "$dir/$f" || \
1596
- die "$COMATE_BLOCK" "BLOCK: change '$change' skeleton incomplete: missing or empty $dir/$f"
1597
- done
1598
- yaml="$dir/.comate.yaml"
1599
- validate_flat_yaml "$yaml"
1600
- [ "$(yaml_get "$yaml" phase)" = "open" ] || \
1601
- die "$COMATE_BLOCK" "BLOCK: change '$change' skeleton must remain in phase=open"
1602
- [ "$(yaml_get "$yaml" source_kind)" = "file" ] || \
1603
- die "$COMATE_BLOCK" "BLOCK: change '$change' skeleton has not tracked its PRD source"
1604
- manifest=$(yaml_get "$yaml" source_manifest)
1605
- [ -n "$manifest" ] && [ "$manifest" != "null" ] && [ -f "$manifest" ] || \
1606
- die "$COMATE_BLOCK" "BLOCK: change '$change' source manifest is missing"
1607
- tracked_prd=$(validate_kb_path "$prd") || \
1608
- die "$COMATE_BLOCK" "BLOCK: PRD source path is outside the knowledge base: $prd"
1609
- local record_ok=0 line record_path="" record_hash=""
1610
- while IFS= read -r line || [ -n "$line" ]; do
1611
- case "$line" in
1612
- path:*) record_path="${line#path:}"; record_hash="" ;;
1613
- sha256:*)
1614
- record_hash="${line#sha256:}"
1615
- if [ "$record_path" = "$tracked_prd" ] && [ -n "$record_hash" ]; then record_ok=1; fi
1616
- ;;
1617
- ---|"") record_path=""; record_hash="" ;;
1618
- esac
1619
- done < "$manifest"
1620
- [ "$record_ok" -eq 1 ] || \
1621
- die "$COMATE_BLOCK" "BLOCK: change '$change' source manifest lacks a complete path/sha256 record for $tracked_prd"
1622
- local drift rc=0
1623
- drift=$(sources_drift "$manifest") || rc=$?
1624
- [ "$rc" -eq 0 ] || die "$COMATE_BLOCK" "BLOCK: change '$change' source evidence is stale: $drift"
1625
- }
1626
-
1627
- # gate_change_names <changes_map> — emit mapped change names, one per line.
1628
- # The persisted format is `name:function item,...;name:function item,...`.
1629
- gate_change_names() {
1630
- local map="$1" entry name
1631
- [ -n "$map" ] && [ "$map" != "null" ] || return 0
1632
- gate_validate_changes_map "$map"
1633
- while [ -n "$map" ]; do
1634
- case "$map" in
1635
- *\;*) entry="${map%%;*}"; map="${map#*;}" ;;
1636
- *) entry="$map"; map="" ;;
1637
- esac
1638
- name="${entry%%:*}"
1639
- [ -n "$name" ] || die "$COMATE_USAGE" "ERROR: malformed changes_map entry '$entry'"
1640
- [ "$name" != "$entry" ] || die "$COMATE_USAGE" "ERROR: malformed changes_map entry '$entry'"
1641
- validate_change_name "$name"
1642
- printf '%s\n' "$name"
1643
- done
1644
- }
1645
-
1646
- # gate_change_is_mapped <changes_map> <change> — membership test for a mapped name.
1647
- gate_change_is_mapped() {
1648
- local map="$1" wanted="$2" name
1649
- gate_validate_changes_map "$map"
1650
- while IFS= read -r name; do
1651
- [ "$name" = "$wanted" ] && return 0
1652
- done <<EOF
1653
- $(gate_change_names "$map")
1654
- EOF
1655
- return 1
1656
- }
1657
-
1658
- # gate_fanout_status <prd> [--json] — calculate persisted fanout progress.
1659
- # This is deliberately derived from changes_map and spawned_changes so an
1660
- # interrupted run can resume from the missing names instead of relying on
1661
- # conversation/task state.
1662
- gate_fanout_status() {
1663
- local prd; prd=$(require_prd "$1")
1664
- local json="${2:-}" gate; gate=$(gate_path_for "$prd")
1665
- [ -f "$gate" ] || die "$COMATE_CORRUPT" "ERROR: gate not found: $gate"
1666
- local map spawned name expected=0 count=0 missing="" invalid=""
1667
- map=$(yaml_get "$gate" changes_map); spawned=$(yaml_get "$gate" spawned_changes)
1668
- gate_validate_changes_map "$map"
1669
- while IFS= read -r name; do
1670
- [ -n "$name" ] || continue
1671
- expected=$((expected + 1))
1672
- case ",$spawned," in
1673
- *",$name,"*) count=$((count + 1)) ;;
1674
- *) [ -n "$missing" ] && missing="$missing,"; missing="$missing$name" ;;
1675
- esac
1676
- done <<EOF
1677
- $(gate_change_names "$map")
1678
- EOF
1679
- if [ -n "$spawned" ] && [ "$spawned" != "null" ]; then
1680
- local rest="$spawned" item
1681
- while [ -n "$rest" ]; do
1682
- case "$rest" in
1683
- *,*) item="${rest%%,*}"; rest="${rest#*,}" ;;
1684
- *) item="$rest"; rest="" ;;
1685
- esac
1686
- gate_change_is_mapped "$map" "$item" || { [ -n "$invalid" ] && invalid="$invalid,"; invalid="$invalid$item"; }
1687
- done
1688
- fi
1689
- local complete=false
1690
- [ "$expected" -gt 0 ] && [ "$count" -eq "$expected" ] && [ -z "$invalid" ] && complete=true
1691
- if [ "$json" = "--json" ]; then
1692
- printf '{"expected":%s,"spawned":%s,"complete":%s,"missing":"%s","invalid":"%s"}\n' \
1693
- "$expected" "$count" "$complete" "$(json_escape "$missing")" "$(json_escape "$invalid")"
1694
- else
1695
- [ -z "$missing" ] && missing=none
1696
- [ -z "$invalid" ] && invalid=none
1697
- echo "fanout: expected=$expected spawned=$count complete=$complete missing=$missing invalid=$invalid"
1698
- fi
1699
- }
1700
-
1701
- # _gate_is_stale <gate> <prd> — true (0) if the recorded hash no longer matches
1702
- # the PRD on disk.
1703
- _gate_is_stale() {
1704
- local gate="$1" prd="$2" recorded actual
1705
- recorded=$(yaml_get "$gate" prd_hash)
1706
- [ -f "$prd" ] || return 0
1707
- actual=$(hash_file "$prd")
1708
- [ "$recorded" != "$actual" ]
1709
- }
1710
-
1711
- # gate <prd> status [--json] — print current gate status, computing stale on the
1712
- # fly (hash drift). Does not mutate the file.
1713
- gate_status() {
1714
- local prd="$1" json="${2:-}"
1715
- [ -n "$prd" ] || die "$COMATE_USAGE" "Usage: comate.sh gate <prd> status [--json]"
1716
- local gate; gate=$(gate_path_for "$prd")
1717
- if [ ! -f "$gate" ]; then
1718
- if [ "$json" = "--json" ]; then
1719
- printf '{"gate":"none","prd":"%s"}\n' "$(json_escape "$prd")"
1720
- else
1721
- echo "gate: none ($prd) — run 'gate review' to start"
1722
- fi
1723
- return "$COMATE_OK"
1724
- fi
1725
- local st score blocking; st=$(yaml_get "$gate" gate_status)
1726
- score=$(yaml_get "$gate" score); blocking=$(yaml_get "$gate" blocking_open)
1727
- if [ -f "$prd" ] && _gate_is_stale "$gate" "$prd"; then st="stale"; fi
1728
- if [ "$json" = "--json" ]; then
1729
- printf '{"gate":"%s","status":"%s","score":"%s","blocking_open":"%s","report":"%s"}\n' \
1730
- "$(json_escape "$gate")" "$(json_escape "$st")" "$(json_escape "$score")" \
1731
- "$(json_escape "$blocking")" "$(json_escape "$(yaml_get "$gate" review_report)")"
1732
- else
1733
- echo "gate: $st score=$score blocking_open=$blocking ($gate)"
1734
- fi
1735
- return "$COMATE_OK"
1736
- }
1737
-
1738
- # gate <prd> check — the precondition guard comate-open runs before spawning a
1739
- # change. PASS (exit 0) requires: gate.yaml exists, status ∈ {passed,
1740
- # passed-with-tbd}, and prd_hash matches the current PRD. Anything else BLOCKs.
1741
- gate_check() {
1742
- local prd; prd=$(require_prd "$1")
1743
- local gate; gate=$(gate_path_for "$prd")
1744
- if [ ! -f "$gate" ]; then
1745
- die "$COMATE_BLOCK" "BLOCK: no gate for $prd — run /comate-gate to review the PRD before creating a change"
1746
- fi
1747
- local st; st=$(yaml_get "$gate" gate_status)
1748
- case "$st" in
1749
- passed|passed-with-tbd) ;;
1750
- *) die "$COMATE_BLOCK" "BLOCK: gate_status='$st' (not passed) — a change cannot be spawned until the PRD gate passes" ;;
1751
- esac
1752
- if _gate_is_stale "$gate" "$prd"; then
1753
- die "$COMATE_BLOCK" "BLOCK: PRD changed since it passed the gate (hash mismatch) — re-run /comate-gate to re-gate before spawning a change"
1754
- fi
1755
- log_pass "[GATE] check PASS ($st) — $prd"
1756
- return "$COMATE_OK"
1757
- }
1758
-
1759
- # =====================================================================
1760
- # archive
1761
- # =====================================================================
1762
-
1763
- archive() {
1764
- local name="$1" dry="${2:-}"
1765
- validate_change_name "$name"
1766
- local dry_run=0
1767
- [ "$dry" = "--dry-run" ] && dry_run=1
1768
-
1769
- local dir="openspec/changes/$name"
1770
- local yaml="$dir/.comate.yaml"
1771
- local today archive_name archive_dir
1772
- today=$(today)
1773
- archive_name="${today}-${name}"
1774
- archive_dir="openspec/changes/archive/${archive_name}"
1775
-
1776
- [ -f "$yaml" ] || die "$COMATE_CORRUPT" "FATAL: .comate.yaml not found in $dir"
1777
- validate_flat_yaml "$yaml"
1778
-
1779
- local phase verify_result archived design_doc plan
1780
- phase=$(yaml_get "$yaml" phase)
1781
- verify_result=$(yaml_get "$yaml" verify_result)
1782
- archived=$(yaml_get "$yaml" archived)
1783
- design_doc=$(yaml_get "$yaml" design_doc)
1784
- plan=$(yaml_get "$yaml" plan)
1785
-
1786
- [ "$phase" = "archive" ] || die "$COMATE_BLOCK" "FATAL: phase is '$phase', expected 'archive'"
1787
- [ "$verify_result" = "pass" ] || die "$COMATE_BLOCK" "FATAL: verify_result is '$verify_result', expected 'pass'"
1788
- [ "$archived" != "true" ] || die "$COMATE_BLOCK" "FATAL: change already archived"
1789
- [ ! -d "$archive_dir" ] || die "$COMATE_BLOCK" "FATAL: archive target already exists: $archive_dir"
1790
-
1791
- local steps_ok=0 steps_total=0
1792
- _step_ok() { log_pass " [OK] $1"; steps_ok=$((steps_ok+1)); steps_total=$((steps_total+1)); }
1793
- _step_dry() { log_warn " [DRY-RUN] $1"; steps_ok=$((steps_ok+1)); steps_total=$((steps_total+1)); }
1794
- _step_fail(){ log_fail " [FAIL] $1"; steps_total=$((steps_total+1)); }
1795
-
1796
- log_info "=== comate Archive: $name ==="
1797
- _step_ok "Entry state verified (phase=archive, verify_result=pass)"
1798
-
1799
- # OpenSpec owns delta-spec merge semantics. Do not copy change specs over
1800
- # main specs directly; delta specs may contain ADDED/MODIFIED sections rather
1801
- # than a complete canonical spec.
1802
- local has_delta_specs=0
1803
- if [ -d "$dir/specs" ] && find "$dir/specs" -path '*/spec.md' -type f -print -quit 2>/dev/null | grep -q .; then
1804
- has_delta_specs=1
1805
- fi
1806
-
1807
- # Annotate frontmatter (design doc + plan), and rewrite any body references to
1808
- # the change's pre-archive OpenSpec path so they survive the move (class B).
1809
- # NOTE: design_doc/plan live under docs/superpowers and do NOT move with the
1810
- # change. Annotation runs AFTER openspec has moved the change, using the REAL
1811
- # archive name discovered from disk — comate must not guess the dir name, as a
1812
- # locally-computed date can skew vs openspec's across a UTC/local midnight.
1813
- local old_prefix="openspec/changes/$name/"
1814
- local new_prefix="" # set to real archive path after the move
1815
- local ann_archive_name="" # set to real archive name after the move
1816
- _annotate() {
1817
- local file="$1" extra="$2"
1818
- [ -f "$file" ] || return 0
1819
- if [ "$dry_run" -eq 1 ]; then _step_dry "Would annotate: $file"; return 0; fi
1820
- local tmp; tmp=$(mktemp)
1821
- if head -1 "$file" | grep -q '^---'; then
1822
- awk -v archive="$ann_archive_name" -v extra="$extra" \
1823
- -v oldp="$old_prefix" -v newp="$new_prefix" '
1824
- /^archived-with:/ { next }
1825
- NR==1 && /^---/ { print; next }
1826
- /^---/ && NR>1 && fm==0 { print "archived-with: " archive; if (extra != "") print extra; fm=1; print; next }
1827
- fm==1 { gsub(oldp, newp) } 1
1828
- ' "$file" > "$tmp"
1829
- else
1830
- {
1831
- echo "---"; echo "archived-with: $ann_archive_name"; [ -n "$extra" ] && echo "$extra"; echo "status: final"; echo "---"
1832
- sed "s#$old_prefix#$new_prefix#g" "$file"
1833
- } > "$tmp"
1834
- fi
1835
- mv "$tmp" "$file"
1836
- _step_ok "Annotated: $file"
1837
- }
1838
- # Dry-run previews annotation up front (real name not needed for the message).
1839
- if [ "$dry_run" -eq 1 ]; then
1840
- [ -n "$design_doc" ] && [ "$design_doc" != "null" ] && _annotate "$design_doc" "status: final"
1841
- [ -n "$plan" ] && [ "$plan" != "null" ] && _annotate "$plan" ""
1842
- fi
1843
-
1844
- # Delegate spec updates and change movement to OpenSpec.
1845
- if [ "$dry_run" -eq 1 ]; then
1846
- if [ "$has_delta_specs" -eq 1 ]; then
1847
- _step_dry "Would run: openspec archive $name -y"
1848
- else
1849
- _step_dry "Would run: openspec archive $name -y --skip-specs"
1850
- fi
1851
- else
1852
- command -v openspec >/dev/null 2>&1 || die "$COMATE_ENV" "FATAL: openspec CLI not found; install OpenSpec before archiving"
1853
- if [ "$has_delta_specs" -eq 1 ]; then
1854
- openspec archive "$name" -y
1855
- _step_ok "OpenSpec archived change and updated main specs"
1856
- else
1857
- openspec archive "$name" -y --skip-specs
1858
- _step_ok "OpenSpec archived change (no delta specs)"
1859
- fi
1860
- fi
1861
-
1862
- # Mark archived via transition. OpenSpec normally uses YYYY-MM-DD-<name>,
1863
- # but fall back to locating the moved state file if that ever changes.
1864
- if [ "$dry_run" -eq 1 ]; then
1865
- _step_dry "Would set archived: true"
1866
- else
1867
- local moved_archive_name="$archive_name"
1868
- if [ ! -f "$archive_dir/.comate.yaml" ]; then
1869
- local found
1870
- 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)
1871
- if [ -n "$found" ]; then
1872
- moved_archive_name=$(basename "$(dirname "$found")")
1873
- archive_dir=$(dirname "$found")
1874
- fi
1875
- fi
1876
- if [ -f "$archive_dir/.comate.yaml" ]; then
1877
- state_transition "$moved_archive_name" archived >/dev/null
1878
- _step_ok "archived: true"
1879
- # Now that the change has moved and the REAL archive name is known, annotate
1880
- # design doc + plan (they stay under docs/superpowers; openspec never moved
1881
- # them). Using the discovered name avoids any local-vs-openspec date skew.
1882
- ann_archive_name="$moved_archive_name"
1883
- new_prefix="$archive_dir/"
1884
- [ -n "$design_doc" ] && [ "$design_doc" != "null" ] && _annotate "$design_doc" "status: final"
1885
- [ -n "$plan" ] && [ "$plan" != "null" ] && _annotate "$plan" ""
1886
- # Rewrite self-pointers that moved with the change (class A): fields under
1887
- # the change's own .comate/ dir become stale after the move. design_doc /
1888
- # plan live under docs/superpowers and do not move, so leave them alone.
1889
- local moved_yaml="$archive_dir/.comate.yaml" k v
1890
- local actual_old="openspec/changes/$name/"
1891
- local actual_new="$archive_dir/"
1892
- for k in source_manifest handoff_context impl_context; do
1893
- v=$(yaml_get "$moved_yaml" "$k")
1894
- case "$v" in
1895
- "$actual_old"*)
1896
- yaml_set "$moved_yaml" "$k" "$actual_new${v#"$actual_old"}"
1897
- _step_ok "Repointed $k -> archive path"
1898
- ;;
1899
- esac
1900
- done
1901
- else
1902
- _step_fail "archived: true (.comate.yaml not found after move)"
1903
- fi
1904
- fi
1905
-
1906
- echo "" >&2
1907
- if [ "$dry_run" -eq 1 ]; then
1908
- log_warn "Dry run complete. $steps_ok/$steps_total steps would succeed."
1909
- else
1910
- log_pass "Archive complete. $steps_ok/$steps_total steps succeeded."
1911
- fi
1912
- if [ "$steps_ok" -lt "$steps_total" ]; then exit "$COMATE_BLOCK"; fi
1913
- exit "$COMATE_OK"
1914
- }
1915
-
1916
- # =====================================================================
1917
- # status — list active changes + next command
1918
- # =====================================================================
1919
-
1920
- _next_command() {
1921
- case "$1" in
1922
- open) echo "/comate-open" ;;
1923
- design) echo "/comate-design" ;;
1924
- build) echo "/comate-build" ;;
1925
- verify) echo "/comate-verify" ;;
1926
- archive) echo "/comate-archive" ;;
1927
- *) echo "" ;;
1928
- esac
1929
- }
1930
-
1931
- status() {
1932
- local base="${1:-.}"
1933
- local changes_dir="$base/openspec/changes"
1934
- if [ ! -d "$changes_dir" ]; then
1935
- echo "No active changes."
1936
- return 0
1937
- fi
1938
- local any=0 d name yaml phase phase_step workflow build_mode vr dd plan total done tasks_file
1939
- for d in "$changes_dir"/*/; do
1940
- [ -d "$d" ] || continue
1941
- name=$(basename "$d")
1942
- [ "$name" = "archive" ] && continue
1943
- yaml="$d/.comate.yaml"
1944
- [ -f "$yaml" ] || continue
1945
- [ "$(yaml_get "$yaml" archived)" = "true" ] && continue
1946
- any=1
1947
- phase=$(yaml_get "$yaml" phase)
1948
- phase_step=$(yaml_get "$yaml" phase_step)
1949
- workflow=$(yaml_get "$yaml" workflow)
1950
- build_mode=$(yaml_get "$yaml" build_mode)
1951
- vr=$(yaml_get "$yaml" verify_result)
1952
- dd=$(yaml_get "$yaml" design_doc)
1953
- plan=$(yaml_get "$yaml" plan)
1954
- tasks_file="$d/tasks.md"
1955
- total=0; done=0
1956
- if [ -f "$tasks_file" ]; then
1957
- total=$(grep -c '^- \[' "$tasks_file" 2>/dev/null || echo 0)
1958
- done=$(grep -c '^- \[x\]' "$tasks_file" 2>/dev/null || echo 0)
1959
- fi
1960
- echo "• $name [phase: $phase, $done/$total tasks]"
1961
- echo " workflow: $workflow | build_mode: $build_mode"
1962
- [ -n "$phase_step" ] && [ "$phase_step" != "null" ] && echo " step: $phase_step"
1963
- [ -n "$dd" ] && [ "$dd" != "null" ] && echo " design: $dd"
1964
- [ -n "$plan" ] && [ "$plan" != "null" ] && echo " plan: $plan"
1965
- [ "$phase" = "verify" ] && echo " verify_result: $vr"
1966
- echo " next: $(_next_command "$phase")"
1967
- echo ""
1968
- done
1969
- [ "$any" -eq 0 ] && echo "No active changes."
1970
- return 0
1971
- }
1972
-
1973
- # =====================================================================
1974
- # doctor — prerequisite self-check
1975
- # =====================================================================
1976
-
1977
- doctor() {
1978
- local fail=0 warn=0
1979
- _d_pass() { log_pass " ✓ $1"; }
1980
- _d_warn() { log_warn " ⚠ $1"; warn=$((warn+1)); }
1981
- _d_fail() { log_fail " ✗ $1"; fail=$((fail+1)); }
1982
-
1983
- log_info "comate doctor"
1984
-
1985
- # The state machine is written for Bash 3.2-compatible syntax.
1986
- if [ "${BASH_VERSINFO[0]:-0}" -ge 3 ]; then
1987
- _d_pass "bash ${BASH_VERSION} (>= 3)"
1988
- else
1989
- _d_fail "bash ${BASH_VERSION:-unknown} — comate needs bash >= 3"
1990
- fi
1991
-
1992
- # git.
1993
- if command -v git >/dev/null 2>&1; then _d_pass "git present"; else _d_fail "git not found — install git"; fi
1994
-
1995
- # sha256 tool.
1996
- if command -v sha256sum >/dev/null 2>&1; then _d_pass "sha256sum present"
1997
- elif command -v shasum >/dev/null 2>&1; then _d_pass "shasum present"
1998
- else _d_fail "neither sha256sum nor shasum found — install coreutils or perl shasum"; fi
1999
-
2000
- # openspec CLI.
2001
- if command -v openspec >/dev/null 2>&1; then
2002
- _d_pass "openspec CLI present ($(openspec --version 2>/dev/null | head -1 || echo installed))"
2003
- else
2004
- _d_warn "openspec CLI not found — install: npm install -g @fission-ai/openspec@latest"
2005
- fi
2006
-
2007
- # Superpowers skill directory — check known skill roots only (bounded; an
2008
- # unbounded $HOME scan is too slow on large home directories). The skills CLI
2009
- # (including `claude codeskills add`) installs Superpowers as a flat bundle of
2010
- # named skills (for example using-superpowers/SKILL.md), so looking only for
2011
- # a directory named "superpowers" produces a false warning.
2012
- local sp_found=0 root
2013
- for root in \
2014
- "./.agents/skills" "./.claude/skills" "./.cursor/skills" "./.codex/skills" \
2015
- "$HOME/.agents/skills" "$HOME/.claude/skills" "$HOME/.config/skills" "$HOME/.cursor/skills" "$HOME/.codex/skills" "$HOME/.gemini/skills" \
2016
- "./.claude/plugins" "$HOME/.claude/plugins"; do
2017
- if [ -f "$root/using-superpowers/SKILL.md" ] || \
2018
- [ -f "$root/brainstorming/SKILL.md" ] || \
2019
- { [ -d "$root" ] && find "$root" -maxdepth 3 -type d -name 'superpowers*' -print -quit 2>/dev/null | grep -q .; }; then
2020
- sp_found=1; break
2021
- fi
2022
- done
2023
- if [ "$sp_found" -eq 1 ]; then
2024
- _d_pass "Superpowers skills found"
2025
- else
2026
- _d_warn "Superpowers skills not found in known roots — run: comate init from your project directory"
2027
- fi
2028
-
2029
- # comate's own files present.
2030
- local missing=0 f
2031
- for f in comate-lib.sh comate.sh; do
2032
- [ -f "$SCRIPT_DIR/$f" ] || { _d_fail "missing comate file: scripts/$f"; missing=1; }
2033
- done
2034
- [ "$missing" -eq 0 ] && _d_pass "comate scripts complete"
2035
-
2036
- # LF line endings on .sh (Windows-critical).
2037
- local crlf=0
2038
- for f in "$SCRIPT_DIR"/*.sh; do
2039
- if grep -lq $'\r' "$f" 2>/dev/null; then _d_warn "CRLF line endings in $(basename "$f") — convert to LF"; crlf=1; fi
2040
- done
2041
- [ "$crlf" -eq 0 ] && _d_pass ".sh files use LF line endings"
2042
-
2043
- echo "" >&2
2044
- if [ "$fail" -gt 0 ]; then
2045
- die "$COMATE_ENV" "$fail failure(s), $warn warning(s) — fix failures before using comate"
2046
- fi
2047
- log_pass "0 failures, $warn warning(s)"
2048
- exit "$COMATE_OK"
2049
- }
2050
-
2051
- # =====================================================================
2052
- # main router
2053
- # =====================================================================
2054
-
2055
- CMD="${1:-}"
2056
- shift || true
2057
-
2058
- case "$CMD" in
2059
- state)
2060
- NAME="${1:-}"; SUB="${2:-}"
2061
- [ -n "$NAME" ] && [ -n "$SUB" ] || { usage_top; exit "$COMATE_USAGE"; }
2062
- shift 2 || true
2063
- case "$SUB" in
2064
- init) state_init "$NAME" "${1:-}" ;;
2065
- get) state_get "$NAME" "${1:-}" ;;
2066
- set) state_set "$NAME" "${1:-}" "${2:-}" ;;
2067
- transition) state_transition "$NAME" "${1:-}" ;;
2068
- check)
2069
- CHECK_JSON=0
2070
- [ "${2:-}" = "--json" ] && CHECK_JSON=1
2071
- state_check "$NAME" "${1:-}"
2072
- ;;
2073
- recover) state_recover "$NAME" "${1:-}" ;;
2074
- scale) state_scale "$NAME" ;;
2075
- validate) state_validate "$NAME" ;;
2076
- *) die "$COMATE_USAGE" "Unknown state subcommand: $SUB" ;;
2077
- esac
2078
- ;;
2079
- guard)
2080
- NAME="${1:-}"; PHASE="${2:-}"
2081
- [ -n "$NAME" ] && [ -n "$PHASE" ] || { usage_top; exit "$COMATE_USAGE"; }
2082
- GUARD_APPLY=0; GUARD_JSON=0
2083
- for arg in "${@:3}"; do
2084
- case "$arg" in
2085
- --apply) GUARD_APPLY=1 ;;
2086
- --json) GUARD_JSON=1 ;;
2087
- esac
2088
- done
2089
- guard "$NAME" "$PHASE"
2090
- ;;
2091
- handoff)
2092
- handoff "${1:-}" "${2:-}" "${3:-}" "${4:-}"
2093
- ;;
2094
- sources)
2095
- NAME="${1:-}"; SUB="${2:-}"
2096
- if [ "$NAME" = "find" ]; then
2097
- sources_find "${2:-}"
2098
- else
2099
- [ -n "$NAME" ] && [ -n "$SUB" ] || { usage_top; exit "$COMATE_USAGE"; }
2100
- shift 2 || true
2101
- case "$SUB" in
2102
- track) sources_track "$NAME" "$@" ;;
2103
- check) sources_check "$NAME" ;;
2104
- list) sources_list "$NAME" ;;
2105
- *) die "$COMATE_USAGE" "Unknown sources subcommand: $SUB" ;;
2106
- esac
2107
- fi
2108
- ;;
2109
- dependency)
2110
- NAME="${1:-}"; SUB="${2:-}"
2111
- [ -n "$NAME" ] && [ "$SUB" = "archived" ] || { usage_top; exit "$COMATE_USAGE"; }
2112
- dependency_archived "$NAME"
2113
- ;;
2114
- gate)
2115
- PRD="${1:-}"; SUB="${2:-}"
2116
- [ -n "$PRD" ] && [ -n "$SUB" ] || { usage_top; exit "$COMATE_USAGE"; }
2117
- shift 2 || true
2118
- case "$SUB" in
2119
- review) gate_review "$PRD" ;;
2120
- set) gate_set "$PRD" "${1:-}" "${2:-}" ;;
2121
- pass) gate_pass "$PRD" "${1:-}" ;;
2122
- spawned) gate_spawned "$PRD" "${1:-}" ;;
2123
- fanout-status) gate_fanout_status "$PRD" "${1:-}" ;;
2124
- status) gate_status "$PRD" "${1:-}" ;;
2125
- check) gate_check "$PRD" ;;
2126
- *) die "$COMATE_USAGE" "Unknown gate subcommand: $SUB" ;;
2127
- esac
2128
- ;;
2129
- archive)
2130
- [ -n "${1:-}" ] || { usage_top; exit "$COMATE_USAGE"; }
2131
- archive "$1" "${2:-}"
2132
- ;;
2133
- status)
2134
- status "${1:-.}"
2135
- ;;
2136
- doctor)
2137
- doctor
2138
- ;;
2139
- ""|-h|--help|help)
2140
- usage_top
2141
- exit "$COMATE_OK"
2142
- ;;
2143
- *)
2144
- usage_top
2145
- exit "$COMATE_USAGE"
2146
- ;;
2147
- esac