cclaw-cli 0.48.8 → 0.48.10

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,1666 +1,6 @@
1
- /**
2
- * Hook helper scripts and harness hook JSON generators.
3
- *
4
- * This module still provides prompt/workflow/context guard scripts and
5
- * cross-harness hook wiring. Observation pipeline scripts are retained only
6
- * for backward compatibility and are not wired by default runtime generation.
7
- */
8
1
  import { RUNTIME_ROOT } from "../constants.js";
9
- import { RUNTIME_SHELL_DETECT_ROOT } from "./hooks.js";
10
- export function promptGuardScript(options = {}) {
11
- const promptGuardMode = options.strictMode === true ? "strict" : "advisory";
12
- return `#!/usr/bin/env bash
13
- # cclaw prompt guard hook — generated by cclaw sync
14
- # Advisory-only guard for risky writes into ${RUNTIME_ROOT} runtime files.
15
- set -uo pipefail
16
- shopt -s globstar 2>/dev/null || true
17
- PROMPT_GUARD_MODE="${promptGuardMode}"
18
-
19
- ${RUNTIME_SHELL_DETECT_ROOT}
20
-
21
- STATE_DIR="$ROOT/${RUNTIME_ROOT}/state"
22
- GUARD_LOG="$STATE_DIR/prompt-guard.jsonl"
23
- mkdir -p "$STATE_DIR" 2>/dev/null || true
24
-
25
- INPUT=$(cat 2>/dev/null || echo '{}')
26
- [ -n "$INPUT" ] || exit 0
27
-
28
- TOOL="unknown"
29
- PAYLOAD="$INPUT"
30
- if command -v cclaw_hook_extract_tool_and_payload >/dev/null 2>&1; then
31
- cclaw_hook_extract_tool_and_payload "$INPUT"
32
- TOOL="\${CCLAW_HOOK_TOOL:-unknown}"
33
- PAYLOAD="\${CCLAW_HOOK_PAYLOAD:-$INPUT}"
34
- elif command -v jq >/dev/null 2>&1; then
35
- TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // .tool // .toolName // .name // .id // .command // .tool.name // .tool.id // .input.tool_name // .input.tool // .input.toolName // .input.name // .input.id // .input.command // .input.tool.name // .input.tool.id // "unknown"' 2>/dev/null || echo "unknown")
36
- PAYLOAD=$(printf '%s' "$INPUT" | jq -r '.tool_input // .input // .arguments // .params // .payload // {} | tostring' 2>/dev/null || echo "")
37
- elif command -v python3 >/dev/null 2>&1; then
38
- TOOL=$(INPUT_JSON="$INPUT" python3 - <<'PY'
39
- import json
40
- import os
41
-
42
- try:
43
- value = json.loads(os.environ.get("INPUT_JSON", "{}"))
44
- except Exception:
45
- value = {}
46
-
47
- def pick_tool(payload):
48
- if not isinstance(payload, dict):
49
- return "unknown"
50
- candidates = [
51
- payload.get("tool_name"),
52
- payload.get("tool"),
53
- payload.get("toolName"),
54
- payload.get("name"),
55
- payload.get("id"),
56
- payload.get("command")
57
- ]
58
- top_tool = payload.get("tool")
59
- if isinstance(top_tool, dict):
60
- candidates.extend([top_tool.get("name"), top_tool.get("id")])
61
- nested = payload.get("input")
62
- if isinstance(nested, dict):
63
- candidates.extend([
64
- nested.get("tool_name"),
65
- nested.get("tool"),
66
- nested.get("toolName"),
67
- nested.get("name"),
68
- nested.get("id"),
69
- nested.get("command")
70
- ])
71
- nested_tool = nested.get("tool")
72
- if isinstance(nested_tool, dict):
73
- candidates.extend([nested_tool.get("name"), nested_tool.get("id")])
74
- for candidate in candidates:
75
- if isinstance(candidate, str) and candidate.strip():
76
- return candidate.strip()
77
- return "unknown"
78
-
79
- print(pick_tool(value))
80
- PY
81
- )
82
- PAYLOAD=$(printf '%s' "$INPUT")
83
- else
84
- PAYLOAD=$(printf '%s' "$INPUT")
85
- fi
86
-
87
- if [ -z "$PAYLOAD" ]; then
88
- PAYLOAD=$(printf '%s' "$INPUT")
89
- fi
90
-
91
- if command -v cclaw_hook_lower >/dev/null 2>&1; then
92
- PAYLOAD_LOWER=$(cclaw_hook_lower "$PAYLOAD")
93
- TOOL_LOWER=$(cclaw_hook_lower "$TOOL")
94
- else
95
- PAYLOAD_LOWER=$(printf '%s' "$PAYLOAD" | tr '[:upper:]' '[:lower:]')
96
- TOOL_LOWER=$(printf '%s' "$TOOL" | tr '[:upper:]' '[:lower:]')
97
- fi
98
- TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
99
- REASONS=""
100
-
101
- case "$TOOL_LOWER" in
102
- write|edit|multiedit|delete|applypatch|runcommand|shell|terminal|execcommand)
103
- if printf '%s' "$PAYLOAD_LOWER" | grep -Eq '\.cclaw/(state|artifacts|hooks|skills|commands|agents|runs|knowledge)'; then
104
- REASONS="write_to_cclaw_runtime"
105
- fi
106
- ;;
107
- esac
108
-
109
- if printf '%s' "$PAYLOAD_LOWER" | grep -Eq '(rm[[:space:]]+-rf[[:space:]]+\.cclaw|curl[[:space:]].*https?://|wget[[:space:]].*https?://|base64[[:space:]]+-d|eval[(]|python[[:space:]]+-c)'; then
110
- if [ -n "$REASONS" ]; then
111
- REASONS="$REASONS,suspicious_payload_pattern"
112
- else
113
- REASONS="suspicious_payload_pattern"
114
- fi
115
- fi
116
-
117
- if [ -n "$REASONS" ]; then
118
- NOTE="Cclaw advisory: potential risky write intent detected for ${RUNTIME_ROOT} runtime (\${REASONS}). Prefer installer commands or explicit confirmation before mutating runtime internals."
119
- if command -v jq >/dev/null 2>&1; then
120
- ENTRY=$(jq -n -c \
121
- --arg ts "$TS" \
122
- --arg harness "$HARNESS" \
123
- --arg tool "$TOOL" \
124
- --arg reasons "$REASONS" \
125
- --arg note "$NOTE" \
126
- '{ts:$ts,harness:$harness,tool:$tool,reasons:($reasons|split(",")),note:$note}' 2>/dev/null || echo "")
127
- elif command -v python3 >/dev/null 2>&1; then
128
- ENTRY=$(TS="$TS" HARNESS="$HARNESS" TOOL="$TOOL" REASONS="$REASONS" NOTE="$NOTE" python3 - <<'PY'
129
- import json, os
130
-
131
- ts = os.environ.get("TS") or ""
132
- harness = os.environ.get("HARNESS") or ""
133
- tool = os.environ.get("TOOL") or "unknown"
134
- reasons_raw = os.environ.get("REASONS") or ""
135
- note = os.environ.get("NOTE") or ""
136
- reasons = [r for r in reasons_raw.split(",") if r]
137
- print(json.dumps({"ts": ts, "harness": harness, "tool": tool, "reasons": reasons, "note": note}, ensure_ascii=False))
138
- PY
139
- )
140
- else
141
- ENTRY=""
142
- fi
143
-
144
- if [ -n "$ENTRY" ]; then
145
- printf '%s\n' "$ENTRY" >> "$GUARD_LOG" 2>/dev/null || true
146
- fi
147
- if [ "$PROMPT_GUARD_MODE" = "strict" ]; then
148
- printf '[cclaw] %s (blocked by strict mode)\n' "$NOTE" >&2
149
- exit 1
150
- fi
151
- printf '[cclaw] %s\n' "$NOTE" >&2
152
- fi
153
-
154
- exit 0
155
- `;
156
- }
157
- export function workflowGuardScript(options = {}) {
158
- const workflowGuardMode = options.workflowGuardMode === "strict" ? "strict" : "advisory";
159
- const tddEnforcementMode = options.tddEnforcementMode === "strict" ? "strict" : "advisory";
160
- const tddTestPathPatterns = options.tddTestPathPatterns && options.tddTestPathPatterns.length > 0
161
- ? options.tddTestPathPatterns.join(",")
162
- : "**/*.test.*,**/tests/**,**/__tests__/**";
163
- const tddProductionPathPatterns = options.tddProductionPathPatterns && options.tddProductionPathPatterns.length > 0
164
- ? options.tddProductionPathPatterns.join(",")
165
- : "";
166
- return `#!/usr/bin/env bash
167
- # cclaw workflow guard hook — generated by cclaw sync
168
- # Enforces stage-aware command discipline and recent flow-state read hygiene.
169
- set -uo pipefail
170
- shopt -s globstar 2>/dev/null || true
171
- WORKFLOW_GUARD_MODE="\${CCLAW_WORKFLOW_GUARD_MODE:-${workflowGuardMode}}"
172
- MAX_FLOW_READ_AGE_SEC="\${CCLAW_WORKFLOW_GUARD_MAX_AGE_SEC:-1800}"
173
- TDD_ENFORCEMENT_MODE="${tddEnforcementMode}"
174
- TDD_TEST_PATH_PATTERNS="${tddTestPathPatterns}"
175
- TDD_PRODUCTION_PATH_PATTERNS="${tddProductionPathPatterns}"
176
-
177
- ${RUNTIME_SHELL_DETECT_ROOT}
178
-
179
- STATE_DIR="$ROOT/${RUNTIME_ROOT}/state"
180
- FLOW_STATE_FILE="$STATE_DIR/flow-state.json"
181
- TDD_LOG_FILE="$STATE_DIR/tdd-cycle-log.jsonl"
182
- IRON_LAWS_FILE="$STATE_DIR/iron-laws.json"
183
- REVIEW_ARMY_FILE="$ROOT/${RUNTIME_ROOT}/artifacts/07-review-army.json"
184
- GUARD_STATE_FILE="$STATE_DIR/workflow-guard.json"
185
- GUARD_LOG="$STATE_DIR/workflow-guard.jsonl"
186
- mkdir -p "$STATE_DIR" 2>/dev/null || true
187
-
188
- INPUT=$(cat 2>/dev/null || echo '{}')
189
- [ -n "$INPUT" ] || exit 0
190
-
191
- TOOL="unknown"
192
- PAYLOAD="$INPUT"
193
- if command -v cclaw_hook_extract_tool_and_payload >/dev/null 2>&1; then
194
- cclaw_hook_extract_tool_and_payload "$INPUT"
195
- TOOL="\${CCLAW_HOOK_TOOL:-unknown}"
196
- PAYLOAD="\${CCLAW_HOOK_PAYLOAD:-$INPUT}"
197
- elif command -v jq >/dev/null 2>&1; then
198
- TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // .tool // .toolName // .name // .id // .command // .tool.name // .tool.id // .input.tool_name // .input.tool // .input.toolName // .input.name // .input.id // .input.command // .input.tool.name // .input.tool.id // "unknown"' 2>/dev/null || echo "unknown")
199
- PAYLOAD=$(printf '%s' "$INPUT" | jq -r '.tool_input // .input // .arguments // .params // .payload // {} | tostring' 2>/dev/null || echo "")
200
- elif command -v python3 >/dev/null 2>&1; then
201
- TOOL=$(INPUT_JSON="$INPUT" python3 - <<'PY'
202
- import json
203
- import os
204
- try:
205
- value = json.loads(os.environ.get("INPUT_JSON", "{}"))
206
- except Exception:
207
- value = {}
208
-
209
- def pick_tool(payload):
210
- if not isinstance(payload, dict):
211
- return "unknown"
212
- candidates = [
213
- payload.get("tool_name"),
214
- payload.get("tool"),
215
- payload.get("toolName"),
216
- payload.get("name"),
217
- payload.get("id"),
218
- payload.get("command")
219
- ]
220
- top_tool = payload.get("tool")
221
- if isinstance(top_tool, dict):
222
- candidates.extend([top_tool.get("name"), top_tool.get("id")])
223
- nested = payload.get("input")
224
- if isinstance(nested, dict):
225
- candidates.extend([
226
- nested.get("tool_name"),
227
- nested.get("tool"),
228
- nested.get("toolName"),
229
- nested.get("name"),
230
- nested.get("id"),
231
- nested.get("command")
232
- ])
233
- nested_tool = nested.get("tool")
234
- if isinstance(nested_tool, dict):
235
- candidates.extend([nested_tool.get("name"), nested_tool.get("id")])
236
- for candidate in candidates:
237
- if isinstance(candidate, str) and candidate.strip():
238
- return candidate.strip()
239
- return "unknown"
240
-
241
- print(pick_tool(value))
242
- PY
243
- )
244
- PAYLOAD=$(printf '%s' "$INPUT")
245
- else
246
- PAYLOAD=$(printf '%s' "$INPUT")
247
- fi
248
-
249
- [ -n "$PAYLOAD" ] || PAYLOAD=$(printf '%s' "$INPUT")
250
- if command -v cclaw_hook_lower >/dev/null 2>&1; then
251
- TOOL_LOWER=$(cclaw_hook_lower "$TOOL")
252
- PAYLOAD_LOWER=$(cclaw_hook_lower "$PAYLOAD")
253
- else
254
- TOOL_LOWER=$(printf '%s' "$TOOL" | tr '[:upper:]' '[:lower:]')
255
- PAYLOAD_LOWER=$(printf '%s' "$PAYLOAD" | tr '[:upper:]' '[:lower:]')
256
- fi
257
- TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
258
- NOW_EPOCH=$(date +%s 2>/dev/null || echo "0")
259
- REASONS=""
260
-
261
- ACTIVE_AGENT="\${CCLAW_ACTIVE_AGENT:-}"
262
- if [ -z "$ACTIVE_AGENT" ]; then
263
- if command -v jq >/dev/null 2>&1; then
264
- ACTIVE_AGENT=$(printf '%s' "$INPUT" | jq -r '.agent_name // .agent // .input.agent_name // .input.agent // .tool_input.agent_name // .tool_input.agent // ""' 2>/dev/null || echo "")
265
- fi
266
- fi
267
- if command -v cclaw_hook_lower >/dev/null 2>&1; then
268
- ACTIVE_AGENT_LOWER=$(cclaw_hook_lower "$ACTIVE_AGENT")
269
- else
270
- ACTIVE_AGENT_LOWER=$(printf '%s' "$ACTIVE_AGENT" | tr '[:upper:]' '[:lower:]')
271
- fi
272
-
273
- CURRENT_STAGE="none"
274
- CURRENT_RUN="active"
275
- if command -v cclaw_hook_read_flow_state_minimal >/dev/null 2>&1; then
276
- cclaw_hook_read_flow_state_minimal "$FLOW_STATE_FILE"
277
- CURRENT_STAGE="\${CCLAW_HOOK_FLOW_STAGE:-none}"
278
- CURRENT_RUN="\${CCLAW_HOOK_FLOW_RUN_ID:-active}"
279
- elif [ -f "$FLOW_STATE_FILE" ]; then
280
- if command -v jq >/dev/null 2>&1; then
281
- CURRENT_STAGE=$(jq -r '.currentStage // "none"' "$FLOW_STATE_FILE" 2>/dev/null || echo "none")
282
- CURRENT_RUN=$(jq -r '.activeRunId // "active"' "$FLOW_STATE_FILE" 2>/dev/null || echo "active")
283
- elif command -v python3 >/dev/null 2>&1; then
284
- CURRENT_STAGE=$(python3 - "$FLOW_STATE_FILE" <<'PY'
285
- import json
286
- import sys
287
- stage = "none"
288
- try:
289
- with open(sys.argv[1], "r", encoding="utf-8") as fh:
290
- parsed = json.load(fh)
291
- value = parsed.get("currentStage")
292
- if isinstance(value, str) and value:
293
- stage = value
294
- except Exception:
295
- pass
296
- print(stage)
297
- PY
298
- )
299
- CURRENT_RUN=$(python3 - "$FLOW_STATE_FILE" <<'PY'
300
- import json
301
- import sys
302
- run_id = "active"
303
- try:
304
- with open(sys.argv[1], "r", encoding="utf-8") as fh:
305
- parsed = json.load(fh)
306
- value = parsed.get("activeRunId")
307
- if isinstance(value, str) and value:
308
- run_id = value
309
- except Exception:
310
- pass
311
- print(run_id)
312
- PY
313
- )
314
- fi
315
- fi
316
-
317
- SHIP_PREFLIGHT_PASSED="false"
318
- if [ -f "$FLOW_STATE_FILE" ]; then
319
- if command -v jq >/dev/null 2>&1; then
320
- SHIP_PREFLIGHT_PASSED=$(jq -r '
321
- if ((.stageGateCatalog.ship.passed // []) | index("ship_preflight_passed")) == null
322
- then "false"
323
- else "true"
324
- end
325
- ' "$FLOW_STATE_FILE" 2>/dev/null || echo "false")
326
- elif command -v python3 >/dev/null 2>&1; then
327
- SHIP_PREFLIGHT_PASSED=$(python3 - "$FLOW_STATE_FILE" <<'PY'
328
- import json
329
- import sys
330
- value = "false"
331
- try:
332
- with open(sys.argv[1], "r", encoding="utf-8") as fh:
333
- parsed = json.load(fh)
334
- ship = ((parsed.get("stageGateCatalog") or {}).get("ship") or {}).get("passed") or []
335
- if isinstance(ship, list) and "ship_preflight_passed" in ship:
336
- value = "true"
337
- except Exception:
338
- value = "false"
339
- print(value)
340
- PY
341
- )
342
- fi
343
- fi
344
-
345
- IRON_LAW_STRICT_ALL="false"
346
- IRON_LAW_STRICT_IDS=""
347
- if [ -f "$IRON_LAWS_FILE" ]; then
348
- if command -v jq >/dev/null 2>&1; then
349
- IRON_LAW_STRICT_ALL=$(jq -r 'if (.mode // "advisory") == "strict" then "true" else "false" end' "$IRON_LAWS_FILE" 2>/dev/null || echo "false")
350
- IRON_LAW_STRICT_IDS=$(jq -r '(.laws // []) | map(select(.strict == true) | (.id // "")) | map(select(length > 0)) | join(",")' "$IRON_LAWS_FILE" 2>/dev/null || echo "")
351
- elif command -v python3 >/dev/null 2>&1; then
352
- IRON_LAW_STRICT_ALL=$(python3 - "$IRON_LAWS_FILE" <<'PY'
353
- import json
354
- import sys
355
- mode = "false"
356
- try:
357
- with open(sys.argv[1], "r", encoding="utf-8") as fh:
358
- parsed = json.load(fh)
359
- if str(parsed.get("mode", "advisory")) == "strict":
360
- mode = "true"
361
- except Exception:
362
- mode = "false"
363
- print(mode)
364
- PY
365
- )
366
- IRON_LAW_STRICT_IDS=$(python3 - "$IRON_LAWS_FILE" <<'PY'
367
- import json
368
- import sys
369
- out = []
370
- try:
371
- with open(sys.argv[1], "r", encoding="utf-8") as fh:
372
- parsed = json.load(fh)
373
- for row in parsed.get("laws", []):
374
- if isinstance(row, dict) and row.get("strict") and isinstance(row.get("id"), str):
375
- out.append(row["id"].strip())
376
- except Exception:
377
- out = []
378
- print(",".join([v for v in out if v]))
379
- PY
380
- )
381
- fi
382
- fi
383
-
384
- iron_law_is_strict() {
385
- local law_id="$1"
386
- if [ "$IRON_LAW_STRICT_ALL" = "true" ]; then
387
- return 0
388
- fi
389
- if [ -z "$IRON_LAW_STRICT_IDS" ]; then
390
- return 1
391
- fi
392
- case ",$IRON_LAW_STRICT_IDS," in
393
- *",$law_id,"*) return 0 ;;
394
- *) return 1 ;;
395
- esac
396
- }
397
-
398
- LAST_FLOW_READ_AT=0
399
- if [ -f "$GUARD_STATE_FILE" ]; then
400
- if command -v jq >/dev/null 2>&1; then
401
- LAST_FLOW_READ_AT=$(jq -r '.lastFlowReadAtEpoch // 0' "$GUARD_STATE_FILE" 2>/dev/null || echo "0")
402
- elif command -v python3 >/dev/null 2>&1; then
403
- LAST_FLOW_READ_AT=$(python3 - "$GUARD_STATE_FILE" <<'PY'
404
- import json
405
- import sys
406
- value = 0
407
- try:
408
- with open(sys.argv[1], "r", encoding="utf-8") as fh:
409
- parsed = json.load(fh)
410
- raw = parsed.get("lastFlowReadAtEpoch", 0)
411
- if isinstance(raw, (int, float)):
412
- value = int(raw)
413
- except Exception:
414
- pass
415
- print(value)
416
- PY
417
- )
418
- fi
419
- fi
420
- [ -n "$LAST_FLOW_READ_AT" ] || LAST_FLOW_READ_AT=0
421
-
422
- stage_index() {
423
- case "$1" in
424
- brainstorm) echo 1 ;;
425
- scope) echo 2 ;;
426
- design) echo 3 ;;
427
- spec) echo 4 ;;
428
- plan) echo 5 ;;
429
- tdd) echo 6 ;;
430
- review) echo 7 ;;
431
- ship) echo 8 ;;
432
- *) echo 0 ;;
433
- esac
434
- }
435
-
436
- is_mutating_tool() {
437
- case "$1" in
438
- write|edit|multiedit|multi_edit|delete|applypatch|apply_patch) return 0 ;;
439
- *) return 1 ;;
440
- esac
441
- }
442
-
443
- is_execution_or_mutating_tool() {
444
- case "$1" in
445
- write|edit|multiedit|multi_edit|delete|applypatch|apply_patch) return 0 ;;
446
- shell|bash|runcommand|run_command|execcommand|exec_command|terminal) return 0 ;;
447
- *) return 1 ;;
448
- esac
449
- }
450
-
451
- is_plan_mode_safe_tool() {
452
- case "$1" in
453
- read|readfile|open|view|cat|head|tail) return 0 ;;
454
- grep|glob|search|semanticsearch|ripgrep|rg|find|list_directory|ls) return 0 ;;
455
- askquestion|askuserquestion|ask_question|ask_user_question|question) return 0 ;;
456
- todowrite|todoread|todo_write|todo_read) return 0 ;;
457
- webfetch|websearch|web_fetch|web_search|fetchmcpresource) return 0 ;;
458
- switchmode|switch_mode) return 0 ;;
459
- task|delegate) return 0 ;;
460
- *) return 1 ;;
461
- esac
462
- }
463
-
464
- is_cclaw_cli_payload() {
465
- printf '%s' "$1" | grep -Eq '(cclaw |npx cclaw |/cc-|/cc[^[:alnum:]_-])'
466
- }
467
-
468
- extract_flow_state_after_json() {
469
- if command -v jq >/dev/null 2>&1; then
470
- printf '%s' "$INPUT" | jq -r '
471
- .tool_input?.content //
472
- .input?.content //
473
- .arguments?.content //
474
- .params?.content //
475
- .payload?.content //
476
- .content //
477
- .input?.new_string //
478
- .tool_input?.new_string //
479
- ""
480
- ' 2>/dev/null || echo ""
481
- return 0
482
- fi
483
-
484
- if command -v python3 >/dev/null 2>&1; then
485
- INPUT_JSON="$INPUT" python3 - <<'PY'
486
- import json
487
- import os
488
-
489
- try:
490
- payload = json.loads(os.environ.get("INPUT_JSON", "{}"))
491
- except Exception:
492
- payload = {}
493
-
494
- def pick(value):
495
- if not isinstance(value, dict):
496
- return ""
497
- for key in ("tool_input", "input", "arguments", "params", "payload"):
498
- nested = value.get(key)
499
- if isinstance(nested, dict):
500
- content = nested.get("content")
501
- if isinstance(content, str) and content.strip():
502
- return content
503
- new_string = nested.get("new_string")
504
- if isinstance(new_string, str) and new_string.strip():
505
- return new_string
506
- content = value.get("content")
507
- if isinstance(content, str) and content.strip():
508
- return content
509
- return ""
510
-
511
- print(pick(payload))
512
- PY
513
- return 0
514
- fi
515
-
516
- printf ''
517
- return 0
518
- }
519
-
520
- verify_flow_state_candidate() {
521
- local candidate_json="$1"
522
- [ -n "$candidate_json" ] || return 1
523
- local tmp_file="$STATE_DIR/.flow-state-candidate.$$.$RANDOM.json"
524
- printf '%s' "$candidate_json" > "$tmp_file" 2>/dev/null || {
525
- rm -f "$tmp_file" 2>/dev/null || true
526
- return 1
527
- }
528
-
529
- if ! command -v cclaw >/dev/null 2>&1; then
530
- rm -f "$tmp_file" 2>/dev/null || true
531
- printf '[cclaw] workflow guard: cclaw binary is required to validate flow-state edits; install cclaw and re-run.\\n' >&2
532
- return 1
533
- fi
534
- local verify_cmd=(cclaw internal verify-flow-state-diff --after-file="$tmp_file" --quiet)
535
-
536
- if "\${verify_cmd[@]}" >/dev/null 2>&1; then
537
- rm -f "$tmp_file" 2>/dev/null || true
538
- return 0
539
- fi
540
-
541
- rm -f "$tmp_file" 2>/dev/null || true
542
- return 1
543
- }
544
-
545
- is_preimplementation_stage() {
546
- case "$1" in
547
- brainstorm|scope|design|spec|plan) return 0 ;;
548
- *) return 1 ;;
549
- esac
550
- }
551
-
552
- normalize_payload_path() {
553
- local raw="$1"
554
- local normalized="$raw"
555
- normalized=$(printf '%s' "$normalized" | tr '\\\\' '/')
556
- normalized=$(printf '%s' "$normalized" | tr '[:upper:]' '[:lower:]')
557
- normalized="\${normalized#./}"
558
- printf '%s' "$normalized"
559
- }
560
-
561
- extract_payload_paths() {
562
- if command -v jq >/dev/null 2>&1; then
563
- printf '%s' "$INPUT" | jq -r '
564
- [.. | objects | (.path?, .file_path?, .filepath?) | select(type == "string" and length > 0)]
565
- | unique
566
- | .[]
567
- ' 2>/dev/null || printf ''
568
- return 0
569
- fi
570
- if command -v python3 >/dev/null 2>&1; then
571
- INPUT_JSON="$INPUT" python3 - <<'PY'
572
- import json
573
- import os
574
-
575
- def visit(node, acc):
576
- if isinstance(node, dict):
577
- for key in ("path", "file_path", "filepath"):
578
- value = node.get(key)
579
- if isinstance(value, str) and value.strip():
580
- acc.add(value.strip())
581
- for value in node.values():
582
- visit(value, acc)
583
- elif isinstance(node, list):
584
- for value in node:
585
- visit(value, acc)
586
-
587
- try:
588
- payload = json.loads(os.environ.get("INPUT_JSON", "{}"))
589
- except Exception:
590
- payload = {}
591
-
592
- items = set()
593
- visit(payload, items)
594
- for value in sorted(items):
595
- print(value)
596
- PY
597
- return 0
598
- fi
599
- printf ''
600
- return 0
601
- }
602
-
603
- matches_path_patterns() {
604
- local candidate="$1"
605
- local patterns_csv="$2"
606
- [ -n "$candidate" ] || return 1
607
- [ -n "$patterns_csv" ] || return 1
608
- local old_ifs="$IFS"
609
- IFS=','
610
- for pattern in $patterns_csv; do
611
- local normalized_pattern
612
- normalized_pattern=$(normalize_payload_path "$pattern")
613
- [ -n "$normalized_pattern" ] || continue
614
- case "$candidate" in
615
- $normalized_pattern)
616
- IFS="$old_ifs"
617
- return 0
618
- ;;
619
- esac
620
- done
621
- IFS="$old_ifs"
622
- return 1
623
- }
624
-
625
- is_code_like_path() {
626
- local candidate="$1"
627
- printf '%s' "$candidate" | grep -Eq '\\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift)$'
628
- }
629
-
630
- is_tdd_test_payload() {
631
- local text="$1"
632
- local payload_paths="$2"
633
- if [ -n "$payload_paths" ]; then
634
- while IFS= read -r raw_path; do
635
- [ -n "$raw_path" ] || continue
636
- local normalized
637
- normalized=$(normalize_payload_path "$raw_path")
638
- if matches_path_patterns "$normalized" "$TDD_TEST_PATH_PATTERNS"; then
639
- return 0
640
- fi
641
- done <<< "$payload_paths"
642
- fi
643
- if printf '%s' "$text" | grep -Eq '/tests?/|/__tests__/|\\.test\\.'; then
644
- return 0
645
- fi
646
- return 1
647
- }
648
-
649
- is_tdd_production_path() {
650
- local normalized="$1"
651
- [ -n "$normalized" ] || return 1
652
- if printf '%s' "$normalized" | grep -Eq '(^|/)\\.cclaw/'; then
653
- return 1
654
- fi
655
- if matches_path_patterns "$normalized" "$TDD_TEST_PATH_PATTERNS"; then
656
- return 1
657
- fi
658
- if [ -n "$TDD_PRODUCTION_PATH_PATTERNS" ]; then
659
- matches_path_patterns "$normalized" "$TDD_PRODUCTION_PATH_PATTERNS"
660
- return $?
661
- fi
662
- is_code_like_path "$normalized"
663
- return $?
664
- }
665
-
666
- is_tdd_production_write_payload() {
667
- local text="$1"
668
- local payload_paths="$2"
669
- if [ -n "$payload_paths" ]; then
670
- while IFS= read -r raw_path; do
671
- [ -n "$raw_path" ] || continue
672
- local normalized
673
- normalized=$(normalize_payload_path "$raw_path")
674
- if is_tdd_production_path "$normalized"; then
675
- return 0
676
- fi
677
- done <<< "$payload_paths"
678
- return 1
679
- fi
680
- if [ -n "$TDD_PRODUCTION_PATH_PATTERNS" ]; then
681
- return 1
682
- fi
683
- if printf '%s' "$text" | grep -Eq '\\.cclaw/'; then
684
- return 1
685
- fi
686
- if ! printf '%s' "$text" | grep -Eq '\\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift)'; then
687
- return 1
688
- fi
689
- if is_tdd_test_payload "$text" ""; then
690
- return 1
691
- fi
692
- return 0
693
- }
694
-
695
- collect_tdd_production_paths() {
696
- local payload_paths="$1"
697
- local out=""
698
- [ -n "$payload_paths" ] || {
699
- printf ''
700
- return 0
701
- }
702
- while IFS= read -r raw_path; do
703
- [ -n "$raw_path" ] || continue
704
- local normalized
705
- normalized=$(normalize_payload_path "$raw_path")
706
- if is_tdd_production_path "$normalized"; then
707
- if [ -n "$out" ]; then
708
- out="$out"$'\n'"$raw_path"
709
- else
710
- out="$raw_path"
711
- fi
712
- fi
713
- done <<< "$payload_paths"
714
- printf '%s' "$out"
715
- }
716
-
717
- review_layer_coverage_complete() {
718
- if [ ! -f "$REVIEW_ARMY_FILE" ]; then
719
- return 1
720
- fi
721
- if command -v jq >/dev/null 2>&1; then
722
- jq -e '
723
- ((.reconciliation.layerCoverage.spec // false) == true) and
724
- ((.reconciliation.layerCoverage.correctness // false) == true) and
725
- ((.reconciliation.layerCoverage.security // false) == true) and
726
- ((.reconciliation.layerCoverage.performance // false) == true) and
727
- ((.reconciliation.layerCoverage.architecture // false) == true) and
728
- ((.reconciliation.layerCoverage["external-safety"] // false) == true)
729
- ' "$REVIEW_ARMY_FILE" >/dev/null 2>&1
730
- return $?
731
- fi
732
- if command -v python3 >/dev/null 2>&1; then
733
- python3 - "$REVIEW_ARMY_FILE" <<'PY'
734
- import json
735
- import sys
736
- keys = ["spec", "correctness", "security", "performance", "architecture", "external-safety"]
737
- try:
738
- with open(sys.argv[1], "r", encoding="utf-8") as handle:
739
- parsed = json.load(handle)
740
- coverage = ((parsed.get("reconciliation") or {}).get("layerCoverage") or {})
741
- ok = all(coverage.get(key) is True for key in keys)
742
- except Exception:
743
- ok = False
744
- raise SystemExit(0 if ok else 1)
745
- PY
746
- return $?
747
- fi
748
- return 1
749
- }
750
-
751
- tdd_cycle_counts() {
752
- if [ ! -f "$TDD_LOG_FILE" ] || [ ! -s "$TDD_LOG_FILE" ]; then
753
- printf '0:0'
754
- return 0
755
- fi
756
- local red_count="0"
757
- local green_count="0"
758
- if command -v jq >/dev/null 2>&1 && jq -n '1' >/dev/null 2>&1; then
759
- red_count=$(jq -r --arg run "$CURRENT_RUN" 'select((.runId // $run) == $run and .phase == "red") | .phase' "$TDD_LOG_FILE" 2>/dev/null | wc -l | tr -d ' ' || echo "0")
760
- green_count=$(jq -r --arg run "$CURRENT_RUN" 'select((.runId // $run) == $run and .phase == "green") | .phase' "$TDD_LOG_FILE" 2>/dev/null | wc -l | tr -d ' ' || echo "0")
761
- elif command -v python3 >/dev/null 2>&1 && python3 - <<'PY' >/dev/null 2>&1
762
- print("ok")
763
- PY
764
- then
765
- red_count=$(python3 - "$TDD_LOG_FILE" "$CURRENT_RUN" <<'PY'
766
- import json
767
- import sys
768
- count = 0
769
- run_id = sys.argv[2]
770
- with open(sys.argv[1], "r", encoding="utf-8") as fh:
771
- for raw in fh:
772
- raw = raw.strip()
773
- if not raw:
774
- continue
775
- try:
776
- parsed = json.loads(raw)
777
- except Exception:
778
- continue
779
- if not isinstance(parsed, dict):
780
- continue
781
- if str(parsed.get("runId", run_id)) != run_id:
782
- continue
783
- if parsed.get("phase") == "red":
784
- count += 1
785
- print(count)
786
- PY
787
- )
788
- green_count=$(python3 - "$TDD_LOG_FILE" "$CURRENT_RUN" <<'PY'
789
- import json
790
- import sys
791
- count = 0
792
- run_id = sys.argv[2]
793
- with open(sys.argv[1], "r", encoding="utf-8") as fh:
794
- for raw in fh:
795
- raw = raw.strip()
796
- if not raw:
797
- continue
798
- try:
799
- parsed = json.loads(raw)
800
- except Exception:
801
- continue
802
- if not isinstance(parsed, dict):
803
- continue
804
- if str(parsed.get("runId", run_id)) != run_id:
805
- continue
806
- if parsed.get("phase") == "green":
807
- count += 1
808
- print(count)
809
- PY
810
- )
811
- else
812
- if command -v awk >/dev/null 2>&1; then
813
- local fallback_counts
814
- fallback_counts=$(awk -v run="$CURRENT_RUN" '
815
- BEGIN { red=0; green=0; }
816
- {
817
- line=$0;
818
- line_run=run;
819
- if (match(line, /"runId"[[:space:]]*:[[:space:]]*"[^"]+"/)) {
820
- line_run=substr(line, RSTART, RLENGTH);
821
- sub(/.*"/, "", line_run);
822
- sub(/"$/, "", line_run);
823
- }
824
- if (line_run != run) next;
825
- if (match(line, /"phase"[[:space:]]*:[[:space:]]*"[^"]+"/)) {
826
- phase=substr(line, RSTART, RLENGTH);
827
- sub(/.*"/, "", phase);
828
- sub(/"$/, "", phase);
829
- if (phase == "red") red += 1;
830
- else if (phase == "green") green += 1;
831
- }
832
- }
833
- END { printf "%d:%d", red, green; }
834
- ' "$TDD_LOG_FILE" 2>/dev/null || true)
835
- if printf '%s' "$fallback_counts" | grep -Eq '^[0-9]+:[0-9]+$'; then
836
- printf '%s' "$fallback_counts"
837
- return 0
838
- fi
839
- fi
840
- printf '__UNAVAILABLE__'
841
- return 0
842
- fi
843
- [ -n "$red_count" ] || red_count="0"
844
- [ -n "$green_count" ] || green_count="0"
845
- printf '%s:%s' "$red_count" "$green_count"
846
- }
847
-
848
- has_open_red_cycle() {
849
- local counts
850
- counts=$(tdd_cycle_counts)
851
- if [ "$counts" = "__UNAVAILABLE__" ]; then
852
- return 2
853
- fi
854
- local red_count="\${counts%%:*}"
855
- local green_count="\${counts##*:}"
856
- if ! printf '%s' "$red_count:$green_count" | grep -Eq '^[0-9]+:[0-9]+$'; then
857
- return 2
858
- fi
859
- if [ "$red_count" -gt "$green_count" ]; then
860
- return 0
861
- fi
862
- return 1
863
- }
864
-
865
- tdd_cycle_state() {
866
- local counts
867
- counts=$(tdd_cycle_counts)
868
- if [ "$counts" = "__UNAVAILABLE__" ]; then
869
- printf '__UNAVAILABLE__'
870
- return 0
871
- fi
872
- local red_count="\${counts%%:*}"
873
- local green_count="\${counts##*:}"
874
- if ! printf '%s' "$red_count:$green_count" | grep -Eq '^[0-9]+:[0-9]+$'; then
875
- printf '__UNAVAILABLE__'
876
- return 0
877
- fi
878
- if [ "$red_count" -le 0 ]; then
879
- printf 'need_red'
880
- return 0
881
- fi
882
- if [ "$red_count" -gt "$green_count" ]; then
883
- printf 'red_open'
884
- return 0
885
- fi
886
- printf 'green_done'
887
- return 0
888
- }
889
-
890
- detect_target_stage() {
891
- local text="$1"
892
- for stage in brainstorm scope design spec plan tdd review ship; do
893
- if printf '%s' "$text" | grep -Eq "(/cc-$stage|cc-$stage)([^[:alnum:]_-]|$)"; then
894
- printf '%s' "$stage"
895
- return 0
896
- fi
897
- done
898
- printf ''
899
- return 0
900
- }
901
-
902
- is_flow_progression_command() {
903
- local text="$1"
904
- if printf '%s' "$text" | grep -Eq '(/cc-next|cc-next)([^[:alnum:]_-]|$)'; then
905
- return 0
906
- fi
907
- if printf '%s' "$text" | grep -Eq '/cc([^[:alnum:]_-]|$)'; then
908
- return 0
909
- fi
910
- return 1
911
- }
912
-
913
- TARGET_STAGE=$(detect_target_stage "$PAYLOAD_LOWER")
914
- FLOW_COMMAND_INVOKED=0
915
- if is_flow_progression_command "$PAYLOAD_LOWER"; then
916
- FLOW_COMMAND_INVOKED=1
917
- fi
918
- MUTATION_PATHS=""
919
- if is_mutating_tool "$TOOL_LOWER"; then
920
- MUTATION_PATHS=$(extract_payload_paths)
921
- fi
922
- TDD_CYCLE_STATE="unknown"
923
- if [ -n "$TARGET_STAGE" ] && [ "$CURRENT_STAGE" != "none" ]; then
924
- CURRENT_IDX=$(stage_index "$CURRENT_STAGE")
925
- TARGET_IDX=$(stage_index "$TARGET_STAGE")
926
- if [ "$CURRENT_IDX" -gt 0 ] && [ "$TARGET_IDX" -gt 0 ]; then
927
- if [ "$TARGET_IDX" -gt $((CURRENT_IDX + 1)) ]; then
928
- REASONS="stage_jump_\${CURRENT_STAGE}_to_\${TARGET_STAGE}"
929
- fi
930
- fi
931
- fi
932
-
933
- if is_mutating_tool "$TOOL_LOWER" && printf '%s' "$PAYLOAD_LOWER" | grep -Eq '\.cclaw/state/flow-state\.json'; then
934
- if [ -n "$REASONS" ]; then
935
- REASONS="$REASONS,direct_flow_state_edit"
936
- else
937
- REASONS="direct_flow_state_edit"
938
- fi
939
- FLOW_STATE_AFTER_JSON=$(extract_flow_state_after_json)
940
- if [ -n "$FLOW_STATE_AFTER_JSON" ]; then
941
- if ! verify_flow_state_candidate "$FLOW_STATE_AFTER_JSON"; then
942
- REASONS="$REASONS,flow_state_edit_failed_internal_validation"
943
- fi
944
- else
945
- REASONS="$REASONS,flow_state_edit_without_serialized_content"
946
- fi
947
- fi
948
-
949
- if is_preimplementation_stage "$CURRENT_STAGE" && is_mutating_tool "$TOOL_LOWER"; then
950
- if ! printf '%s' "$PAYLOAD_LOWER" | grep -Eq '\.cclaw/'; then
951
- if [ -n "$REASONS" ]; then
952
- REASONS="$REASONS,implementation_write_before_\${CURRENT_STAGE}_completion"
953
- else
954
- REASONS="implementation_write_before_\${CURRENT_STAGE}_completion"
955
- fi
956
- fi
957
- fi
958
-
959
- if [ "$CURRENT_STAGE" = "tdd" ] && is_mutating_tool "$TOOL_LOWER"; then
960
- TDD_MISSING_RED_PATHS=""
961
- if is_tdd_production_write_payload "$PAYLOAD_LOWER" "$MUTATION_PATHS"; then
962
- PRODUCTION_PATHS=$(collect_tdd_production_paths "$MUTATION_PATHS")
963
- PER_PATH_RED_CHECKED="false"
964
- if [ -n "$PRODUCTION_PATHS" ] && command -v cclaw >/dev/null 2>&1; then
965
- PER_PATH_RED_CHECKED="true"
966
- while IFS= read -r production_path; do
967
- [ -n "$production_path" ] || continue
968
- cclaw internal tdd-red-evidence --path="$production_path" --run-id="$CURRENT_RUN" --quiet >/dev/null 2>&1
969
- EVIDENCE_STATUS=$?
970
- if [ "$EVIDENCE_STATUS" -eq 0 ]; then
971
- continue
972
- fi
973
- if [ "$EVIDENCE_STATUS" -eq 2 ]; then
974
- if [ -n "$TDD_MISSING_RED_PATHS" ]; then
975
- TDD_MISSING_RED_PATHS="$TDD_MISSING_RED_PATHS, $production_path"
976
- else
977
- TDD_MISSING_RED_PATHS="$production_path"
978
- fi
979
- continue
980
- fi
981
- if [ -n "$REASONS" ]; then
982
- REASONS="$REASONS,tdd_red_evidence_check_failed"
983
- else
984
- REASONS="tdd_red_evidence_check_failed"
985
- fi
986
- done <<< "$PRODUCTION_PATHS"
987
- if [ -n "$TDD_MISSING_RED_PATHS" ]; then
988
- if [ -n "$REASONS" ]; then
989
- REASONS="$REASONS,tdd_write_without_red_for_path"
990
- else
991
- REASONS="tdd_write_without_red_for_path"
992
- fi
993
- fi
994
- fi
995
- if [ "$PER_PATH_RED_CHECKED" != "true" ]; then
996
- if has_open_red_cycle; then
997
- TDD_CYCLE_STATE="red_open"
998
- else
999
- OPEN_RED_STATUS=$?
1000
- if [ "$OPEN_RED_STATUS" -eq 2 ]; then
1001
- TDD_CYCLE_STATE="counts_unavailable"
1002
- if [ -n "$REASONS" ]; then
1003
- REASONS="$REASONS,tdd_cycle_counts_unavailable"
1004
- else
1005
- REASONS="tdd_cycle_counts_unavailable"
1006
- fi
1007
- else
1008
- TDD_CYCLE_STATE=$(tdd_cycle_state)
1009
- fi
1010
- fi
1011
- if [ "$TDD_CYCLE_STATE" = "need_red" ]; then
1012
- if [ -n "$REASONS" ]; then
1013
- REASONS="$REASONS,tdd_write_without_open_red"
1014
- else
1015
- REASONS="tdd_write_without_open_red"
1016
- fi
1017
- elif [ "$TDD_CYCLE_STATE" = "__UNAVAILABLE__" ]; then
1018
- if [ -n "$REASONS" ]; then
1019
- REASONS="$REASONS,tdd_cycle_counts_unavailable"
1020
- else
1021
- REASONS="tdd_cycle_counts_unavailable"
1022
- fi
1023
- fi
1024
- fi
1025
- fi
1026
- fi
1027
-
1028
- if [ "$CURRENT_STAGE" = "tdd" ] && is_mutating_tool "$TOOL_LOWER"; then
1029
- ACTIVE_AGENT_EFFECTIVE="$ACTIVE_AGENT_LOWER"
1030
- if [ -z "$ACTIVE_AGENT_EFFECTIVE" ]; then
1031
- INFERRED_TDD_PHASE=$(tdd_cycle_state)
1032
- case "$INFERRED_TDD_PHASE" in
1033
- need_red) ACTIVE_AGENT_EFFECTIVE="tdd-red" ;;
1034
- red_open) ACTIVE_AGENT_EFFECTIVE="tdd-green" ;;
1035
- green_done) ACTIVE_AGENT_EFFECTIVE="tdd-refactor" ;;
1036
- *) ACTIVE_AGENT_EFFECTIVE="" ;;
1037
- esac
1038
- fi
1039
- if [ "$ACTIVE_AGENT_EFFECTIVE" = "tdd-red" ] && is_tdd_production_write_payload "$PAYLOAD_LOWER" "$MUTATION_PATHS"; then
1040
- if [ -n "$REASONS" ]; then
1041
- REASONS="$REASONS,tdd_red_agent_cannot_write_production"
1042
- else
1043
- REASONS="tdd_red_agent_cannot_write_production"
1044
- fi
1045
- fi
1046
- if [ "$ACTIVE_AGENT_EFFECTIVE" = "tdd-green" ] && is_tdd_test_payload "$PAYLOAD_LOWER" "$MUTATION_PATHS"; then
1047
- if [ -n "$REASONS" ]; then
1048
- REASONS="$REASONS,tdd_green_agent_cannot_write_tests"
1049
- else
1050
- REASONS="tdd_green_agent_cannot_write_tests"
1051
- fi
1052
- fi
1053
- if [ "$ACTIVE_AGENT_EFFECTIVE" = "tdd-refactor" ]; then
1054
- TDD_AGENT_STATE=$(tdd_cycle_state)
1055
- if [ "$TDD_AGENT_STATE" != "green_done" ]; then
1056
- if [ -n "$REASONS" ]; then
1057
- REASONS="$REASONS,tdd_refactor_before_green"
1058
- else
1059
- REASONS="tdd_refactor_before_green"
1060
- fi
1061
- fi
1062
- fi
1063
- fi
1064
-
1065
- if is_mutating_tool "$TOOL_LOWER"; then
1066
- if [ "$LAST_FLOW_READ_AT" -le 0 ] || [ "$NOW_EPOCH" -le 0 ] || [ $((NOW_EPOCH - LAST_FLOW_READ_AT)) -gt "$MAX_FLOW_READ_AGE_SEC" ]; then
1067
- if [ -n "$REASONS" ]; then
1068
- REASONS="$REASONS,mutating_without_recent_flow_read"
1069
- else
1070
- REASONS="mutating_without_recent_flow_read"
1071
- fi
1072
- fi
1073
- fi
1074
-
1075
- if is_mutating_tool "$TOOL_LOWER" && printf '%s' "$PAYLOAD_LOWER" | grep -Eq '\.cclaw/(state|hooks|skills)'; then
1076
- if ! is_cclaw_cli_payload "$PAYLOAD_LOWER"; then
1077
- if [ -n "$REASONS" ]; then
1078
- REASONS="$REASONS,runtime_write_requires_managed_only"
1079
- else
1080
- REASONS="runtime_write_requires_managed_only"
1081
- fi
1082
- fi
1083
- fi
1084
-
1085
- if [ "$CURRENT_STAGE" = "ship" ] && is_execution_or_mutating_tool "$TOOL_LOWER"; then
1086
- if printf '%s' "$PAYLOAD_LOWER" | grep -Eq '(npm publish|pnpm publish|yarn publish|gh release create|git push[[:space:]].*--tags|npm version)'; then
1087
- if [ "$SHIP_PREFLIGHT_PASSED" != "true" ]; then
1088
- if [ -n "$REASONS" ]; then
1089
- REASONS="$REASONS,ship_preflight_required"
1090
- else
1091
- REASONS="ship_preflight_required"
1092
- fi
1093
- fi
1094
- if ! review_layer_coverage_complete; then
1095
- if [ -n "$REASONS" ]; then
1096
- REASONS="$REASONS,ship_review_coverage_required"
1097
- else
1098
- REASONS="ship_review_coverage_required"
1099
- fi
1100
- fi
1101
- fi
1102
- fi
1103
-
1104
- if is_preimplementation_stage "$CURRENT_STAGE" && ! is_plan_mode_safe_tool "$TOOL_LOWER"; then
1105
- if ! is_mutating_tool "$TOOL_LOWER"; then
1106
- if ! printf '%s' "$PAYLOAD_LOWER" | grep -Eq '\.cclaw/' && ! is_cclaw_cli_payload "$PAYLOAD_LOWER"; then
1107
- if [ -n "$REASONS" ]; then
1108
- REASONS="$REASONS,non_safe_tool_in_plan_stage_\${CURRENT_STAGE}"
1109
- else
1110
- REASONS="non_safe_tool_in_plan_stage_\${CURRENT_STAGE}"
1111
- fi
1112
- fi
1113
- fi
1114
- fi
1115
-
1116
- if [ -n "$TARGET_STAGE" ] || [ "$FLOW_COMMAND_INVOKED" -eq 1 ]; then
1117
- if [ "$LAST_FLOW_READ_AT" -le 0 ] || [ "$NOW_EPOCH" -le 0 ] || [ $((NOW_EPOCH - LAST_FLOW_READ_AT)) -gt "$MAX_FLOW_READ_AGE_SEC" ]; then
1118
- if [ -n "$REASONS" ]; then
1119
- REASONS="$REASONS,stage_invocation_without_recent_flow_read"
1120
- else
1121
- REASONS="stage_invocation_without_recent_flow_read"
1122
- fi
1123
- fi
1124
- fi
1125
-
1126
- SHOULD_RECORD_FLOW_READ=0
1127
- case "$TOOL_LOWER" in
1128
- read|readfile|open|view|cat) SHOULD_RECORD_FLOW_READ=1 ;;
1129
- shell|runcommand|run_command|execcommand|exec_command|terminal) SHOULD_RECORD_FLOW_READ=1 ;;
1130
- esac
1131
-
1132
- if [ "$SHOULD_RECORD_FLOW_READ" -eq 1 ] && printf '%s' "$PAYLOAD_LOWER" | grep -Eq '(\.cclaw/state/flow-state\.json|cclaw doctor|cclaw sync)'; then
1133
- TMP_STATE_FILE="$GUARD_STATE_FILE.tmp.$$"
1134
- if command -v jq >/dev/null 2>&1 && [ -f "$GUARD_STATE_FILE" ]; then
1135
- jq --arg ts "$TS" --argjson epoch "$NOW_EPOCH" '
1136
- .lastFlowReadAt = $ts
1137
- | .lastFlowReadAtEpoch = $epoch
1138
- ' "$GUARD_STATE_FILE" > "$TMP_STATE_FILE" 2>/dev/null || true
1139
- elif command -v python3 >/dev/null 2>&1; then
1140
- python3 - "$GUARD_STATE_FILE" "$TMP_STATE_FILE" "$TS" "$NOW_EPOCH" <<'PY'
1141
- import json
1142
- import sys
1143
- from pathlib import Path
1144
- source = Path(sys.argv[1])
1145
- target = Path(sys.argv[2])
1146
- ts = sys.argv[3]
1147
- epoch = int(float(sys.argv[4])) if sys.argv[4] else 0
1148
- payload = {}
1149
- if source.exists():
1150
- try:
1151
- raw = json.loads(source.read_text(encoding="utf-8"))
1152
- if isinstance(raw, dict):
1153
- payload.update(raw)
1154
- except Exception:
1155
- pass
1156
- payload["lastFlowReadAt"] = ts
1157
- payload["lastFlowReadAtEpoch"] = epoch
1158
- target.write_text(json.dumps(payload, indent=2) + "\\n", encoding="utf-8")
1159
- PY
1160
- fi
1161
- if [ -s "$TMP_STATE_FILE" ]; then
1162
- mv "$TMP_STATE_FILE" "$GUARD_STATE_FILE" 2>/dev/null || rm -f "$TMP_STATE_FILE" 2>/dev/null || true
1163
- else
1164
- printf '{\\n "lastFlowReadAt": "%s",\\n "lastFlowReadAtEpoch": %s\\n}\\n' "$TS" "$NOW_EPOCH" > "$GUARD_STATE_FILE" 2>/dev/null || true
1165
- fi
1166
- fi
1167
-
1168
- if [ -n "$REASONS" ]; then
1169
- if printf '%s' "$REASONS" | grep -Eq 'tdd_write_without_red_for_path'; then
1170
- NOTE="Cclaw workflow guard: missing failing RED evidence for production path(s): \${TDD_MISSING_RED_PATHS:-unknown}. Log failing tests before touching these files."
1171
- elif printf '%s' "$REASONS" | grep -Eq 'tdd_write_without_open_red'; then
1172
- NOTE="Cclaw workflow guard: Write a failing test first before editing production files during tdd stage (state=\${TDD_CYCLE_STATE})."
1173
- elif printf '%s' "$REASONS" | grep -Eq 'tdd_red_evidence_check_failed'; then
1174
- NOTE="Cclaw workflow guard: failed to validate per-path RED evidence via \`cclaw internal tdd-red-evidence\`; refusing write until evidence check succeeds."
1175
- elif printf '%s' "$REASONS" | grep -Eq 'tdd_red_agent_cannot_write_production'; then
1176
- NOTE="Cclaw workflow guard: tdd-red agent is limited to test-side RED work and cannot edit production files."
1177
- elif printf '%s' "$REASONS" | grep -Eq 'tdd_green_agent_cannot_write_tests'; then
1178
- NOTE="Cclaw workflow guard: tdd-green agent can implement production fixes but should not author new RED tests."
1179
- elif printf '%s' "$REASONS" | grep -Eq 'tdd_refactor_before_green'; then
1180
- NOTE="Cclaw workflow guard: tdd-refactor requires a green_done cycle state before refactor edits."
1181
- elif printf '%s' "$REASONS" | grep -Eq 'ship_preflight_required'; then
1182
- NOTE="Cclaw workflow guard: ship finalization command detected before ship_preflight_passed gate. Run preflight and record evidence first."
1183
- elif printf '%s' "$REASONS" | grep -Eq 'ship_review_coverage_required'; then
1184
- NOTE="Cclaw workflow guard: ship finalization requires review layer coverage for spec/correctness/security/performance/architecture/external-safety in 07-review-army.json."
1185
- elif printf '%s' "$REASONS" | grep -Eq 'tdd_cycle_counts_unavailable'; then
1186
- NOTE="Cclaw workflow guard: unable to inspect run-scoped tdd-cycle counts (missing usable jq/python3/awk). Install one of these tools before writing production code in tdd."
1187
- elif printf '%s' "$REASONS" | grep -Eq 'runtime_write_requires_managed_only|direct_flow_state_edit'; then
1188
- NOTE="Cclaw workflow guard: runtime write to managed ${RUNTIME_ROOT} internals detected (\${REASONS}). Prefer cclaw-managed helpers (stage-complete, sync, command contracts) instead of ad-hoc edits."
1189
- elif printf '%s' "$REASONS" | grep -Eq 'mutating_without_recent_flow_read'; then
1190
- NOTE="Cclaw workflow guard: mutating action requires a fresh read of ${RUNTIME_ROOT}/state/flow-state.json before edits."
1191
- else
1192
- NOTE="Cclaw workflow guard: detected potential flow violation (\${REASONS}). Re-read ${RUNTIME_ROOT}/state/flow-state.json, avoid source edits before tdd stage, and enforce RED -> GREEN -> REFACTOR discipline inside tdd."
1193
- fi
1194
- if command -v jq >/dev/null 2>&1; then
1195
- ENTRY=$(jq -n -c \
1196
- --arg ts "$TS" \
1197
- --arg tool "$TOOL" \
1198
- --arg stage "$CURRENT_STAGE" \
1199
- --arg target "$TARGET_STAGE" \
1200
- --arg reasons "$REASONS" \
1201
- --arg note "$NOTE" \
1202
- '{ts:$ts,tool:$tool,currentStage:$stage,targetStage:$target,reasons:($reasons|split(",")),note:$note}' 2>/dev/null || echo "")
1203
- else
1204
- ENTRY=""
1205
- fi
1206
- if [ -n "$ENTRY" ]; then
1207
- printf '%s\n' "$ENTRY" >> "$GUARD_LOG" 2>/dev/null || true
1208
- fi
1209
- SHOULD_BLOCK="false"
1210
- if printf '%s' "$REASONS" | grep -Eq 'implementation_write_before_'; then
1211
- SHOULD_BLOCK="true"
1212
- fi
1213
- if printf '%s' "$REASONS" | grep -Eq 'tdd_write_without_open_red|tdd_write_without_red_for_path' && [ "$TDD_ENFORCEMENT_MODE" = "strict" ]; then
1214
- SHOULD_BLOCK="true"
1215
- fi
1216
- if printf '%s' "$REASONS" | grep -Eq 'tdd_write_without_open_red|tdd_write_without_red_for_path' && iron_law_is_strict "tdd-red-before-write"; then
1217
- SHOULD_BLOCK="true"
1218
- fi
1219
- if printf '%s' "$REASONS" | grep -Eq 'runtime_write_requires_managed_only|direct_flow_state_edit' && iron_law_is_strict "runtime-writes-managed-only"; then
1220
- SHOULD_BLOCK="true"
1221
- fi
1222
- if printf '%s' "$REASONS" | grep -Eq 'mutating_without_recent_flow_read|stage_invocation_without_recent_flow_read' && iron_law_is_strict "flow-state-read-fresh"; then
1223
- SHOULD_BLOCK="true"
1224
- fi
1225
- if printf '%s' "$REASONS" | grep -Eq 'ship_preflight_required' && iron_law_is_strict "ship-preflight-required"; then
1226
- SHOULD_BLOCK="true"
1227
- fi
1228
- if printf '%s' "$REASONS" | grep -Eq 'ship_review_coverage_required' && iron_law_is_strict "review-coverage-complete-before-ship"; then
1229
- SHOULD_BLOCK="true"
1230
- fi
1231
- if printf '%s' "$REASONS" | grep -Eq 'implementation_write_before_plan_completion' && iron_law_is_strict "plan-requires-approval"; then
1232
- SHOULD_BLOCK="true"
1233
- fi
1234
- if printf '%s' "$REASONS" | grep -Eq 'tdd_cycle_counts_unavailable|tdd_red_evidence_check_failed'; then
1235
- SHOULD_BLOCK="true"
1236
- fi
1237
- if printf '%s' "$REASONS" | grep -Eq 'tdd_red_agent_cannot_write_production|tdd_green_agent_cannot_write_tests|tdd_refactor_before_green'; then
1238
- SHOULD_BLOCK="true"
1239
- fi
1240
- if [ "$WORKFLOW_GUARD_MODE" = "strict" ] || [ "$SHOULD_BLOCK" = "true" ]; then
1241
- printf '[cclaw] %s (blocked by workflow guard)\n' "$NOTE" >&2
1242
- exit 1
1243
- fi
1244
- printf '[cclaw] %s\n' "$NOTE" >&2
1245
- fi
1246
-
1247
- exit 0
1248
- `;
1249
- }
1250
- export function contextMonitorScript() {
1251
- return `#!/usr/bin/env bash
1252
- # cclaw context monitor hook — generated by cclaw sync
1253
- # Advisory-only context pressure warnings (best effort).
1254
- set -uo pipefail
1255
-
1256
- ${RUNTIME_SHELL_DETECT_ROOT}
1257
-
1258
- STATE_DIR="$ROOT/${RUNTIME_ROOT}/state"
1259
- MONITOR_STATE="$STATE_DIR/context-monitor.json"
1260
- WARNINGS_FILE="$STATE_DIR/context-warnings.jsonl"
1261
- FLOW_STATE_FILE="$STATE_DIR/flow-state.json"
1262
- TDD_AUTO_EVIDENCE_FILE="$STATE_DIR/tdd-red-evidence.jsonl"
1263
- mkdir -p "$STATE_DIR" 2>/dev/null || true
1264
-
1265
- INPUT=$(cat 2>/dev/null || echo '{}')
1266
- [ -n "$INPUT" ] || exit 0
1267
-
1268
- CURRENT_STAGE="none"
1269
- CURRENT_RUN="active"
1270
- if command -v cclaw_hook_read_flow_state_minimal >/dev/null 2>&1; then
1271
- cclaw_hook_read_flow_state_minimal "$FLOW_STATE_FILE"
1272
- CURRENT_STAGE="\${CCLAW_HOOK_FLOW_STAGE:-none}"
1273
- CURRENT_RUN="\${CCLAW_HOOK_FLOW_RUN_ID:-active}"
1274
- elif [ -f "$FLOW_STATE_FILE" ]; then
1275
- if command -v jq >/dev/null 2>&1; then
1276
- CURRENT_STAGE=$(jq -r '.currentStage // "none"' "$FLOW_STATE_FILE" 2>/dev/null || echo "none")
1277
- CURRENT_RUN=$(jq -r '.activeRunId // "active"' "$FLOW_STATE_FILE" 2>/dev/null || echo "active")
1278
- elif command -v python3 >/dev/null 2>&1; then
1279
- FLOW_META=$(python3 - "$FLOW_STATE_FILE" <<'PY'
1280
- import json
1281
- import sys
1282
- stage = "none"
1283
- run_id = "active"
1284
- try:
1285
- with open(sys.argv[1], "r", encoding="utf-8") as fh:
1286
- payload = json.load(fh)
1287
- stage_value = payload.get("currentStage")
1288
- run_value = payload.get("activeRunId")
1289
- if isinstance(stage_value, str) and stage_value:
1290
- stage = stage_value
1291
- if isinstance(run_value, str) and run_value:
1292
- run_id = run_value
1293
- except Exception:
1294
- pass
1295
- print(stage)
1296
- print(run_id)
1297
- PY
1298
- )
1299
- {
1300
- IFS= read -r CURRENT_STAGE
1301
- IFS= read -r CURRENT_RUN
1302
- } <<EOF
1303
- $FLOW_META
1304
- EOF
1305
- fi
1306
- fi
1307
-
1308
- AUTO_TOOL=""
1309
- AUTO_COMMAND=""
1310
- AUTO_EXIT_CODE=""
1311
- AUTO_PATHS_CSV=""
1312
- if command -v python3 >/dev/null 2>&1; then
1313
- AUTO_META=$(INPUT_JSON="$INPUT" python3 - <<'PY'
1314
- import json
1315
- import os
1316
- import re
1317
- from typing import Any, Iterator
1318
-
1319
- raw = os.environ.get("INPUT_JSON", "{}")
1320
- try:
1321
- payload = json.loads(raw)
1322
- except Exception:
1323
- payload = {}
1324
-
1325
- def walk(node: Any) -> Iterator[Any]:
1326
- if isinstance(node, dict):
1327
- yield node
1328
- for value in node.values():
1329
- yield from walk(value)
1330
- elif isinstance(node, list):
1331
- for value in node:
1332
- yield from walk(value)
1333
-
1334
- def first_string(keys: list[str]) -> str:
1335
- for node in walk(payload):
1336
- if not isinstance(node, dict):
1337
- continue
1338
- for key in keys:
1339
- value = node.get(key)
1340
- if isinstance(value, str) and value.strip():
1341
- return value.strip()
1342
- return ""
1343
-
1344
- tool = first_string(["tool_name", "tool", "toolName", "name", "id"])
1345
- command = ""
1346
- for node in walk(payload):
1347
- if not isinstance(node, dict):
1348
- continue
1349
- for key in ("command", "cmd"):
1350
- value = node.get(key)
1351
- if isinstance(value, str) and value.strip():
1352
- command = value.strip()
1353
- break
1354
- if command:
1355
- break
1356
-
1357
- exit_code = ""
1358
- for node in walk(payload):
1359
- if not isinstance(node, dict):
1360
- continue
1361
- for key in ("exitCode", "exit_code", "code", "status"):
1362
- value = node.get(key)
1363
- if isinstance(value, bool):
1364
- exit_code = "0" if value else "1"
1365
- break
1366
- if isinstance(value, (int, float)):
1367
- exit_code = str(int(value))
1368
- break
1369
- if exit_code:
1370
- break
1371
-
1372
- blob_parts: list[str] = []
1373
- for node in walk(payload):
1374
- if not isinstance(node, dict):
1375
- continue
1376
- for key in ("stderr", "stdout", "output", "text", "message"):
1377
- value = node.get(key)
1378
- if isinstance(value, str) and value:
1379
- blob_parts.append(value)
1380
- blob_parts.append(command)
1381
- blob = "\\n".join(blob_parts)
1382
- path_pattern = re.compile(r"(?:[A-Za-z0-9_.-]+/)+[A-Za-z0-9_.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift)")
1383
- seen: set[str] = set()
1384
- paths: list[str] = []
1385
- for match in path_pattern.findall(blob):
1386
- normalized = match.strip().strip("\\"'.,:;()[]{}<>")
1387
- if not normalized or normalized in seen:
1388
- continue
1389
- seen.add(normalized)
1390
- paths.append(normalized)
1391
-
1392
- print(tool.replace("\\t", " ").replace("\\n", " "))
1393
- print(command.replace("\\t", " ").replace("\\n", " "))
1394
- print(exit_code)
1395
- print(",".join(paths[:20]).replace("\\t", " ").replace("\\n", " "))
1396
- PY
1397
- )
1398
- {
1399
- IFS= read -r AUTO_TOOL
1400
- IFS= read -r AUTO_COMMAND
1401
- IFS= read -r AUTO_EXIT_CODE
1402
- IFS= read -r AUTO_PATHS_CSV
1403
- } <<EOF
1404
- $AUTO_META
1405
- EOF
1406
- fi
1407
-
1408
- if [ "$CURRENT_STAGE" = "tdd" ] && [ -n "$AUTO_COMMAND" ] && [ -n "$AUTO_EXIT_CODE" ]; then
1409
- if command -v cclaw_hook_lower >/dev/null 2>&1; then
1410
- AUTO_COMMAND_LOWER=$(cclaw_hook_lower "$AUTO_COMMAND")
1411
- else
1412
- AUTO_COMMAND_LOWER=$(printf '%s' "$AUTO_COMMAND" | tr '[:upper:]' '[:lower:]')
1413
- fi
1414
- if printf '%s' "$AUTO_COMMAND_LOWER" | grep -Eq '(npm test|npm run test|pnpm test|pnpm run test|yarn test|bun test|vitest|jest|pytest|go test|cargo test|mvn test|gradle test|dotnet test)'; then
1415
- if printf '%s' "$AUTO_EXIT_CODE" | grep -Eq '^-?[0-9]+$' && [ "$AUTO_EXIT_CODE" -ne 0 ]; then
1416
- TS_AUTO=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
1417
- if command -v jq >/dev/null 2>&1; then
1418
- AUTO_ENTRY=$(jq -n -c \
1419
- --arg ts "$TS_AUTO" \
1420
- --arg run "$CURRENT_RUN" \
1421
- --arg command "$AUTO_COMMAND" \
1422
- --arg tool "$AUTO_TOOL" \
1423
- --argjson exitCode "$AUTO_EXIT_CODE" \
1424
- --arg paths "$AUTO_PATHS_CSV" \
1425
- '{
1426
- ts: $ts,
1427
- runId: $run,
1428
- stage: "tdd",
1429
- source: "posttool-auto",
1430
- command: $command,
1431
- tool: $tool,
1432
- exitCode: $exitCode,
1433
- paths: ($paths | split(",") | map(select(length > 0)))
1434
- }' 2>/dev/null || echo "")
1435
- elif command -v python3 >/dev/null 2>&1; then
1436
- AUTO_ENTRY=$(python3 - "$TS_AUTO" "$CURRENT_RUN" "$AUTO_COMMAND" "$AUTO_TOOL" "$AUTO_EXIT_CODE" "$AUTO_PATHS_CSV" <<'PY'
1437
- import json
1438
- import sys
1439
- ts, run_id, command, tool, exit_code, paths_csv = sys.argv[1:7]
1440
- paths = [value for value in paths_csv.split(",") if value]
1441
- entry = {
1442
- "ts": ts,
1443
- "runId": run_id,
1444
- "stage": "tdd",
1445
- "source": "posttool-auto",
1446
- "command": command,
1447
- "tool": tool,
1448
- "exitCode": int(exit_code),
1449
- "paths": paths
1450
- }
1451
- print(json.dumps(entry, ensure_ascii=False))
1452
- PY
1453
- )
1454
- else
1455
- AUTO_ENTRY=""
1456
- fi
1457
- if [ -n "$AUTO_ENTRY" ]; then
1458
- printf '%s\n' "$AUTO_ENTRY" >> "$TDD_AUTO_EVIDENCE_FILE" 2>/dev/null || true
1459
- fi
1460
- fi
1461
- fi
1462
- fi
1463
-
1464
- REMAINING_PERCENT=""
1465
- if command -v python3 >/dev/null 2>&1; then
1466
- REMAINING_PERCENT=$(INPUT_JSON="$INPUT" python3 - <<'PY'
1467
- import json
1468
- import os
1469
- from typing import Any
1470
-
1471
- raw = os.environ.get("INPUT_JSON", "{}")
1472
- try:
1473
- payload = json.loads(raw)
1474
- except Exception:
1475
- print("")
1476
- raise SystemExit(0)
1477
-
1478
- def pick(path: list[str]) -> Any:
1479
- node: Any = payload
1480
- for key in path:
1481
- if not isinstance(node, dict):
1482
- return None
1483
- node = node.get(key)
1484
- return node
1485
-
1486
- def as_percent(value: Any, invert: bool = False):
1487
- if not isinstance(value, (int, float)):
1488
- return None
1489
- number = float(value)
1490
- if number <= 1.0:
1491
- number *= 100.0
1492
- if invert:
1493
- number = 100.0 - number
1494
- if number < 0:
1495
- number = 0.0
1496
- if number > 100:
1497
- number = 100.0
1498
- return number
1499
-
1500
- candidates = [
1501
- (["context", "remaining_percent"], False),
1502
- (["context", "remainingPercent"], False),
1503
- (["context_usage", "remaining_percent"], False),
1504
- (["context_usage", "remainingPercent"], False),
1505
- (["contextUsage", "remainingPercent"], False),
1506
- (["context_window", "remaining_percent"], False),
1507
- (["remaining_context_percent"], False),
1508
- (["remainingContextPercent"], False),
1509
- (["remaining_context_ratio"], False),
1510
- (["remainingContextRatio"], False),
1511
- (["context", "used_percent"], True),
1512
- (["context", "usedPercent"], True),
1513
- (["context_usage", "used_percent"], True),
1514
- (["context_usage", "usedPercent"], True),
1515
- (["contextUsage", "usedPercent"], True),
1516
- (["context_window", "used_ratio"], True),
1517
- (["context_window", "usedRatio"], True),
1518
- ]
1519
-
1520
- for path, invert in candidates:
1521
- value = pick(path)
1522
- percent = as_percent(value, invert=invert)
1523
- if percent is not None:
1524
- print(f"{percent:.2f}")
1525
- raise SystemExit(0)
1526
-
1527
- print("")
1528
- PY
1529
- )
1530
- fi
1531
-
1532
- [ -n "$REMAINING_PERCENT" ] || exit 0
1533
-
1534
- BAND="none"
1535
- if awk "BEGIN { exit !($REMAINING_PERCENT <= 20) }"; then
1536
- BAND="critical"
1537
- elif awk "BEGIN { exit !($REMAINING_PERCENT <= 35) }"; then
1538
- BAND="warning"
1539
- fi
1540
-
1541
- TTL_SECONDS_RAW="\${CCLAW_CONTEXT_MONITOR_TTL_SEC:-900}"
1542
- if printf '%s' "$TTL_SECONDS_RAW" | grep -Eq '^[0-9]+$'; then
1543
- TTL_SECONDS="$TTL_SECONDS_RAW"
1544
- else
1545
- TTL_SECONDS="900"
1546
- fi
1547
-
1548
- LAST_BAND="none"
1549
- LAST_ADVISORY_BAND="none"
1550
- LAST_ADVISORY_AT=""
1551
- LAST_ADVISORY_EPOCH="0"
1552
- if [ -f "$MONITOR_STATE" ]; then
1553
- if command -v jq >/dev/null 2>&1; then
1554
- LAST_BAND=$(jq -r '.lastBand // "none"' "$MONITOR_STATE" 2>/dev/null || echo "none")
1555
- LAST_ADVISORY_BAND=$(jq -r '.lastAdvisoryBand // .lastBand // "none"' "$MONITOR_STATE" 2>/dev/null || echo "none")
1556
- LAST_ADVISORY_AT=$(jq -r '.lastAdvisoryAt // ""' "$MONITOR_STATE" 2>/dev/null || echo "")
1557
- LAST_ADVISORY_EPOCH=$(jq -r 'try ((.lastAdvisoryAt // "" | fromdateiso8601)) catch 0' "$MONITOR_STATE" 2>/dev/null || echo "0")
1558
- elif command -v python3 >/dev/null 2>&1; then
1559
- STATE_META=$(python3 - "$MONITOR_STATE" <<'PY'
1560
- import json
1561
- import sys
1562
- try:
1563
- with open(sys.argv[1], "r", encoding="utf-8") as handle:
1564
- value = json.load(handle)
1565
- last_band = value.get("lastBand")
1566
- if not isinstance(last_band, str):
1567
- last_band = "none"
1568
- advisory_band = value.get("lastAdvisoryBand")
1569
- if not isinstance(advisory_band, str):
1570
- advisory_band = last_band
1571
- advisory_at = value.get("lastAdvisoryAt")
1572
- if not isinstance(advisory_at, str):
1573
- advisory_at = ""
1574
- advisory_epoch = 0
1575
- if advisory_at:
1576
- try:
1577
- from datetime import datetime
1578
- normalized = advisory_at.replace("Z", "+00:00")
1579
- advisory_epoch = int(datetime.fromisoformat(normalized).timestamp())
1580
- except Exception:
1581
- advisory_epoch = 0
1582
- print(f"{last_band}|{advisory_band}|{advisory_at}|{advisory_epoch}")
1583
- except Exception:
1584
- print("none|none||0")
1585
- PY
1586
- )
1587
- LAST_BAND=$(printf '%s' "$STATE_META" | cut -d'|' -f1)
1588
- LAST_ADVISORY_BAND=$(printf '%s' "$STATE_META" | cut -d'|' -f2)
1589
- LAST_ADVISORY_AT=$(printf '%s' "$STATE_META" | cut -d'|' -f3)
1590
- LAST_ADVISORY_EPOCH=$(printf '%s' "$STATE_META" | cut -d'|' -f4)
1591
- fi
1592
- fi
1593
-
1594
- TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
1595
- NOW_EPOCH=$(date +%s 2>/dev/null || echo "0")
1596
- if ! printf '%s' "$LAST_ADVISORY_EPOCH" | grep -Eq '^[0-9]+$'; then
1597
- LAST_ADVISORY_EPOCH="0"
1598
- fi
1599
-
1600
- SHOULD_EMIT="false"
1601
- if [ "$BAND" != "none" ]; then
1602
- if [ "$BAND" != "$LAST_ADVISORY_BAND" ]; then
1603
- SHOULD_EMIT="true"
1604
- elif [ "$TTL_SECONDS" -eq 0 ]; then
1605
- SHOULD_EMIT="true"
1606
- else
1607
- ELAPSED=$((NOW_EPOCH - LAST_ADVISORY_EPOCH))
1608
- if [ "$ELAPSED" -ge "$TTL_SECONDS" ]; then
1609
- SHOULD_EMIT="true"
1610
- fi
1611
- fi
1612
- fi
1613
-
1614
- NEXT_ADVISORY_BAND="$LAST_ADVISORY_BAND"
1615
- NEXT_ADVISORY_AT="$LAST_ADVISORY_AT"
1616
- if [ "$SHOULD_EMIT" = "true" ]; then
1617
- NOTE="Cclaw advisory: context remaining is \${REMAINING_PERCENT}% (\${BAND}). Consider checkpointing or compacting soon."
1618
- if command -v jq >/dev/null 2>&1; then
1619
- ENTRY=$(jq -n -c \
1620
- --arg ts "$TS" \
1621
- --arg harness "$HARNESS" \
1622
- --arg band "$BAND" \
1623
- --arg remaining "$REMAINING_PERCENT" \
1624
- --arg note "$NOTE" \
1625
- '{ts:$ts,harness:$harness,band:$band,remainingPercent:($remaining|tonumber),note:$note}' 2>/dev/null || echo "")
1626
- else
1627
- ENTRY=$(printf '{"ts":"%s","harness":"%s","band":"%s","remainingPercent":"%s","note":"%s"}' "$TS" "$HARNESS" "$BAND" "$REMAINING_PERCENT" "$NOTE")
1628
- fi
1629
-
1630
- if [ -n "$ENTRY" ]; then
1631
- printf '%s\n' "$ENTRY" >> "$WARNINGS_FILE" 2>/dev/null || true
1632
- fi
1633
- printf '[cclaw] %s\n' "$NOTE" >&2
1634
- NEXT_ADVISORY_BAND="$BAND"
1635
- NEXT_ADVISORY_AT="$TS"
1636
- fi
1637
-
1638
- TMP_STATE="$MONITOR_STATE.tmp.$$"
1639
- if command -v jq >/dev/null 2>&1; then
1640
- jq -n \
1641
- --arg ts "$TS" \
1642
- --arg band "$BAND" \
1643
- --arg advisoryBand "$NEXT_ADVISORY_BAND" \
1644
- --arg advisoryAt "$NEXT_ADVISORY_AT" \
1645
- --arg remaining "$REMAINING_PERCENT" \
1646
- --arg harness "$HARNESS" \
1647
- '{lastUpdated:$ts,lastBand:$band,lastRemainingPercent:($remaining|tonumber),harness:$harness,lastAdvisoryBand:$advisoryBand,lastAdvisoryAt:$advisoryAt}' > "$TMP_STATE" 2>/dev/null || true
1648
- else
1649
- printf '{\n "lastUpdated": "%s",\n "lastBand": "%s",\n "lastRemainingPercent": %s,\n "harness": "%s",\n "lastAdvisoryBand": "%s",\n "lastAdvisoryAt": "%s"\n}\n' \
1650
- "$TS" "$BAND" "$REMAINING_PERCENT" "$HARNESS" "$NEXT_ADVISORY_BAND" "$NEXT_ADVISORY_AT" > "$TMP_STATE" 2>/dev/null || true
1651
- fi
1652
- if [ -s "$TMP_STATE" ]; then
1653
- mv "$TMP_STATE" "$MONITOR_STATE" 2>/dev/null || rm -f "$TMP_STATE" 2>/dev/null || true
1654
- fi
1655
-
1656
- exit 0
1657
- `;
1658
- }
1659
- /**
1660
- * Updated hooks.json generators with PreToolUse/PostToolUse observation.
1661
- */
1662
- function hookDispatcherCommand(scriptName) {
1663
- return `bash ${RUNTIME_ROOT}/hooks/run-hook.cmd ${scriptName}`;
2
+ function hookDispatcherCommand(hookName) {
3
+ return `node ${RUNTIME_ROOT}/hooks/run-hook.mjs ${hookName}`;
1664
4
  }
1665
5
  export function claudeHooksJsonWithObservation() {
1666
6
  return JSON.stringify({
@@ -1670,39 +10,33 @@ export function claudeHooksJsonWithObservation() {
1670
10
  matcher: "startup|resume|clear|compact",
1671
11
  hooks: [{
1672
12
  type: "command",
1673
- command: hookDispatcherCommand("session-start.sh")
13
+ command: hookDispatcherCommand("session-start")
1674
14
  }]
1675
15
  }],
1676
16
  PreToolUse: [{
1677
- // `prompt-guard.sh` inspects tool inputs across all tool calls;
1678
- // it has to stay on `*` so it sees MCP/Edit/Write/WebSearch
1679
- // traffic too. `workflow-guard.sh`, however, only checks TDD
1680
- // ordering on write-like operations — it is a no-op for reads.
1681
- // Splitting the two matchers cuts Claude's per-read hook
1682
- // overhead in half without reducing coverage on write paths.
1683
17
  matcher: "*",
1684
18
  hooks: [{
1685
19
  type: "command",
1686
- command: hookDispatcherCommand("prompt-guard.sh")
20
+ command: hookDispatcherCommand("prompt-guard")
1687
21
  }]
1688
22
  }, {
1689
23
  matcher: "Write|Edit|MultiEdit|NotebookEdit|Bash",
1690
24
  hooks: [{
1691
25
  type: "command",
1692
- command: hookDispatcherCommand("workflow-guard.sh")
26
+ command: hookDispatcherCommand("workflow-guard")
1693
27
  }]
1694
28
  }],
1695
29
  PostToolUse: [{
1696
30
  matcher: "*",
1697
31
  hooks: [{
1698
32
  type: "command",
1699
- command: hookDispatcherCommand("context-monitor.sh")
33
+ command: hookDispatcherCommand("context-monitor")
1700
34
  }]
1701
35
  }],
1702
36
  Stop: [{
1703
37
  hooks: [{
1704
38
  type: "command",
1705
- command: hookDispatcherCommand("stop-checkpoint.sh"),
39
+ command: hookDispatcherCommand("stop-checkpoint"),
1706
40
  timeout: 10
1707
41
  }]
1708
42
  }],
@@ -1710,7 +44,7 @@ export function claudeHooksJsonWithObservation() {
1710
44
  matcher: "manual|auto",
1711
45
  hooks: [{
1712
46
  type: "command",
1713
- command: hookDispatcherCommand("pre-compact.sh"),
47
+ command: hookDispatcherCommand("pre-compact"),
1714
48
  timeout: 10
1715
49
  }]
1716
50
  }]
@@ -1723,53 +57,37 @@ export function cursorHooksJsonWithObservation() {
1723
57
  version: 1,
1724
58
  hooks: {
1725
59
  sessionStart: [{
1726
- command: hookDispatcherCommand("session-start.sh")
60
+ command: hookDispatcherCommand("session-start")
1727
61
  }],
1728
62
  sessionResume: [{
1729
- command: hookDispatcherCommand("session-start.sh")
63
+ command: hookDispatcherCommand("session-start")
1730
64
  }],
1731
65
  sessionClear: [{
1732
- command: hookDispatcherCommand("session-start.sh")
66
+ command: hookDispatcherCommand("session-start")
1733
67
  }],
1734
68
  sessionCompact: [{
1735
- command: hookDispatcherCommand("pre-compact.sh")
69
+ command: hookDispatcherCommand("pre-compact")
1736
70
  }, {
1737
- command: hookDispatcherCommand("session-start.sh")
71
+ command: hookDispatcherCommand("session-start")
1738
72
  }],
1739
73
  preToolUse: [{
1740
74
  matcher: "*",
1741
- command: hookDispatcherCommand("prompt-guard.sh")
75
+ command: hookDispatcherCommand("prompt-guard")
1742
76
  }, {
1743
77
  matcher: "*",
1744
- command: hookDispatcherCommand("workflow-guard.sh")
78
+ command: hookDispatcherCommand("workflow-guard")
1745
79
  }],
1746
80
  postToolUse: [{
1747
81
  matcher: "*",
1748
- command: hookDispatcherCommand("context-monitor.sh")
82
+ command: hookDispatcherCommand("context-monitor")
1749
83
  }],
1750
- stop: [{ command: hookDispatcherCommand("stop-checkpoint.sh"), timeout: 10 }]
84
+ stop: [{
85
+ command: hookDispatcherCommand("stop-checkpoint"),
86
+ timeout: 10
87
+ }]
1751
88
  }
1752
89
  }, null, 2);
1753
90
  }
1754
- /**
1755
- * Codex CLI ≥ v0.114 hooks. Differences vs. the Claude shape:
1756
- *
1757
- * - `SessionStart` matcher is limited to `startup|resume` — Codex does
1758
- * not emit `clear` or `compact` lifecycle phases.
1759
- * - `PreToolUse` / `PostToolUse` fire **only for the `Bash` tool**
1760
- * (documented Codex limitation, v0.114/v0.115). We match both `Bash`
1761
- * and `bash` variants to tolerate casing drift across Codex builds.
1762
- * - `UserPromptSubmit` is supported and is the closest analogue to
1763
- * Cursor's `preToolUse` for non-Bash tooling — we run prompt-guard
1764
- * there so workflow/prompt checks still fire when the tool being
1765
- * used is `Write` or `Edit` rather than `Bash`.
1766
- * - There is no `PreCompact` event in Codex CLI — pre-compact
1767
- * semantics are carried by the agent itself inside `/cc-ops retro`.
1768
- *
1769
- * The entire file is inert unless the user opts into
1770
- * `[features] codex_hooks = true` in `~/.codex/config.toml`; cclaw
1771
- * doctor and the init prompt handle that flag.
1772
- */
1773
91
  export function codexHooksJsonWithObservation() {
1774
92
  return JSON.stringify({
1775
93
  cclawHookSchemaVersion: 1,
@@ -1778,51 +96,42 @@ export function codexHooksJsonWithObservation() {
1778
96
  matcher: "startup|resume",
1779
97
  hooks: [{
1780
98
  type: "command",
1781
- command: hookDispatcherCommand("session-start.sh")
99
+ command: hookDispatcherCommand("session-start")
1782
100
  }]
1783
101
  }],
1784
102
  UserPromptSubmit: [{
1785
103
  hooks: [{
1786
104
  type: "command",
1787
- command: hookDispatcherCommand("prompt-guard.sh")
105
+ command: hookDispatcherCommand("prompt-guard")
1788
106
  }, {
1789
- // `workflow-guard.sh` also runs here because Codex's PreToolUse
1790
- // only sees Bash; Write/Edit/MCP writes never reach the hook
1791
- // surface. Running workflow-guard on UserPromptSubmit catches
1792
- // TDD-order violations that originate from the user's prompt
1793
- // text (e.g. "edit X.ts to ..."). Payload is a prompt envelope,
1794
- // not a tool call, so the script's TOOL extraction falls back
1795
- // to "unknown" and advisory mode is a no-op by design — the
1796
- // value is that prompt text is scanned for write-shaped intent
1797
- // via the existing PAYLOAD_LOWER heuristics.
1798
107
  type: "command",
1799
- command: hookDispatcherCommand("workflow-guard.sh")
108
+ command: hookDispatcherCommand("workflow-guard")
1800
109
  }, {
1801
110
  type: "command",
1802
- command: "bash -lc 'if ! command -v cclaw >/dev/null 2>&1; then echo \"[cclaw] codex hook: cclaw binary is required for verify-current-state\" >&2; exit 1; fi; MODE=\"${CCLAW_WORKFLOW_GUARD_MODE:-advisory}\"; if [ \"$MODE\" = \"strict\" ]; then cclaw internal verify-current-state --quiet >/dev/null; else cclaw internal verify-current-state --quiet >/dev/null || true; fi'"
111
+ command: hookDispatcherCommand("verify-current-state")
1803
112
  }]
1804
113
  }],
1805
114
  PreToolUse: [{
1806
115
  matcher: "Bash|bash",
1807
116
  hooks: [{
1808
117
  type: "command",
1809
- command: hookDispatcherCommand("prompt-guard.sh")
118
+ command: hookDispatcherCommand("prompt-guard")
1810
119
  }, {
1811
120
  type: "command",
1812
- command: hookDispatcherCommand("workflow-guard.sh")
121
+ command: hookDispatcherCommand("workflow-guard")
1813
122
  }]
1814
123
  }],
1815
124
  PostToolUse: [{
1816
125
  matcher: "Bash|bash",
1817
126
  hooks: [{
1818
127
  type: "command",
1819
- command: hookDispatcherCommand("context-monitor.sh")
128
+ command: hookDispatcherCommand("context-monitor")
1820
129
  }]
1821
130
  }],
1822
131
  Stop: [{
1823
132
  hooks: [{
1824
133
  type: "command",
1825
- command: hookDispatcherCommand("stop-checkpoint.sh"),
134
+ command: hookDispatcherCommand("stop-checkpoint"),
1826
135
  timeout: 10
1827
136
  }]
1828
137
  }]