tickmarkr 2.2.1 → 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 (48) hide show
  1. package/README.md +10 -9
  2. package/dist/adapters/types.d.ts +20 -1
  3. package/dist/adapters/types.js +42 -2
  4. package/dist/cli/commands/approve.js +5 -4
  5. package/dist/cli/commands/beat.js +7 -4
  6. package/dist/cli/commands/doctor.d.ts +5 -1
  7. package/dist/cli/commands/doctor.js +67 -7
  8. package/dist/cli/commands/init.js +36 -21
  9. package/dist/cli/commands/plan.js +16 -2
  10. package/dist/cli/commands/report.js +37 -1
  11. package/dist/cli/commands/verify.d.ts +5 -0
  12. package/dist/cli/commands/verify.js +140 -25
  13. package/dist/compile/collateral.js +15 -9
  14. package/dist/compile/native.js +3 -3
  15. package/dist/config/config.js +1 -1
  16. package/dist/drivers/index.d.ts +6 -0
  17. package/dist/drivers/index.js +19 -4
  18. package/dist/drivers/orca.d.ts +22 -1
  19. package/dist/drivers/orca.js +147 -6
  20. package/dist/drivers/subprocess.d.ts +3 -3
  21. package/dist/drivers/subprocess.js +16 -9
  22. package/dist/drivers/types.d.ts +2 -0
  23. package/dist/gates/baseline.d.ts +2 -0
  24. package/dist/gates/baseline.js +21 -4
  25. package/dist/gates/llm.d.ts +6 -0
  26. package/dist/gates/llm.js +25 -9
  27. package/dist/gates/review.d.ts +3 -1
  28. package/dist/gates/review.js +40 -12
  29. package/dist/gates/run-gates.js +17 -10
  30. package/dist/gates/verdict-cause.d.ts +6 -2
  31. package/dist/gates/verdict-cause.js +8 -4
  32. package/dist/run/consult.js +5 -1
  33. package/dist/run/daemon.d.ts +12 -0
  34. package/dist/run/daemon.js +218 -25
  35. package/dist/run/git.d.ts +1 -0
  36. package/dist/run/git.js +4 -0
  37. package/dist/run/journal.d.ts +15 -2
  38. package/dist/run/journal.js +70 -12
  39. package/dist/run/supervision.d.ts +6 -0
  40. package/dist/run/supervision.js +29 -1
  41. package/dist/tui/ink/init-app.js +4 -4
  42. package/package.json +1 -1
  43. package/skills/tickmarkr-overseer/SKILL.md +77 -18
  44. package/skills/tickmarkr-overseer/scripts/seat-send.sh +88 -18
  45. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +36 -2
  46. package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +36 -15
  47. package/skills/tickmarkr-overseer/scripts/watch-context.sh +33 -7
  48. package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +32 -8
@@ -5,13 +5,13 @@ import { ToggleMark } from "./components.js";
5
5
  import { clip, INK, inkInput, inkOutput, KeyBar, padCell, Pointer } from "./frame.js";
6
6
  const DRIVERS = ["auto", "herdr", "subprocess", "orca"];
7
7
  // One line per driver, shown for the value currently selected: four environments no longer fit on
8
- // a single description row at 80 columns (the shipped three already clipped), and orca is an
9
- // EXPLICIT choice — `auto` still means herdr-else-subprocess and never reaches for it.
8
+ // a single description row at 80 columns (the shipped three already clipped). Auto identifies its
9
+ // innermost host from environment markers only; outside an Orca terminal, Orca is named explicitly.
10
10
  const DRIVER_DESC = {
11
- auto: "auto: herdr when HERDR_ENV=1, else subprocess — never orca",
11
+ auto: "auto: herdr when HERDR_ENV=1, else orca inside an Orca terminal, else subprocess",
12
12
  herdr: "herdr: every worker runs in a visible pane you can watch and unblock",
13
13
  subprocess: "subprocess: headless child processes — no cockpit, same fail-closed gates",
14
- orca: "orca: visible terminals in the Orca app — an explicit choice, never auto's",
14
+ orca: "orca: visible terminals in the Orca app — picked by auto inside one; name it elsewhere",
15
15
  };
16
16
  const VISIBILITY = ["pane", "headless"];
17
17
  // Three fields, one toggle, one action — a descriptor array, deliberately not a forms framework.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,6 +48,15 @@ through brief lineage. **An executor choice nobody made is still an executor cho
48
48
  a watcher you know is alive. Earned 2026-08-25 (OBS-622): a handoff recorded *"artifact watcher armed"*
49
49
  over two live consult verdicts; at adopt the only `watch-artifacts.sh` on the machine belonged to a
50
50
  different repository, and nothing had been watching either file.
51
+ **WATCHER OWNERSHIP IS THE ARMING SEAT'S RECORDED PID, NEVER A NAME PATTERN.** Every bundled
52
+ `watch-*.sh` arm sets `TKR_ARMING_SEAT=<seat>` and writes its own pid under
53
+ `<state-dir>/overseer/pids/<arming-seat>-<script>-<pid>.pid`. A seat retires only watchers it armed,
54
+ by reading those files and killing the exact recorded pids; it never uses `pkill -f`, `pgrep -f`, or
55
+ any argv/path pattern. A journal path is shared by partner tiers and therefore cannot prove ownership.
56
+ Verify each executing pid in two process-table reads before acting, kill-by-pid, arm the replacement,
57
+ then verify its new pid in two process-table reads. A stand-down order inventories both sets: the
58
+ ordering seat's recorded pids to retire, and the partner's watchers armed on the ordering seat that
59
+ must survive it. This is the stand-down order of 222-11, not a best-effort sweep.
51
60
  **An adopted seat ANNOUNCES itself, in the same act as re-arming:** tell the adopted orchestrator the
52
61
  fresh seat is live (verified send: probe token + read-back). Through the gap its view of your tier read
53
62
  STALE, and a tier that believes it is unsupervised escalates into a file nobody is reading. Earned
@@ -289,8 +298,11 @@ on its owner being free is scheduled, not armed* — applies to itself:
289
298
  .claude/skills/tickmarkr-overseer/scripts/watch-contamination.sh <journal> <load-ceiling> <poll-s> <cap-s>
290
299
  ```
291
300
 
292
- It wakes on a new **failed** `gate-result` carrying an infrastructure fingerprint, or on sustained load
293
- above a ceiling, and prints one wake reason. **Two triggers, because one is provably not enough:** run
301
+ It wakes on a new **failed runner `test`, `build`, or `lint`** `gate-result` carrying an infrastructure
302
+ fingerprint in the runner-emitted failure lines, or on sustained load above a ceiling, and prints one
303
+ wake reason. It deliberately ignores the same tokens inside review findings, acceptance prose, and the
304
+ secondary fingerprint echo list: quoted discussion of contamination is not contamination. **Two triggers,
305
+ because one is provably not enough:** run
294
306
  against the four reds above, the fingerprint trigger caught the `vitest-worker` timeout and correctly
295
307
  refused to launder either review rejection — and **missed** the 1-of-3074 case, whose text looks like an
296
308
  ordinary assertion failure. Only the load ceiling catches that one. A single-signal version reads as
@@ -323,7 +335,10 @@ when the run's own status text pushed the percentage off the statusline; and an
323
335
  The protocol, in both directions:
324
336
 
325
337
  1. **Overseer sees orch at ≥50%** → nudge it: write `HANDOFF-ORCH-<ver>.md`, then `/clear`, then re-read
326
- its brief **and** its handoff, then **re-arm every watcher it listed** (a cleared session has none).
338
+ its brief **and** its handoff. **A same-process `/clear` keeps every background task alive**; it clears
339
+ conversation context, not processes. Re-arm is therefore a controlled replacement: read only this
340
+ seat's pid files, verify each pid in two process-table reads, kill those exact pids, arm replacements,
341
+ and verify every new pid in two reads. Never retire by pattern.
327
342
  2. **The returning orch, now fresh, checks the OVERSEER.** If the overseer is at ≥50%, it directs the
328
343
  overseer to write its handoff and clear, and **points it at `HANDOFF-OVERSEER-<ver>.md` by path**.
329
344
  3. Whichever seat is fresh performs the check. **Never both at once** — the run keeps one supervising tier
@@ -345,8 +360,9 @@ The protocol, in both directions:
345
360
  herdr pane run <my-pane> "/clear"
346
361
  # read the pane back — a cleared claude session shows an empty prompt and a reset context gauge
347
362
  # 4. and only THEN, as a SEPARATE send, the re-orientation:
348
- herdr pane run <my-pane> "You were cleared at <N>%. Read <handoff> and <brief>, re-arm EVERY watcher
349
- they name — a cleared session has none — then confirm you are back."
363
+ herdr pane run <my-pane> "You were cleared at <N>%. Read <handoff> and <brief>. Same-process clear kept
364
+ every background task alive: retire only your recorded watcher pids, verify twice, kill-by-pid, arm,
365
+ verify every new pid twice, preserve the partner-owned watchers the stand-down lists, then confirm."
350
366
  ```
351
367
 
352
368
  ⚠ **Steps 3 and 4 are two sends, never one.** A pointer batched with the clear lands *during* it and is
@@ -507,7 +523,11 @@ they are left implicit:
507
523
  sleep 2–3s → send-keys Enter → read back (input empty / agent `working`). Never report "briefed" without
508
524
  the read-back. Long content goes in a brief file, never pane text. `scripts/seat-send.sh` encodes
509
525
  this whole path — size guard, atomic prompt, prompt-line read-back, optional interrupt — and never
510
- auto-resends.
526
+ auto-resends. Each adapter declares its prompt glyph beside its input-box matchers; `seat-send.sh` reads
527
+ that declaration rather than assuming Claude's `❯`. **Probe the prompt line before writing:** if it
528
+ already holds a non-ghost draft, wait only the script's bounded window and return `SEND_DEFERRED`
529
+ without writing a byte. An unsubmitted post-send draft returns `SEND_UNSUBMITTED`. This protects both
530
+ Codex's `›` prompt and a HUMAN typing in a pane; delivery is never allowed to glue onto either draft.
511
531
  **PROBE THE READ-BACK WITH THE SHORTEST DISTINCTIVE TOKEN — a commit hash, a pid, an OBS id — NEVER a
512
532
  sentence.** A long phrase crosses the pane's render wrap boundary, so grepping for it returns zero on a
513
533
  message that arrived intact, and **a badly-probed successful send is byte-identical to a truncated one.**
@@ -802,12 +822,17 @@ nowhere.
802
822
  **The artifact watcher** — bundled, and keyed on the deliverable rather than the seat:
803
823
 
804
824
  ```bash
805
- .claude/skills/tickmarkr-overseer/scripts/watch-artifacts.sh <MARKER> <cap-s> <poll-s> <file>...
825
+ TKR_ARMING_SEAT=<this-seat> .claude/skills/tickmarkr-overseer/scripts/watch-artifacts.sh \
826
+ [--changed-from <content-sha-or-now>] <MARKER> <cap-s> <poll-s> <file>...
806
827
  ```
807
828
 
808
829
  It wakes when every named file exists AND ends with its terminal marker, and on timeout it reports each
809
830
  file as READY / PARTIAL / ABSENT so a quiet arm still proves the watcher was alive. Tell each seat, in its
810
831
  brief, the exact marker its report must end with — you cannot watch for a marker you never demanded.
832
+ **A re-arm over an artifact that already has its marker uses `--changed-from now` (or its captured SHA).**
833
+ The existing marked bytes are the baseline, not a fresh delivery: the watcher fires only after content
834
+ differs from that baseline and then stabilizes, once. Without the changed-from baseline, re-arm is an
835
+ instant replay of old completion rather than observation of new work.
811
836
  **Arm on the marker YOU demanded, verified against the FILE — never on the seat's report of its own
812
837
  marker.** Measured 2026-08-17: a seat reported its sweep "ends `SWEEP-END`"; the file on disk ended
813
838
  `ORDER4-END`. A watcher armed on the reported marker never fires while the artifact sits COMPLETE, and
@@ -941,9 +966,18 @@ orchestrator turn boundary.
941
966
  filed a *different* finding as OBS-437, then repeated it as OBS-438 and OBS-439 — and a sweep of the
942
967
  ledger's history found **twelve** more. A duplicated id makes every citation ambiguous, and this project
943
968
  cites them in rulings, handoffs, memory entries and shipped source comments. **Allocate from the current
944
- maximum and then VERIFY with `grep -o '^## OBS-[0-9]*' <ledger> | sort | uniq -d`, which must print
945
- nothing** — allocation alone is a guess about what the other tier is doing, and only the check catches
946
- you both guessing the same. Renumber the LATER entry and say so in its heading. **Never renumber a
969
+ maximum and then run the duplicate-id check against the committed historical baseline below; it must
970
+ print nothing NEW** — the raw `uniq -d` list is intentionally non-empty history and cannot be used as a
971
+ zero-output gate:
972
+
973
+ ```bash
974
+ comm -13 \
975
+ <(printf '%s\n' OBS-106 OBS-12 OBS-129 OBS-148 OBS-24 OBS-26 OBS-29 OBS-35 OBS-36 OBS-40 OBS-41 OBS-415 OBS-459 OBS-463 OBS-466 OBS-470 OBS-472 OBS-542 OBS-548 OBS-552 OBS-562 OBS-563 OBS-564 OBS-592 OBS-785 OBS-791 | sort -u) \
976
+ <(grep -o '^## OBS-[0-9]*' <ledger> | sed 's/^## //' | sort | uniq -d)
977
+ ```
978
+
979
+ Allocation alone is a guess about what the other tier is doing, and only this baseline-relative check
980
+ catches you both guessing the same. Renumber the LATER entry and say so in its heading. **Never renumber a
947
981
  historical id**: every record already citing it would then point at the wrong finding.
948
982
  7. **Every fix is evaluated for shipping.** The tarball is `files: [dist, schema, skills, fixtures]` — so
949
983
  `src/**` and `skills/**` reach users while `.overseer/**` and `.tickmarkr/**` reach nobody. Before
@@ -1029,6 +1063,29 @@ Distilled from a v1.86 spec-repair mission that produced 31 numbered rules, ~90
1029
1063
  errors authored by the supervising seat itself. **Every line below was earned by a defect, most of them
1030
1064
  twice.** They are mission-independent on purpose: nothing here names a task, a line number or a figure.
1031
1065
 
1066
+ **Five queue-and-handoff method laws — execute them; citing their OBS id is not execution:**
1067
+
1068
+ 1. **CITE-IS-NOT-READ.** A queue, handoff, finding, or brief that cites an observation does not prove its
1069
+ author opened it. Before acting on a cited premise, open the primary record, quote the operative bytes,
1070
+ and state the qualifier or falsifier the citation would otherwise hide.
1071
+ 2. **EXECUTING-FORM PROBE.** Process ownership starts from the watcher pid file. Where legacy discovery is
1072
+ unavoidable, match the executing form — interpreter plus exact script path and arguments — not a journal
1073
+ path or name substring, resolve the candidate's cwd/parent, read the arm log for startup failure, then
1074
+ require the same pid in two reads.
1075
+ 3. **RESUME-AWARE CONTAMINATION.** Capture the current engagement boundary before watching a resumable
1076
+ journal. Never terminate on a historical `run-end`: continue across `run-resume`, and stop only at the
1077
+ cap or after current lock release proves no later engagement follows. Classify only runner-emitted lines
1078
+ of new failed `test`, `build`, or `lint` gates; review prose and the secondary fingerprint echo list are
1079
+ evidence about findings, not contamination occurrences.
1080
+ 4. **THE QUEUE-INHERITANCE DIFF.** In the same act that opens a queue, extract observation ids from the
1081
+ previous queue and the new queue, run `comm -23 <previous-queue-ids> <new-queue-ids>`, paste its output
1082
+ into the new queue, and classify every row as queued, shipped, or no-ship with a falsifiable removal
1083
+ condition. Reading the previous queue is not a diff.
1084
+ 5. **RE-RUN A PREMISE.** Before inheriting any queue item or handoff conclusion, run its named positive and
1085
+ negative controls on the current tree. For an executable premise, execute it with a negative control;
1086
+ for a call-site premise, open the code line and read any rationale through its rejected alternatives.
1087
+ Record the current output; if the premise dissolved, mark it superseded rather than carrying it forward.
1088
+
1032
1089
  **Rot**
1033
1090
 
1034
1091
  1. **A quotation is exact bytes.** `grep` it before attributing it; if it does not hit, it is not a
@@ -1112,11 +1169,12 @@ twice.** They are mission-independent on purpose: nothing here names a task, a l
1112
1169
  owns it** — "watchers alive" is the one claim a seat cannot verify about itself. Measured 2026-08-06:
1113
1170
  an orchestrator sat `idle` through three merges and two dispatches with no journal watcher in the
1114
1171
  process table, while its own last report read *"daemon, board, sweeper, watcher all alive"* (OBS-366).
1115
- **STATE THE LIFETIME, because an unstated one is read as the mission's: a session-scoped watcher DIES
1116
- WITH THE SEAT THAT ARMED IT.** Every watcher a seat arms — journal, artifact, dialog, beat loop — is
1117
- session-scoped unless it was deliberately detached (`ppid 1`, the heartbeat form below), so `/clear`,
1118
- a crash, an adopt or a stand-down ends it, and **a handoff is the one moment the arming seat stops
1119
- existing** — which is exactly when its watchers are most likely to be believed. The inverse failure is
1172
+ **STATE THE LIFETIME, because an unstated one is read as the mission's.** Every watcher a seat arms —
1173
+ journal, artifact, dialog, beat loop — records its own pid and arming seat. **A same-process `/clear`
1174
+ keeps every background task alive**; it does not retire even one watcher. A crash may orphan or kill
1175
+ tasks according to how they were launched, while adopt and stand-down are explicit kill-by-recorded-pid
1176
+ transitions. A handoff must therefore inventory real pids, not infer process lifetime from the context
1177
+ lifecycle. The inverse failure is
1120
1178
  the same root read the other way: a DETACHED loop outlives its seat and holds a tier `ARMED` with
1121
1179
  nobody home (OBS-583). Neither direction may be assumed; the lifetime is a property of how the watcher
1122
1180
  was launched, and it belongs in writing next to every claim that one is armed.
@@ -1173,9 +1231,10 @@ twice.** They are mission-independent on purpose: nothing here names a task, a l
1173
1231
  The general rule: **an exclusion filter is exactly as
1174
1232
  dangerous as an over-broad inclusion filter, and it fails in the direction that reads as "not there" —
1175
1233
  which is the direction that gets acted on.**
1176
- Two corollaries: **re-arm a wake-and-exit watcher as the same turn's LAST act**, not the next turn's
1177
- first — the gap between them is unwatched and its width is however long the seat stays busy; and **a
1178
- handoff that re-arms one tier's watchers must say which tier's it did NOT re-arm.**
1234
+ Two corollaries: **handling a fire-and-exit watcher's wake and re-arming it are the same act.** The wake
1235
+ handling line is incomplete until it names the new recorded pid and two successful process-table reads;
1236
+ never defer the arm to the turn's end. And **a handoff that re-arms one tier's watchers must say which
1237
+ tier's it did NOT re-arm.**
1179
1238
  **That first corollary prescribes DISCIPLINE, and discipline is the wrong fix — measured 2026-08-06.**
1180
1239
  One orchestrator lapsed its journal tier **31 minutes**, then, after diagnosing it and fully intending
1181
1240
  to re-arm, lapsed it again for 3 minutes **while actively thinking about watchers**. Its own diagnosis
@@ -5,6 +5,7 @@
5
5
  # under load; this encodes the steps.
6
6
  #
7
7
  # seat-send.sh <agent-name-or-pane> <message>
8
+ # TKR_ADAPTER=<id> supplies the adapter when a bare pane/name does not expose it in `agent get`.
8
9
  #
9
10
  # TKR_INTERRUPT=1 end a `working` seat's turn first (Esc, at most twice, verified between) so the
10
11
  # directive is READ now instead of queued behind the turn. A message to a working
@@ -24,8 +25,8 @@
24
25
  # alone, or nothing — a decorative check beside a real one reads as corroboration.
25
26
  #
26
27
  # First output line is the machine-greppable verdict:
27
- # DELIVERED_SUBMITTED | QUEUED_BEHIND_TURN | SEND_UNSUBMITTED | INTERRUPT_FAILED | TARGET_GONE
28
- # | REFUSED_SIZE | REFUSED_BOX_OCCUPIED | SEND_UNVERIFIED | SEND_REFUSED | SEND_FAILED
28
+ # DELIVERED_SUBMITTED | QUEUED_BEHIND_TURN | SEND_UNSUBMITTED | SEND_DEFERRED
29
+ # | INTERRUPT_FAILED | TARGET_GONE | REFUSED_SIZE | SEND_UNVERIFIED | SEND_REFUSED | SEND_FAILED
29
30
  #
30
31
  # A success verdict names the surface that delivered when it was not the default one, so a fallback
31
32
  # is visible in the transcript rather than inferred (OBS-552).
@@ -36,6 +37,7 @@ set -u
36
37
 
37
38
  TARGET="${1:?usage: seat-send.sh <agent-name-or-pane> <message>}"
38
39
  MSG="${2:?message required}"
40
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
39
41
 
40
42
  # PTY input front-truncates around 1KB and a truncated brief silently drops policy — the drop is at
41
43
  # the FRONT, so what survives still reads as a complete message. Long content goes in a FILE; the
@@ -57,9 +59,68 @@ pane_of() {
57
59
  | sed -n 's/.*"pane_id":"\([^"]*\)".*/\1/p' | head -1
58
60
  }
59
61
 
60
- # The rendered `❯` line — the only state that discriminates submitted from sitting. tail -1 takes
61
- # the LAST `❯` line, which is always the LIVE INPUT BOX: a wrapped draft's continuation lines carry
62
- # no `❯`, so the last one is still that draft's FIRST rendered line, i.e. a PREFIX of the draft —
62
+ # Adapter identity comes from the worker name or Herdr's agent field; a caller targeting a bare pane
63
+ # supplies TKR_ADAPTER. The glyph itself is never duplicated here: this parser reads the declaration
64
+ # beside the input-box matchers in adapters/types.ts (or its built dist twin in an installed package).
65
+ adapter_of() {
66
+ if [ -n "${TKR_ADAPTER:-}" ]; then printf '%s\n' "$TKR_ADAPTER"; return 0; fi
67
+ local record identity adapter
68
+ record=$(herdr agent get "$TARGET" 2>/dev/null)
69
+ identity=$(printf '%s\n' "$record" |
70
+ sed -n 's/.*"agent":"\([^"]*\)".*/\1/p; s/.*"name":"\([^"]*\)".*/\1/p' | head -1)
71
+ [ -n "$identity" ] || identity="$TARGET"
72
+ adapter=$(printf '%s\n' "$identity" |
73
+ sed -n 's/^.*-worker-\(.*\)-a[0-9][0-9]*-.*$/\1/p' | head -1)
74
+ [ -n "$adapter" ] || adapter="$identity"
75
+ case "$adapter" in claude) adapter="claude-code" ;; cursor) adapter="cursor-agent" ;; esac
76
+ printf '%s\n' "$adapter"
77
+ }
78
+
79
+ prompt_glyph_of() {
80
+ local adapter="$1" source glyph root tickmarkr_bin tickmarkr_entry
81
+ # The canonical script is <repo>/skills/.../scripts; the independently tracked installed copy is
82
+ # <repo>/.claude/skills/.../scripts. Search both ancestor shapes so identical bytes work from either
83
+ # shipped location. TKR_ADAPTER_TYPES remains the explicit consumer-install override.
84
+ for source in ${TKR_ADAPTER_TYPES:+"$TKR_ADAPTER_TYPES"}; do
85
+ [ -f "$source" ] || continue
86
+ glyph=$(awk -F'"' -v adapter="$adapter" '$2 == adapter && $3 ~ /^[[:space:]]*:[[:space:]]*$/ { print $4; exit }' "$source")
87
+ if [ -n "$glyph" ]; then printf '%s\n' "$glyph"; return 0; fi
88
+ done
89
+ for root in "$SCRIPT_DIR/../../.." "$SCRIPT_DIR/../../../.."; do
90
+ root=$(CDPATH= cd -- "$root" 2>/dev/null && pwd) || continue
91
+ for source in "$root/src/adapters/types.ts" "$root/dist/adapters/types.js"; do
92
+ [ -f "$source" ] || continue
93
+ glyph=$(awk -F'"' -v adapter="$adapter" '$2 == adapter && $3 ~ /^[[:space:]]*:[[:space:]]*$/ { print $4; exit }' "$source")
94
+ if [ -n "$glyph" ]; then printf '%s\n' "$glyph"; return 0; fi
95
+ done
96
+ done
97
+ # `tickmarkr init` copies this skill into a consumer repository, so neither ancestor above belongs
98
+ # to the package. The executable is shipped from <package>/dist/cli/index.js; resolve its symlink and
99
+ # read the adjacent compiled declaration. Node is already a runtime requirement of that executable.
100
+ tickmarkr_bin=$(command -v tickmarkr 2>/dev/null) || tickmarkr_bin=""
101
+ if [ -n "$tickmarkr_bin" ]; then
102
+ tickmarkr_entry=$(node -e 'try { process.stdout.write(require("fs").realpathSync(process.argv[1])) } catch {}' "$tickmarkr_bin" 2>/dev/null)
103
+ if [ -n "$tickmarkr_entry" ]; then
104
+ root=$(CDPATH= cd -- "$(dirname -- "$tickmarkr_entry")/../.." 2>/dev/null && pwd) || root=""
105
+ source="$root/dist/adapters/types.js"
106
+ if [ -n "$root" ] && [ -f "$source" ]; then
107
+ glyph=$(awk -F'"' -v adapter="$adapter" '$2 == adapter && $3 ~ /^[[:space:]]*:[[:space:]]*$/ { print $4; exit }' "$source")
108
+ if [ -n "$glyph" ]; then printf '%s\n' "$glyph"; return 0; fi
109
+ fi
110
+ fi
111
+ fi
112
+ return 1
113
+ }
114
+
115
+ ADAPTER=$(adapter_of)
116
+ if ! PROMPT_GLYPH=$(prompt_glyph_of "$ADAPTER"); then
117
+ echo "SEND_UNVERIFIED $TARGET — adapter '$ADAPTER' has no prompt glyph declaration; nothing was typed"
118
+ exit 1
119
+ fi
120
+
121
+ # The adapter-declared prompt line is the only state that discriminates submitted from sitting.
122
+ # tail -1 takes the LAST matching line: a wrapped draft's continuation lines carry no prompt glyph,
123
+ # so the last one is still that draft's FIRST rendered line, i.e. a PREFIX of the draft —
63
124
  # callers compare prefixes, never whole sentences (OBS-396: a full-sentence match returns false on a
64
125
  # message that arrived intact).
65
126
  #
@@ -84,7 +145,11 @@ pane_of() {
84
145
  prompt_line() {
85
146
  herdr agent read "$TARGET" --source visible --lines 14 2>/dev/null \
86
147
  | sed '/↑ to edit/{/steer/d;}' \
87
- | sed -n 's/^[[:space:]]*❯[[:space:]]*//p' | tail -1 | sed 's/[[:space:]]*$//'
148
+ | awk -v glyph="$PROMPT_GLYPH" '
149
+ { pos = index($0, glyph); if (!pos) next
150
+ prefix = substr($0, 1, pos - 1); gsub(/[[:space:]|]/, "", prefix); gsub(/│/, "", prefix)
151
+ if (prefix == "") print substr($0, pos + length(glyph)) }
152
+ ' | tail -1 | sed 's/^[[:space:]]*//; s/[[:space:]│|]*$//'
88
153
  }
89
154
 
90
155
  # STAGED-QUEUE DISCRIMINATOR (OBS-552 addendum, captured 2026-08-19 on a live kimi seat). After its
@@ -114,7 +179,7 @@ is_ours() {
114
179
  # wrapped in SGR dim; typed text renders default. A dim-wrapped line is rendering, not state.
115
180
  is_ghost() {
116
181
  herdr agent read "$TARGET" --source visible --lines 14 --format ansi 2>/dev/null \
117
- | grep -F -- "❯" | grep -F -- "$1" | head -1 | grep -q "$(printf '\033')\[2m"
182
+ | grep -F -- "$PROMPT_GLYPH" | grep -F -- "$1" | head -1 | grep -q "$(printf '\033')\[2m"
118
183
  }
119
184
 
120
185
  S=$(status_of)
@@ -123,17 +188,22 @@ if [ -z "$S" ]; then
123
188
  exit 1
124
189
  fi
125
190
 
126
- # A pre-existing draft in the box is a decision, not an obstacle: `agent prompt` would APPEND to it
127
- # and submit both. Superseding someone else's text needs an author and the arrow form — refuse and
128
- # surface it rather than silently stomping it (D-206: an unattributed line in a box, origin never
129
- # resolved because it was cleared).
130
- PRE=$(prompt_line)
131
- if [ -n "$PRE" ] && ! is_ghost "$PRE"; then
132
- echo "REFUSED_BOX_OCCUPIED $TARGET — input box already holds: $PRE"
133
- echo " ANSI-verify first (dim SGR = autosuggest ghost, ignorable). A real draft is superseded by a"
134
- echo " DECISION, not by this script: send the arrow form naming what you are overriding."
135
- exit 2
136
- fi
191
+ # Probe BEFORE writing: `agent prompt` and `pane run` both append to a live draft and submit the
192
+ # concatenation. Give a human a short bounded window to finish or clear; if it remains, defer without
193
+ # touching the pane. Re-running the receipt is the retry, so SEND_DEFERRED remains safe indefinitely.
194
+ DRAFT_WAIT="${TKR_DRAFT_WAIT_S:-10}"
195
+ DRAFT_POLL="${TKR_DRAFT_POLL_S:-1}"
196
+ waited=0
197
+ while :; do
198
+ PRE=$(prompt_line)
199
+ if [ -z "$PRE" ] || is_ghost "$PRE"; then break; fi
200
+ if [ "$waited" -ge "$DRAFT_WAIT" ] 2>/dev/null; then
201
+ echo "SEND_DEFERRED $TARGET — draft present; nothing was typed: $PRE"
202
+ exit 2
203
+ fi
204
+ sleep "$DRAFT_POLL"
205
+ waited=$((waited + DRAFT_POLL))
206
+ done
137
207
 
138
208
  QUEUED=""
139
209
  if [ "$S" = "working" ]; then
@@ -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)"
@@ -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
  }
@@ -224,7 +250,7 @@ act_on() {
224
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