@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,559 +0,0 @@
1
- #!/bin/bash
2
- # comate-lib.sh — shared library for comate (sourced, never executed directly)
3
- #
4
- # Single source of truth for: YAML flat read/write, field-order template + enums,
5
- # hashing, logging, exit-code contract. Other scripts `source` this file.
6
- #
7
- # - all YAML logic lives here
8
- # - .comate.yaml is a declared flat `key: value` subset; validate_flat_yaml enforces it.
9
- # - yaml_set does full-rewrite + atomic mv + write-back verification.
10
- # - stable exit codes — 0 OK | 1 BLOCK | 2 USAGE | 3 ENV | 4 CORRUPT.
11
- #
12
- # This file MUST NOT set `set -e` / `set -u`: it is sourced into hosts that
13
- # manage their own shell options.
14
-
15
- # --- Exit-code contract ---
16
- readonly COMATE_OK=0
17
- readonly COMATE_BLOCK=1
18
- readonly COMATE_USAGE=2
19
- readonly COMATE_ENV=3
20
- readonly COMATE_CORRUPT=4
21
-
22
- # --- Color logging on stderr ---
23
- _comate_color() {
24
- # $1 = ansi code, $2 = message
25
- if [ -t 2 ]; then
26
- printf '\033[%sm%s\033[0m\n' "$1" "$2" >&2
27
- else
28
- printf '%s\n' "$2" >&2
29
- fi
30
- }
31
- log_pass() { _comate_color "32" "$1"; }
32
- log_fail() { _comate_color "31" "$1"; }
33
- log_warn() { _comate_color "33" "$1"; }
34
- log_info() { _comate_color "36" "$1"; }
35
-
36
- # die <code> <msg> — print red error to stderr and exit with the given code.
37
- die() {
38
- local code="$1"
39
- shift
40
- _comate_color "31" "$*"
41
- exit "$code"
42
- }
43
-
44
- # --- Canonical field order ---
45
- # Every yaml_set rewrites the whole file in this order, so diffs stay stable
46
- # and duplicate keys are impossible.
47
- # Three-place sync rule: a new field must be added here, in ENUMS (if enum),
48
- # and in the bats yaml fixtures.
49
- FIELD_ORDER=(
50
- schema_version
51
- workflow
52
- phase
53
- phase_step
54
- phase_step_data
55
- build_mode
56
- tdd_mode
57
- isolation
58
- verify_mode
59
- verify_result
60
- design_doc
61
- plan
62
- verification_report
63
- branch_status
64
- handoff_context
65
- handoff_hash
66
- impl_context
67
- impl_hash
68
- direct_override
69
- base_ref
70
- base_branch
71
- source_kind
72
- source_manifest
73
- build_command
74
- verify_command
75
- created_at
76
- verified_at
77
- archived
78
- )
79
-
80
- # Current schema version written by `state init`.
81
- readonly COMATE_SCHEMA_VERSION=1
82
-
83
- # --- Enum tables ---
84
- # Keep these as case-based lookups instead of associative arrays. macOS ships
85
- # Bash 3.2, which has normal arrays but not `declare -A`.
86
- enum_keys() {
87
- printf '%s\n' \
88
- schema_version workflow phase build_mode tdd_mode isolation verify_mode \
89
- verify_result branch_status direct_override archived source_kind
90
- }
91
-
92
- enum_values() {
93
- case "$1" in
94
- schema_version) printf '%s\n' '1' ;;
95
- workflow) printf '%s\n' 'full fix' ;;
96
- phase) printf '%s\n' 'open design build verify archive' ;;
97
- build_mode) printf '%s\n' 'subagent-driven-development executing-plans direct' ;;
98
- tdd_mode) printf '%s\n' 'tdd direct' ;;
99
- isolation) printf '%s\n' 'branch worktree' ;;
100
- verify_mode) printf '%s\n' 'light full' ;;
101
- verify_result) printf '%s\n' 'pending pass fail' ;;
102
- branch_status) printf '%s\n' 'pending handled discarded' ;;
103
- direct_override) printf '%s\n' 'true false' ;;
104
- archived) printf '%s\n' 'true false' ;;
105
- source_kind) printf '%s\n' 'conversation file' ;;
106
- *) return 1 ;;
107
- esac
108
- }
109
-
110
- # Keys whose values are free-form (paths, dates, commands, hashes, refs).
111
- # Listed as a case lookup so unknown fields can still be rejected on Bash 3.2.
112
- is_freeform_key() {
113
- case "$1" in
114
- design_doc|plan|verification_report|handoff_context|handoff_hash|\
115
- impl_context|impl_hash|base_ref|base_branch|source_manifest|\
116
- build_command|verify_command|created_at|verified_at|phase_step|phase_step_data)
117
- return 0 ;;
118
- *) return 1 ;;
119
- esac
120
- }
121
-
122
- # --- Path location ---
123
-
124
- # comate_change_dir <name> — echo the change directory, preferring the active
125
- # location but falling back to archive/ if that is where it lives.
126
- comate_change_dir() {
127
- local name="$1"
128
- if [ -d "openspec/changes/$name" ]; then
129
- echo "openspec/changes/$name"
130
- elif [ -d "openspec/changes/archive/$name" ]; then
131
- echo "openspec/changes/archive/$name"
132
- else
133
- echo "openspec/changes/$name"
134
- fi
135
- }
136
-
137
- # comate_yaml_for <name> — echo path to that change's .comate.yaml.
138
- comate_yaml_for() {
139
- echo "$(comate_change_dir "$1")/.comate.yaml"
140
- }
141
-
142
- # --- Input validation ---
143
-
144
- # validate_change_name <name> — only [A-Za-z0-9_-]; reject empty and `..`.
145
- validate_change_name() {
146
- local name="$1"
147
- if [ -z "$name" ]; then
148
- die "$COMATE_USAGE" "ERROR: change name cannot be empty"
149
- fi
150
- if [[ "$name" == *..* ]]; then
151
- die "$COMATE_USAGE" "ERROR: change name cannot contain '..' (path traversal not allowed): '$name'"
152
- fi
153
- if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
154
- die "$COMATE_USAGE" "ERROR: invalid change name '$name' (allowed: a-z A-Z 0-9 - _)"
155
- fi
156
- }
157
-
158
- # validate_enum <key> <value> — die if value not legal for an enum key.
159
- # Free-form keys and unlisted keys pass through. null/empty always allowed.
160
- validate_enum() {
161
- local key="$1" value="$2"
162
- local legal
163
- legal=$(enum_values "$key" 2>/dev/null) || legal=""
164
- [ -z "$legal" ] && return 0
165
- [ -z "$value" ] && return 0
166
- [ "$value" = "null" ] && return 0
167
- local v
168
- for v in $legal; do
169
- [ "$value" = "$v" ] && return 0
170
- done
171
- die "$COMATE_USAGE" "ERROR: invalid value '$value' for '$key' (expected: $legal)"
172
- }
173
-
174
- # --- YAML read (single implementation) ---
175
-
176
- # _strip_inline_comment — drop a ` #...` comment that is not inside quotes.
177
- _strip_inline_comment() {
178
- printf '%s\n' "$1" | awk -v squote="'" '
179
- {
180
- out = ""; quote = ""
181
- for (i = 1; i <= length($0); i++) {
182
- c = substr($0, i, 1)
183
- if (quote == "") {
184
- if (c == "\"" || c == squote) { quote = c }
185
- else if (c == "#" && (i == 1 || substr($0, i - 1, 1) ~ /[[:space:]]/)) {
186
- sub(/[[:space:]]+$/, "", out); print out; next
187
- }
188
- } else if (c == quote) { quote = "" }
189
- out = out c
190
- }
191
- print out
192
- }'
193
- }
194
-
195
- # _strip_wrapping_quotes — remove a single pair of wrapping quotes.
196
- _strip_wrapping_quotes() {
197
- local value="$1"
198
- case "$value" in
199
- \"*\") printf '%s\n' "${value:1:${#value}-2}" ;;
200
- \'*\') printf '%s\n' "${value:1:${#value}-2}" ;;
201
- *) printf '%s\n' "$value" ;;
202
- esac
203
- }
204
-
205
- # yaml_get <file> <key> — echo the value (comment/quote-stripped), empty if absent.
206
- yaml_get() {
207
- local file="$1" key="$2"
208
- [ -f "$file" ] || return 0
209
- local value
210
- value=$(grep "^${key}:" "$file" 2>/dev/null | head -1 | sed "s/^${key}:[[:space:]]*//" || true)
211
- value=$(_strip_inline_comment "$value")
212
- _strip_wrapping_quotes "$value"
213
- }
214
-
215
- # yaml_keys <file> — list all top-level keys, one per line.
216
- yaml_keys() {
217
- local file="$1"
218
- [ -f "$file" ] || return 0
219
- grep -E '^[A-Za-z0-9_-]+:' "$file" 2>/dev/null | sed 's/:.*//' || true
220
- }
221
-
222
- # validate_flat_yaml <file> — enforce that .comate.yaml is the
223
- # declared subset: top-level `key: value` scalars plus `#` comments only.
224
- # Reject indented lines, list items, block/flow collections. die(CORRUPT) on violation.
225
- validate_flat_yaml() {
226
- local file="$1"
227
- [ -f "$file" ] || die "$COMATE_CORRUPT" "ERROR: state file not found: $file"
228
- local lineno=0 line
229
- while IFS= read -r line || [ -n "$line" ]; do
230
- lineno=$((lineno + 1))
231
- # Allow blank lines and full-line comments.
232
- case "$line" in
233
- "" ) continue ;;
234
- esac
235
- if [[ "$line" =~ ^[[:space:]]*# ]]; then
236
- continue
237
- fi
238
- # Reject leading whitespace (nesting / continuation / list under a key).
239
- if [[ "$line" =~ ^[[:space:]]+ ]]; then
240
- die "$COMATE_CORRUPT" "ERROR: $file:$lineno indented line not allowed (flat key:value only): $line"
241
- fi
242
- # Reject top-level list items.
243
- if [[ "$line" =~ ^- ]]; then
244
- die "$COMATE_CORRUPT" "ERROR: $file:$lineno list item not allowed (flat key:value only): $line"
245
- fi
246
- # Require a `key: value` shape.
247
- if [[ ! "$line" =~ ^[A-Za-z0-9_-]+: ]]; then
248
- die "$COMATE_CORRUPT" "ERROR: $file:$lineno not a 'key: value' line: $line"
249
- fi
250
- # Reject values that open a block scalar (| or >).
251
- local val
252
- val=$(printf '%s\n' "$line" | sed 's/^[A-Za-z0-9_-]*:[[:space:]]*//')
253
- val=$(_strip_inline_comment "$val")
254
- case "$val" in
255
- "|"|">"|"|-"|">-"|"|+"|">+")
256
- die "$COMATE_CORRUPT" "ERROR: $file:$lineno block scalar not allowed (flat key:value only): $line" ;;
257
- esac
258
- done < "$file"
259
- return 0
260
- }
261
-
262
- # --- YAML write (full rewrite + atomic mv + write-back check) ---
263
-
264
- # yaml_set <file> <key> <value> — validate enum, read whole file, set key in
265
- # memory, rewrite every field in FIELD_ORDER to a temp file, atomically mv into
266
- # place, then read back and confirm the value matches.
267
- yaml_set() {
268
- local file="$1" key="$2" value="$3"
269
- [ -f "$file" ] || die "$COMATE_CORRUPT" "ERROR: state file not found: $file"
270
-
271
- # Reject keys we do not know about (prevents silent typo fields).
272
- if ! enum_values "$key" >/dev/null 2>&1 && ! is_freeform_key "$key" && [ "$key" != "schema_version" ]; then
273
- die "$COMATE_USAGE" "ERROR: unknown field '$key'"
274
- fi
275
- validate_enum "$key" "$value"
276
-
277
- local tmp
278
- tmp=$(mktemp "${TMPDIR:-/tmp}/comate.XXXXXX") || die "$COMATE_ENV" "ERROR: mktemp failed"
279
- {
280
- printf '# comate state — flat key:value only, no nesting\n'
281
- local k
282
- for k in "${FIELD_ORDER[@]}"; do
283
- local current
284
- if [ "$k" = "$key" ]; then
285
- current="$value"
286
- else
287
- current=$(yaml_get "$file" "$k")
288
- fi
289
- [ -z "$current" ] && current="null"
290
- printf '%s: %s\n' "$k" "$current"
291
- done
292
- } > "$tmp"
293
-
294
- mv "$tmp" "$file" || { rm -f "$tmp"; die "$COMATE_ENV" "ERROR: failed to replace $file"; }
295
-
296
- # Write-back verification.
297
- local readback
298
- readback=$(yaml_get "$file" "$key")
299
- if [ "$readback" != "$value" ]; then
300
- die "$COMATE_CORRUPT" "ERROR: write-back mismatch for '$key': wrote '$value', read '$readback'"
301
- fi
302
- }
303
-
304
- # --- Hashing (cross-platform) ---
305
-
306
- # _hash_stream — sha256 of stdin, hex only.
307
- _hash_stream() {
308
- if command -v sha256sum >/dev/null 2>&1; then
309
- sha256sum | awk '{print $1}'
310
- elif command -v shasum >/dev/null 2>&1; then
311
- shasum -a 256 | awk '{print $1}'
312
- else
313
- die "$COMATE_ENV" "ERROR: sha256sum or shasum is required"
314
- fi
315
- }
316
-
317
- # _hash_file_stream <file> - emit canonical content for hashing.
318
- # Text files are normalized to LF so a checkout/tool that temporarily writes
319
- # CRLF cannot create a false provenance or impl_hash drift. Binary files stay
320
- # byte-for-byte unchanged.
321
- _hash_file_stream() {
322
- local file="$1"
323
- if [ ! -s "$file" ] || LC_ALL=C grep -Iq . "$file" 2>/dev/null; then
324
- sed 's/\r$//' "$file"
325
- else
326
- cat "$file"
327
- fi
328
- }
329
-
330
- # hash_file <file> - sha256 hex of one file (LF-canonical for text).
331
- hash_file() {
332
- local file="$1"
333
- if command -v sha256sum >/dev/null 2>&1; then
334
- _hash_file_stream "$file" | sha256sum | awk '{print $1}'
335
- elif command -v shasum >/dev/null 2>&1; then
336
- _hash_file_stream "$file" | shasum -a 256 | awk '{print $1}'
337
- else
338
- die "$COMATE_ENV" "ERROR: sha256sum or shasum is required"
339
- fi
340
- }
341
-
342
- # hash_files_manifest <file...> — for each existing file emit `path:`/`sha256:`
343
- # lines, then hash the whole manifest. Order-stable, missing files skipped.
344
- hash_files_manifest() {
345
- local f
346
- for f in "$@"; do
347
- if [ -f "$f" ]; then
348
- printf 'path:%s\n' "$f"
349
- printf 'sha256:%s\n' "$(hash_file "$f")"
350
- fi
351
- done | _hash_stream
352
- }
353
-
354
- # file_nonempty <path>
355
- file_nonempty() { [ -f "$1" ] && [ -s "$1" ]; }
356
-
357
- # --- Source tracking (knowledge-base provenance) ---
358
-
359
- # comate_kb_root — repo-relative root under which source files may be tracked.
360
- comate_kb_root() { echo "docs/knowledge-base"; }
361
-
362
- # _abs_path <path> — absolute, symlink-resolved path; empty + non-zero if it
363
- # cannot be resolved. realpath/readlink -f preferred; dir-level fallback otherwise.
364
- _abs_path() {
365
- local p="$1"
366
- if command -v realpath >/dev/null 2>&1; then
367
- realpath "$p" 2>/dev/null
368
- elif command -v readlink >/dev/null 2>&1 && readlink -f "$p" >/dev/null 2>&1; then
369
- readlink -f "$p" 2>/dev/null
370
- else
371
- local dir base
372
- dir=$(dirname "$p"); base=$(basename "$p")
373
- ( cd "$dir" 2>/dev/null && printf '%s/%s\n' "$(pwd -P)" "$base" )
374
- fi
375
- }
376
-
377
- # validate_kb_path <path> — echo the repo-relative path if it resolves to an
378
- # existing file under the knowledge-base root, else echo nothing and return 1.
379
- # Rejects '..' traversal, absolute/repo-external paths, and symlink escapes.
380
- validate_kb_path() {
381
- local raw="$1" root_abs target_abs cwd
382
- [ -n "$raw" ] || return 1
383
- case "$raw" in *..*) return 1 ;; esac
384
- [ -f "$raw" ] || return 1
385
- root_abs=$(_abs_path "$(comate_kb_root)") || return 1
386
- [ -n "$root_abs" ] || return 1
387
- target_abs=$(_abs_path "$raw") || return 1
388
- [ -n "$target_abs" ] || return 1
389
- case "$target_abs" in
390
- "$root_abs"/*) ;;
391
- *) return 1 ;;
392
- esac
393
- cwd=$(_abs_path .) || return 1
394
- printf '%s\n' "${target_abs#"$cwd"/}"
395
- }
396
-
397
- # sources_manifest_for <name> — echo path to a change's source manifest.
398
- sources_manifest_for() {
399
- echo "$(comate_change_dir "$1")/.comate/sources.manifest"
400
- }
401
-
402
- # sources_manifest_paths <manifest> — list recorded `path:` values, one per line.
403
- sources_manifest_paths() {
404
- local m="$1"
405
- [ -f "$m" ] || return 0
406
- grep '^path:' "$m" 2>/dev/null | sed 's/^path://' || true
407
- }
408
-
409
- # sources_drift <manifest> — recompute sha256 for each recorded path and compare
410
- # against the manifest. Echo human-readable detail; return COMATE_OK if all
411
- # match, COMATE_BLOCK on drift/missing file, COMATE_CORRUPT on malformed entries.
412
- sources_drift() {
413
- local m="$1" path="" recorded="" rc=0 line actual
414
- [ -f "$m" ] || { echo "manifest not found: $m"; return "$COMATE_CORRUPT"; }
415
- while IFS= read -r line || [ -n "$line" ]; do
416
- case "$line" in
417
- path:*) path="${line#path:}" ;;
418
- sha256:*)
419
- recorded="${line#sha256:}"
420
- if [ -z "$path" ]; then echo "manifest corrupt: sha256 with no preceding path"; return "$COMATE_CORRUPT"; fi
421
- if [ ! -f "$path" ]; then
422
- echo "source file missing: $path"; rc=1
423
- else
424
- actual=$(hash_file "$path")
425
- if [ "$actual" != "$recorded" ]; then
426
- echo "source drifted: $path"
427
- echo " recorded: $recorded"
428
- echo " actual: $actual"
429
- rc=1
430
- fi
431
- fi
432
- ;;
433
- ---|"") path=""; recorded="" ;;
434
- esac
435
- done < "$m"
436
- [ "$rc" -eq 0 ] && return "$COMATE_OK"
437
- return "$COMATE_BLOCK"
438
- }
439
-
440
- # json_escape <string> — escape backslash and double-quote for JSON values.
441
- json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
442
-
443
- # --- PRD gate (phase 0: quality gate + split) ---
444
- #
445
- # gate.yaml lives NEXT TO the PRD (<prd>.gate.yaml), not under openspec/changes,
446
- # so it has its own flat schema kept separate from the change .comate.yaml
447
- # FIELD_ORDER (mixing them would pollute every change state file with gate keys).
448
- # It is the machine truth for "may a change be spawned from this PRD"; the
449
- # markdown review report is the human-readable derivative.
450
-
451
- readonly GATE_SCHEMA_VERSION=1
452
-
453
- # Canonical field order for gate.yaml (same full-rewrite discipline as yaml_set).
454
- GATE_FIELD_ORDER=(
455
- schema_version
456
- prd_path
457
- prd_hash
458
- gate_status
459
- score
460
- blocking_open
461
- review_report
462
- spawned_changes
463
- changes_map
464
- gated_at
465
- created_at
466
- )
467
-
468
- # gate_status is the only enum. `stale` is computed (hash mismatch) but also
469
- # storable so a detected-stale gate persists across runs.
470
- gate_enum_values() {
471
- case "$1" in
472
- schema_version) printf '%s\n' '1' ;;
473
- gate_status) printf '%s\n' 'reviewing blocked passed passed-with-tbd stale' ;;
474
- *) return 1 ;;
475
- esac
476
- }
477
-
478
- # gate_stem_for <prd> — derive the short stem used to name a PRD's gate.yaml
479
- # and review report. A conforming PRD file is `PRD-<NNN>-<title>.md`, whose
480
- # gate/report drop the title and key off `PRD-<NNN>` (so one dir with
481
- # PRD-001/PRD-002 gets PRD-001.gate.yaml / PRD-002.gate.yaml). Non-conforming
482
- # names (no PRD-NNN prefix) fall back to the full basename so callers still get
483
- # a stable, collision-free stem instead of an error.
484
- gate_stem_for() {
485
- local base stem
486
- base=$(basename "$1")
487
- base="${base%.markdown}"
488
- base="${base%.md}"
489
- # Avoid a capture group in `[[ =~ ]]`: the old parser in macOS Bash 3.2
490
- # treats an unquoted `(` as syntax before the conditional can run.
491
- stem=$(printf '%s\n' "$base" | sed -n 's/^\([Pp][Rr][Dd]-[0-9][0-9]*\).*/\1/p')
492
- if [ -n "$stem" ]; then
493
- echo "$stem"
494
- else
495
- echo "$base"
496
- fi
497
- }
498
-
499
- # gate_path_for <prd> — echo the gate.yaml path adjacent to the PRD.
500
- # Named `<stem>.gate.yaml` where stem is PRD-<NNN> (see gate_stem_for).
501
- gate_path_for() {
502
- local prd="$1" dir stem
503
- dir=$(dirname "$prd")
504
- stem=$(gate_stem_for "$prd")
505
- echo "${dir}/${stem}.gate.yaml"
506
- }
507
-
508
- # gate_report_for <prd> — echo the review report path (PRD same dir).
509
- # Named `<stem>-REVIEW.md` where stem is PRD-<NNN> (see gate_stem_for).
510
- gate_report_for() {
511
- local prd="$1" dir stem
512
- dir=$(dirname "$prd")
513
- stem=$(gate_stem_for "$prd")
514
- echo "${dir}/${stem}-REVIEW.md"
515
- }
516
-
517
- # gate_yaml_set <file> <key> <value> — gate-file analogue of yaml_set: validate
518
- # enum, read the whole file, set one key, rewrite every GATE_FIELD_ORDER field,
519
- # atomic mv, write-back verify.
520
- gate_yaml_set() {
521
- local file="$1" key="$2" value="$3"
522
- [ -f "$file" ] || die "$COMATE_CORRUPT" "ERROR: gate file not found: $file"
523
-
524
- local known=0 k
525
- for k in "${GATE_FIELD_ORDER[@]}"; do
526
- [ "$k" = "$key" ] && { known=1; break; }
527
- done
528
- [ "$known" -eq 1 ] || die "$COMATE_USAGE" "ERROR: unknown gate field '$key'"
529
-
530
- local legal
531
- legal=$(gate_enum_values "$key" 2>/dev/null) || legal=""
532
- if [ -n "$legal" ] && [ -n "$value" ] && [ "$value" != "null" ]; then
533
- local ok=0 v
534
- for v in $legal; do [ "$value" = "$v" ] && { ok=1; break; }; done
535
- [ "$ok" -eq 1 ] || die "$COMATE_USAGE" "ERROR: invalid value '$value' for '$key' (expected: $legal)"
536
- fi
537
-
538
- local tmp
539
- tmp=$(mktemp "${TMPDIR:-/tmp}/comate-gate.XXXXXX") || die "$COMATE_ENV" "ERROR: mktemp failed"
540
- {
541
- printf '# comate PRD gate — flat key:value only, no nesting\n'
542
- for k in "${GATE_FIELD_ORDER[@]}"; do
543
- local current
544
- if [ "$k" = "$key" ]; then
545
- current="$value"
546
- else
547
- current=$(yaml_get "$file" "$k")
548
- fi
549
- [ -z "$current" ] && current="null"
550
- printf '%s: %s\n' "$k" "$current"
551
- done
552
- } > "$tmp"
553
- mv "$tmp" "$file" || { rm -f "$tmp"; die "$COMATE_ENV" "ERROR: failed to replace $file"; }
554
-
555
- local readback; readback=$(yaml_get "$file" "$key")
556
- if [ "$readback" != "$value" ]; then
557
- die "$COMATE_CORRUPT" "ERROR: gate write-back mismatch for '$key': wrote '$value', read '$readback'"
558
- fi
559
- }