tickmarkr 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +10 -9
  2. package/dist/adapters/model-lints.js +9 -0
  3. package/dist/adapters/pi.d.ts +1 -0
  4. package/dist/adapters/pi.js +15 -1
  5. package/dist/adapters/prompt.js +11 -3
  6. package/dist/adapters/registry.js +13 -4
  7. package/dist/adapters/types.d.ts +23 -1
  8. package/dist/adapters/types.js +43 -2
  9. package/dist/cli/commands/approve.d.ts +3 -7
  10. package/dist/cli/commands/approve.js +26 -20
  11. package/dist/cli/commands/beat.js +7 -4
  12. package/dist/cli/commands/doctor.d.ts +6 -2
  13. package/dist/cli/commands/doctor.js +79 -9
  14. package/dist/cli/commands/init.js +36 -21
  15. package/dist/cli/commands/plan.js +20 -3
  16. package/dist/cli/commands/report.js +37 -1
  17. package/dist/cli/commands/verify.d.ts +5 -0
  18. package/dist/cli/commands/verify.js +142 -25
  19. package/dist/cli/commands/version.d.ts +2 -1
  20. package/dist/cli/commands/version.js +25 -4
  21. package/dist/compile/collateral.js +15 -9
  22. package/dist/compile/native.js +5 -3
  23. package/dist/config/config.js +1 -1
  24. package/dist/drivers/index.d.ts +7 -0
  25. package/dist/drivers/index.js +40 -10
  26. package/dist/drivers/orca.d.ts +41 -1
  27. package/dist/drivers/orca.js +192 -15
  28. package/dist/drivers/subprocess.d.ts +3 -3
  29. package/dist/drivers/subprocess.js +16 -9
  30. package/dist/drivers/types.d.ts +2 -0
  31. package/dist/gates/baseline.d.ts +4 -0
  32. package/dist/gates/baseline.js +68 -16
  33. package/dist/gates/llm.d.ts +7 -1
  34. package/dist/gates/llm.js +66 -35
  35. package/dist/gates/review.d.ts +3 -1
  36. package/dist/gates/review.js +42 -12
  37. package/dist/gates/run-gates.d.ts +6 -0
  38. package/dist/gates/run-gates.js +25 -10
  39. package/dist/gates/verdict-cause.d.ts +6 -2
  40. package/dist/gates/verdict-cause.js +8 -4
  41. package/dist/run/consult.d.ts +7 -0
  42. package/dist/run/consult.js +21 -3
  43. package/dist/run/daemon.d.ts +14 -0
  44. package/dist/run/daemon.js +249 -27
  45. package/dist/run/git.d.ts +1 -0
  46. package/dist/run/git.js +4 -0
  47. package/dist/run/journal.d.ts +15 -2
  48. package/dist/run/journal.js +70 -12
  49. package/dist/run/supervision.d.ts +6 -0
  50. package/dist/run/supervision.js +29 -1
  51. package/dist/tui/ink/init-app.js +4 -4
  52. package/package.json +1 -1
  53. package/skills/tickmarkr-loop/SKILL.md +1 -0
  54. package/skills/tickmarkr-overseer/SKILL.md +88 -18
  55. package/skills/tickmarkr-overseer/scripts/seat-send.sh +88 -18
  56. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +36 -2
  57. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +36 -15
  58. package/skills/tickmarkr-overseer/scripts/watch-context.sh +35 -9
  59. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +32 -8
@@ -1,7 +1,7 @@
1
1
  #!/bin/bash
2
2
  # Wake the overseer when spawned seats have actually DELIVERED.
3
3
  #
4
- # watch-artifacts.sh <marker> <cap-seconds> <poll-seconds> <file>...
4
+ # watch-artifacts.sh [--changed-from <sha|mtime|now>] <marker> <cap-seconds> <poll-seconds> <file>...
5
5
  #
6
6
  # Completion is the ARTIFACT plus its TERMINAL MARKER — never an agent's `done`, which is turn end and
7
7
  # fires the moment a seat finishes acknowledging you. Never file existence alone either: a seat killed
@@ -26,12 +26,29 @@ set -u
26
26
  # arm crashed before printing its reason — a watcher that dies without a wake reason is the exact failure
27
27
  # this script exists to prevent.
28
28
 
29
- MARKER="${1:?usage: watch-artifacts.sh <marker> <cap-seconds> <poll-seconds> <file>...}"
29
+ CHANGED_FROM=""
30
+ if [ "${1:-}" = "--changed-from" ]; then
31
+ CHANGED_FROM="${2:?--changed-from requires a sha, prior mtime, or 'now'}"
32
+ shift 2
33
+ fi
34
+
35
+ MARKER="${1:?usage: watch-artifacts.sh [--changed-from <sha|mtime|now>] <marker> <cap-seconds> <poll-seconds> <file>...}"
30
36
  CAP="${2:?}"
31
37
  POLL="${3:?}"
32
38
  shift 3
33
39
  [ "$#" -gt 0 ] || { echo "watch-artifacts: no files given" >&2; exit 2; }
34
40
 
41
+ # OBS-848: ownership is a pid-file fact, never an argv/path pattern. Two tiers intentionally watch
42
+ # the same artifacts, so the journal/file argument cannot identify which watcher a seat may retire.
43
+ PID_DIR="${TKR_STATE_DIR:-.tickmarkr}/overseer/pids"
44
+ PID_SEAT=$(printf '%s' "${TKR_ARMING_SEAT:-unattributed}" | sed 's/[^A-Za-z0-9_-]/_/g')
45
+ PID_FILE="$PID_DIR/${PID_SEAT}-watch-artifacts-$$.pid"
46
+ mkdir -p "$PID_DIR" && (umask 077; printf '%s\n' "$$" > "$PID_FILE") || {
47
+ echo "watch-artifacts: cannot record pid under $PID_DIR" >&2; exit 73;
48
+ }
49
+ clear_pid() { rm -f "$PID_FILE"; }
50
+ trap clear_pid EXIT
51
+
35
52
  # Cap BELOW the host's background-job kill so every arm ends by PRINTING something. A job killed at the
36
53
  # limit carries no wake reason and is indistinguishable from a real wake until you read the output —
37
54
  # OBS-325, re-earned by a third watcher that had not been capped because the fix was applied only to the
@@ -74,6 +91,22 @@ END=$((SECONDS + CAP))
74
91
  # RUNTIME, where the arithmetic then errors, `done_file` returns 1 forever, and the watcher never wakes.
75
92
  # Caught by the drill below, not by the syntax check. A syntax check is not a positive control.
76
93
  PREV=()
94
+ BASELINE=()
95
+ i=0
96
+ for f in "$@"; do
97
+ if [ -n "$CHANGED_FROM" ]; then
98
+ # A SHA is an explicit single-artifact baseline. Historical callers recorded mtimes; retain that
99
+ # spelling as the arming signal but capture content now, because equal-length rewrites and touches
100
+ # prove that mtime is not the property this watcher claims to read.
101
+ if printf '%s' "$CHANGED_FROM" | grep -qE '^[0-9a-fA-F]{40}$' && [ "$#" -eq 1 ]; then
102
+ BASELINE[$i]=$(printf '%s' "$CHANGED_FROM" | tr 'A-F' 'a-f')
103
+ else
104
+ BASELINE[$i]=$(shasum -a 1 "$f" 2>/dev/null | cut -d' ' -f1)
105
+ fi
106
+ fi
107
+ i=$((i + 1))
108
+ done
109
+
77
110
  done_file() { # $1 = index into "$@", $2 = path
78
111
  local i="$1" f="$2" sig
79
112
  [ -s "$f" ] || return 1
@@ -85,6 +118,7 @@ done_file() { # $1 = index into "$@", $2 = path
85
118
  # catch. Hashing 48KB per poll costs nothing.
86
119
  sig=$(shasum -a 1 "$f" 2>/dev/null | cut -d' ' -f1)
87
120
  [ -n "$sig" ] || return 1
121
+ if [ -n "$CHANGED_FROM" ] && [ "${BASELINE[$i]:-}" = "$sig" ]; then return 1; fi
88
122
  if [ "${PREV[$i]:-}" = "$sig" ]; then return 0; fi
89
123
  PREV[$i]="$sig" # marked but still moving — hold it one more poll
90
124
  return 1
@@ -21,8 +21,16 @@ LOAD_CEIL="${2:-24}"
21
21
  POLL="${3:-45}"
22
22
  CAP="${4:-14400}"
23
23
 
24
- STATE="$(dirname "$J")/.contamination.seen"
25
- : > "$STATE" 2>/dev/null || STATE="/tmp/.contamination.seen.$$"
24
+ # OBS-848: the arming seat owns this pid, even when a partner watches the same journal. Never derive
25
+ # retirement ownership from the script or journal argv: both are deliberately shared across tiers.
26
+ PID_DIR="${TKR_STATE_DIR:-.tickmarkr}/overseer/pids"
27
+ PID_SEAT=$(printf '%s' "${TKR_ARMING_SEAT:-unattributed}" | sed 's/[^A-Za-z0-9_-]/_/g')
28
+ PID_FILE="$PID_DIR/${PID_SEAT}-watch-contamination-$$.pid"
29
+ mkdir -p "$PID_DIR" && (umask 077; printf '%s\n' "$$" > "$PID_FILE") || {
30
+ echo "watch-contamination: cannot record pid under $PID_DIR" >&2; exit 73;
31
+ }
32
+ clear_pid() { rm -f "$PID_FILE"; }
33
+ trap clear_pid EXIT
26
34
 
27
35
  # Fingerprints of a harness collapse rather than a real regression. Deliberately narrow: a genuine
28
36
  # assertion failure must NOT match, or the watcher launders real defects into "infra".
@@ -35,11 +43,8 @@ load1() { uptime | sed 's/.*load averages*: *//' | awk '{print $1}' | tr -d ',';
35
43
  vitest_n() { ps -axo comm,command 2>/dev/null | grep -Ec 'node_modules/(\.bin/)?[v]itest|[v]itest/dist/|\([v]itest [0-9]+\)'; }
36
44
 
37
45
  # Seed the seen-set so we wake on what happens NEXT, not on history already ruled on.
38
- if [ -f "$J" ]; then
39
- grep -c '' "$J" > "$STATE" 2>/dev/null || echo 0 > "$STATE"
40
- else
41
- echo 0 > "$STATE"
42
- fi
46
+ if [ -f "$J" ]; then seen=$(grep -c '' "$J" 2>/dev/null); else seen=0; fi
47
+ [ -n "$seen" ] || seen=0
43
48
 
44
49
  elapsed=0
45
50
  while [ "$elapsed" -lt "$CAP" ]; do
@@ -54,18 +59,34 @@ while [ "$elapsed" -lt "$CAP" ]; do
54
59
  Li=$(printf %s "$L" | sed "s/[^0-9].*//"); [ -z "$Li" ] && Li=0
55
60
 
56
61
  if [ -f "$J" ]; then
57
- seen=$(cat "$STATE" 2>/dev/null); [ -z "$seen" ] && seen=0
58
62
  now=$(grep -c '' "$J" 2>/dev/null); [ -z "$now" ] && now=0
59
63
  if [ "$now" -gt "$seen" ]; then
60
64
  newrows=$(tail -n "$((now - seen))" "$J" 2>/dev/null)
61
- echo "$now" > "$STATE"
62
- hit=$(printf '%s\n' "$newrows" \
63
- | grep '"event":"gate-result"' \
64
- | grep '"pass":false' \
65
- | grep -E "$INFRA_RE" | head -1)
65
+ seen="$now"
66
+ # A fingerprint in arbitrary JSON details is prose, not an occurrence. Parse the event shape,
67
+ # admit only runner gates, and stop before the baseline classifier's secondary echo list. Review
68
+ # findings and acceptance prose are deliberately outside this instrument's claim.
69
+ hit=$(printf '%s\n' "$newrows" | python3 -c '
70
+ import json, re, sys
71
+ infra = re.compile(sys.argv[1], re.I)
72
+ for raw in sys.stdin:
73
+ try: row = json.loads(raw)
74
+ except Exception: continue
75
+ data = row.get("data") or {}
76
+ gate = data.get("gate")
77
+ if row.get("event") != "gate-result" or data.get("pass") is not False or gate not in {"test", "build", "lint"}: continue
78
+ details = str(data.get("details") or "")
79
+ runner = re.split(r"(?im)^new failure fingerprints vs baseline \(secondary\):[ \t]*$", details, maxsplit=1)[0]
80
+ match = infra.search(runner)
81
+ if match:
82
+ end = runner.find("\n", match.end())
83
+ line = runner[runner.rfind("\n", 0, match.start()) + 1:end if end >= 0 else len(runner)]
84
+ print("%s\t%s\t%s" % (row.get("taskId", ""), gate, line))
85
+ break
86
+ ' "$INFRA_RE")
66
87
  if [ -n "$hit" ]; then
67
- task=$(printf '%s' "$hit" | sed -n 's/.*"taskId":"\([^"]*\)".*/\1/p')
68
- gate=$(printf '%s' "$hit" | sed -n 's/.*"gate":"\([^"]*\)".*/\1/p')
88
+ task=$(printf '%s' "$hit" | cut -f1)
89
+ gate=$(printf '%s' "$hit" | cut -f2)
69
90
  echo "CONTAMINATED_VERDICT task=$task gate=$gate load=$L vitest=$V"
70
91
  echo " an infra fingerprint appeared in a FAILED gate — this red is not evidence about the diff"
71
92
  echo " ruling owed: is the attempt chargeable? (OBS-426: infra failures are not)"
@@ -33,7 +33,7 @@
33
33
  # usage: watch-context.sh <role-slug> <agent|pane> <warn-pct> <act-pct> [handoff-file] [poll-s] [cap-s]
34
34
  # <role-slug> is ANY seat role — orchestrator, overseer, surgeon, consult — and names the tier
35
35
  # `<role>-context`. It is deliberately NOT a closed set: see OBS-730 at the guard below.
36
- # TKR_AUTO_CLEAR=1 at act-pct WITH a fresh handoff, send /clear and re-brief instead of waking.
36
+ # TKR_AUTO_CLEAR=1 at act-pct WITH a fresh handoff, auto-clear orchestrator/overseer; other roles wake only.
37
37
  # TKR_REBRIEF=<path> the file the re-briefed seat is told to read (defaults to the handoff).
38
38
  # TKR_HANDOFF_MAX_AGE_S how fresh "fresh" is (default 900).
39
39
  # TKR_CLEAR_SETTLE_S seconds to let a cleared seat settle before the re-brief (default 6).
@@ -63,6 +63,15 @@ case "$ROLE" in
63
63
  esac
64
64
  TIER="${ROLE}-context"
65
65
 
66
+ # A shared target does not identify its watcher owner. The arming seat records this exact pid under
67
+ # the repository state dir; retirement reads that file and never matches a script/target name pattern.
68
+ PID_DIR="${TKR_STATE_DIR:-.tickmarkr}/overseer/pids"
69
+ PID_SEAT=$(printf '%s' "${TKR_ARMING_SEAT:-unattributed}" | sed 's/[^A-Za-z0-9_-]/_/g')
70
+ PID_FILE="$PID_DIR/${PID_SEAT}-watch-context-$$.pid"
71
+ mkdir -p "$PID_DIR" && (umask 077; printf '%s\n' "$$" > "$PID_FILE") || {
72
+ echo "watch-context: cannot record pid under $PID_DIR" >&2; exit 73;
73
+ }
74
+ clear_pid() { rm -f "$PID_FILE"; }
66
75
 
67
76
  # The supervision beat interval (SUPERVISION_BEAT_MS = 10s). The loop ticks at the beat cadence or the
68
77
  # caller's poll, whichever is SHORTER. Every successful screen read beats; its result is either a
@@ -100,7 +109,8 @@ beat() {
100
109
  stand_down() { tickmarkr beat "$TIER" --stand-down --seat "$SEAT" >/dev/null 2>&1; return 0; }
101
110
  # EVERY terminal exit — act, unsafe-act, cap — leaves through here, so none of them can forget to
102
111
  # record the hand-off. A killed watcher never runs it, which is the one case that must read STALE.
103
- trap stand_down EXIT
112
+ cleanup() { stand_down; clear_pid; }
113
+ trap cleanup EXIT
104
114
 
105
115
  # ── WHERE THE NUMBER COMES FROM, and this is the whole 2.1.7-series lesson ────────────────────────────
106
116
  # The shipped version read a TERMINAL RENDERING and nothing else. Every context-measurement failure of
@@ -189,15 +199,31 @@ print(int(round(fill * 100.0 / window)))
189
199
  ' "$CTX_JSONL" "$CTX_WINDOW" 2>/dev/null
190
200
  }
191
201
 
192
- # The banner path stays as the FALLBACK, unchanged: select the model line first, then read a percentage
193
- # only from it, so a bare numeric search cannot borrow an old N% from scrollback (OBS-780).
202
+ # The banner path stays as the FALLBACK, and it now selects by POSITION (OBS-865). A seat's statusline
203
+ # renders BELOW the input box's horizontal rule, so the only rows that can carry THIS seat's fill are the
204
+ # rows after the LAST rule line in the window. A tip, a transcript row, or a decoy quoting another seat's
205
+ # percentage sits ABOVE that rule and can no longer be chosen. The previous selector took the last
206
+ # vendor-word line ANYWHERE in the window: on a Fable seat that was `Tip: Ask Claude ...` (UNREADABLE,
207
+ # duty silently dead), and a quoted `opus 12%` would have been read as this seat's fill. The model
208
+ # vocabulary survives only as a SANITY CHECK on the already-chosen row, never as the selector (OBS-780).
194
209
  banner_pct() {
195
210
  local screen banner pct
196
211
  screen=$(herdr agent read "$TARGET" --source visible --lines 8 2>/dev/null) || return 1
197
- banner=$(printf '%s\n' "$screen" |
198
- grep -Ei '(^|[^[:alnum:]])(claude|opus|sonnet|haiku|gpt|gemini|glm|kimi|grok|composer|openai|zai)[[:alnum:]_./-]*([[:space:]]|$)' |
199
- tail -1)
212
+ # The rule glyph is passed IN, never written as an escape: this host's awk (BWK 20200816) does not
213
+ # honour \xNN, and an unsupported escape matches nothing — the selector would find no rule line and
214
+ # report UNREADABLE on every real pane while staying green against any fixture that skipped awk.
215
+ banner=$(printf '%s\n' "$screen" | awk -v rule='─' '
216
+ { line[NR] = $0
217
+ bare = $0; gsub(/[[:space:]]/, "", bare)
218
+ stripped = bare; gsub(rule, "", stripped)
219
+ if (bare != "" && stripped == "") last = NR }
220
+ END { if (last) for (i = last + 1; i <= NR; i++) if (line[i] ~ /[0-9]+%/) print line[i] }
221
+ ' | tail -1)
222
+ # No rule line in the window, or no percentage below it: say so out loud rather than guess.
200
223
  [ -n "$banner" ] || { printf 'UNREADABLE\n'; return 0; }
224
+ printf '%s\n' "$banner" |
225
+ grep -Eqi '(^|[^[:alnum:]])(claude|opus|sonnet|haiku|fable|gpt|gemini|glm|kimi|grok|composer|openai|zai)[[:alnum:]_./-]*([[:space:]]|$)' ||
226
+ { printf 'UNREADABLE\n'; return 0; }
201
227
  pct=$(printf '%s\n' "$banner" | grep -oE '[0-9]+%' | tail -1 | tr -d '%')
202
228
  [ -n "$pct" ] && printf '%s\n' "$pct" || printf 'UNREADABLE\n'
203
229
  }
@@ -221,10 +247,10 @@ handoff_fresh() {
221
247
  act_on() {
222
248
  local P="$1"
223
249
  if handoff_fresh; then
224
- if [ "${TKR_AUTO_CLEAR:-0}" = "1" ]; then
250
+ if [ "${TKR_AUTO_CLEAR:-0}" = "1" ] && { [ "$ROLE" = "orchestrator" ] || [ "$ROLE" = "overseer" ]; }; then
225
251
  herdr agent prompt "$TARGET" "/clear" >/dev/null 2>&1
226
252
  sleep "$SETTLE"
227
- herdr agent prompt "$TARGET" "Read ${REBRIEF} and continue exactly where it says. Your context was cleared at ${P}% against that handoff; it is current as of $(date '+%H:%M'). Do not reconstruct from memory — everything you need is on disk." >/dev/null 2>&1
253
+ herdr agent prompt "$TARGET" "Read ${REBRIEF} and continue exactly where it says. Your context was cleared at ${P}% against that handoff; it is current as of $(date '+%H:%M'). The same-process clear kept every background task alive: kill only this seat's recorded watcher pids first, verify the process table twice, then re-arm. Do not reconstruct from memory — everything you need is on disk." >/dev/null 2>&1
228
254
  echo "CONTEXT_CLEARED $TARGET at ${P}% — handoff fresh, re-briefed from ${REBRIEF}"
229
255
  exit 0
230
256
  fi
@@ -21,6 +21,17 @@ POLL="${2:-30}"
21
21
  CAP="${3:-14400}"
22
22
  CONFIRM="${4:-2}" # consecutive polls before waking — a draft mid-typing is not a stall
23
23
 
24
+ # The path/target is shared evidence, not ownership. Record the arming seat and this exact process so
25
+ # stand-down can retire only what that seat armed, without a pgrep pattern that catches its partner.
26
+ PID_DIR="${TKR_STATE_DIR:-.tickmarkr}/overseer/pids"
27
+ PID_SEAT=$(printf '%s' "${TKR_ARMING_SEAT:-unattributed}" | sed 's/[^A-Za-z0-9_-]/_/g')
28
+ PID_FILE="$PID_DIR/${PID_SEAT}-watch-pending-input-$$.pid"
29
+ mkdir -p "$PID_DIR" && (umask 077; printf '%s\n' "$$" > "$PID_FILE") || {
30
+ echo "watch-pending-input: cannot record pid under $PID_DIR" >&2; exit 73;
31
+ }
32
+ clear_pid() { rm -f "$PID_FILE"; }
33
+ trap clear_pid EXIT
34
+
24
35
  status_of() {
25
36
  herdr agent get "$TARGET" 2>/dev/null \
26
37
  | sed -n 's/.*"agent_status":"\([a-z_]*\)".*/\1/p' | head -1
@@ -35,8 +46,11 @@ status_of() {
35
46
  # no wake, no streak. Negative scope: sampled on one theme/version; a theme rendering typed input dim
36
47
  # would blind this check TOWARD silence, so a wake is still authoritative but its absence is not proof.
37
48
  is_ghost() {
38
- herdr agent read "$TARGET" --source visible --lines 14 --format ansi 2>/dev/null \
39
- | grep -F -- "❯" | grep -F -- "$1" | head -1 | grep -q "$(printf '\033')\[2m"
49
+ (
50
+ export LC_ALL=C
51
+ herdr agent read "$TARGET" --source visible --lines 14 --format ansi 2>/dev/null \
52
+ | grep -F -- "❯" | grep -F -- "$1" | head -1 | grep -q "$(printf '\033')\[2m"
53
+ )
40
54
  }
41
55
 
42
56
  pending_text() {
@@ -175,8 +189,11 @@ while [ "$elapsed" -lt "$CAP" ]; do
175
189
  # typed draft. Ghost text renders dim/grey; typed text renders default. Emit the styled
176
190
  # bytes so the reader can discriminate before treating this as a draft.
177
191
  echo " styling evidence (dim/grey SGR around the text = autosuggest ghost, NOT a draft):"
178
- herdr agent read "$TARGET" --source visible --lines 14 --format ansi 2>/dev/null \
179
- | grep -F -- "$T" | head -2 | cat -v | sed 's/^/ /'
192
+ (
193
+ export LC_ALL=C
194
+ herdr agent read "$TARGET" --source visible --lines 14 --format ansi 2>/dev/null \
195
+ | grep -F -- "$T" | head -2 | cat -v | sed 's/^/ /'
196
+ )
180
197
  echo " the seat is idle and holding live work in its prompt — supersede it, do not re-send:"
181
198
  echo " herdr agent prompt $TARGET \" <-- disregard everything before this arrow (stale draft). ACTUAL: …\""
182
199
  # Name the ACTUAL reason we fell through. Saying "budget exhausted" when the cause was the
@@ -208,10 +225,17 @@ done
208
225
  echo "WATCH_CAP_REACHED $TARGET status=$(status_of) — no sustained pending input in ${CAP}s"
209
226
  FINAL=$(pending_text)
210
227
  if [ -n "$FINAL" ]; then
211
- echo " final read (UNSUSTAINED — present at cap, not confirmed across polls): $FINAL"
212
- echo " styling evidence (dim/grey SGR = autosuggest ghost, NOT a draft):"
213
- herdr agent read "$TARGET" --source visible --lines 14 --format ansi 2>/dev/null \
214
- | grep -F -- "$FINAL" | head -2 | cat -v | sed 's/^/ /'
228
+ if is_ghost "$FINAL"; then
229
+ echo " final read: autosuggest ghost ignored (dim SGR), input box has no typed draft"
230
+ else
231
+ echo " final read (UNSUSTAINED — present at cap, not confirmed across polls): $FINAL"
232
+ echo " styling evidence (dim/grey SGR = autosuggest ghost, NOT a draft):"
233
+ (
234
+ export LC_ALL=C
235
+ herdr agent read "$TARGET" --source visible --lines 14 --format ansi 2>/dev/null \
236
+ | grep -F -- "$FINAL" | head -2 | cat -v | sed 's/^/ /'
237
+ )
238
+ fi
215
239
  else
216
240
  echo " final read: input box empty"
217
241
  fi