kijito-tools 0.2.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 (104) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +6 -0
  3. package/README.md +220 -0
  4. package/bin/cli.js +34 -0
  5. package/install.sh +161 -0
  6. package/package.json +50 -0
  7. package/providers/_shared/wake-core.mjs +358 -0
  8. package/providers/claude/CLAUDE.md.snippet +31 -0
  9. package/providers/claude/install.sh +186 -0
  10. package/providers/claude/scripts/arm-session.sh +66 -0
  11. package/providers/claude/scripts/claude-armed.sh +101 -0
  12. package/providers/claude/scripts/heartbeat-watchdog.sh +209 -0
  13. package/providers/claude/scripts/inbox-selftest.sh +277 -0
  14. package/providers/claude/scripts/kijito-persona-lib.sh +94 -0
  15. package/providers/claude/scripts/kijito-qa-pass.sh +12 -0
  16. package/providers/claude/scripts/lifecycle-lib.sh +167 -0
  17. package/providers/claude/scripts/myctx.sh +46 -0
  18. package/providers/claude/scripts/self-clear.sh +134 -0
  19. package/providers/claude/scripts/session-autosend.sh +85 -0
  20. package/providers/claude/scripts/session-catchup-hint.sh +330 -0
  21. package/providers/claude/scripts/statusline-context.sh +67 -0
  22. package/providers/claude/skills/kijito-qa-memory/SKILL.md +110 -0
  23. package/providers/claude/skills/kijito-recall/SKILL.md +53 -0
  24. package/providers/claude/skills/kijito-start/SKILL.md +122 -0
  25. package/providers/claude/wiring/README.md +93 -0
  26. package/providers/claude/wiring/kijito-heartbeat@.service +21 -0
  27. package/providers/codex/README.md +75 -0
  28. package/providers/codex/codex-kijito-parity-plan.md +265 -0
  29. package/providers/codex/docs-codex-setup.md +64 -0
  30. package/providers/codex/install.mjs +184 -0
  31. package/providers/codex/n0-capability-probe-protocol.md +472 -0
  32. package/providers/codex/n0-harness/README.md +57 -0
  33. package/providers/codex/n0-harness/cli.mjs +58 -0
  34. package/providers/codex/n0-harness/evidence-manifest.mjs +77 -0
  35. package/providers/codex/n0-harness/fixture.mjs +279 -0
  36. package/providers/codex/n0-harness/lib.mjs +141 -0
  37. package/providers/codex/n0-harness/manifest.mjs +36 -0
  38. package/providers/codex/n0-harness/oracle.mjs +464 -0
  39. package/providers/codex/n0-harness/parser.mjs +200 -0
  40. package/providers/codex/n0-harness/prompt.mjs +43 -0
  41. package/providers/codex/n0-harness/snapshot.mjs +120 -0
  42. package/providers/codex/n0-harness/specimen.mjs +37 -0
  43. package/providers/codex/notify/kijito-notify-count.mjs +117 -0
  44. package/providers/codex/notify/kijito-notify-count.test.mjs +76 -0
  45. package/providers/codex/plans/gate4-battery-log.md +184 -0
  46. package/providers/codex/plans/gate4-battery-protocol.md +43 -0
  47. package/providers/codex/plans/hive-user-first-plan-QA.md +34 -0
  48. package/providers/codex/plans/hive-user-first-plan.md +247 -0
  49. package/providers/codex/release-manifest.json +53 -0
  50. package/providers/codex/same-chat-continuation-plan-gate.md +235 -0
  51. package/providers/codex/same-chat-continuation-plan.md +458 -0
  52. package/providers/codex/skills/kijito-qa-memory/SKILL.md +193 -0
  53. package/providers/codex/skills/kijito-qa-memory/agents/openai.yaml +4 -0
  54. package/providers/codex/skills/kijito-recall/SKILL.md +53 -0
  55. package/providers/codex/skills/kijito-start/SKILL.md +173 -0
  56. package/providers/codex/skills/kijito-start/agents/openai.yaml +4 -0
  57. package/providers/codex/test/n0-cli-refusal.test.mjs +31 -0
  58. package/providers/codex/test/n0-counterexample-matrix.test.mjs +118 -0
  59. package/providers/codex/test/n0-guard-census-core.mjs +1372 -0
  60. package/providers/codex/test/n0-guard-census.json +35479 -0
  61. package/providers/codex/test/n0-guard-census.mjs +31 -0
  62. package/providers/codex/test/n0-guard-census.test.mjs +374 -0
  63. package/providers/codex/test/n0-guard-counterexamples.mjs +1068 -0
  64. package/providers/codex/test/n0-guard-independent-projection.mjs +70 -0
  65. package/providers/codex/test/n0-guard-independent-projection.test.mjs +53 -0
  66. package/providers/codex/test/n0-guard-manifest-author.mjs +424 -0
  67. package/providers/codex/test/n0-guard-mutation-operators.test.mjs +244 -0
  68. package/providers/codex/test/n0-guard-mutation-runner.mjs +599 -0
  69. package/providers/codex/test/n0-guard-remainder-classes.test.mjs +314 -0
  70. package/providers/codex/test/n0-guard-sanitizer-pairs.test.mjs +68 -0
  71. package/providers/codex/test/n0-guard-shared-context-predicates.test.mjs +84 -0
  72. package/providers/codex/test/n0-harness.test.mjs +553 -0
  73. package/providers/codex/test/n0-notable-regressions.test.mjs +70 -0
  74. package/providers/codex/test/n0-subject-boundary.mjs +97 -0
  75. package/providers/codex/test/n0-subject-boundary.test.mjs +72 -0
  76. package/providers/codex/test/same-chat-plan-preflight.sh +167 -0
  77. package/providers/codex/tools/refresh-manifest.mjs +81 -0
  78. package/providers/codex/wake-helper/TRANSPORT-NOTES.md +25 -0
  79. package/providers/codex/wake-helper/integration.test.mjs +285 -0
  80. package/providers/codex/wake-helper/kijito-wake-helper.mjs +488 -0
  81. package/providers/codex/wake-helper/kijito-wake-helper.test.mjs +128 -0
  82. package/providers/codex/wake-helper/mock-daemon.mjs +64 -0
  83. package/providers/codex/wake-helper/status-probe.mjs +45 -0
  84. package/providers/codex/wake-helper/ws-uds.mjs +153 -0
  85. package/providers/monitor/.github/workflows/publish-npm.yml +38 -0
  86. package/providers/monitor/.github/workflows/publish-pypi.yml +30 -0
  87. package/providers/monitor/CHANGELOG.md +465 -0
  88. package/providers/monitor/IMPORT-PROVENANCE.md +68 -0
  89. package/providers/monitor/LICENSE +202 -0
  90. package/providers/monitor/NOTICE +7 -0
  91. package/providers/monitor/OPAQUE-OUTPUT-ENFORCEMENT.md +45 -0
  92. package/providers/monitor/README.md +571 -0
  93. package/providers/monitor/RELEASING.md +125 -0
  94. package/providers/monitor/arm-hive-monitor.sh +13 -0
  95. package/providers/monitor/bin/cli.js +43 -0
  96. package/providers/monitor/com.kijito.inbox-monitor.plist.template +59 -0
  97. package/providers/monitor/docs/DESIGN.md +984 -0
  98. package/providers/monitor/kijito-inbox-monitor@.service.template +95 -0
  99. package/providers/monitor/kijito_inbox_monitor.py +3666 -0
  100. package/providers/monitor/package.json +35 -0
  101. package/providers/monitor/pyproject.toml +42 -0
  102. package/providers/monitor/scripts/mutation-check.py +399 -0
  103. package/providers/monitor/scripts/prepublish-gate.sh +284 -0
  104. package/providers/monitor/test_kijito_monitor.py +5609 -0
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env bash
2
+ # ── THE WARNING WHERE THE READER ACTUALLY IS ─────────────────────────────────────────────────────
3
+ # This check's EXIT STATUS is its answer. Pipe it to `tail`/`head`/`grep` and `$?` becomes the
4
+ # PIPE's status, not this script's — a NOT WORKING then reads as a pass. Emitted at RUN time on
5
+ # STDERR (which does not travel down the pipe) so it still reaches a terminal.
6
+ if [ ! -t 1 ]; then
7
+ printf '%s\n' "note: stdout is not a terminal. If you piped this, \$? is the LAST pipeline stage's status, NOT this check's answer. Run it unpiped." >&2
8
+ fi
9
+
10
+ # PROVE THE WAKE PATH, END TO END, BEFORE ANYONE BELIEVES THE INSTALL (row M304).
11
+ #
12
+ # WHY THIS EXISTS. From Jason's onboarding call with the first external operator: "Inbox didn't arm
13
+ # properly on initial install." The installer had printed its ✓ lines and exited 0; the first real
14
+ # message did not wake the agent; the HUMAN had to notice the silence, ask for a diagnosis, ask for
15
+ # a repair, and only then did the agent think to mail itself as a test. Everything the installer
16
+ # measured was true. None of it was the thing that mattered.
17
+ #
18
+ # ⚠️ THE FAILURE MODE IS SILENCE, WHICH IS WHY "IT INSTALLED FINE" IS NOT EVIDENCE. A wake path that
19
+ # is broken looks exactly like a wake path with no mail on it: no error, no log line, nothing to
20
+ # notice. The only way to tell them apart is to PUT A KNOWN MESSAGE THROUGH IT and watch each hop.
21
+ # That is this script. It is not a status report; it is an experiment with a control.
22
+ #
23
+ # THE THREE HOPS, each independently observable, each named in the verdict when it is the one that
24
+ # broke — because "your inbox isn't working" sends a new user to read all three, and two of them are
25
+ # fine:
26
+ # 1. PRODUCER a producer process is running AND it covers THIS persona (not a sibling's).
27
+ # 2. STREAM a message sent to this persona actually LANDS in this persona's event stream.
28
+ # 3. CONSUMER something wake-capable is attached to that stream and would be re-invoked by it.
29
+ #
30
+ # ⛔ HOP 3 IS THE SESSION'S JOB, NOT THE INSTALLER'S, AND IT IS STILL PART OF THE VERDICT. A fresh
31
+ # install legitimately has no consumer yet — no session has armed one. Reporting WORKING at that
32
+ # point would be the exact lie this row exists to remove ("the install declared success with a dead
33
+ # inbox"), so the verdict says PARTIAL, names the consumer hop, and prints what to run. The install
34
+ # is then honestly described: proven as far as your stream file, not yet proven to wake you.
35
+ #
36
+ # EXIT: 0 WORKING · 1 NOT WORKING or PARTIAL (a hop failed — the verdict names it) · 2 COULD NOT
37
+ # MEASURE (no token, no resolvable persona/stream: the check could not be run at all, which is not
38
+ # the same as a failure and must never be reported as one).
39
+ # ⚠️ RUN IT UNPIPED.
40
+ set -eu
41
+
42
+ PERSONA=""
43
+ TIMEOUT=90
44
+ DO_SEND=1
45
+ CANARY=0
46
+ # TWO CALLERS, TWO CONTRACTS, ONE VERDICT FUNCTION (river ruling, 2026-09-21).
47
+ # The INSTALL-TIME run legitimately has no consumer — no agent session exists yet — so a missing
48
+ # consumer there is PARTIAL with the next step, not a failed install. The AGENT-DRIVEN ONBOARDING
49
+ # flow runs this same check AFTER the agent has armed its consumer and must report a machine-readable
50
+ # verdict to the server; that caller needs "WORKING means all three hops" with no PARTIAL wording to
51
+ # misread. --require-consumer selects the second contract. The HOPS are measured identically either
52
+ # way — only what counts as passing changes, which is the only difference that should ever be
53
+ # configurable in a check.
54
+ REQUIRE_CONSUMER=0
55
+ while [ $# -gt 0 ]; do
56
+ case "$1" in
57
+ --persona) PERSONA="${2:-}"; [ -n "$PERSONA" ] || { echo "ABORT: --persona needs a value" >&2; exit 2; }; shift 2 ;;
58
+ --timeout) TIMEOUT="${2:-}"; shift 2 ;;
59
+ --no-send) DO_SEND=0; shift ;; # observe the hops without putting a message through
60
+ --require-consumer) REQUIRE_CONSUMER=1; shift ;;
61
+ --canary) CANARY=1; shift ;;
62
+ -h|--help) sed -n '1,40p' "$0"; exit 0 ;;
63
+ *) echo "ABORT: unknown option $1" >&2; exit 2 ;;
64
+ esac
65
+ done
66
+
67
+ KIJITO_BASE=${KIJITO_BASE:-https://api.kijito.ai}
68
+ # Cloudflare 403s the default python-urllib UA; a named UA is cheap insurance for any client here.
69
+ UA=${KIJITOMON_UA:-kijito-inbox-selftest/1.0}
70
+
71
+ # ── THE VERDICT IS A PURE FUNCTION OF THE THREE HOPS ─────────────────────────────────────────────
72
+ # Kept pure so the canary can drive every combination offline, including the ones that are hard to
73
+ # stage on a real host. A verdict that could only be produced by the live path would be a verdict
74
+ # nobody had ever seen fail.
75
+ verdict() { # $1=producer_ok $2=stream_ok $3=consumer_ok (each 1/0) -> prints, returns 0/1
76
+ _p=$1; _s=$2; _c=$3
77
+ if [ "$_p" = 0 ]; then
78
+ printf '%s\n' "VERDICT: NOT WORKING — failing hop: PRODUCER"
79
+ printf '%s\n' " Nothing is collecting ${PERSONA:-this persona}'s mail, so no message can reach you."
80
+ printf '%s\n' " Run: $(restart_hint)"
81
+ return 1
82
+ fi
83
+ if [ "$_s" = 0 ]; then
84
+ printf '%s\n' "VERDICT: NOT WORKING — failing hop: STREAM FILE"
85
+ printf '%s\n' " A producer is running, but the test message did not appear in this persona's"
86
+ printf '%s\n' " event stream within ${TIMEOUT}s. The producer may be covering a DIFFERENT persona,"
87
+ printf '%s\n' " or writing somewhere other than where this check is looking."
88
+ printf '%s\n' " Run: producer-health.sh --persona ${PERSONA:-<persona>} (unpiped)"
89
+ return 1
90
+ fi
91
+ if [ "$_c" = 0 ] && [ "$REQUIRE_CONSUMER" = 1 ]; then
92
+ printf '%s\n' "VERDICT: NOT WORKING — failing hop: CONSUMER"
93
+ printf '%s\n' " Mail reaches the stream, but nothing is READING it, so nothing will wake this"
94
+ printf '%s\n' " session. --require-consumer was passed, so a missing consumer is a FAILURE here"
95
+ printf '%s\n' " rather than the expected state of a fresh install."
96
+ printf '%s\n' " Arm a wake-capable consumer on: ${EVENTS:-<your events file>}"
97
+ return 1
98
+ fi
99
+ if [ "$_c" = 0 ]; then
100
+ printf '%s\n' "VERDICT: PARTIAL — failing hop: CONSUMER"
101
+ printf '%s\n' " Mail reaches your event stream, but nothing is READING it, so nothing will wake"
102
+ printf '%s\n' " you. This is the normal state immediately after an install: a session arms the"
103
+ printf '%s\n' " consumer, the installer cannot. The install is proven as far as your stream file"
104
+ printf '%s\n' " and NOT yet proven to wake you."
105
+ printf '%s\n' " In your agent session, arm a wake-capable consumer on:"
106
+ printf '%s\n' " ${EVENTS:-<your events file>}"
107
+ printf '%s\n' " then re-run this check. A background \`tail\` that only writes to a file is NOT a"
108
+ printf '%s\n' " consumer: it captures without re-invoking you, which is indistinguishable from"
109
+ printf '%s\n' " silence for as long as it lasts."
110
+ return 1
111
+ fi
112
+ printf '%s\n' "VERDICT: WORKING — a message sent to ${PERSONA} reached the stream and a consumer is attached."
113
+ return 0
114
+ }
115
+
116
+ restart_hint() {
117
+ if [ -d "$HOME/.kijito-monitor" ] || command -v systemctl >/dev/null 2>&1; then
118
+ printf 'systemctl --user enable --now kijito-inbox-monitor@%s' "${PERSONA:-<persona>}"
119
+ else
120
+ printf 'launchctl kickstart -k gui/$(id -u)/com.kijito.inbox-monitor'
121
+ fi
122
+ }
123
+
124
+ # ── CANARY: prove each hop is NAMED when it is the one that broke ────────────────────────────────
125
+ # ⛔ The point is not that a broken pipe produces SOME error — it is that the verdict points at the
126
+ # right hop. A check that said "not working" for all three would pass a naive test and send every
127
+ # new user to read all three sections of the docs.
128
+ if [ "$CANARY" = 1 ]; then
129
+ fail=0
130
+ PERSONA="canary"; EVENTS="/tmp/canary-events"
131
+ expect() { # $1=label $2=expected-substring $3..=hop states
132
+ _lbl=$1; _want=$2; shift 2
133
+ out=$(verdict "$@" || true)
134
+ if printf '%s' "$out" | grep -q "$_want"; then
135
+ printf ' ok %s\n' "$_lbl"
136
+ else
137
+ printf ' FAIL %s: verdict did not name it\n%s\n' "$_lbl" "$out"; fail=1
138
+ fi
139
+ }
140
+ expect "producer down names PRODUCER" "failing hop: PRODUCER" 0 0 0
141
+ expect "producer down outranks the later hops" "failing hop: PRODUCER" 0 1 1
142
+ expect "stream silent names STREAM FILE" "failing hop: STREAM FILE" 1 0 0
143
+ expect "stream silent outranks the consumer hop" "failing hop: STREAM FILE" 1 0 1
144
+ expect "no consumer names CONSUMER" "failing hop: CONSUMER" 1 1 0
145
+ expect "no consumer is PARTIAL by default" "VERDICT: PARTIAL" 1 1 0
146
+ # ...and the SECOND CONTRACT: the same missing hop must read as a flat failure for the caller that
147
+ # has already armed a consumer. Both are asserted, because a mode that silently behaved like the
148
+ # default would be worse than no mode at all — the onboarding flow would report verified=true on a
149
+ # session that cannot be woken.
150
+ REQUIRE_CONSUMER=1
151
+ expect "with --require-consumer it is NOT WORKING" "VERDICT: NOT WORKING" 1 1 0
152
+ expect "and it still names the CONSUMER hop" "failing hop: CONSUMER" 1 1 0
153
+ if verdict 1 1 1 | grep -q "VERDICT: WORKING"; then
154
+ printf ' ok --require-consumer still says WORKING when all three are green\n'
155
+ else
156
+ printf ' FAIL --require-consumer broke the all-green verdict\n'; fail=1
157
+ fi
158
+ REQUIRE_CONSUMER=0
159
+ expect "all three green says WORKING" "VERDICT: WORKING" 1 1 1
160
+ # ⛔ AND THE DIRECTION THAT MATTERS MOST: WORKING must require ALL THREE. A verdict that said
161
+ # WORKING with a dead hop is the defect this row was opened for, stated exactly.
162
+ for combo in "0 0 0" "0 0 1" "0 1 0" "0 1 1" "1 0 0" "1 0 1" "1 1 0"; do
163
+ # shellcheck disable=SC2086
164
+ if verdict $combo 2>/dev/null | grep -q "VERDICT: WORKING"; then
165
+ printf ' FAIL a dead hop (%s) still reported WORKING\n' "$combo"; fail=1
166
+ fi
167
+ done
168
+ [ "$fail" = 0 ] && printf ' ok WORKING is never reported with a dead hop\n'
169
+ # exit codes are part of the contract: a failing verdict must be non-zero, or a caller that tests
170
+ # $? (the installer does) treats a dead inbox as a success.
171
+ if verdict 1 1 0 >/dev/null 2>&1; then printf ' FAIL a PARTIAL verdict exited 0\n'; fail=1
172
+ else printf ' ok a failing verdict exits non-zero\n'; fi
173
+ [ "$fail" = 0 ] && { printf '%s\n' "CANARY CLEAN - each broken hop is named, and WORKING needs all three."; exit 0; }
174
+ printf '%s\n' "CANARY FAILED"; exit 2
175
+ fi
176
+
177
+ # ── LIVE PATH ────────────────────────────────────────────────────────────────────────────────────
178
+ # Resolve the persona from the same marker the SessionStart hook uses, so the check and the hook can
179
+ # never disagree about WHO this seat is.
180
+ if [ -z "$PERSONA" ]; then
181
+ for d in "${CLAUDE_PROJECT_DIR:-}" "$PWD"; do
182
+ if [ -n "$d" ] && [ -f "$d/.kijito_persona" ]; then
183
+ PERSONA=$(head -n1 "$d/.kijito_persona" | tr -d '\r\n')
184
+ # trim the ENDS only — deleting interior spaces renames the user's persona (row M290).
185
+ PERSONA="${PERSONA#"${PERSONA%%[![:space:]]*}"}"
186
+ PERSONA="${PERSONA%"${PERSONA##*[![:space:]]}"}"
187
+ [ -n "$PERSONA" ] && break
188
+ fi
189
+ done
190
+ fi
191
+ [ -n "$PERSONA" ] || { echo "COULD NOT MEASURE: no persona (pass --persona, or add a .kijito_persona marker)."; exit 2; }
192
+
193
+ # The producer publishes the persona->filename rule; asking it is the only way to be sure this check
194
+ # looks where the producer writes (row M290 — three hand-written copies had already drifted).
195
+ # Resolved through the SHARED helper (row M291 moved it into kijito-persona-lib.sh, so the heartbeat
196
+ # watchdog and this check cannot disagree about where a persona's stream lives).
197
+ _st_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/kijito-persona-lib.sh"
198
+ [ -r "$_st_lib" ] || _st_lib="$HOME/.claude/kijito-persona-lib.sh"
199
+ # shellcheck source=/dev/null
200
+ . "$_st_lib" || { echo "COULD NOT MEASURE: cannot source kijito-persona-lib.sh"; exit 2; }
201
+ EVENTS=$(kijito_stream_for_persona "$PERSONA" || true)
202
+
203
+ printf '%s\n' "kijito inbox self-test [persona=$PERSONA]"
204
+
205
+ # ── HOP 1: PRODUCER ──────────────────────────────────────────────────────────────────────────────
206
+ # ⚠️ "A producer is running" is a HOST-GLOBAL fact; "a producer covers ME" is a per-persona one, and
207
+ # on a multi-persona seat they come apart routinely. The second is the one that matters here.
208
+ producer_ok=0
209
+ if [ -n "$EVENTS" ] && [ -e "$EVENTS" ]; then
210
+ producer_ok=1
211
+ printf ' ok producer: covers %s (stream: %s)\n' "$PERSONA" "$EVENTS"
212
+ elif pgrep -f "kijito_inbox_monitor\.py|bin/kijito-inbox-monitor" >/dev/null 2>&1; then
213
+ printf ' FAIL producer: a producer is running, but none of it is writing a stream for %s\n' "$PERSONA"
214
+ else
215
+ printf ' FAIL producer: no producer process is running on this host\n'
216
+ fi
217
+
218
+ # ── HOP 2: STREAM ────────────────────────────────────────────────────────────────────────────────
219
+ stream_ok=0
220
+ if [ "$producer_ok" = 1 ]; then
221
+ if [ "$DO_SEND" = 0 ]; then
222
+ stream_ok=1
223
+ printf ' -- stream: not exercised (--no-send); the verdict below is about the OTHER hops only\n'
224
+ else
225
+ TOKEN=""
226
+ for tf in "${KIJITOMON_TOKEN_FILE:-}" "$HOME/.claude/.kijito_api_token.$PERSONA" \
227
+ "$HOME/.claude/.kijito_api_token" "$HOME/.config/kijito-inbox-monitor/token"; do
228
+ [ -n "$tf" ] && [ -r "$tf" ] && { TOKEN=$(tr -d ' \t\r\n' < "$tf"); break; }
229
+ done
230
+ if [ -z "$TOKEN" ]; then
231
+ printf ' ???? stream: COULD NOT MEASURE - no API token found, so no test message can be sent\n'
232
+ printf '%s\n' "COULD NOT MEASURE: the wake path was not exercised. This is not a pass."
233
+ exit 2
234
+ fi
235
+ before=$(wc -c < "$EVENTS" 2>/dev/null || echo 0)
236
+ stamp="kijito-inbox-selftest $(date -u +%FT%TZ) $$"
237
+ # Send to SELF. A self-addressed message is the only test message that cannot bother anyone else,
238
+ # and it exercises exactly the path a real sender uses.
239
+ if ! curl -fsS -m 20 -A "$UA" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
240
+ -X POST "$KIJITO_BASE/api/send" \
241
+ -d "{\"to\":\"$PERSONA\",\"persona\":\"$PERSONA\",\"content\":\"$stamp — automated install self-test; safe to ignore and to delete.\"}" \
242
+ >/dev/null 2>&1; then
243
+ printf ' ???? stream: COULD NOT MEASURE - the test message could not be SENT (API unreachable or token rejected)\n'
244
+ printf '%s\n' "COULD NOT MEASURE: the wake path was not exercised. This is not a pass."
245
+ exit 2
246
+ fi
247
+ printf ' .. stream: test message sent; waiting up to %ss for it to appear in the stream\n' "$TIMEOUT"
248
+ waited=0
249
+ while [ "$waited" -lt "$TIMEOUT" ]; do
250
+ now=$(wc -c < "$EVENTS" 2>/dev/null || echo 0)
251
+ if [ "$now" -gt "$before" ] && tail -c $((now - before + 1)) "$EVENTS" 2>/dev/null \
252
+ | grep -q '"event": *"new"'; then
253
+ stream_ok=1; break
254
+ fi
255
+ sleep 3; waited=$((waited + 3))
256
+ done
257
+ if [ "$stream_ok" = 1 ]; then
258
+ printf ' ok stream: the message reached %s after ~%ss\n' "$EVENTS" "$waited"
259
+ else
260
+ printf ' FAIL stream: nothing new arrived in %s within %ss\n' "$EVENTS" "$TIMEOUT"
261
+ fi
262
+ fi
263
+ fi
264
+
265
+ # ── HOP 3: CONSUMER ──────────────────────────────────────────────────────────────────────────────
266
+ # ⚠️ ANCHOR THE PATTERN. An unanchored pgrep on the events path SELF-MATCHES the producer (its own
267
+ # argv contains that path) and reports a consumer where there is none — armed-looking and deaf.
268
+ consumer_ok=0
269
+ if [ -n "$EVENTS" ] && kijito_stream_consumed "$EVENTS"; then consumer_ok=1; fi
270
+ if [ "$consumer_ok" = 1 ]; then
271
+ printf ' ok consumer: a wake-capable consumer is attached to the stream\n'
272
+ else
273
+ printf ' FAIL consumer: nothing is reading the stream\n'
274
+ fi
275
+
276
+ printf '\n'
277
+ verdict "$producer_ok" "$stream_ok" "$consumer_ok"
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env bash
2
+ # ONE place that turns a project directory into a persona NAME. Sourced, never executed.
3
+ #
4
+ # WHY THIS FILE EXISTS. Row M290 was two defects, and both were copies: the SessionStart hook and the
5
+ # producer disagreed about how a persona name becomes a FILENAME, and separately the hook mangled the
6
+ # NAME itself before any of that (`tr -d '[:space:]'` deletes interior spaces, so `name (purpose)`
7
+ # became `name(purpose)`). The filename half is now owned by the producer and published as
8
+ # `kijito-inbox-monitor --safe-persona`. This file owns the other half — reading the marker — so that
9
+ # the status line, the hook, and anything added later cannot drift the way those did.
10
+ #
11
+ # ⛔ THE RULE, AND IT IS THE WHOLE FILE: A MARKER'S PAYLOAD IS ITS FIRST LINE WITH THE ENDS TRIMMED.
12
+ # Anything stricter silently RENAMES the user's persona, and the rename is invisible — it surfaces
13
+ # later as "your mail is not being collected", pointing at a file nobody writes.
14
+
15
+ # kijito_persona_from_marker [dir ...] -> prints the persona, or nothing.
16
+ # Searches the given directories in order; defaults to $CLAUDE_PROJECT_DIR then $PWD.
17
+ kijito_persona_from_marker() {
18
+ local d p
19
+ if [ "$#" -eq 0 ]; then set -- "${CLAUDE_PROJECT_DIR:-}" "$PWD"; fi
20
+ for d in "$@"; do
21
+ [ -n "$d" ] || continue
22
+ [ -f "$d/.kijito_persona" ] || continue
23
+ # first line, CR/LF stripped — NOT all whitespace (see the rule above)
24
+ p=$(head -n1 "$d/.kijito_persona" 2>/dev/null | tr -d '\r\n')
25
+ p="${p#"${p%%[![:space:]]*}"}" # leading blanks
26
+ p="${p%"${p##*[![:space:]]}"}" # trailing blanks
27
+ if [ -n "$p" ]; then printf '%s' "$p"; return 0; fi
28
+ done
29
+ return 1
30
+ }
31
+
32
+ # kijito_truncate <string> <max> -> prints the string, ellipsised if longer than max.
33
+ # A status line shares a terminal with everything else, so a long persona must not push the context
34
+ # figure off the edge — the figure is the thing the user was watching before the persona existed.
35
+ kijito_truncate() {
36
+ local s=$1 max=$2
37
+ if [ "${#s}" -le "$max" ]; then printf '%s' "$s"; return 0; fi
38
+ [ "$max" -le 1 ] && { printf '%s' "${s:0:$max}"; return 0; }
39
+ printf '%s…' "${s:0:$((max-1))}"
40
+ }
41
+
42
+ # kijito_stream_for_persona <persona> -> prints the producer's event-stream path for it, or nothing.
43
+ # TWO NON-GUESSING ROUTES, in order (moved here from inbox-selftest.sh for row M291, so the heartbeat
44
+ # watchdog and the self-test cannot drift — the M290 lesson):
45
+ # 1. ask the producer: `kijito-inbox-monitor --safe-persona` publishes the persona->filename rule;
46
+ # 2. read the streams: the producer stamps every event with the persona it was written for, so the
47
+ # file itself says whose mail it holds — works on a seat whose producer predates --safe-persona.
48
+ kijito_stream_for_persona() {
49
+ local want=${1:-} km="" c safe="" cand
50
+ [ -n "$want" ] || return 1
51
+ for c in "${KIJITOMON_BIN:-}" "$(command -v kijito-inbox-monitor 2>/dev/null)" \
52
+ "$HOME/.local/bin/kijito-inbox-monitor" "/usr/local/bin/kijito-inbox-monitor"; do
53
+ if [ -n "$c" ] && [ -x "$c" ]; then km=$c; break; fi
54
+ done
55
+ [ -n "$km" ] && safe=$("$km" --safe-persona "$want" 2>/dev/null)
56
+ if [ -n "$safe" ]; then
57
+ for cand in "$HOME/.kijito-monitor/$safe.jsonl" "$HOME/.cache/kijito-inbox-monitor/events.$safe.ndjson"; do
58
+ [ -e "$cand" ] && { printf '%s' "$cand"; return 0; }
59
+ done
60
+ fi
61
+ command -v python3 >/dev/null 2>&1 || return 1
62
+ cand=$(KJ_WANT="$want" python3 - <<'PYSCAN' 2>/dev/null
63
+ import glob, json, os, sys
64
+ want = os.environ["KJ_WANT"].casefold(); home = os.path.expanduser("~"); hits = []
65
+ for pat in (os.path.join(home, ".kijito-monitor", "*.jsonl"),
66
+ os.path.join(home, ".cache", "kijito-inbox-monitor", "events.*.ndjson")):
67
+ for path in glob.glob(pat):
68
+ try:
69
+ with open(path, "rb") as fh:
70
+ who = json.loads(fh.readline(65536)).get("persona")
71
+ except Exception:
72
+ continue
73
+ if isinstance(who, str) and who.casefold() == want:
74
+ hits.append(path)
75
+ if len(hits) == 1:
76
+ sys.stdout.write(hits[0])
77
+ PYSCAN
78
+ )
79
+ [ -n "$cand" ] && { printf '%s' "$cand"; return 0; }
80
+ return 1
81
+ }
82
+
83
+ # kijito_stream_consumed <stream-path> -> 0 if a wake-capable consumer (`tail -n 0 -F …`) reads it.
84
+ # ⚠️ ANCHOR ON WHAT THE PROCESS *IS*. An unanchored pgrep on the events path SELF-MATCHES the producer
85
+ # (its own argv contains that path), and the harness's `bash -c … eval` wrappers carry the same argv —
86
+ # armed-looking and deaf. Only a process whose comm is `tail` counts.
87
+ kijito_stream_consumed() {
88
+ local s=${1:-} p
89
+ [ -n "$s" ] || return 1
90
+ for p in $(pgrep -f "tail -n 0 -F.*$(basename "$s")" 2>/dev/null); do
91
+ [ "$(ps -o comm= -p "$p" 2>/dev/null)" = tail ] && return 0
92
+ done
93
+ return 1
94
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+ # Record that /kijito-qa-memory Phase-4 cold-boot verification PASSED for THIS session.
3
+ # self-clear.sh REQUIRES a fresh token from this before it will fire (gate C1) — i.e. you
4
+ # cannot self-clear without a passing cold-boot verify. The token is consumed by one /clear.
5
+ # Resolve the shared lib NEXT TO THIS SCRIPT so the repo copy is runnable/testable in place, and
6
+ # fall back to the installed location for a stray single-file copy. KIJITO_LC_LIB overrides both.
7
+ _kjt_lib="${KIJITO_LC_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lifecycle-lib.sh}"
8
+ [ -f "$_kjt_lib" ] || _kjt_lib="$HOME/.claude/lifecycle-lib.sh"
9
+ . "$_kjt_lib"
10
+ lc_now > "$(lc_qa_token)"
11
+ lc_log QA_PASS "cold-boot verified"
12
+ echo "Recorded kijito-qa-memory pass for session ${CLAUDE_CODE_SESSION_ID:-?}. self-clear unlocked for ONE clear."
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env bash
2
+ # Shared helpers for Kijito session-lifecycle scripts. SOURCE this (`. lifecycle-lib.sh`), don't exec.
3
+ KIJITO_LC_DIR="${KIJITO_LC_DIR:-$HOME/.claude/.lifecycle}"
4
+ mkdir -p "$KIJITO_LC_DIR" 2>/dev/null
5
+ KIJITO_LC_LOG="$KIJITO_LC_DIR/lifecycle.log"
6
+ KIJITO_LC_STOP="$KIJITO_LC_DIR/STOP"
7
+
8
+ lc_now() { date +%s; } # epoch (portable BSD/GNU)
9
+
10
+ lc_log() { # M2 — audit log: action [detail]
11
+ printf '%s sid=%s pane=%s %s %s\n' \
12
+ "$(date '+%Y-%m-%dT%H:%M:%S')" "${CLAUDE_CODE_SESSION_ID:-?}" "${TMUX_PANE:-?}" "$1" "${2:-}" \
13
+ >> "$KIJITO_LC_LOG" 2>/dev/null
14
+ }
15
+
16
+ lc_stopped() { [ -f "$KIJITO_LC_STOP" ]; } # M1 — kill switch: `touch ~/.claude/.lifecycle/STOP` halts all
17
+
18
+ # C3 — best-effort subagent guard. VERIFIED 2026-06-24: a subagent shares the parent's
19
+ # CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_CHILD_SESSION / ENTRYPOINT, so there is NO reliable env
20
+ # discriminator today. This only trips on FUTURE markers and NEVER false-positives the main
21
+ # session (both are unset now). Real C3 protection = consumable QA token + kill switch.
22
+ # (The cycle cap was part of this list until 2026-07-29, when it was removed as non-discriminating —
23
+ # see self-clear.sh "C2". Do not cite it as protection.)
24
+ lc_is_child() { [ -n "${CLAUDE_AGENT_TYPE:-}" ] || [ -n "${CLAUDE_CODE_AGENT:-}" ]; }
25
+
26
+ # ⛔ THIS GATE RETURNED TRUE FOR EVERY INPUT, INCLUDING GARBAGE — IT HAD NEVER ONCE REFUSED.
27
+ # Found by argus 2026-08-01, measured on Linux tmux 3.4 AND macOS tmux 3.6a. The old body asked
28
+ # `tmux display-message -p -t "$1" '#{session_name}'` and read its EXIT CODE — but display-message
29
+ # EXITS 0 FOR A NONEXISTENT PANE, it simply prints empty fields:
30
+ # $ tmux display-message -p -t %999 'sess=#{session_name}' -> "sess=" rc=0
31
+ # so `lc_pane_alive %999`, and even `lc_pane_alive nonsense`, were both TRUE.
32
+ #
33
+ # ★ WHY IT SURVIVED SO LONG: it was only ever exercised against a LIVE pane — the one input
34
+ # incapable of exposing it. A control verified solely in the direction it was designed to move is
35
+ # not verified at all. (Reproduced before fixing: %999 and "nonsense" TRUE on the old body, both
36
+ # FALSE on this one, real pane still TRUE.)
37
+ #
38
+ # ⚠️ BOUNDED HONESTLY, per argus: `send-keys` itself refuses on a dead pane, and enumerating every
39
+ # pane on the host confirmed a dead-pane /clear lands in NO pane — so this could not misfire into a
40
+ # sibling's session on a shared seat. The gate was decorative, not dangerous.
41
+ #
42
+ # ENUMERATE, DON'T ASK. `list-panes -a` is the authoritative set; `grep -Fqx` matches a whole line
43
+ # literally, so `%1` cannot match `%11` and a metacharacter in the argument cannot act as a pattern.
44
+ # Portable across BSD and GNU userland.
45
+ lc_pane_alive() {
46
+ command -v tmux >/dev/null 2>&1 || return 1
47
+ [ -n "${1:-}" ] || return 1
48
+ tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -Fqx -- "$1"
49
+ }
50
+
51
+ # M4 (FIXED) — a running claude pane reports pane_current_command as its VERSION (e.g. "2.1.190"),
52
+ # NOT "claude"/"node" (verified 2026-06-24). So check "not a bare shell" instead of whitelisting claude.
53
+ lc_pane_usable() {
54
+ [ "${KIJITO_LC_TEST:-0}" = "1" ] && return 0 # test-harness escape hatch
55
+ local c; c=$(tmux display-message -p -t "$1" '#{pane_current_command}' 2>/dev/null)
56
+ case "$c" in ""|zsh|bash|sh|-zsh|-bash|-sh|fish|tcsh|dash) return 1 ;; *) return 0 ;; esac
57
+ }
58
+
59
+ # Arming has TWO INDEPENDENT INPUTS, and they are ORed. Keep them as separate named predicates:
60
+ # anything that REPORTS on arming, or claims to change it, must be able to say WHICH one is in force.
61
+ # (1) per-pane marker — claude-armed.sh / arm-session.sh drop a file keyed to the pane; the hook
62
+ # (which reliably has TMUX_PANE) reads it. Session-scoped, removable by the agent.
63
+ # (2) KIJITO_AUTOCATCHUP=1 — a SEAT-WIDE env var, typically set in ~/.claude/settings.json, which
64
+ # reaches every session on the host. A running process CANNOT unset it for itself, so it is not
65
+ # revocable from inside a session at all.
66
+ # ⛔ WHY THE SPLIT EXISTS: while (2) is in force, deleting the marker changes NOTHING. `arm-session.sh
67
+ # off` did exactly that and printed "AUTONOMY OFF" — a control that reported success without acting,
68
+ # which is worse than one that errors (measured by ladybug on the Ubuntu VM 2026-08-01: with
69
+ # KIJITO_AUTOCATCHUP=1 live, `lc_is_armed %99999` — a pane that does not exist — returns ARMED).
70
+ # The only brake that works against (2) is the kill switch: touch "$KIJITO_LC_DIR/STOP".
71
+ # ⛔ A ZERO-BYTE MARKER FILE IS NOT EVIDENCE THAT *YOU* ARMED *THIS* SESSION — AND tmux PANE IDS
72
+ # RESTART AT %0 WHEN THE SERVER RESTARTS, AND RECYCLE. ladybug found 13 stale `arm.*` markers on the
73
+ # Mac (2026-08-01) with `arm.%2` matching a LIVE, UNRELATED pane. Markers are never garbage-collected
74
+ # and carry no provenance, so a fresh session landing on a low-numbered pane silently INHERITS an
75
+ # arming performed weeks ago by a different agent — and arming gates an IRREVERSIBLE `/clear`.
76
+ # ★ THE FIX IS NOT A BETTER KEY, IT IS EVIDENCE IN THE CONTENT. Any key recycles eventually (pane
77
+ # ids recycle; session NAMES recycle too — kill and recreate reuses the name). So the marker records
78
+ # the SESSION FINGERPRINT it was written against, and `lc_marker_armed` re-derives that fingerprint
79
+ # from the live tmux server on EVERY read. `#{session_created}` is immutable per session INSTANCE, so
80
+ # a server restart or a recreated session yields a different value and the stale marker stops
81
+ # validating — no GC required, and the check cannot be fooled by a coincidence of names.
82
+ # ⛔ FAIL CLOSED, DELIBERATELY: no file, an EMPTY (legacy, pre-provenance) file, an unreadable
83
+ # fingerprint, a dead pane, or any mismatch ⇒ NOT ARMED. Not-armed is the safe state — an agent that
84
+ # wants autonomy re-arms in one second, whereas a falsely-inherited arming gates a one-way wipe.
85
+ # ⚠️ UPGRADE NOTE: legacy zero-byte markers therefore stop counting. That is intended, it is the safe
86
+ # direction, and `arm-session.sh status` says so explicitly rather than reporting a bare "not armed".
87
+ # ⚠️ NOT part of the fingerprint: $CLAUDE_CODE_SESSION_ID. It ROTATES on every /clear, so validating
88
+ # against it would disarm the pane at exactly the moment the self-clear loop needs it. It is recorded
89
+ # for the audit trail only.
90
+ _lc_sess_fp() { # "<session_name> <session_created>"
91
+ command -v tmux >/dev/null 2>&1 || return 1
92
+ tmux display-message -p -t "$1" '#{session_name} #{session_created}' 2>/dev/null
93
+ }
94
+
95
+ lc_marker_write() { # $1 = pane id (default $TMUX_PANE)
96
+ local pane="${1:-${TMUX_PANE:-}}" fp
97
+ [ -n "$pane" ] || return 1
98
+ lc_pane_alive "$pane" || return 1 # enumerates; display-message alone exits 0 on a dead pane
99
+ fp=$(_lc_sess_fp "$pane") || return 1
100
+ case "$fp" in ''|' ') return 1 ;; esac
101
+ { echo "v=1"; echo "pane=$pane"
102
+ echo "session=${fp% *}"; echo "session_created=${fp##* }"
103
+ echo "claude_session=${CLAUDE_CODE_SESSION_ID:-}"; echo "armed_at=$(lc_now)"
104
+ } > "$KIJITO_LC_DIR/arm.$pane" 2>/dev/null
105
+ }
106
+
107
+ lc_marker_armed() {
108
+ local pane="${1:-${TMUX_PANE:-x}}" f fp want_s want_c
109
+ f="$KIJITO_LC_DIR/arm.$pane"
110
+ [ -s "$f" ] || return 1 # missing OR legacy zero-byte → fail closed
111
+ want_s=$(awk -F= '$1=="session"{sub(/^[^=]*=/,"");print}' "$f" 2>/dev/null)
112
+ want_c=$(awk -F= '$1=="session_created"{print $2}' "$f" 2>/dev/null)
113
+ [ -n "$want_c" ] || return 1
114
+ lc_pane_alive "$pane" || return 1
115
+ fp=$(_lc_sess_fp "$pane") || return 1
116
+ [ -n "$fp" ] || return 1
117
+ [ "$want_c" = "${fp##* }" ] && [ "$want_s" = "${fp% *}" ]
118
+ }
119
+
120
+ lc_marker_legacy() { # exists but carries no provenance
121
+ local f="$KIJITO_LC_DIR/arm.${1:-${TMUX_PANE:-x}}"
122
+ [ -f "$f" ] && [ ! -s "$f" ]
123
+ }
124
+
125
+ # ── M291: NEVER TYPE INTO A MENU ─────────────────────────────────────────────────────────────────
126
+ # Anything that sends keys into a pane (the heartbeat nudge above all) must first ask whether the pane
127
+ # is showing an interactive MENU rather than the chat input. The case that makes this a hard rule:
128
+ # Claude Code's folder-trust dialog, whose second option is "No, exit" — a nudge's Enter there does
129
+ # not deliver a prompt, it can QUIT the session, turning a stuck-but-recoverable pane into a dead one.
130
+ # Any numbered selection menu has the same shape (Enter picks whatever is highlighted), so the rule is
131
+ # stated for menus, with the trust dialog as the case pinned by a test.
132
+ # Reads pane TEXT on stdin (pure, so a test can drive it with fixtures); 0 = a menu is showing.
133
+ lc_text_is_menu() {
134
+ grep -Eq -- 'No, exit|Do you trust|trust (the files in )?this folder|Enter to confirm|❯ ?[0-9]+\.'
135
+ }
136
+ lc_pane_at_menu() { # $1 = pane id; the last 20 NON-BLANK-ended lines
137
+ # ⚠️ capture-pane returns the WHOLE pane height, blank rows included, and a dialog drawn in a fresh
138
+ # pane sits at the TOP — so a bare `tail -20` of a 50-row pane reads twenty blank lines and misses the
139
+ # trust dialog entirely (measured by this row's own test). Trim trailing blank rows first.
140
+ tmux capture-pane -p -t "$1" 2>/dev/null \
141
+ | awk '{ l[NR] = $0 } NF { last = NR } END { s = (last > 20) ? last - 19 : 1; for (i = s; i <= last; i++) print l[i] }' \
142
+ | lc_text_is_menu
143
+ }
144
+
145
+ # ── M291: IS A BACKUP HEARTBEAT ALREADY WATCHING THIS PANE? ──────────────────────────────────────
146
+ # A watchdog's argv ENDS with its pane id (hand-run, nohup, the systemd unit's `%%%i`, and a launchd
147
+ # ProgramArguments all render `…/heartbeat-watchdog.sh %N`), so anchoring on the END is what stops
148
+ # `%1` from matching `%11`.
149
+ # ⚠️ KNOWN LIMIT: the argv names a pane, not a tmux SERVER. A seat running two tmux servers (a second
150
+ # `-L` socket) can see a watchdog for ITS `%2` as one for yours; the result is a skipped start, never a
151
+ # double one. One server per seat — the normal layout — is unaffected.
152
+ lc_heartbeat_running() { # $1 = pane id
153
+ [ -n "${1:-}" ] || return 1
154
+ pgrep -f "heartbeat-watchdog\.sh ${1}\$" >/dev/null 2>&1
155
+ }
156
+
157
+ lc_env_armed() { [ "${KIJITO_AUTOCATCHUP:-0}" = "1" ]; }
158
+ lc_is_armed() { lc_marker_armed "${1:-}" || lc_env_armed; }
159
+
160
+ # qa-token is SESSION-keyed (correct: each post-/clear session must earn its OWN fresh QA pass).
161
+ lc_qa_token() { echo "$KIJITO_LC_DIR/qa-pass.${CLAUDE_CODE_SESSION_ID:-nosession}"; }
162
+ # The cycle counter is PANE-keyed: /clear ROTATES CLAUDE_CODE_SESSION_ID (verified live: 1c5947c1→
163
+ # 6f305fa1 in the same pane %19), so a session-keyed counter would reset every clear and never
164
+ # accumulate across the self-clear loop. The pane persists across clears → accumulates correctly.
165
+ # ⚠️ Since 2026-07-29 this counter is TELEMETRY ONLY — nothing gates on it (see self-clear.sh "C2").
166
+ # It stays because the cycle number is useful in the audit log; it is not a limit.
167
+ lc_cycle_file() { echo "$KIJITO_LC_DIR/cycles.${TMUX_PANE:-${CLAUDE_CODE_SESSION_ID:-nosession}}"; }
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # "How full am I, really?" — hard data vs. gut. Run this when you FEEL full.
3
+ # Bulletproof: finds THIS session's transcript via CLAUDE_CODE_SESSION_ID (no
4
+ # "newest file" guessing), then reads the API's own token ledger (== /context).
5
+ #
6
+ # ⛔ THIS SCRIPT USED TO PRINT A CONFIDENT `0.0%` WHEN IT COULD NOT MEASURE, AND THAT IS WRONG IN
7
+ # THE ONE DIRECTION THAT COSTS SOMETHING. Found by ladybug 2026-08-01, hit live at a session start.
8
+ # On the FIRST tool call of a session the transcript carries no assistant row with `.message.usage`
9
+ # yet; `last // {}` then yields `{}`, the arithmetic gives 0, and the output read:
10
+ # context: 0 tok = 0.0% of 1000k (free: 100.0%, ~1000000 tok)
11
+ # The ONE caller whose entire job is deciding whether to recycle was told it had a full window.
12
+ #
13
+ # ★ 0 IS NOT A POSSIBLE ANSWER IN A LIVE SESSION — the system prompt alone is thousands of tokens —
14
+ # so a 0 here has only ever meant "I could not read the ledger". A meter that cannot distinguish
15
+ # EMPTY from CANNOT-MEASURE is worse than no meter, because the felt-sense it exists to override is
16
+ # at least known to be unreliable. ⇒ REFUSE, LOUDLY, ON STDERR, WITH A NON-ZERO EXIT. This is the
17
+ # same rule the gate runner lives by: "I could not check" is BLOCKED, never a green number.
18
+ set -u
19
+
20
+ command -v jq >/dev/null 2>&1 || {
21
+ echo "myctx: UNMEASURABLE — jq is not on PATH, so the token ledger cannot be read." >&2; exit 1; }
22
+
23
+ sid="${CLAUDE_CODE_SESSION_ID:?CLAUDE_CODE_SESSION_ID not set}"
24
+ # Quote the search root: an unquoted ~/.claude/projects breaks on a HOME containing spaces, which
25
+ # is not exotic on macOS.
26
+ f=$(find "$HOME/.claude/projects" -name "$sid.jsonl" 2>/dev/null | head -1)
27
+ [ -z "$f" ] && { echo "myctx: UNMEASURABLE — no transcript for session $sid" >&2; exit 1; }
28
+
29
+ win="${CTX_WINDOW:-1000000}"
30
+ used=$(jq -s 'map(select(.type=="assistant" and .message.usage!=null).message.usage)|last // {}
31
+ |(.input_tokens//0)+(.cache_read_input_tokens//0)+(.cache_creation_input_tokens//0)' "$f")
32
+
33
+ # Refuse rather than report. `0` is folded in with empty/null deliberately — see the header.
34
+ case "$used" in
35
+ ''|null|0)
36
+ echo "myctx: UNMEASURABLE — this session's token ledger is not populated yet (no assistant turn" >&2
37
+ echo " has been flushed to the transcript). NORMAL on the first tool call of a session." >&2
38
+ echo " Read the live <total_tokens> count instead. DO NOT read this as 0%." >&2
39
+ exit 1 ;;
40
+ *[!0-9]*)
41
+ echo "myctx: UNMEASURABLE — non-numeric ledger value ('$used')" >&2
42
+ exit 1 ;;
43
+ esac
44
+
45
+ awk -v u="$used" -v w="$win" 'BEGIN{
46
+ printf "context: %d tok = %.1f%% of %dk (free: %.1f%%, ~%d tok)\n", u, u/w*100, w/1000, (1-u/w)*100, w-u }'