@rpamis/comet 0.2.4 → 0.2.6

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.
@@ -1,616 +1,680 @@
1
- #!/bin/bash
2
- # Comet State — unified interface for .comet.yaml state management
3
- # Usage: comet-state.sh <subcommand> <change-name> [args...]
4
- #
5
- # Subcommands:
6
- # init <change-name> <workflow> — Initialize .comet.yaml with workflow defaults
7
- # get <change-name> <field> — Read a field value from .comet.yaml
8
- # set <change-name> <field> <val> — Update a field value
9
- # transition <change-name> <event> — Apply a validated state transition
10
- # check <change-name> <phase> — Verify entry requirements for a phase
11
- # scale <change-name> — Assess and set verification mode based on metrics
12
- #
13
- # Workflows: full, hotfix, tweak
14
- # Phases for check: open, design, build, verify, archive
15
-
16
- set -euo pipefail
17
-
18
- # --- Color output helpers ---
19
-
20
- red() { echo -e "\033[31m$1\033[0m" >&2; }
21
- green() { echo -e "\033[32m$1\033[0m" >&2; }
22
- yellow() { echo -e "\033[33m$1\033[0m" >&2; }
23
-
24
- # --- Script location ---
25
-
26
- # shellcheck disable=SC2034
27
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
28
-
29
- # --- Input validation ---
30
-
31
- validate_change_name() {
32
- local name="$1"
33
- # Reject empty names
34
- if [ -z "$name" ]; then
35
- red "ERROR: Change name cannot be empty" >&2
36
- exit 1
37
- fi
38
- # Only allow alphanumeric, hyphens, and underscores
39
- if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
40
- red "ERROR: Invalid change name: '$name'" >&2
41
- red "Valid characters: a-z, A-Z, 0-9, -, _" >&2
42
- exit 1
43
- fi
44
- # Reject path traversal attempts
45
- if [[ "$name" =~ \.\. ]]; then
46
- red "ERROR: Change name cannot contain '..' (path traversal not allowed)" >&2
47
- exit 1
48
- fi
49
- }
50
-
51
- validate_enum() {
52
- local value="$1"
53
- shift
54
- local valid_values=("$@")
55
-
56
- for valid in "${valid_values[@]}"; do
57
- if [ "$value" = "$valid" ]; then
58
- return 0
59
- fi
60
- done
61
-
62
- red "ERROR: Invalid value: '$value'" >&2
63
- red "Valid values: ${valid_values[*]}" >&2
64
- exit 1
65
- }
66
-
67
- # --- Helper functions ---
68
-
69
- yaml_field() {
70
- local field="$1"
71
- local yaml_file="$2"
72
- if [ -f "$yaml_file" ]; then
73
- grep "^${field}:" "$yaml_file" | sed "s/^${field}: *//" | tr -d '"' | tr -d "'"
74
- fi
75
- }
76
-
77
- file_nonempty() {
78
- [ -f "$1" ] && [ -s "$1" ]
79
- }
80
-
81
- change_dir_for() {
82
- local change_name="$1"
83
- if [ -d "openspec/changes/$change_name" ]; then
84
- echo "openspec/changes/$change_name"
85
- elif [ -d "openspec/changes/archive/$change_name" ]; then
86
- echo "openspec/changes/archive/$change_name"
87
- else
88
- echo "openspec/changes/$change_name"
89
- fi
90
- }
91
-
92
- yaml_file_for() {
93
- local change_name="$1"
94
- local change_dir
95
- change_dir=$(change_dir_for "$change_name")
96
- echo "$change_dir/.comet.yaml"
97
- }
98
-
99
- # --- Subcommands ---
100
-
101
- cmd_init() {
102
- local change_name="$1"
103
- local workflow="$2"
104
-
105
- validate_change_name "$change_name"
106
- validate_enum "$workflow" "full" "hotfix" "tweak"
107
-
108
- local change_dir yaml_file
109
- change_dir=$(change_dir_for "$change_name")
110
- yaml_file=$(yaml_file_for "$change_name")
111
-
112
- # Check if .comet.yaml already exists
113
- if [ -f "$yaml_file" ]; then
114
- red "ERROR: .comet.yaml already exists at $yaml_file"
115
- exit 1
116
- fi
117
-
118
- # Create change directory if it doesn't exist
119
- mkdir -p "$change_dir"
120
-
121
- # Set workflow-appropriate defaults
122
- local phase build_mode isolation verify_mode
123
- phase="open"
124
-
125
- case "$workflow" in
126
- full)
127
- build_mode="null"
128
- isolation="null"
129
- verify_mode="null"
130
- ;;
131
- hotfix|tweak)
132
- build_mode="direct"
133
- isolation="branch"
134
- verify_mode="light"
135
- ;;
136
- esac
137
-
138
- # Write .comet.yaml
139
- cat > "$yaml_file" <<EOF
140
- workflow: $workflow
141
- phase: $phase
142
- build_mode: $build_mode
143
- isolation: $isolation
144
- verify_mode: $verify_mode
145
- design_doc: null
146
- plan: null
147
- verify_result: pending
148
- verification_report: null
149
- branch_status: pending
150
- verified_at: null
151
- archived: false
152
- EOF
153
-
154
- green "Initialized: $yaml_file (workflow=$workflow)"
155
- }
156
-
157
- cmd_get() {
158
- local change_name="$1"
159
- local field="$2"
160
-
161
- validate_change_name "$change_name"
162
-
163
- local yaml_file
164
- yaml_file=$(yaml_file_for "$change_name")
165
-
166
- # Check if .comet.yaml exists
167
- if [ ! -f "$yaml_file" ]; then
168
- red "ERROR: .comet.yaml not found at $yaml_file"
169
- exit 1
170
- fi
171
-
172
- # Read and output the field value
173
- local value
174
- value=$(yaml_field "$field" "$yaml_file")
175
- echo "${value:-}"
176
- }
177
-
178
- cmd_set() {
179
- local change_name="$1"
180
- local field="$2"
181
- local value="$3"
182
-
183
- validate_change_name "$change_name"
184
-
185
- local yaml_file
186
- yaml_file=$(yaml_file_for "$change_name")
187
-
188
- # Check if .comet.yaml exists
189
- if [ ! -f "$yaml_file" ]; then
190
- red "ERROR: .comet.yaml not found at $yaml_file"
191
- exit 1
192
- fi
193
-
194
- # Validate field name
195
- case "$field" in
196
- workflow|phase|build_mode|isolation|verify_mode|verify_result|verification_report|branch_status|archived|design_doc|plan|verified_at)
197
- # Valid field
198
- ;;
199
- *)
200
- red "ERROR: Unknown field: '$field'" >&2
201
- red "Valid fields: workflow, phase, design_doc, plan, build_mode, isolation, verify_mode, verify_result, verification_report, branch_status, verified_at, archived" >&2
202
- exit 1
203
- ;;
204
- esac
205
-
206
- # Validate enum values
207
- case "$field" in
208
- workflow)
209
- validate_enum "$value" "full" "hotfix" "tweak"
210
- ;;
211
- phase)
212
- validate_enum "$value" "open" "design" "build" "verify" "archive"
213
- ;;
214
- build_mode)
215
- validate_enum "$value" "subagent-driven-development" "executing-plans" "direct"
216
- ;;
217
- isolation)
218
- validate_enum "$value" "branch" "worktree"
219
- ;;
220
- verify_mode)
221
- validate_enum "$value" "light" "full"
222
- ;;
223
- verify_result)
224
- validate_enum "$value" "pending" "pass" "fail"
225
- ;;
226
- branch_status)
227
- validate_enum "$value" "pending" "handled"
228
- ;;
229
- archived)
230
- validate_enum "$value" "true" "false"
231
- ;;
232
- design_doc|plan|verification_report|verified_at)
233
- # No validation for path fields and date fields
234
- ;;
235
- esac
236
-
237
- # Write or update the field
238
- if grep -q "^${field}:" "$yaml_file"; then
239
- # Field exists, replace it (use | delimiter to avoid / conflicts in paths)
240
- sed -i "s|^${field}:.*|${field}: ${value}|" "$yaml_file"
241
- else
242
- # Field doesn't exist, append it
243
- echo "${field}: ${value}" >> "$yaml_file"
244
- fi
245
-
246
- green "[SET] ${field}=${value}"
247
- }
248
-
249
- require_phase() {
250
- local change_name="$1"
251
- local expected="$2"
252
- local actual
253
- actual=$(cmd_get "$change_name" "phase")
254
- if [ "$actual" != "$expected" ]; then
255
- red "ERROR: Cannot transition '$change_name': expected phase ${expected}, got ${actual}" >&2
256
- exit 1
257
- fi
258
- }
259
-
260
- require_verification_evidence() {
261
- local change_name="$1"
262
- local report branch_status
263
- report=$(cmd_get "$change_name" "verification_report")
264
- branch_status=$(cmd_get "$change_name" "branch_status")
265
-
266
- if [ -z "$report" ] || [ "$report" = "null" ] || [ ! -f "$report" ]; then
267
- red "ERROR: Cannot transition '$change_name': verification_report must point to an existing report file" >&2
268
- exit 1
269
- fi
270
-
271
- if [ "$branch_status" != "handled" ]; then
272
- red "ERROR: Cannot transition '$change_name': branch_status must be handled" >&2
273
- exit 1
274
- fi
275
- }
276
-
277
- cmd_transition() {
278
- local change_name="$1"
279
- local event="$2"
280
-
281
- validate_change_name "$change_name"
282
- validate_enum "$event" "open-complete" "design-complete" "build-complete" "verify-pass" "verify-fail" "archived"
283
-
284
- case "$event" in
285
- open-complete)
286
- require_phase "$change_name" "open"
287
- local workflow
288
- workflow=$(cmd_get "$change_name" "workflow")
289
- if [ "$workflow" = "full" ]; then
290
- cmd_set "$change_name" phase design
291
- else
292
- cmd_set "$change_name" phase build
293
- fi
294
- ;;
295
- design-complete)
296
- require_phase "$change_name" "design"
297
- cmd_set "$change_name" phase build
298
- ;;
299
- build-complete)
300
- require_phase "$change_name" "build"
301
- cmd_set "$change_name" phase verify
302
- cmd_set "$change_name" verify_result pending
303
- cmd_set "$change_name" verification_report null
304
- cmd_set "$change_name" branch_status pending
305
- ;;
306
- verify-pass)
307
- require_phase "$change_name" "verify"
308
- require_verification_evidence "$change_name"
309
- cmd_set "$change_name" verify_result pass
310
- cmd_set "$change_name" phase archive
311
- cmd_set "$change_name" verified_at "$(date +%Y-%m-%d)"
312
- ;;
313
- verify-fail)
314
- require_phase "$change_name" "verify"
315
- cmd_set "$change_name" verify_result fail
316
- cmd_set "$change_name" phase build
317
- cmd_set "$change_name" branch_status pending
318
- ;;
319
- archived)
320
- require_phase "$change_name" "archive"
321
- cmd_set "$change_name" archived true
322
- ;;
323
- esac
324
-
325
- green "[TRANSITION] ${event}"
326
- }
327
-
328
- # --- Check helpers for entry verification ---
329
-
330
- CHECK_BLOCK=0
331
-
332
- check_pass() {
333
- local msg="$1"
334
- echo " $(green "[PASS]") $msg"
335
- }
336
-
337
- check_fail() {
338
- local msg="$1"
339
- echo " $(red "[FAIL]") $msg"
340
- CHECK_BLOCK=1
341
- }
342
-
343
- check_nonempty() {
344
- local desc="$1"
345
- local path="$2"
346
- if file_nonempty "$path"; then
347
- check_pass "$desc non-empty"
348
- else
349
- check_fail "$desc missing or empty"
350
- fi
351
- }
352
-
353
- check_yaml_is() {
354
- local field="$1"
355
- local expected="$2"
356
- local change_name="$3"
357
- local actual
358
- actual=$(cmd_get "$change_name" "$field")
359
- if [ "$actual" = "$expected" ]; then
360
- check_pass "${field}=${actual} (expected: ${expected})"
361
- else
362
- check_fail "${field}=${actual} (expected: ${expected})"
363
- fi
364
- }
365
-
366
- check_yaml_empty() {
367
- local field="$1"
368
- local change_name="$2"
369
- local value
370
- value=$(cmd_get "$change_name" "$field")
371
- if [ -z "$value" ] || [ "$value" = "null" ]; then
372
- check_pass "${field} is empty/null"
373
- else
374
- check_fail "${field}=${value} (expected: empty/null)"
375
- fi
376
- }
377
-
378
- check_file_not_exists() {
379
- local desc="$1"
380
- local path="$2"
381
- if [ ! -f "$path" ]; then
382
- check_pass "$desc does not exist"
383
- else
384
- check_fail "$desc exists (should not exist)"
385
- fi
386
- }
387
-
388
- cmd_check() {
389
- local change_name="$1"
390
- local phase="$2"
391
-
392
- validate_change_name "$change_name"
393
- validate_enum "$phase" "open" "design" "build" "verify" "archive"
394
-
395
- local change_dir="openspec/changes/$change_name"
396
- local yaml_file="$change_dir/.comet.yaml"
397
- local proposal_file="$change_dir/proposal.md"
398
- local design_file="$change_dir/design.md"
399
- local tasks_file="$change_dir/tasks.md"
400
-
401
- echo "=== Entry Check: comet-${phase} ==="
402
-
403
- # .comet.yaml must exist for all phases (state machine core)
404
- if [ ! -f "$yaml_file" ]; then
405
- red "ERROR: .comet.yaml not found at $yaml_file"
406
- exit 1
407
- fi
408
-
409
- # Phase-specific checks
410
- case "$phase" in
411
- open)
412
- check_pass ".comet.yaml exists"
413
- check_yaml_is "phase" "open" "$change_name"
414
- ;;
415
- design)
416
- check_pass ".comet.yaml exists"
417
- check_yaml_is "phase" "design" "$change_name"
418
- check_yaml_is "workflow" "full" "$change_name"
419
- check_yaml_empty "design_doc" "$change_name"
420
- check_nonempty "proposal.md" "$proposal_file"
421
- check_nonempty "design.md" "$design_file"
422
- check_nonempty "tasks.md" "$tasks_file"
423
- ;;
424
- build)
425
- check_pass ".comet.yaml exists"
426
- check_yaml_is "phase" "build" "$change_name"
427
- # design_doc required for full workflow only
428
- local workflow
429
- workflow=$(cmd_get "$change_name" "workflow")
430
- if [ "$workflow" = "full" ]; then
431
- local design_doc
432
- design_doc=$(cmd_get "$change_name" "design_doc")
433
- if [ -n "$design_doc" ] && [ "$design_doc" != "null" ] && [ -f "$design_doc" ]; then
434
- check_pass "design_doc=${design_doc} (file exists)"
435
- else
436
- check_fail "design_doc=${design_doc} (expected: non-null and file exists)"
437
- fi
438
- else
439
- check_pass "workflow=${workflow} (design_doc not required)"
440
- fi
441
- check_nonempty "proposal.md" "$proposal_file"
442
- check_nonempty "tasks.md" "$tasks_file"
443
- ;;
444
- verify)
445
- check_pass ".comet.yaml exists"
446
- check_yaml_is "phase" "verify" "$change_name"
447
- # Check verify_result is pending or null
448
- local verify_result
449
- verify_result=$(cmd_get "$change_name" "verify_result")
450
- if [ "$verify_result" = "pending" ] || [ -z "$verify_result" ] || [ "$verify_result" = "null" ]; then
451
- check_pass "verify_result=${verify_result} (expected: pending or null)"
452
- else
453
- check_fail "verify_result=${verify_result} (expected: pending or null)"
454
- fi
455
- ;;
456
- archive)
457
- check_pass ".comet.yaml exists"
458
- check_yaml_is "phase" "archive" "$change_name"
459
- check_yaml_is "verify_result" "pass" "$change_name"
460
- # Check archived is NOT true
461
- local archived
462
- archived=$(cmd_get "$change_name" "archived")
463
- if [ "$archived" != "true" ]; then
464
- check_pass "archived=${archived} (expected: not true)"
465
- else
466
- check_fail "archived=${archived} (expected: not true)"
467
- fi
468
- ;;
469
- *)
470
- red "ERROR: Unknown phase for check: $phase"
471
- exit 1
472
- ;;
473
- esac
474
-
475
- echo ""
476
- if [ "$CHECK_BLOCK" -eq 1 ]; then
477
- red "BLOCKED fix failing checks before proceeding"
478
- exit 1
479
- else
480
- green "ALL CHECKS PASSED — ready to proceed"
481
- exit 0
482
- fi
483
- }
484
-
485
- cmd_scale() {
486
- local change_name="$1"
487
-
488
- validate_change_name "$change_name"
489
-
490
- local change_dir="openspec/changes/$change_name"
491
- local yaml_file="$change_dir/.comet.yaml"
492
-
493
- # Verify .comet.yaml exists
494
- if [ ! -f "$yaml_file" ]; then
495
- red "ERROR: .comet.yaml not found at $yaml_file"
496
- exit 1
497
- fi
498
-
499
- # Read metrics
500
- # 1. Task count: count lines matching `- [` in tasks.md
501
- local tasks_file="$change_dir/tasks.md"
502
- local task_count=0
503
- if [ -f "$tasks_file" ]; then
504
- task_count=$(grep -c '^\- \[' "$tasks_file" 2>/dev/null || echo "0")
505
- fi
506
-
507
- # 2. Delta spec count: count files named spec.md under specs/*/spec.md
508
- local delta_spec_count=0
509
- if [ -d "$change_dir/specs" ]; then
510
- delta_spec_count=$(find "$change_dir/specs" -name "spec.md" -type f 2>/dev/null | wc -l | tr -d ' ')
511
- fi
512
-
513
- # 3. Changed files: prefer plan base-ref, fall back to worktree diff
514
- local changed_files=0
515
- if git rev-parse --git-dir > /dev/null 2>&1; then
516
- local plan_file base_ref
517
- plan_file=$(cmd_get "$change_name" "plan" 2>/dev/null || true)
518
- if [ -n "$plan_file" ] && [ "$plan_file" != "null" ] && [ -f "$plan_file" ]; then
519
- base_ref=$(grep '^base-ref:' "$plan_file" 2>/dev/null | head -1 | sed 's/^base-ref: *//')
520
- fi
521
-
522
- if [ -n "${base_ref:-}" ] && git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
523
- changed_files=$(git diff --name-only "$base_ref"...HEAD 2>/dev/null | wc -l | tr -d ' ')
524
- else
525
- changed_files=$(git diff --name-only HEAD 2>/dev/null | wc -l | tr -d ' ')
526
- fi
527
- fi
528
-
529
- # Decision rules
530
- local result="light"
531
- if [ "$task_count" -gt 3 ] || [ "$delta_spec_count" -gt 1 ] || [ "$changed_files" -gt 5 ]; then
532
- result="full"
533
- fi
534
-
535
- # Output assessment to stderr
536
- echo "=== Scale Assessment: $change_name ===" >&2
537
- echo " Tasks: $task_count (threshold: 3)" >&2
538
- echo " Delta specs: $delta_spec_count capabilities (threshold: 1)" >&2
539
- echo " Changed files: $changed_files (threshold: 5)" >&2
540
- echo " → Result: $result" >&2
541
-
542
- # Update verify_mode in .comet.yaml
543
- sed -i "s/^verify_mode:.*/verify_mode: $result/" "$yaml_file"
544
-
545
- green "[SCALE] verify_mode=$result"
546
- }
547
-
548
- # --- Main ---
549
-
550
- SUBCOMMAND="${1:-}"
551
- shift || true
552
-
553
- case "$SUBCOMMAND" in
554
- init)
555
- if [ $# -lt 2 ]; then
556
- red "Usage: comet-state.sh init <change-name> <workflow>" >&2
557
- red "Workflows: full, hotfix, tweak" >&2
558
- exit 1
559
- fi
560
- cmd_init "$@"
561
- ;;
562
- get)
563
- if [ $# -lt 2 ]; then
564
- red "Usage: comet-state.sh get <change-name> <field>" >&2
565
- exit 1
566
- fi
567
- cmd_get "$@"
568
- ;;
569
- set)
570
- if [ $# -lt 3 ]; then
571
- red "Usage: comet-state.sh set <change-name> <field> <value>" >&2
572
- exit 1
573
- fi
574
- cmd_set "$@"
575
- ;;
576
- transition)
577
- if [ $# -lt 2 ]; then
578
- red "Usage: comet-state.sh transition <change-name> <event>" >&2
579
- red "Events: open-complete, design-complete, build-complete, verify-pass, verify-fail, archived" >&2
580
- exit 1
581
- fi
582
- cmd_transition "$@"
583
- ;;
584
- check)
585
- if [ $# -lt 2 ]; then
586
- red "Usage: comet-state.sh check <change-name> <phase>" >&2
587
- red "Phases: open, design, build, verify, archive" >&2
588
- exit 1
589
- fi
590
- cmd_check "$@"
591
- ;;
592
- scale)
593
- if [ $# -lt 1 ]; then
594
- red "Usage: comet-state.sh scale <change-name>" >&2
595
- exit 1
596
- fi
597
- cmd_scale "$@"
598
- ;;
599
- *)
600
- red "Unknown subcommand: $SUBCOMMAND" >&2
601
- echo "" >&2
602
- echo "Usage: comet-state.sh <subcommand> <change-name> [args...]" >&2
603
- echo "" >&2
604
- echo "Subcommands:" >&2
605
- echo " init <change-name> <workflow> — Initialize .comet.yaml with workflow defaults" >&2
606
- echo " get <change-name> <field> — Read a field value from .comet.yaml" >&2
607
- echo " set <change-name> <field> <val> — Update a field value in .comet.yaml" >&2
608
- echo " transition <change-name> <event> — Apply a validated state transition" >&2
609
- echo " check <change-name> <phase> — Verify entry requirements for a phase" >&2
610
- echo " scale <change-name> — Assess and set verification mode based on metrics" >&2
611
- echo "" >&2
612
- echo "Workflows: full, hotfix, tweak" >&2
613
- echo "Phases for check: open, design, build, verify, archive" >&2
614
- exit 1
615
- ;;
616
- esac
1
+ #!/bin/bash
2
+ # Comet State — unified interface for .comet.yaml state management
3
+ # Usage: comet-state.sh <subcommand> <change-name> [args...]
4
+ #
5
+ # Subcommands:
6
+ # init <change-name> <workflow> — Initialize .comet.yaml with workflow defaults
7
+ # get <change-name> <field> — Read a field value from .comet.yaml
8
+ # set <change-name> <field> <val> — Update a field value
9
+ # transition <change-name> <event> — Apply a validated state transition
10
+ # check <change-name> <phase> — Verify entry requirements for a phase
11
+ # scale <change-name> — Assess and set verification mode based on metrics
12
+ #
13
+ # Workflows: full, hotfix, tweak
14
+ # Phases for check: open, design, build, verify, archive
15
+
16
+ set -euo pipefail
17
+
18
+ # --- Color output helpers ---
19
+
20
+ red() { echo -e "\033[31m$1\033[0m" >&2; }
21
+ green() { echo -e "\033[32m$1\033[0m" >&2; }
22
+ yellow() { echo -e "\033[33m$1\033[0m" >&2; }
23
+
24
+ # --- Script location ---
25
+
26
+ # shellcheck disable=SC2034
27
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
28
+
29
+ # --- Input validation ---
30
+
31
+ validate_change_name() {
32
+ local name="$1"
33
+ # Reject empty names
34
+ if [ -z "$name" ]; then
35
+ red "ERROR: Change name cannot be empty" >&2
36
+ exit 1
37
+ fi
38
+ # Only allow alphanumeric, hyphens, and underscores
39
+ if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
40
+ red "ERROR: Invalid change name: '$name'" >&2
41
+ red "Valid characters: a-z, A-Z, 0-9, -, _" >&2
42
+ exit 1
43
+ fi
44
+ # Reject path traversal attempts
45
+ if [[ "$name" =~ \.\. ]]; then
46
+ red "ERROR: Change name cannot contain '..' (path traversal not allowed)" >&2
47
+ exit 1
48
+ fi
49
+ }
50
+
51
+ validate_enum() {
52
+ local value="$1"
53
+ shift
54
+ local valid_values=("$@")
55
+
56
+ for valid in "${valid_values[@]}"; do
57
+ if [ "$value" = "$valid" ]; then
58
+ return 0
59
+ fi
60
+ done
61
+
62
+ red "ERROR: Invalid value: '$value'" >&2
63
+ red "Valid values: ${valid_values[*]}" >&2
64
+ exit 1
65
+ }
66
+
67
+ # --- Helper functions ---
68
+
69
+ yaml_field() {
70
+ local field="$1"
71
+ local yaml_file="$2"
72
+ if [ -f "$yaml_file" ]; then
73
+ local value
74
+ value=$(grep "^${field}:" "$yaml_file" 2>/dev/null | sed "s/^${field}: *//" || true)
75
+ strip_wrapping_quotes "$value"
76
+ fi
77
+ }
78
+
79
+ strip_wrapping_quotes() {
80
+ local value="$1"
81
+ case "$value" in
82
+ \"*\")
83
+ printf '%s\n' "${value:1:${#value}-2}"
84
+ ;;
85
+ \'*\')
86
+ printf '%s\n' "${value:1:${#value}-2}"
87
+ ;;
88
+ *)
89
+ printf '%s\n' "$value"
90
+ ;;
91
+ esac
92
+ }
93
+
94
+ replace_yaml_field() {
95
+ local yaml_file="$1"
96
+ local field="$2"
97
+ local value="$3"
98
+ local tmp_file
99
+
100
+ tmp_file=$(mktemp)
101
+ awk -v field="$field" -v value="$value" '
102
+ index($0, field ":") == 1 { print field ": " value; next }
103
+ { print }
104
+ ' "$yaml_file" > "$tmp_file"
105
+ mv "$tmp_file" "$yaml_file"
106
+ }
107
+
108
+ file_nonempty() {
109
+ [ -f "$1" ] && [ -s "$1" ]
110
+ }
111
+
112
+ change_dir_for() {
113
+ local change_name="$1"
114
+ if [ -d "openspec/changes/$change_name" ]; then
115
+ echo "openspec/changes/$change_name"
116
+ elif [ -d "openspec/changes/archive/$change_name" ]; then
117
+ echo "openspec/changes/archive/$change_name"
118
+ else
119
+ echo "openspec/changes/$change_name"
120
+ fi
121
+ }
122
+
123
+ yaml_file_for() {
124
+ local change_name="$1"
125
+ local change_dir
126
+ change_dir=$(change_dir_for "$change_name")
127
+ echo "$change_dir/.comet.yaml"
128
+ }
129
+
130
+ # --- Subcommands ---
131
+
132
+ cmd_init() {
133
+ local change_name="$1"
134
+ local workflow="$2"
135
+
136
+ validate_change_name "$change_name"
137
+ validate_enum "$workflow" "full" "hotfix" "tweak"
138
+
139
+ local change_dir yaml_file
140
+ change_dir=$(change_dir_for "$change_name")
141
+ yaml_file=$(yaml_file_for "$change_name")
142
+
143
+ # Check if .comet.yaml already exists
144
+ if [ -f "$yaml_file" ]; then
145
+ red "ERROR: .comet.yaml already exists at $yaml_file"
146
+ exit 1
147
+ fi
148
+
149
+ # Create change directory if it doesn't exist
150
+ mkdir -p "$change_dir"
151
+
152
+ # Set workflow-appropriate defaults
153
+ local phase build_mode isolation verify_mode
154
+ phase="open"
155
+
156
+ case "$workflow" in
157
+ full)
158
+ build_mode="null"
159
+ isolation="null"
160
+ verify_mode="null"
161
+ ;;
162
+ hotfix|tweak)
163
+ build_mode="direct"
164
+ isolation="branch"
165
+ verify_mode="light"
166
+ ;;
167
+ esac
168
+
169
+ # Write .comet.yaml
170
+ cat > "$yaml_file" <<EOF
171
+ workflow: $workflow
172
+ phase: $phase
173
+ build_mode: $build_mode
174
+ isolation: $isolation
175
+ verify_mode: $verify_mode
176
+ design_doc: null
177
+ plan: null
178
+ verify_result: pending
179
+ verification_report: null
180
+ branch_status: pending
181
+ verified_at: null
182
+ archived: false
183
+ EOF
184
+
185
+ green "Initialized: $yaml_file (workflow=$workflow)"
186
+ }
187
+
188
+ cmd_get() {
189
+ local change_name="$1"
190
+ local field="$2"
191
+
192
+ validate_change_name "$change_name"
193
+
194
+ local yaml_file
195
+ yaml_file=$(yaml_file_for "$change_name")
196
+
197
+ # Check if .comet.yaml exists
198
+ if [ ! -f "$yaml_file" ]; then
199
+ red "ERROR: .comet.yaml not found at $yaml_file"
200
+ exit 1
201
+ fi
202
+
203
+ # Read and output the field value
204
+ local value
205
+ value=$(yaml_field "$field" "$yaml_file")
206
+ echo "${value:-}"
207
+ }
208
+
209
+ cmd_set() {
210
+ local change_name="$1"
211
+ local field="$2"
212
+ local value="$3"
213
+
214
+ validate_change_name "$change_name"
215
+
216
+ local yaml_file
217
+ yaml_file=$(yaml_file_for "$change_name")
218
+
219
+ # Check if .comet.yaml exists
220
+ if [ ! -f "$yaml_file" ]; then
221
+ red "ERROR: .comet.yaml not found at $yaml_file"
222
+ exit 1
223
+ fi
224
+
225
+ # Validate field name
226
+ case "$field" in
227
+ workflow|phase|build_mode|isolation|verify_mode|verify_result|verification_report|branch_status|archived|design_doc|plan|verified_at|direct_override|build_command|verify_command)
228
+ # Valid field
229
+ ;;
230
+ *)
231
+ red "ERROR: Unknown field: '$field'" >&2
232
+ red "Valid fields: workflow, phase, design_doc, plan, build_mode, isolation, verify_mode, verify_result, verification_report, branch_status, verified_at, archived, direct_override, build_command, verify_command" >&2
233
+ exit 1
234
+ ;;
235
+ esac
236
+
237
+ # Validate enum values
238
+ case "$field" in
239
+ workflow)
240
+ validate_enum "$value" "full" "hotfix" "tweak"
241
+ ;;
242
+ phase)
243
+ validate_enum "$value" "open" "design" "build" "verify" "archive"
244
+ ;;
245
+ build_mode)
246
+ validate_enum "$value" "subagent-driven-development" "executing-plans" "direct"
247
+ ;;
248
+ isolation)
249
+ validate_enum "$value" "branch" "worktree"
250
+ ;;
251
+ verify_mode)
252
+ validate_enum "$value" "light" "full"
253
+ ;;
254
+ verify_result)
255
+ validate_enum "$value" "pending" "pass" "fail"
256
+ ;;
257
+ branch_status)
258
+ validate_enum "$value" "pending" "handled"
259
+ ;;
260
+ archived)
261
+ validate_enum "$value" "true" "false"
262
+ ;;
263
+ direct_override)
264
+ validate_enum "$value" "true" "false"
265
+ ;;
266
+ design_doc|plan|verification_report|verified_at|build_command|verify_command)
267
+ # No validation for path fields, date fields, or project command strings
268
+ ;;
269
+ esac
270
+
271
+ # Write or update the field
272
+ if grep -q "^${field}:" "$yaml_file"; then
273
+ replace_yaml_field "$yaml_file" "$field" "$value"
274
+ else
275
+ # Field doesn't exist, append it
276
+ echo "${field}: ${value}" >> "$yaml_file"
277
+ fi
278
+
279
+ green "[SET] ${field}=${value}"
280
+ }
281
+
282
+ require_phase() {
283
+ local change_name="$1"
284
+ local expected="$2"
285
+ local actual
286
+ actual=$(cmd_get "$change_name" "phase")
287
+ if [ "$actual" != "$expected" ]; then
288
+ red "ERROR: Cannot transition '$change_name': expected phase ${expected}, got ${actual}" >&2
289
+ exit 1
290
+ fi
291
+ }
292
+
293
+ require_verification_evidence() {
294
+ local change_name="$1"
295
+ local report branch_status
296
+ report=$(cmd_get "$change_name" "verification_report")
297
+ branch_status=$(cmd_get "$change_name" "branch_status")
298
+
299
+ if [ -z "$report" ] || [ "$report" = "null" ] || [ ! -f "$report" ]; then
300
+ red "ERROR: Cannot transition '$change_name': verification_report must point to an existing report file" >&2
301
+ exit 1
302
+ fi
303
+
304
+ if [ "$branch_status" != "handled" ]; then
305
+ red "ERROR: Cannot transition '$change_name': branch_status must be handled" >&2
306
+ exit 1
307
+ fi
308
+ }
309
+
310
+ require_build_decisions() {
311
+ local change_name="$1"
312
+ local workflow build_mode isolation direct_override
313
+ workflow=$(cmd_get "$change_name" "workflow")
314
+ build_mode=$(cmd_get "$change_name" "build_mode")
315
+ isolation=$(cmd_get "$change_name" "isolation")
316
+ direct_override=$(cmd_get "$change_name" "direct_override" 2>/dev/null || true)
317
+
318
+ case "$isolation" in
319
+ branch|worktree) ;;
320
+ *)
321
+ red "ERROR: Cannot transition '$change_name': isolation must be branch or worktree, got '${isolation:-null}'" >&2
322
+ exit 1
323
+ ;;
324
+ esac
325
+
326
+ case "$build_mode" in
327
+ subagent-driven-development|executing-plans|direct) ;;
328
+ *)
329
+ red "ERROR: Cannot transition '$change_name': build_mode must be selected before leaving build, got '${build_mode:-null}'" >&2
330
+ exit 1
331
+ ;;
332
+ esac
333
+
334
+ if [ "$build_mode" = "direct" ] && [ "$workflow" != "hotfix" ] && [ "$workflow" != "tweak" ] && [ "$direct_override" != "true" ]; then
335
+ red "ERROR: Cannot transition '$change_name': build_mode=direct is only allowed for hotfix/tweak unless direct_override=true" >&2
336
+ exit 1
337
+ fi
338
+ }
339
+
340
+ cmd_transition() {
341
+ local change_name="$1"
342
+ local event="$2"
343
+
344
+ validate_change_name "$change_name"
345
+ validate_enum "$event" "open-complete" "design-complete" "build-complete" "verify-pass" "verify-fail" "archived"
346
+
347
+ case "$event" in
348
+ open-complete)
349
+ require_phase "$change_name" "open"
350
+ local workflow
351
+ workflow=$(cmd_get "$change_name" "workflow")
352
+ if [ "$workflow" = "full" ]; then
353
+ cmd_set "$change_name" phase design
354
+ else
355
+ cmd_set "$change_name" phase build
356
+ fi
357
+ ;;
358
+ design-complete)
359
+ require_phase "$change_name" "design"
360
+ cmd_set "$change_name" phase build
361
+ ;;
362
+ build-complete)
363
+ require_phase "$change_name" "build"
364
+ require_build_decisions "$change_name"
365
+ cmd_set "$change_name" phase verify
366
+ cmd_set "$change_name" verify_result pending
367
+ cmd_set "$change_name" verification_report null
368
+ cmd_set "$change_name" branch_status pending
369
+ ;;
370
+ verify-pass)
371
+ require_phase "$change_name" "verify"
372
+ require_verification_evidence "$change_name"
373
+ cmd_set "$change_name" verify_result pass
374
+ cmd_set "$change_name" phase archive
375
+ cmd_set "$change_name" verified_at "$(date +%Y-%m-%d)"
376
+ ;;
377
+ verify-fail)
378
+ require_phase "$change_name" "verify"
379
+ cmd_set "$change_name" verify_result fail
380
+ cmd_set "$change_name" phase build
381
+ cmd_set "$change_name" branch_status pending
382
+ ;;
383
+ archived)
384
+ require_phase "$change_name" "archive"
385
+ cmd_set "$change_name" archived true
386
+ ;;
387
+ esac
388
+
389
+ green "[TRANSITION] ${event}"
390
+ }
391
+
392
+ # --- Check helpers for entry verification ---
393
+
394
+ CHECK_BLOCK=0
395
+
396
+ check_pass() {
397
+ local msg="$1"
398
+ echo " $(green "[PASS]") $msg"
399
+ }
400
+
401
+ check_fail() {
402
+ local msg="$1"
403
+ echo " $(red "[FAIL]") $msg"
404
+ CHECK_BLOCK=1
405
+ }
406
+
407
+ check_nonempty() {
408
+ local desc="$1"
409
+ local path="$2"
410
+ if file_nonempty "$path"; then
411
+ check_pass "$desc non-empty"
412
+ else
413
+ check_fail "$desc missing or empty"
414
+ fi
415
+ }
416
+
417
+ check_yaml_is() {
418
+ local field="$1"
419
+ local expected="$2"
420
+ local change_name="$3"
421
+ local actual
422
+ actual=$(cmd_get "$change_name" "$field")
423
+ if [ "$actual" = "$expected" ]; then
424
+ check_pass "${field}=${actual} (expected: ${expected})"
425
+ else
426
+ check_fail "${field}=${actual} (expected: ${expected})"
427
+ fi
428
+ }
429
+
430
+ check_yaml_empty() {
431
+ local field="$1"
432
+ local change_name="$2"
433
+ local value
434
+ value=$(cmd_get "$change_name" "$field")
435
+ if [ -z "$value" ] || [ "$value" = "null" ]; then
436
+ check_pass "${field} is empty/null"
437
+ else
438
+ check_fail "${field}=${value} (expected: empty/null)"
439
+ fi
440
+ }
441
+
442
+ check_file_not_exists() {
443
+ local desc="$1"
444
+ local path="$2"
445
+ if [ ! -f "$path" ]; then
446
+ check_pass "$desc does not exist"
447
+ else
448
+ check_fail "$desc exists (should not exist)"
449
+ fi
450
+ }
451
+
452
+ cmd_check() {
453
+ local change_name="$1"
454
+ local phase="$2"
455
+
456
+ validate_change_name "$change_name"
457
+ validate_enum "$phase" "open" "design" "build" "verify" "archive"
458
+
459
+ local change_dir="openspec/changes/$change_name"
460
+ local yaml_file="$change_dir/.comet.yaml"
461
+ local proposal_file="$change_dir/proposal.md"
462
+ local design_file="$change_dir/design.md"
463
+ local tasks_file="$change_dir/tasks.md"
464
+
465
+ echo "=== Entry Check: comet-${phase} ==="
466
+
467
+ # .comet.yaml must exist for all phases (state machine core)
468
+ if [ ! -f "$yaml_file" ]; then
469
+ red "ERROR: .comet.yaml not found at $yaml_file"
470
+ exit 1
471
+ fi
472
+
473
+ # Phase-specific checks
474
+ case "$phase" in
475
+ open)
476
+ check_pass ".comet.yaml exists"
477
+ check_yaml_is "phase" "open" "$change_name"
478
+ ;;
479
+ design)
480
+ check_pass ".comet.yaml exists"
481
+ check_yaml_is "phase" "design" "$change_name"
482
+ check_yaml_is "workflow" "full" "$change_name"
483
+ check_yaml_empty "design_doc" "$change_name"
484
+ check_nonempty "proposal.md" "$proposal_file"
485
+ check_nonempty "design.md" "$design_file"
486
+ check_nonempty "tasks.md" "$tasks_file"
487
+ ;;
488
+ build)
489
+ check_pass ".comet.yaml exists"
490
+ check_yaml_is "phase" "build" "$change_name"
491
+ # design_doc required for full workflow only
492
+ local workflow
493
+ workflow=$(cmd_get "$change_name" "workflow")
494
+ if [ "$workflow" = "full" ]; then
495
+ local design_doc
496
+ design_doc=$(cmd_get "$change_name" "design_doc")
497
+ if [ -n "$design_doc" ] && [ "$design_doc" != "null" ] && [ -f "$design_doc" ]; then
498
+ check_pass "design_doc=${design_doc} (file exists)"
499
+ else
500
+ check_fail "design_doc=${design_doc} (expected: non-null and file exists)"
501
+ fi
502
+ else
503
+ check_pass "workflow=${workflow} (design_doc not required)"
504
+ fi
505
+ check_nonempty "proposal.md" "$proposal_file"
506
+ check_nonempty "tasks.md" "$tasks_file"
507
+ ;;
508
+ verify)
509
+ check_pass ".comet.yaml exists"
510
+ check_yaml_is "phase" "verify" "$change_name"
511
+ # Check verify_result is pending or null
512
+ local verify_result
513
+ verify_result=$(cmd_get "$change_name" "verify_result")
514
+ if [ "$verify_result" = "pending" ] || [ -z "$verify_result" ] || [ "$verify_result" = "null" ]; then
515
+ check_pass "verify_result=${verify_result} (expected: pending or null)"
516
+ else
517
+ check_fail "verify_result=${verify_result} (expected: pending or null)"
518
+ fi
519
+ ;;
520
+ archive)
521
+ check_pass ".comet.yaml exists"
522
+ check_yaml_is "phase" "archive" "$change_name"
523
+ check_yaml_is "verify_result" "pass" "$change_name"
524
+ # Check archived is NOT true
525
+ local archived
526
+ archived=$(cmd_get "$change_name" "archived")
527
+ if [ "$archived" != "true" ]; then
528
+ check_pass "archived=${archived} (expected: not true)"
529
+ else
530
+ check_fail "archived=${archived} (expected: not true)"
531
+ fi
532
+ ;;
533
+ *)
534
+ red "ERROR: Unknown phase for check: $phase"
535
+ exit 1
536
+ ;;
537
+ esac
538
+
539
+ echo ""
540
+ if [ "$CHECK_BLOCK" -eq 1 ]; then
541
+ red "BLOCKED — fix failing checks before proceeding"
542
+ exit 1
543
+ else
544
+ green "ALL CHECKS PASSED — ready to proceed"
545
+ exit 0
546
+ fi
547
+ }
548
+
549
+ cmd_scale() {
550
+ local change_name="$1"
551
+
552
+ validate_change_name "$change_name"
553
+
554
+ local change_dir="openspec/changes/$change_name"
555
+ local yaml_file="$change_dir/.comet.yaml"
556
+
557
+ # Verify .comet.yaml exists
558
+ if [ ! -f "$yaml_file" ]; then
559
+ red "ERROR: .comet.yaml not found at $yaml_file"
560
+ exit 1
561
+ fi
562
+
563
+ # Read metrics
564
+ # 1. Task count: count lines matching `- [` in tasks.md
565
+ local tasks_file="$change_dir/tasks.md"
566
+ local task_count=0
567
+ if [ -f "$tasks_file" ]; then
568
+ task_count=$(grep -c '^\- \[' "$tasks_file" 2>/dev/null || echo "0")
569
+ fi
570
+
571
+ # 2. Delta spec count: count files named spec.md under specs/*/spec.md
572
+ local delta_spec_count=0
573
+ if [ -d "$change_dir/specs" ]; then
574
+ delta_spec_count=$(find "$change_dir/specs" -name "spec.md" -type f 2>/dev/null | wc -l | tr -d ' ')
575
+ fi
576
+
577
+ # 3. Changed files: prefer plan base-ref, fall back to worktree diff
578
+ local changed_files=0
579
+ if git rev-parse --git-dir > /dev/null 2>&1; then
580
+ local plan_file base_ref
581
+ plan_file=$(cmd_get "$change_name" "plan" 2>/dev/null || true)
582
+ if [ -n "$plan_file" ] && [ "$plan_file" != "null" ] && [ -f "$plan_file" ]; then
583
+ base_ref=$(grep '^base-ref:' "$plan_file" 2>/dev/null | head -1 | sed 's/^base-ref: *//')
584
+ fi
585
+
586
+ if [ -n "${base_ref:-}" ] && git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
587
+ changed_files=$(git diff --name-only "$base_ref"...HEAD 2>/dev/null | wc -l | tr -d ' ')
588
+ else
589
+ changed_files=$(git diff --name-only HEAD 2>/dev/null | wc -l | tr -d ' ')
590
+ fi
591
+ fi
592
+
593
+ # Decision rules
594
+ local result="light"
595
+ if [ "$task_count" -gt 3 ] || [ "$delta_spec_count" -gt 1 ] || [ "$changed_files" -gt 5 ]; then
596
+ result="full"
597
+ fi
598
+
599
+ # Output assessment to stderr
600
+ echo "=== Scale Assessment: $change_name ===" >&2
601
+ echo " Tasks: $task_count (threshold: 3)" >&2
602
+ echo " Delta specs: $delta_spec_count capabilities (threshold: 1)" >&2
603
+ echo " Changed files: $changed_files (threshold: 5)" >&2
604
+ echo " → Result: $result" >&2
605
+
606
+ # Update verify_mode in .comet.yaml
607
+ replace_yaml_field "$yaml_file" "verify_mode" "$result"
608
+
609
+ green "[SCALE] verify_mode=$result"
610
+ }
611
+
612
+ # --- Main ---
613
+
614
+ SUBCOMMAND="${1:-}"
615
+ shift || true
616
+
617
+ case "$SUBCOMMAND" in
618
+ init)
619
+ if [ $# -lt 2 ]; then
620
+ red "Usage: comet-state.sh init <change-name> <workflow>" >&2
621
+ red "Workflows: full, hotfix, tweak" >&2
622
+ exit 1
623
+ fi
624
+ cmd_init "$@"
625
+ ;;
626
+ get)
627
+ if [ $# -lt 2 ]; then
628
+ red "Usage: comet-state.sh get <change-name> <field>" >&2
629
+ exit 1
630
+ fi
631
+ cmd_get "$@"
632
+ ;;
633
+ set)
634
+ if [ $# -lt 3 ]; then
635
+ red "Usage: comet-state.sh set <change-name> <field> <value>" >&2
636
+ exit 1
637
+ fi
638
+ cmd_set "$@"
639
+ ;;
640
+ transition)
641
+ if [ $# -lt 2 ]; then
642
+ red "Usage: comet-state.sh transition <change-name> <event>" >&2
643
+ red "Events: open-complete, design-complete, build-complete, verify-pass, verify-fail, archived" >&2
644
+ exit 1
645
+ fi
646
+ cmd_transition "$@"
647
+ ;;
648
+ check)
649
+ if [ $# -lt 2 ]; then
650
+ red "Usage: comet-state.sh check <change-name> <phase>" >&2
651
+ red "Phases: open, design, build, verify, archive" >&2
652
+ exit 1
653
+ fi
654
+ cmd_check "$@"
655
+ ;;
656
+ scale)
657
+ if [ $# -lt 1 ]; then
658
+ red "Usage: comet-state.sh scale <change-name>" >&2
659
+ exit 1
660
+ fi
661
+ cmd_scale "$@"
662
+ ;;
663
+ *)
664
+ red "Unknown subcommand: $SUBCOMMAND" >&2
665
+ echo "" >&2
666
+ echo "Usage: comet-state.sh <subcommand> <change-name> [args...]" >&2
667
+ echo "" >&2
668
+ echo "Subcommands:" >&2
669
+ echo " init <change-name> <workflow> — Initialize .comet.yaml with workflow defaults" >&2
670
+ echo " get <change-name> <field> — Read a field value from .comet.yaml" >&2
671
+ echo " set <change-name> <field> <val> — Update a field value in .comet.yaml" >&2
672
+ echo " transition <change-name> <event> — Apply a validated state transition" >&2
673
+ echo " check <change-name> <phase> — Verify entry requirements for a phase" >&2
674
+ echo " scale <change-name> — Assess and set verification mode based on metrics" >&2
675
+ echo "" >&2
676
+ echo "Workflows: full, hotfix, tweak" >&2
677
+ echo "Phases for check: open, design, build, verify, archive" >&2
678
+ exit 1
679
+ ;;
680
+ esac