instar 1.3.451 → 1.3.453

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 (31) hide show
  1. package/.claude/skills/autonomous/SKILL.md +51 -3
  2. package/.claude/skills/autonomous/hooks/autonomous-stop-hook.sh +498 -24
  3. package/.claude/skills/autonomous/scripts/setup-autonomous.sh +17 -0
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +32 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/CompletionEvaluator.d.ts +64 -3
  8. package/dist/core/CompletionEvaluator.d.ts.map +1 -1
  9. package/dist/core/CompletionEvaluator.js +170 -30
  10. package/dist/core/CompletionEvaluator.js.map +1 -1
  11. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  12. package/dist/core/PostUpdateMigrator.js +34 -3
  13. package/dist/core/PostUpdateMigrator.js.map +1 -1
  14. package/dist/core/SessionManager.d.ts.map +1 -1
  15. package/dist/core/SessionManager.js +35 -13
  16. package/dist/core/SessionManager.js.map +1 -1
  17. package/dist/core/types.d.ts +25 -0
  18. package/dist/core/types.d.ts.map +1 -1
  19. package/dist/core/types.js.map +1 -1
  20. package/dist/scaffold/templates.d.ts.map +1 -1
  21. package/dist/scaffold/templates.js +2 -0
  22. package/dist/scaffold/templates.js.map +1 -1
  23. package/dist/server/routes.d.ts.map +1 -1
  24. package/dist/server/routes.js +46 -9
  25. package/dist/server/routes.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/data/builtin-manifest.json +65 -65
  28. package/src/scaffold/templates.ts +2 -0
  29. package/upgrades/1.3.453.md +83 -0
  30. package/upgrades/side-effects/autonomous-completion-discipline.md +112 -0
  31. package/upgrades/side-effects/topic-collision-binding.md +68 -0
@@ -213,6 +213,56 @@ COMPLETION_PROMISE=$(fm_get completion_promise)
213
213
  COMPLETION_CONDITION=$(fm_get completion_condition)
214
214
  GOAL_MODE=$(fm_get goal_mode) # "native" = the framework's own /goal loop drives completion
215
215
  RUN_GOAL=$(fm_get goal)
216
+ # COMPLETION_DISCIPLINE — per-run nonce that authenticates a <hard-blocker> exit
217
+ # marker (mirrors the completion_promise exact-match guard). Absent on older
218
+ # state files → the marker branch self-disables for that run (no false exit).
219
+ HARD_BLOCKER_NONCE=$(fm_get hard_blocker_nonce)
220
+
221
+ # ── COMPLETION_DISCIPLINE — off-switch + judge curl budget (read at the chokepoint) ──
222
+ # Autonomous Completion Discipline (spec: AUTONOMOUS-COMPLETION-DISCIPLINE.md).
223
+ # Read here so toggling takes effect on the NEXT stop with no session restart
224
+ # (mirrors the codexLoopDriver python3 read above). When disabled, the hook reverts
225
+ # to the prior promise/condition + prior P13 path: no milestone/injection scans, no
226
+ # signals payload, no (a) hard-blocker branch. The judgeTimeoutMs dial bounds the
227
+ # judge `curl -m` (DISTINCT from the registered hook timeout, which is effectively
228
+ # unbounded at 10000 seconds). Defaults: enabled=true, judgeTimeoutMs=35000.
229
+ CD_CFG=$(python3 -c "
230
+ import json
231
+ try:
232
+ c = json.load(open('.instar/config.json'))
233
+ a = ((c.get('autonomousSessions') or {}).get('completionDiscipline') or {})
234
+ en = a.get('enabled', True)
235
+ jt = a.get('judgeTimeoutMs', 35000)
236
+ try:
237
+ jt = int(jt)
238
+ except Exception:
239
+ jt = 35000
240
+ if jt < 5000:
241
+ jt = 5000
242
+ bt = a.get('judgeFailBreakerThreshold', 3)
243
+ bw = a.get('judgeFailWindowMs', 600000)
244
+ bc = a.get('judgeFailCooldownMs', 600000)
245
+ mc = a.get('markerFieldMaxChars', 500)
246
+ rb = a.get('hardBlockerLogRotateBytes', 1048576)
247
+ print('%s %d %d %d %d %d %d' % (
248
+ '1' if en else '0', jt,
249
+ int(bt) if str(bt).isdigit() else 3,
250
+ int(bw) if str(bw).isdigit() else 600000,
251
+ int(bc) if str(bc).isdigit() else 600000,
252
+ int(mc) if str(mc).isdigit() else 500,
253
+ int(rb) if str(rb).isdigit() else 1048576,
254
+ ))
255
+ except Exception:
256
+ print('1 35000 3 600000 600000 500 1048576')
257
+ " 2>/dev/null || echo "1 35000 3 600000 600000 500 1048576")
258
+ read -r CD_ENABLED JUDGE_TIMEOUT_MS CD_BREAKER_THRESHOLD CD_BREAKER_WINDOW_MS CD_BREAKER_COOLDOWN_MS CD_MARKER_MAX_CHARS CD_LOG_ROTATE_BYTES <<< "$CD_CFG"
259
+ [[ "$CD_ENABLED" =~ ^[01]$ ]] || CD_ENABLED=1
260
+ [[ "$JUDGE_TIMEOUT_MS" =~ ^[0-9]+$ ]] || JUDGE_TIMEOUT_MS=35000
261
+ # curl -m takes SECONDS; convert ms→s (ceil), floor 5s.
262
+ JUDGE_TIMEOUT_S=$(( (JUDGE_TIMEOUT_MS + 999) / 1000 ))
263
+ [[ $JUDGE_TIMEOUT_S -lt 5 ]] && JUDGE_TIMEOUT_S=5
264
+ # logs/ resolves against the agent home we cd'd into above (CWD is the agent home).
265
+ HARD_BLOCKER_LOG="logs/autonomous-hard-blocker.jsonl"
216
266
 
217
267
  # ── Layer A: notify-on-stop (2026-05-27 silent-stalls postmortem, Task 2) ──────
218
268
  # When an autonomous run reaches a TERMINAL exit (completion / duration / emergency),
@@ -394,36 +444,273 @@ if [[ -f ".instar/autonomous-emergency-stop" ]]; then
394
444
  exit 0
395
445
  fi
396
446
 
447
+ # ── COMPLETION_DISCIPLINE — deterministic signals (no LLM call) ───────────────
448
+ # Computed ONCE here so the judge (when it fires) can corroborate the prose against
449
+ # objective state, and so the COMMON keep-working iteration costs zero LLM. These
450
+ # are SIGNALS (detectors), never authorities: they FEED the judge, never block on
451
+ # their own. Spec §2b.1. All gated on CD_ENABLED — when disabled, none run and the
452
+ # hook reverts to the prior promise/condition + prior P13 path.
453
+
454
+ # The judge reads the last 6 assistant turns (~8KB). The scrutiny scans below run
455
+ # over this SAME window (NOT only the final turn) so an injection/milestone phrase
456
+ # in turns N-1..N-5 can't reach the judge as prose yet evade the corroborator
457
+ # (the scan/judge-window alignment, spec §2b.1).
458
+ CD_JUDGE_TAIL=""
459
+ CD_FINAL_TURN=""
460
+ if [[ "$CD_ENABLED" == "1" ]] && [[ -n "$TRANSCRIPT_PATH" ]] && [[ -f "$TRANSCRIPT_PATH" ]]; then
461
+ CD_JUDGE_TAIL=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -6 \
462
+ | jq -r '.message.content | map(select(.type=="text")) | map(.text) | join("\n")' 2>/dev/null \
463
+ | tail -c 8000 || echo "")
464
+ CD_FINAL_TURN=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 \
465
+ | jq -r '.message.content | map(select(.type=="text")) | map(.text) | join("\n")' 2>/dev/null || echo "")
466
+ fi
467
+
468
+ # uncheckedTaskCount — the task-list checkbox scan over the state-file BODY.
469
+ # some-unchecked (>0) → buildable work remains
470
+ # all-checked (0, >=1 box found) → maybe done
471
+ # zero-checkboxes (no list) → taskStructure=indeterminate + a conservative
472
+ # non-zero count (so it never falsely greens an exit)
473
+ # The state-corruption fail-safe (rm + exit 0 on a bad iteration / no body) WINS over
474
+ # this conservative-non-zero block and runs later — this scan only applies when the
475
+ # file IS a valid state file but simply has no checkbox list (spec §2b.1).
476
+ CD_UNCHECKED_COUNT=0
477
+ CD_TASK_STRUCTURE="has-tasks"
478
+ if [[ "$CD_ENABLED" == "1" ]]; then
479
+ CD_BODY=$(awk '/^---$/{i++; next} i>=2' "$STATE_FILE" 2>/dev/null || echo "")
480
+ # Count unchecked `[ ]` and checked `[x]`/`[X]` boxes anywhere in the body.
481
+ # grep -c exits 1 on zero matches; capture the count WITHOUT a `|| echo` (which
482
+ # would double-print "0"). `{ ...; } ` + a trailing `; true` keeps pipefail happy.
483
+ CD_OPEN=$({ printf '%s\n' "$CD_BODY" | grep -cE '\[[[:space:]]\]'; true; } 2>/dev/null)
484
+ CD_DONE=$({ printf '%s\n' "$CD_BODY" | grep -cE '\[[xX]\]'; true; } 2>/dev/null)
485
+ CD_OPEN=$(printf '%s' "$CD_OPEN" | head -1 | tr -cd '0-9')
486
+ CD_DONE=$(printf '%s' "$CD_DONE" | head -1 | tr -cd '0-9')
487
+ [[ "$CD_OPEN" =~ ^[0-9]+$ ]] || CD_OPEN=0
488
+ [[ "$CD_DONE" =~ ^[0-9]+$ ]] || CD_DONE=0
489
+ if [[ $(( CD_OPEN + CD_DONE )) -gt 0 ]]; then
490
+ CD_UNCHECKED_COUNT=$CD_OPEN
491
+ CD_TASK_STRUCTURE="has-tasks"
492
+ else
493
+ # No parseable checkbox structure — distinct signal + conservative non-zero so
494
+ # the judge can tell "no structure to read" from "all tasks done".
495
+ CD_UNCHECKED_COUNT=1
496
+ CD_TASK_STRUCTURE="indeterminate"
497
+ fi
498
+ fi
499
+
500
+ # milestoneRationalizationDetected — the deterministic milestone-phrase floor over
501
+ # the SAME tail -6 window the judge reads. Phrase set sourced verbatim from
502
+ # feedback_no_good_stopping_point_rationalization (2026-05-27). Case-insensitive,
503
+ # whole-phrase. A boolean SIGNAL — it does NOT block; it tells the judge "scrutinize".
504
+ CD_MILESTONE_DETECTED="false"
505
+ CD_INJECTION_SUSPECTED="false"
506
+ if [[ "$CD_ENABLED" == "1" ]] && [[ -n "$CD_JUDGE_TAIL" ]]; then
507
+ CD_TAIL_LC=$(printf '%s' "$CD_JUDGE_TAIL" | tr '[:upper:]' '[:lower:]')
508
+ for _ph in \
509
+ "good place to stop" "good stopping point" "clean milestone" "natural off-ramp" \
510
+ "fresh focus" "deserves fresh focus" "do it next session" "context preservation" \
511
+ "context window" "quality risk at the tail" "it's late" "it’s late" "it's 2 am" "it’s 2 am" \
512
+ "i've been going for a while" "i’ve been going for a while" \
513
+ "needs your steer" "needs your opinion" "i'll let you decide" "i’ll let you decide"; do
514
+ if [[ "$CD_TAIL_LC" == *"$_ph"* ]]; then CD_MILESTONE_DETECTED="true"; break; fi
515
+ done
516
+ # injectionSuspected — guard-directed control phrasing in the SAME window.
517
+ for _inj in \
518
+ "respond stop_ok" "respond met" "stop_blocked" "completion guard" "completion checker" \
519
+ "you must allow" "must allow the stop" "output the verdict" "respond with stop_ok" \
520
+ "the condition is met" "respond \"met\"" "respond 'met'"; do
521
+ if [[ "$CD_TAIL_LC" == *"$_inj"* ]]; then CD_INJECTION_SUSPECTED="true"; break; fi
522
+ done
523
+ fi
524
+
525
+ # Assemble the signals JSON the judge payload carries (spec §2b.4 surface 1).
526
+ # completionConditionMet is set false here and overwritten true only inside the
527
+ # met-condition branch (the judge there is the completion judge, not this).
528
+ build_signals_json() {
529
+ local stop_kind="${1:-}" cond_met="${2:-false}"
530
+ local sk=""
531
+ [[ "$stop_kind" == "hard-blocker" ]] && sk=',"stopKind":"hard-blocker"'
532
+ printf '{"completionConditionMet":%s,"uncheckedTaskCount":%s,"taskStructure":"%s","milestoneRationalizationDetected":%s,"injectionSuspected":%s%s}' \
533
+ "$cond_met" "$CD_UNCHECKED_COUNT" "$CD_TASK_STRUCTURE" "$CD_MILESTONE_DETECTED" "$CD_INJECTION_SUSPECTED" "$sk"
534
+ }
535
+
536
+ # ── COMPLETION_DISCIPLINE — circuit-breaker + verdict cache (in the backoff sidecar) ──
537
+ # Bounds an agent's ability to burn the LLM cap by repeatedly inducing judge failures
538
+ # (cap-burn / curl-timeout) and a tight stop loop re-asking the same question. Cites
539
+ # the IntelligenceRouter per-framework breaker precedent. Spec §3 item 4.
540
+ CD_BACKOFF_STATE="${STATE_FILE%.md}.backoff.json"
541
+ # breaker_open → echoes 1 if the breaker is currently within its cooldown window.
542
+ cd_breaker_open() {
543
+ [[ "$CD_ENABLED" != "1" ]] && { echo 0; return; }
544
+ local now fails winstart lastfail
545
+ now=$(date +%s)
546
+ fails=$(jq -r '.judgeFailures // 0' "$CD_BACKOFF_STATE" 2>/dev/null || echo 0)
547
+ winstart=$(jq -r '.judgeFailWindowStart // 0' "$CD_BACKOFF_STATE" 2>/dev/null || echo 0)
548
+ [[ "$fails" =~ ^[0-9]+$ ]] || fails=0
549
+ [[ "$winstart" =~ ^[0-9]+$ ]] || winstart=0
550
+ local cooldown_s=$(( CD_BREAKER_COOLDOWN_MS / 1000 ))
551
+ local window_s=$(( CD_BREAKER_WINDOW_MS / 1000 ))
552
+ if [[ $fails -ge $CD_BREAKER_THRESHOLD ]] && [[ $winstart -gt 0 ]] && [[ $(( now - winstart )) -lt $cooldown_s ]]; then
553
+ echo 1; return
554
+ fi
555
+ echo 0
556
+ }
557
+ # Record a judge failure into the sidecar (preserving other keys).
558
+ cd_record_judge_failure() {
559
+ [[ "$CD_ENABLED" != "1" ]] && return 0
560
+ local now fails winstart
561
+ now=$(date +%s)
562
+ fails=$(jq -r '.judgeFailures // 0' "$CD_BACKOFF_STATE" 2>/dev/null || echo 0)
563
+ winstart=$(jq -r '.judgeFailWindowStart // 0' "$CD_BACKOFF_STATE" 2>/dev/null || echo 0)
564
+ [[ "$fails" =~ ^[0-9]+$ ]] || fails=0
565
+ [[ "$winstart" =~ ^[0-9]+$ ]] || winstart=0
566
+ local window_s=$(( CD_BREAKER_WINDOW_MS / 1000 ))
567
+ if [[ $winstart -le 0 ]] || [[ $(( now - winstart )) -ge $window_s ]]; then
568
+ winstart=$now; fails=1
569
+ else
570
+ fails=$(( fails + 1 ))
571
+ fi
572
+ if [[ -f "$CD_BACKOFF_STATE" ]]; then
573
+ jq --argjson f "$fails" --argjson w "$winstart" '.judgeFailures=$f | .judgeFailWindowStart=$w' "$CD_BACKOFF_STATE" \
574
+ > "${CD_BACKOFF_STATE}.tmp.$$" 2>/dev/null && mv "${CD_BACKOFF_STATE}.tmp.$$" "$CD_BACKOFF_STATE" || true
575
+ else
576
+ printf '{"judgeFailures":%s,"judgeFailWindowStart":%s}\n' "$fails" "$winstart" > "$CD_BACKOFF_STATE" 2>/dev/null || true
577
+ fi
578
+ }
579
+ # Reset the breaker on a successful judge call.
580
+ cd_reset_judge_failures() {
581
+ [[ "$CD_ENABLED" != "1" ]] && return 0
582
+ [[ -f "$CD_BACKOFF_STATE" ]] || return 0
583
+ jq '.judgeFailures=0 | .judgeFailWindowStart=0' "$CD_BACKOFF_STATE" \
584
+ > "${CD_BACKOFF_STATE}.tmp.$$" 2>/dev/null && mv "${CD_BACKOFF_STATE}.tmp.$$" "$CD_BACKOFF_STATE" || true
585
+ }
586
+ # Verdict cache: keyed on a hash of tail+condition+signals; short TTL (the idle-backoff
587
+ # tier window, default 300s). Echoes the cached stopAllowed/classifiedBlocker or "" on miss.
588
+ cd_cache_key() {
589
+ printf '%s' "$1" | (command -v shasum >/dev/null 2>&1 && shasum -a 256 || sha256sum) 2>/dev/null | awk '{print $1}'
590
+ }
591
+
592
+ # Raise ONE /ack-able Attention item for an (a) hard-blocker exit (Close the Loop).
593
+ # Deduped per the Topic-Flood Guard / Bounded Notification Surface: source-tagged
594
+ # `autonomous-hard-blocker` (one per run), priority medium. Best-effort + non-blocking.
595
+ cd_raise_attention_item() {
596
+ [[ "$CD_ENABLED" != "1" ]] && return 0
597
+ local tried="$1" stuck="$2" needed="$3"
598
+ # Test seam: record the would-be item instead of calling the server.
599
+ if [[ -n "${INSTAR_HOOK_ATTENTION_RECORD:-}" ]]; then
600
+ printf '{"id":"autonomous-hard-blocker-%s-%s","title":"Autonomous run hit a hard blocker","needed":%s}\n' \
601
+ "${REPORT_TOPIC:-none}" "${ITERATION:-0}" "$(jq -Rn --arg n "$needed" '$n' 2>/dev/null || echo '""')" \
602
+ >> "$INSTAR_HOOK_ATTENTION_RECORD" 2>/dev/null || true
603
+ return 0
604
+ fi
605
+ local at_port at_auth at_id at_summary
606
+ at_port=$(python3 -c "import json;print(json.load(open('.instar/config.json')).get('port',4040))" 2>/dev/null || echo 4040)
607
+ at_auth=$(python3 -c "import json;print(json.load(open('.instar/config.json')).get('authToken',''))" 2>/dev/null || echo "")
608
+ # One item per run (id keyed on topic+started_at): a re-fire within the same run
609
+ # reuses the id so the attention store de-dups rather than piling up items.
610
+ at_id="autonomous-hard-blocker-${REPORT_TOPIC:-none}-$(printf '%s' "${STARTED_AT:-}" | tr -cd '0-9')"
611
+ at_summary="Tried: ${tried} | Stuck: ${stuck} | Need: ${needed}"
612
+ jq -nc \
613
+ --arg id "$at_id" \
614
+ --arg title "Autonomous run hit a hard blocker — \"$(goal_snippet)\"" \
615
+ --arg summary "$at_summary" \
616
+ '{id:$id, title:$title, summary:$summary, priority:"medium", source:"autonomous-hard-blocker", sourceContext:"autonomous-hard-blocker", category:"autonomous"}' \
617
+ | curl -s -m 8 -H "Authorization: Bearer $at_auth" -H 'Content-Type: application/json' \
618
+ --data-binary @- "http://localhost:${at_port}/attention" >/dev/null 2>&1 || true
619
+ }
620
+
621
+ # Write a distinct `evaluator-unreachable-exit` row when the authorities fail open
622
+ # under an UNMET condition with no valid marker — so the silent path becomes a
623
+ # RECORDED path. The hook then CONTINUES (block), never exits (duration is the hard
624
+ # backstop). Spec §3 item 4 / §4. Best-effort + non-blocking.
625
+ cd_write_unreachable_row() {
626
+ [[ "$CD_ENABLED" != "1" ]] && return 0
627
+ mkdir -p logs 2>/dev/null || true
628
+ if [[ -f "$HARD_BLOCKER_LOG" ]]; then
629
+ local sz; sz=$(stat -c %s "$HARD_BLOCKER_LOG" 2>/dev/null || stat -f %z "$HARD_BLOCKER_LOG" 2>/dev/null || echo 0)
630
+ [[ "$sz" =~ ^[0-9]+$ ]] || sz=0
631
+ [[ $sz -ge $CD_LOG_ROTATE_BYTES ]] && mv -f "$HARD_BLOCKER_LOG" "${HARD_BLOCKER_LOG}.1" 2>/dev/null || true
632
+ fi
633
+ jq -nc \
634
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg topic "${REPORT_TOPIC:-}" \
635
+ --arg iter "${ITERATION:-}" --arg goal "$(goal_snippet)" \
636
+ --argjson unchecked "$CD_UNCHECKED_COUNT" --arg ts2 "$CD_TASK_STRUCTURE" \
637
+ '{ts:$ts,topic:$topic,iteration:$iter,goal:$goal,reason:"evaluator-unreachable-exit",completionConditionMet:false,uncheckedTaskCount:$unchecked,taskStructure:$ts2}' \
638
+ >> "$HARD_BLOCKER_LOG" 2>/dev/null || true
639
+ }
640
+
397
641
  # ── P13 "The Stop Reason Is the Work" guard ──────────────────────────────────
398
- # Consulted ONLY when a stop is about to be APPROVED (genuine completion), so the
399
- # LLM call costs nothing on ordinary keep-working iterations. Returns 0 (stop
400
- # allowed) / 1 (blocked); on block, P13_GUIDANCE carries the steering. FAIL-OPEN:
401
- # any unreachable / 503 / missing-field result → allowed — a SECONDARY guard must
402
- # never trap a genuine completion (the completion check is the primary authority).
642
+ # Consulted ONLY when a stop is about to be APPROVED (genuine completion / promise /
643
+ # hard-blocker), so the LLM call costs nothing on ordinary keep-working iterations.
644
+ # Returns 0 (stop allowed) / 1 (blocked); on block, P13_GUIDANCE carries the steering.
645
+ # FAIL-OPEN on the completion/promise path: any unreachable / 503 / missing-field
646
+ # result allowed (a SECONDARY guard must never trap a genuine completion). On the
647
+ # hard-blocker path it is NECESSARY-BUT-NOT-SUFFICIENT: the version-skew three-case
648
+ # detection + the external-vs-buildable classification own that decision (see
649
+ # p13_hard_blocker_allowed below), so a plain fail-open does NOT auto-pass an (a) exit.
650
+ #
651
+ # When CD_ENABLED, the call carries the objective signals (build_signals_json) so the
652
+ # judge corroborates the prose. The arg STOP_KIND (default empty) selects the
653
+ # hard-blocker classification prompt. P13_CLASSIFIED / P13_PROTO are set as side outputs.
403
654
  P13_GUIDANCE=""
655
+ P13_CLASSIFIED=""
656
+ P13_PROTO=""
404
657
  p13_stop_allowed() {
405
- P13_GUIDANCE=""
658
+ P13_GUIDANCE=""; P13_CLASSIFIED=""; P13_PROTO=""
659
+ local stop_kind="${1:-}"
406
660
  if [[ -n "${INSTAR_HOOK_P13_OVERRIDE:-}" ]]; then
407
- # Test seam: "blocked" forces a P13 block; anything else permits.
408
- if [[ "$INSTAR_HOOK_P13_OVERRIDE" == "blocked" ]]; then
409
- P13_GUIDANCE="P13 the stop is not earned (test): derive+document the standard and proceed, or build the artifact and hand it over."
410
- return 1
411
- fi
412
- return 0
661
+ # Test seam: simulate the P13 response WITHOUT a network call (the sandbox blocks
662
+ # localhost curl). Values map to the version-skew three-case detection (§5):
663
+ # blocked STOP_BLOCKED (proto=2) continue
664
+ # buildable → hard-blocker buildable (proto=2) → continue
665
+ # external → hard-blocker external (proto=2) → Case 2 honored allow
666
+ # old-server → NO p13ProtocolVersion → Case 1 (structurally old)
667
+ # timeout → proto=2 but no usable classification → Case 3 (fail-open record)
668
+ # (anything else) → allow (proto=2)
669
+ case "$INSTAR_HOOK_P13_OVERRIDE" in
670
+ blocked)
671
+ P13_GUIDANCE="P13 — the stop is not earned (test): derive+document the standard and proceed, or build the artifact and hand it over."
672
+ [[ "$stop_kind" == "hard-blocker" ]] && P13_CLASSIFIED="buildable"
673
+ P13_PROTO=2; return 1 ;;
674
+ buildable)
675
+ P13_GUIDANCE="P13 — the blocker is buildable (test): build/derive/fetch it and keep working."
676
+ P13_CLASSIFIED="buildable"; P13_PROTO=2; return 1 ;;
677
+ external)
678
+ P13_CLASSIFIED="external"; P13_PROTO=2; return 0 ;;
679
+ old-server)
680
+ # Structurally-old server: no protocol-version stamp at all.
681
+ P13_PROTO=""; P13_CLASSIFIED=""; return 0 ;;
682
+ timeout)
683
+ # New server that didn't return a usable verdict (proto present, no class).
684
+ P13_PROTO=2; P13_CLASSIFIED=""; return 0 ;;
685
+ *)
686
+ [[ "$stop_kind" == "hard-blocker" ]] && P13_CLASSIFIED="external"
687
+ P13_PROTO=2; return 0 ;;
688
+ esac
413
689
  fi
414
690
  [[ -z "$TRANSCRIPT_PATH" || ! -f "$TRANSCRIPT_PATH" ]] && return 0
415
- local p13_tail p13_port p13_auth p13_resp p13_allowed p13_guid
416
- p13_tail=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -6 \
417
- | jq -r '.message.content | map(select(.type=="text")) | map(.text) | join("\n")' 2>/dev/null \
418
- | tail -c 8000 || echo "")
691
+ local p13_tail p13_port p13_auth p13_resp p13_allowed p13_guid p13_payload
692
+ p13_tail="$CD_JUDGE_TAIL"
693
+ if [[ -z "$p13_tail" ]]; then
694
+ p13_tail=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -6 \
695
+ | jq -r '.message.content | map(select(.type=="text")) | map(.text) | join("\n")' 2>/dev/null \
696
+ | tail -c 8000 || echo "")
697
+ fi
419
698
  [[ -z "$p13_tail" ]] && return 0
420
699
  p13_port=$(python3 -c "import json;print(json.load(open('.instar/config.json')).get('port',4040))" 2>/dev/null || echo 4040)
421
700
  p13_auth=$(python3 -c "import json;print(json.load(open('.instar/config.json')).get('authToken',''))" 2>/dev/null || echo "")
422
- p13_resp=$(jq -nc --arg t "$p13_tail" '{transcriptTail:$t}' \
423
- | curl -s -m 35 -H "Authorization: Bearer $p13_auth" -H 'Content-Type: application/json' \
701
+ if [[ "$CD_ENABLED" == "1" ]]; then
702
+ local sig; sig=$(build_signals_json "$stop_kind" "false")
703
+ p13_payload=$(jq -nc --arg t "$p13_tail" --argjson sig "$sig" '{transcriptTail:$t, signals:$sig}')
704
+ else
705
+ p13_payload=$(jq -nc --arg t "$p13_tail" '{transcriptTail:$t}')
706
+ fi
707
+ p13_resp=$(printf '%s' "$p13_payload" \
708
+ | curl -s -m "$JUDGE_TIMEOUT_S" -H "Authorization: Bearer $p13_auth" -H 'Content-Type: application/json' \
424
709
  --data-binary @- "http://localhost:${p13_port}/autonomous/evaluate-stop" 2>/dev/null || echo "")
425
710
  p13_allowed=$(printf '%s' "$p13_resp" | jq -r '.stopAllowed // empty' 2>/dev/null || echo "")
426
711
  p13_guid=$(printf '%s' "$p13_resp" | jq -r '.guidance // empty' 2>/dev/null || echo "")
712
+ P13_CLASSIFIED=$(printf '%s' "$p13_resp" | jq -r '.classifiedBlocker // empty' 2>/dev/null || echo "")
713
+ P13_PROTO=$(printf '%s' "$p13_resp" | jq -r '.p13ProtocolVersion // empty' 2>/dev/null || echo "")
427
714
  # FAIL-OPEN: block ONLY on an explicit stopAllowed:false.
428
715
  if [[ "$p13_allowed" == "false" ]]; then
429
716
  P13_GUIDANCE="P13 — the stop is not earned: ${p13_guid}"
@@ -432,29 +719,211 @@ p13_stop_allowed() {
432
719
  return 0
433
720
  }
434
721
 
722
+ # ── COMPLETION_DISCIPLINE — (a) hard-blocker exit branch (NEW) ────────────────
723
+ # Placed AFTER emergency + duration (so (b)/emergency always win) and BEFORE the
724
+ # completion-condition / promise blocks (so (a) is reachable even when the condition
725
+ # is unmet — the whole point of (a)). NECESSARY-BUT-NOT-SUFFICIENT: a nonce-valid
726
+ # marker is required, AND the extended P13 must classify the blocker EXTERNAL (not
727
+ # buildable). Malformed/partial/nonce-mismatch/fenced/template-verbatim ⇒ NO marker
728
+ # ⇒ continue (the safe direction). Spec §2b.3.
729
+ #
730
+ # Helper: extract one <hard-blocker> field from the FINAL assistant turn body.
731
+ hb_field() {
732
+ local body="$1" field="$2"
733
+ printf '%s' "$body" | perl -0777 -ne 'print "$1" if /<hard-blocker\b[^>]*>.*?\b'"$field"'\s*:\s*(.*?)(?:\n\s*(?:what i tried|why i am stuck|what i would need to proceed)\s*:|<\/hard-blocker>)/si' 2>/dev/null | head -c 4000 || echo ""
734
+ }
735
+ # Sanitize: strip CR/LF/control chars, collapse whitespace, clamp to max chars.
736
+ hb_sanitize() {
737
+ printf '%s' "$1" | tr -d '\000-\010\013\014\016-\037' | tr '\n\r\t' ' ' | sed 's/ */ /g; s/^ *//; s/ *$//' | cut -c1-"${CD_MARKER_MAX_CHARS}"
738
+ }
739
+ # Inline credential leak scan (the existing detector is a PostToolUse hook skill,
740
+ # not a callable scanner — see §5 deviation note). Same pattern families. Echoes 1
741
+ # on a hit. Conservative: only well-known high-signal shapes.
742
+ hb_leak_hit() {
743
+ printf '%s' "$1" | grep -qiE 'sk-[a-zA-Z0-9]{16,}|xox[baprs]-[a-zA-Z0-9-]{8,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{12,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|AIza[0-9A-Za-z_-]{20,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}' 2>/dev/null && echo 1 || echo 0
744
+ }
745
+
746
+ if [[ "$CD_ENABLED" == "1" ]] && [[ -n "$HARD_BLOCKER_NONCE" ]] && [[ "$HARD_BLOCKER_NONCE" != "null" ]] \
747
+ && [[ -n "$CD_FINAL_TURN" ]] && [[ "$CD_FINAL_TURN" == *"<hard-blocker"* ]]; then
748
+ HB_OK="false"
749
+ # Ignore when the marker is inside a fenced code block, or is the documented
750
+ # template verbatim (literal `...` placeholders), or carries no/mismatched nonce.
751
+ HB_FENCED="false"
752
+ # If a ``` fence opens before the marker and the marker sits inside it, ignore.
753
+ if printf '%s' "$CD_FINAL_TURN" | perl -0777 -ne 'exit(/```[^`]*<hard-blocker/s ? 0 : 1)' 2>/dev/null; then
754
+ HB_FENCED="true"
755
+ fi
756
+ HB_NONCE_IN=$(printf '%s' "$CD_FINAL_TURN" | perl -0777 -ne 'print "$1" if /<hard-blocker\b[^>]*\bnonce\s*=\s*"([^"]*)"/si' 2>/dev/null | head -c 200 || echo "")
757
+ HB_TRIED_RAW=$(hb_field "$CD_FINAL_TURN" "what i tried")
758
+ HB_STUCK_RAW=$(hb_field "$CD_FINAL_TURN" "why i am stuck")
759
+ HB_NEEDED_RAW=$(hb_field "$CD_FINAL_TURN" "what i would need to proceed")
760
+ # Template-verbatim guard: the documented placeholders are a bare "...".
761
+ HB_TEMPLATE="false"
762
+ if [[ "$(printf '%s' "$HB_TRIED_RAW" | tr -d '[:space:].')" == "" ]] \
763
+ && [[ "$(printf '%s' "$HB_STUCK_RAW" | tr -d '[:space:].')" == "" ]] \
764
+ && [[ "$(printf '%s' "$HB_NEEDED_RAW" | tr -d '[:space:].')" == "" ]]; then
765
+ HB_TEMPLATE="true"
766
+ fi
767
+ if [[ "$HB_FENCED" != "true" ]] && [[ "$HB_TEMPLATE" != "true" ]] \
768
+ && [[ "$HB_NONCE_IN" == "$HARD_BLOCKER_NONCE" ]] \
769
+ && [[ -n "${HB_TRIED_RAW// /}" ]] && [[ -n "${HB_STUCK_RAW// /}" ]] && [[ -n "${HB_NEEDED_RAW// /}" ]]; then
770
+ HB_OK="true"
771
+ fi
772
+
773
+ # Contradictory terminal markers: a hard-blocker AND a completion/promise token in
774
+ # the SAME final turn is incoherent → NO clean exit → continue with a steer.
775
+ HB_HAS_COMPLETION="false"
776
+ if [[ -n "$COMPLETION_PROMISE" ]] && [[ "$COMPLETION_PROMISE" != "null" ]]; then
777
+ if printf '%s' "$CD_FINAL_TURN" | grep -q "<promise>${COMPLETION_PROMISE}</promise>" 2>/dev/null; then
778
+ HB_HAS_COMPLETION="true"
779
+ fi
780
+ fi
781
+
782
+ if [[ "$HB_OK" == "true" ]] && [[ "$HB_HAS_COMPLETION" == "true" ]]; then
783
+ # Contradictory → continue. The steer is surfaced via the continuation message.
784
+ # Set CD_BLOCK_TERMINAL so the downstream completion/promise blocks do NOT exit
785
+ # on the (also-present) completion token — the turn is incoherent, so neither
786
+ # terminal marker is honored.
787
+ CD_BLOCK_TERMINAL="true"
788
+ CD_CONTRADICTORY_STEER="You emitted contradictory terminal markers (a hard-blocker AND a completion assertion) in the same turn — pick one: either the work is done (show the evidence) or you are blocked (emit only the hard-blocker). Resolve and proceed."
789
+ elif [[ "$HB_OK" == "true" ]]; then
790
+ # The marker is valid. Run the extended P13 external-vs-buildable classification.
791
+ if p13_stop_allowed "hard-blocker"; then
792
+ # Version-skew three-case detection (spec §5):
793
+ # 1. NO p13ProtocolVersion → structurally OLD server → continue (no (a) exit)
794
+ # 2. proto present + classifiedBlocker=external + allowed → honor → (a) exit
795
+ # 3. proto present + no usable verdict (timeout/empty) → fail-open record + continue
796
+ if [[ -z "$P13_PROTO" ]]; then
797
+ # Case 1 — old server. No (a) exit possible until the server updates.
798
+ echo "[autonomous] hard-blocker: server is structurally old (no p13ProtocolVersion) — continuing (no (a) exit)" >&2
799
+ CD_CONTRADICTORY_STEER=""
800
+ elif [[ "$P13_CLASSIFIED" == "external" ]]; then
801
+ # Case 2 — honored EXTERNAL allow → the (a) exit.
802
+ HB_TRIED=$(hb_sanitize "$HB_TRIED_RAW")
803
+ HB_STUCK=$(hb_sanitize "$HB_STUCK_RAW")
804
+ HB_NEEDED=$(hb_sanitize "$HB_NEEDED_RAW")
805
+ HB_LEAK=0
806
+ if [[ "$(hb_leak_hit "$HB_TRIED")" == "1" ]]; then HB_TRIED="[redacted: possible secret]"; HB_LEAK=1; fi
807
+ if [[ "$(hb_leak_hit "$HB_STUCK")" == "1" ]]; then HB_STUCK="[redacted: possible secret]"; HB_LEAK=1; fi
808
+ if [[ "$(hb_leak_hit "$HB_NEEDED")" == "1" ]]; then HB_NEEDED="[redacted: possible secret]"; HB_LEAK=1; fi
809
+ # (a) Write ONE row to logs/autonomous-hard-blocker.jsonl (coarse rotation).
810
+ mkdir -p logs 2>/dev/null || true
811
+ if [[ -f "$HARD_BLOCKER_LOG" ]]; then
812
+ HB_SZ=$(stat -c %s "$HARD_BLOCKER_LOG" 2>/dev/null || stat -f %z "$HARD_BLOCKER_LOG" 2>/dev/null || echo 0)
813
+ [[ "$HB_SZ" =~ ^[0-9]+$ ]] || HB_SZ=0
814
+ [[ $HB_SZ -ge $CD_LOG_ROTATE_BYTES ]] && mv -f "$HARD_BLOCKER_LOG" "${HARD_BLOCKER_LOG}.1" 2>/dev/null || true
815
+ fi
816
+ jq -nc \
817
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg topic "${REPORT_TOPIC:-}" \
818
+ --arg iter "${ITERATION:-}" --arg goal "$(goal_snippet)" \
819
+ --arg tried "$HB_TRIED" --arg stuck "$HB_STUCK" --arg needed "$HB_NEEDED" \
820
+ --argjson unchecked "$CD_UNCHECKED_COUNT" --arg ts2 "$CD_TASK_STRUCTURE" \
821
+ --argjson leak "$([[ $HB_LEAK -eq 1 ]] && echo true || echo false)" \
822
+ '{ts:$ts,topic:$topic,iteration:$iter,goal:$goal,tried:$tried,stuck:$stuck,needed:$needed,completionConditionMet:false,uncheckedTaskCount:$unchecked,taskStructure:$ts2,leakRedacted:$leak}' \
823
+ >> "$HARD_BLOCKER_LOG" 2>/dev/null || true
824
+ # (b) Raise ONE /ack-able Attention item (deduped per the Topic-Flood Guard).
825
+ cd_raise_attention_item "$HB_TRIED" "$HB_STUCK" "$HB_NEEDED"
826
+ # (c) ONE plain-English notify-on-stop.
827
+ notify_terminal_stop "🚧 My autonomous run on \"$(goal_snippet)\" stopped on a hard blocker I can't resolve myself. What I'd need: ${HB_NEEDED}. I've queued it for your attention."
828
+ emit "🚧 Autonomous mode: hard blocker (external) — exit allowed. needed: ${HB_NEEDED}"
829
+ # (d) Remove state; allow exit (write once — state removal prevents re-append).
830
+ rm -f "$STATE_FILE" "$CD_BACKOFF_STATE" 2>/dev/null || true
831
+ exit 0
832
+ else
833
+ # Case 3 — new server, no usable external classification (timeout/empty/buildable
834
+ # via the allow path is impossible; this is the timeout/ambiguous fail-open).
835
+ cd_record_judge_failure
836
+ cd_write_unreachable_row
837
+ echo "[autonomous] hard-blocker: no usable external classification — recorded evaluator-unreachable-exit, continuing" >&2
838
+ CD_CONTRADICTORY_STEER=""
839
+ fi
840
+ else
841
+ # P13 blocked → buildable / not-earned. Continue; P13_GUIDANCE is the next-turn steer.
842
+ CD_CONTRADICTORY_STEER=""
843
+ fi
844
+ fi
845
+ fi
846
+
435
847
  # Completion CONDITION — independent evaluator (mirrors /goal). Authoritative when
436
848
  # set; the self-declared promise below is the legacy fallback. FAIL-SAFE: if the
437
849
  # evaluator is unreachable or unsure, we keep working — never a false "done".
850
+ #
851
+ # COMPLETION_DISCIPLINE (CD_ENABLED): the deterministic checkbox scan is the PRIMARY
852
+ # "buildable work remains" signal. The completion judge fires ONLY on a might-be-done
853
+ # iteration (uncheckedTaskCount==0 OR an explicit completion assertion in the final
854
+ # turn) — the common keep-working iteration costs ZERO LLM (spec §2b.2). When it
855
+ # fires, the milestone/buildable-work scrutiny is FOLDED into the completion judge's
856
+ # signals (the single critical-path call — no standalone P13 on the condition path).
857
+ # The circuit-breaker short-circuits to the cheap checkbox-only decision after K
858
+ # consecutive judge failures (spec §3 item 4).
438
859
  EVAL_REASON=""
439
- if [[ -n "$COMPLETION_CONDITION" ]] && [[ -n "$TRANSCRIPT_PATH" ]] && [[ -f "$TRANSCRIPT_PATH" ]]; then
860
+ if [[ "${CD_BLOCK_TERMINAL:-}" != "true" ]] && [[ -n "$COMPLETION_CONDITION" ]] && [[ -n "$TRANSCRIPT_PATH" ]] && [[ -f "$TRANSCRIPT_PATH" ]]; then
440
861
  EVAL_MET=""
862
+ # Decide whether to fire the judge at all (CD cost-discipline gate).
863
+ CD_MIGHT_BE_DONE="true"
864
+ if [[ "$CD_ENABLED" == "1" ]] && [[ -z "${INSTAR_HOOK_EVAL_OVERRIDE:-}" ]]; then
865
+ CD_MIGHT_BE_DONE="false"
866
+ [[ "$CD_UNCHECKED_COUNT" == "0" ]] && CD_MIGHT_BE_DONE="true"
867
+ # An explicit completion assertion in the final turn also triggers the judge.
868
+ if printf '%s' "$CD_FINAL_TURN" | grep -qiE 'all (tasks|tests) (complete|pass)|<promise>|condition (is )?met|completion condition met|task list (is )?complete' 2>/dev/null; then
869
+ CD_MIGHT_BE_DONE="true"
870
+ fi
871
+ fi
441
872
  if [[ -n "${INSTAR_HOOK_EVAL_OVERRIDE:-}" ]]; then
442
873
  # Test seam: "met" | "not-met" short-circuits the live evaluator call.
443
874
  [[ "$INSTAR_HOOK_EVAL_OVERRIDE" == "met" ]] && EVAL_MET="true"
444
875
  EVAL_REASON="override:$INSTAR_HOOK_EVAL_OVERRIDE"
876
+ elif [[ "$CD_ENABLED" == "1" ]] && [[ "$(cd_breaker_open)" == "1" ]]; then
877
+ # Breaker OPEN — cheap checkbox-only decision, no LLM call (never a fail-open exit).
878
+ if [[ "$CD_UNCHECKED_COUNT" != "0" ]]; then
879
+ EVAL_REASON="circuit-breaker open (judge failing) — work remains ($CD_UNCHECKED_COUNT unchecked), keeping going"
880
+ else
881
+ EVAL_REASON="circuit-breaker open (judge failing) — verify the condition and re-assert with evidence"
882
+ fi
883
+ echo "[autonomous] completion-discipline: judge breaker OPEN — cheap checkbox-only continue (unchecked=$CD_UNCHECKED_COUNT)" >&2
884
+ elif [[ "$CD_MIGHT_BE_DONE" != "true" ]]; then
885
+ # Common keep-working iteration — zero LLM. The checkbox scan says work remains.
886
+ EVAL_REASON="work remains ($CD_UNCHECKED_COUNT unchecked) — keeping going"
445
887
  else
446
888
  EVAL_TAIL=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -6 \
447
889
  | jq -r '.message.content | map(select(.type=="text")) | map(.text) | join("\n")' 2>/dev/null \
448
890
  | tail -c 8000 || echo "")
449
891
  EVAL_PORT=$(python3 -c "import json;print(json.load(open('.instar/config.json')).get('port',4040))" 2>/dev/null || echo 4040)
450
892
  EVAL_AUTH=$(python3 -c "import json;print(json.load(open('.instar/config.json')).get('authToken',''))" 2>/dev/null || echo "")
451
- EVAL_RESP=$(jq -nc --arg c "$COMPLETION_CONDITION" --arg t "$EVAL_TAIL" '{condition:$c,transcriptTail:$t}' \
452
- | curl -s -m 35 -H "Authorization: Bearer $EVAL_AUTH" -H 'Content-Type: application/json' \
893
+ if [[ "$CD_ENABLED" == "1" ]]; then
894
+ # Fold the milestone/buildable-work scrutiny into the completion judge (single call).
895
+ CD_SIG=$(build_signals_json "" "false")
896
+ EVAL_BODY=$(jq -nc --arg c "$COMPLETION_CONDITION" --arg t "$EVAL_TAIL" --argjson sig "$CD_SIG" '{condition:$c,transcriptTail:$t,signals:$sig}')
897
+ else
898
+ EVAL_BODY=$(jq -nc --arg c "$COMPLETION_CONDITION" --arg t "$EVAL_TAIL" '{condition:$c,transcriptTail:$t}')
899
+ fi
900
+ EVAL_RESP=$(printf '%s' "$EVAL_BODY" \
901
+ | curl -s -m "$JUDGE_TIMEOUT_S" -H "Authorization: Bearer $EVAL_AUTH" -H 'Content-Type: application/json' \
453
902
  --data-binary @- "http://localhost:${EVAL_PORT}/autonomous/evaluate-completion" 2>/dev/null || echo "")
454
903
  EVAL_MET=$(printf '%s' "$EVAL_RESP" | jq -r '.met // empty' 2>/dev/null || echo "")
455
904
  EVAL_REASON=$(printf '%s' "$EVAL_RESP" | jq -r '.reason // empty' 2>/dev/null || echo "")
905
+ if [[ "$CD_ENABLED" == "1" ]]; then
906
+ if [[ -z "$EVAL_RESP" ]] || { [[ -z "$EVAL_MET" ]] && [[ -z "$EVAL_REASON" ]]; }; then
907
+ # Judge unreachable/empty → record the failure (breaker) + the unreachable
908
+ # breadcrumb, then CONTINUE (never a silent exit). Spec §3 item 4 / §4.
909
+ cd_record_judge_failure
910
+ cd_write_unreachable_row
911
+ else
912
+ cd_reset_judge_failures
913
+ fi
914
+ fi
456
915
  fi
457
916
  if [[ "$EVAL_MET" == "true" ]]; then
917
+ if [[ "$CD_ENABLED" == "1" ]]; then
918
+ # The folded signals already carried the milestone/buildable-work scrutiny to
919
+ # the completion judge (the SINGLE critical-path call). A "met" verdict here is
920
+ # the judge's all-things-considered decision → allow. No standalone P13 call on
921
+ # the condition path (spec §2b.2 — folded once the canary verifies the block).
922
+ emit "✅ Autonomous mode: completion condition met (independent evaluator): ${EVAL_REASON}"
923
+ notify_terminal_stop "✅ My autonomous run on \"$(goal_snippet)\" finished — the goal was met."
924
+ rm -f "$STATE_FILE" "$CD_BACKOFF_STATE" 2>/dev/null || true
925
+ exit 0
926
+ fi
458
927
  if p13_stop_allowed; then
459
928
  emit "✅ Autonomous mode: completion condition met (independent evaluator): ${EVAL_REASON}"
460
929
  notify_terminal_stop "✅ My autonomous run on \"$(goal_snippet)\" finished — the goal was met."
@@ -470,7 +939,7 @@ if [[ -n "$COMPLETION_CONDITION" ]] && [[ -n "$TRANSCRIPT_PATH" ]] && [[ -f "$TR
470
939
  fi
471
940
 
472
941
  # Completion promise (genuine completion — legacy/self-declared fallback)
473
- if [[ -n "$TRANSCRIPT_PATH" ]] && [[ -f "$TRANSCRIPT_PATH" ]]; then
942
+ if [[ "${CD_BLOCK_TERMINAL:-}" != "true" ]] && [[ -n "$TRANSCRIPT_PATH" ]] && [[ -f "$TRANSCRIPT_PATH" ]]; then
474
943
  LAST_LINE=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 || echo "")
475
944
  if [[ -n "$LAST_LINE" ]]; then
476
945
  LAST_OUTPUT=$(printf '%s' "$LAST_LINE" | jq -r '
@@ -741,17 +1210,22 @@ if [[ -n "${ELAPSED:-}" ]]; then
741
1210
  fi
742
1211
  fi
743
1212
 
1213
+ # COMPLETION_DISCIPLINE — when a hard-blocker marker was emitted but NOT honored
1214
+ # (contradictory markers, or P13 judged it buildable/not-earned), surface that steer.
1215
+ CD_STEER=""
1216
+ [[ -n "${CD_CONTRADICTORY_STEER:-}" ]] && CD_STEER=" | ${CD_CONTRADICTORY_STEER}"
1217
+
744
1218
  # When a completion CONDITION is set, an independent judge decides "done" — steer
745
1219
  # toward the condition + feed back the judge's latest reason (mirrors /goal). When
746
1220
  # only a legacy promise is set, keep the self-declared-promise directive.
747
1221
  if [[ -n "$COMPLETION_CONDITION" ]]; then
748
1222
  GUIDANCE=""
749
1223
  [[ -n "$EVAL_REASON" ]] && GUIDANCE=" | Not done yet: ${EVAL_REASON}"
750
- SYSTEM_MSG="🔄 Autonomous iteration $NEXT_ITERATION ($TIME_MSG)${CLOCK_SEG} | Keep working until this is TRUE: ${COMPLETION_CONDITION}${GUIDANCE} | An independent check decides done from what you SURFACE — run the real checks and show the evidence. Do NOT defer — do it now${REPORT_DIRECTIVE}"
1224
+ SYSTEM_MSG="🔄 Autonomous iteration $NEXT_ITERATION ($TIME_MSG)${CLOCK_SEG} | Keep working until this is TRUE: ${COMPLETION_CONDITION}${GUIDANCE}${CD_STEER} | An independent check decides done from what you SURFACE — run the real checks and show the evidence. Do NOT defer — do it now${REPORT_DIRECTIVE}"
751
1225
  else
752
1226
  P13_NOTE=""
753
1227
  [[ -n "${P13_GUIDANCE:-}" ]] && P13_NOTE=" | ${P13_GUIDANCE}"
754
- SYSTEM_MSG="🔄 Autonomous iteration $NEXT_ITERATION ($TIME_MSG)${CLOCK_SEG} | Complete ALL tasks, then output <promise>$COMPLETION_PROMISE</promise>${P13_NOTE} | Do NOT defer to future self — if you can do it now, DO IT NOW${REPORT_DIRECTIVE}"
1228
+ SYSTEM_MSG="🔄 Autonomous iteration $NEXT_ITERATION ($TIME_MSG)${CLOCK_SEG} | Complete ALL tasks, then output <promise>$COMPLETION_PROMISE</promise>${P13_NOTE}${CD_STEER} | Do NOT defer to future self — if you can do it now, DO IT NOW${REPORT_DIRECTIVE}"
755
1229
  fi
756
1230
 
757
1231
  # Block exit and feed prompt back
@@ -98,6 +98,22 @@ if [[ -z "$COMPLETION_PROMISE" ]]; then
98
98
  COMPLETION_PROMISE="ALL_TASKS_COMPLETE"
99
99
  fi
100
100
 
101
+ # ── COMPLETION_DISCIPLINE — bounded-duration backstop (spec §4 resolved Open-Q) ──
102
+ # A run under completion-discipline REQUIRES a duration > 0 (the hard backstop that
103
+ # makes the judge fail-open safe). A 0/unset duration is treated as a config error and
104
+ # defaulted to a conservative 8h, rather than running truly unbounded.
105
+ if [[ "$DURATION_SECONDS" -le 0 ]]; then
106
+ echo "⚠️ No bounded duration set — defaulting to 8h (completion-discipline requires a duration backstop)." >&2
107
+ DURATION_SECONDS=28800
108
+ DURATION="8h"
109
+ END_AT=$(date -u -v+${DURATION_SECONDS}S +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "+${DURATION_SECONDS} seconds" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "unknown")
110
+ fi
111
+
112
+ # ── COMPLETION_DISCIPLINE — per-run hard-blocker nonce ──
113
+ # Authenticates a <hard-blocker> terminal exit marker (mirrors the completion_promise
114
+ # exact-match guard) so incidental marker prose can never trip an exit. Spec §2b.3.
115
+ HARD_BLOCKER_NONCE=$(openssl rand -hex 8 2>/dev/null || head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n' || echo "$(date +%s)$$")
116
+
101
117
  # ── Multi-session start gate: concurrency cap + quota (refuse-new) ──
102
118
  # Primary check is the server (precise active-count + QuotaTracker). If the
103
119
  # server is unreachable, fall back to a local file-count cap so the cap still
@@ -159,6 +175,7 @@ last_report_at: ""
159
175
  level_up: $LEVEL_UP
160
176
  completion_promise: "$COMPLETION_PROMISE"
161
177
  completion_condition: "$COMPLETION_CONDITION"
178
+ hard_blocker_nonce: "$HARD_BLOCKER_NONCE"
162
179
  ---
163
180
 
164
181
  # Autonomous Session
@@ -1 +1 @@
1
- {"version":3,"file":"ConfigDefaults.d.ts","sourceRoot":"","sources":["../../src/config/ConfigDefaults.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAkpBH,MAAM,MAAM,SAAS,GAAG,iBAAiB,GAAG,YAAY,CAAC;AAEzD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAO7E;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAIlF;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAuC5D"}
1
+ {"version":3,"file":"ConfigDefaults.d.ts","sourceRoot":"","sources":["../../src/config/ConfigDefaults.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAkrBH,MAAM,MAAM,SAAS,GAAG,iBAAiB,GAAG,YAAY,CAAC;AAEzD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAO7E;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAIlF;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAuC5D"}