overclaude 1.0.0__py3-none-any.whl
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.
- overclaude/__init__.py +1 -0
- overclaude/cli.py +53 -0
- overclaude/payload/bin/swap-guard +352 -0
- overclaude/payload/claude/ULTRACODE.md +79 -0
- overclaude/payload/hooks/ctx-notify.sh +58 -0
- overclaude/payload/hooks/ctx-watch.sh +79 -0
- overclaude/payload/hooks/handoff-inject.sh +67 -0
- overclaude/payload/install.sh +303 -0
- overclaude/payload/settings/settings-fragment.json +46 -0
- overclaude/payload/shell/zshrc-snippet.sh +31 -0
- overclaude/payload/skills/handoff/SKILL.md +79 -0
- overclaude/payload/skills/swap/SKILL.md +99 -0
- overclaude/payload/statusline/statusline-command.sh +247 -0
- overclaude/payload/uninstall.sh +166 -0
- overclaude/payload/vendor/claude-swap/.github/ISSUE_TEMPLATE/bug_report.yml +47 -0
- overclaude/payload/vendor/claude-swap/.github/ISSUE_TEMPLATE/config.yml +5 -0
- overclaude/payload/vendor/claude-swap/.github/ISSUE_TEMPLATE/feature_request.yml +16 -0
- overclaude/payload/vendor/claude-swap/.github/workflows/ci.yml +69 -0
- overclaude/payload/vendor/claude-swap/.github/workflows/publish.yml +28 -0
- overclaude/payload/vendor/claude-swap/.gitignore +13 -0
- overclaude/payload/vendor/claude-swap/.python-version +1 -0
- overclaude/payload/vendor/claude-swap/.vscode/launch.json +60 -0
- overclaude/payload/vendor/claude-swap/LICENSE +21 -0
- overclaude/payload/vendor/claude-swap/PKG-INFO +391 -0
- overclaude/payload/vendor/claude-swap/README.md +360 -0
- overclaude/payload/vendor/claude-swap/assets/tui-watch.png +0 -0
- overclaude/payload/vendor/claude-swap/pyproject.toml +58 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/__init__.py +9 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/__main__.py +6 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/autoswitch.py +1232 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/cache.py +44 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/claude_locks.py +147 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/cli.py +1210 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/credentials.py +1026 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/exceptions.py +96 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/json_output.py +180 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/locking.py +83 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/logging_config.py +64 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/macos_keychain.py +226 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/mappings.py +141 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/menubar.py +857 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/migrations.py +536 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/models.py +211 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/oauth.py +666 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/paths.py +201 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/poll_policy.py +203 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/printer.py +205 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/process_detection.py +146 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/session.py +1066 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/settings.py +425 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/snapshot_source.py +42 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/switcher.py +4264 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/transfer.py +552 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/tui/__init__.py +27 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/tui/app.py +290 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/tui/autoview.py +320 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/tui/cswap.tcss +202 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/tui/dashboard.py +403 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/tui/data.py +194 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/tui/modals.py +159 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/tui/theme.py +67 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/tui/widgets.py +369 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/update_check.py +135 -0
- overclaude/payload/vendor/claude-swap/src/claude_swap/usage_store.py +526 -0
- overclaude/payload/vendor/claude-swap/tests/__init__.py +1 -0
- overclaude/payload/vendor/claude-swap/tests/conftest.py +284 -0
- overclaude/payload/vendor/claude-swap/tests/fixtures/sample_config.json +10 -0
- overclaude/payload/vendor/claude-swap/tests/test_api_key_accounts.py +370 -0
- overclaude/payload/vendor/claude-swap/tests/test_autoswitch.py +1878 -0
- overclaude/payload/vendor/claude-swap/tests/test_cache.py +72 -0
- overclaude/payload/vendor/claude-swap/tests/test_claude_locks.py +99 -0
- overclaude/payload/vendor/claude-swap/tests/test_cli.py +1493 -0
- overclaude/payload/vendor/claude-swap/tests/test_config_cli.py +269 -0
- overclaude/payload/vendor/claude-swap/tests/test_json_output.py +590 -0
- overclaude/payload/vendor/claude-swap/tests/test_locking.py +162 -0
- overclaude/payload/vendor/claude-swap/tests/test_logging_config.py +59 -0
- overclaude/payload/vendor/claude-swap/tests/test_macos_keychain.py +212 -0
- overclaude/payload/vendor/claude-swap/tests/test_macos_keychain_contract.py +283 -0
- overclaude/payload/vendor/claude-swap/tests/test_mappings.py +185 -0
- overclaude/payload/vendor/claude-swap/tests/test_menubar.py +440 -0
- overclaude/payload/vendor/claude-swap/tests/test_migrations.py +596 -0
- overclaude/payload/vendor/claude-swap/tests/test_oauth.py +1379 -0
- overclaude/payload/vendor/claude-swap/tests/test_paths.py +316 -0
- overclaude/payload/vendor/claude-swap/tests/test_poll_policy.py +201 -0
- overclaude/payload/vendor/claude-swap/tests/test_printer.py +181 -0
- overclaude/payload/vendor/claude-swap/tests/test_process_detection.py +356 -0
- overclaude/payload/vendor/claude-swap/tests/test_session.py +1614 -0
- overclaude/payload/vendor/claude-swap/tests/test_settings.py +232 -0
- overclaude/payload/vendor/claude-swap/tests/test_switcher.py +6472 -0
- overclaude/payload/vendor/claude-swap/tests/test_transfer.py +1510 -0
- overclaude/payload/vendor/claude-swap/tests/test_tui.py +1282 -0
- overclaude/payload/vendor/claude-swap/tests/test_update_check.py +297 -0
- overclaude/payload/vendor/claude-swap/tests/test_usage_store.py +505 -0
- overclaude/payload/vendor/claude-swap/uv.lock +451 -0
- overclaude-1.0.0.dist-info/METADATA +160 -0
- overclaude-1.0.0.dist-info/RECORD +99 -0
- overclaude-1.0.0.dist-info/WHEEL +4 -0
- overclaude-1.0.0.dist-info/entry_points.txt +2 -0
- overclaude-1.0.0.dist-info/licenses/LICENSE +21 -0
overclaude/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""overclaude — Claude Code, overclocked."""
|
overclaude/cli.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""overclaude CLI — runs the bundled kit installer/uninstaller.
|
|
2
|
+
|
|
3
|
+
The package wheel carries the same payload a git clone has (bin/, skills/,
|
|
4
|
+
hooks/, statusline/, claude/, settings/, shell/, vendor/claude-swap, and the
|
|
5
|
+
install/uninstall scripts). This CLI just locates that payload and runs the
|
|
6
|
+
battle-tested bash scripts against it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from importlib.metadata import version as pkg_version
|
|
14
|
+
from importlib.resources import files
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _payload_dir():
|
|
18
|
+
p = files("overclaude").joinpath("payload")
|
|
19
|
+
path = str(p)
|
|
20
|
+
if not os.path.isdir(path) or not os.path.isfile(os.path.join(path, "install.sh")):
|
|
21
|
+
sys.exit("overclaude: bundled payload is missing — broken install; reinstall the package")
|
|
22
|
+
return path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run_script(name):
|
|
26
|
+
return subprocess.call(["bash", os.path.join(_payload_dir(), name)])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main():
|
|
30
|
+
ap = argparse.ArgumentParser(
|
|
31
|
+
prog="overclaude",
|
|
32
|
+
description="Claude Code, overclocked — /swap, /handoff, statusline, ULTRACODE model routing.",
|
|
33
|
+
)
|
|
34
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
35
|
+
sub.add_parser("install", help="install/refresh the kit into ~/.claude (idempotent, backs everything up)")
|
|
36
|
+
sub.add_parser("uninstall", help="remove exactly what install added (state in ~/.claude-swap-backup survives)")
|
|
37
|
+
sub.add_parser("path", help="print the bundled payload directory")
|
|
38
|
+
sub.add_parser("version", help="print the overclaude version")
|
|
39
|
+
args = ap.parse_args()
|
|
40
|
+
|
|
41
|
+
if args.cmd == "install":
|
|
42
|
+
sys.exit(_run_script("install.sh"))
|
|
43
|
+
if args.cmd == "uninstall":
|
|
44
|
+
sys.exit(_run_script("uninstall.sh"))
|
|
45
|
+
if args.cmd == "path":
|
|
46
|
+
print(_payload_dir())
|
|
47
|
+
return
|
|
48
|
+
if args.cmd == "version":
|
|
49
|
+
print(pkg_version("overclaude"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
main()
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# swap-guard — shared state/guard engine for the /swap + /handoff Claude Code integration.
|
|
3
|
+
# Contract: section A of the binding interface contract.
|
|
4
|
+
# Subcommands:
|
|
5
|
+
# whoami own session registry JSON (compact); exit 1 if not in a session
|
|
6
|
+
# sessions JSON array of OTHER live claude sessions (always exit 0)
|
|
7
|
+
# preflight <target> switch-safety verdict JSON for slot number / alias / email (always exit 0)
|
|
8
|
+
# path flag|handoff [--cwd <dir>] print per-cwd state-file path
|
|
9
|
+
# flag <json> validate + fill + atomic-write relaunch flag; prints path (exit 1 on bad input)
|
|
10
|
+
# status own-session status JSON (always exit 0)
|
|
11
|
+
# cancel [--cwd <dir>] remove pending handoff + flag for cwd (always exit 0)
|
|
12
|
+
# schedule-kill <pid> v1 stub (exit 1)
|
|
13
|
+
set -u
|
|
14
|
+
|
|
15
|
+
STATE_ROOT="$HOME/.claude-swap-backup"
|
|
16
|
+
CTX_DIR="$STATE_ROOT/ctx"
|
|
17
|
+
ARCHIVE_DIR="$STATE_ROOT/handoff-archive"
|
|
18
|
+
SESSIONS_DIR="$HOME/.claude/sessions"
|
|
19
|
+
|
|
20
|
+
usage() {
|
|
21
|
+
cat >&2 <<'EOF'
|
|
22
|
+
usage: swap-guard <subcommand> [args]
|
|
23
|
+
whoami own session registry JSON (compact)
|
|
24
|
+
sessions JSON array of other live claude sessions
|
|
25
|
+
preflight <target> switch-safety verdict for slot / alias / email
|
|
26
|
+
path flag|handoff [--cwd <dir>] print per-cwd state-file path
|
|
27
|
+
flag <json> write relaunch flag ({"mode":"handoff"|"restart",...}); prints path
|
|
28
|
+
status own-session status JSON
|
|
29
|
+
cancel [--cwd <dir>] remove pending handoff + flag for cwd
|
|
30
|
+
schedule-kill <pid> phase-2 stub
|
|
31
|
+
EOF
|
|
32
|
+
exit 2
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# HASH(cwd) per shared constants: first 12 hex of sha256
|
|
36
|
+
hash_cwd() { printf '%s' "$1" | /usr/bin/shasum -a 256 | awk '{print $1}' | cut -c1-12; }
|
|
37
|
+
|
|
38
|
+
flag_path_for() { printf '%s/relaunch-%s.json\n' "$STATE_ROOT" "$(hash_cwd "$1")"; }
|
|
39
|
+
pending_path_for() { printf '%s/handoff-pending-%s.md\n' "$STATE_ROOT" "$(hash_cwd "$1")"; }
|
|
40
|
+
|
|
41
|
+
# whoami_json [start_pid] — walk the PPID chain from start_pid (default $$) up to pid 1;
|
|
42
|
+
# print the first matching registry file's JSON (compact) and return 0, else return 1.
|
|
43
|
+
whoami_json() {
|
|
44
|
+
local p="${1:-$$}"
|
|
45
|
+
while :; do
|
|
46
|
+
case "$p" in ''|*[!0-9]*) return 1;; esac
|
|
47
|
+
[ "$p" -gt 1 ] || return 1
|
|
48
|
+
if [ -f "$SESSIONS_DIR/$p.json" ]; then
|
|
49
|
+
jq -c . "$SESSIONS_DIR/$p.json" 2>/dev/null || cat "$SESSIONS_DIR/$p.json"
|
|
50
|
+
return 0
|
|
51
|
+
fi
|
|
52
|
+
p="$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ')"
|
|
53
|
+
done
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# session_live <pid> — true iff the process exists (kill -0) AND its comm contains "claude"
|
|
57
|
+
session_live() {
|
|
58
|
+
local pid="${1:-}" comm
|
|
59
|
+
case "$pid" in ''|*[!0-9]*) return 1;; esac
|
|
60
|
+
kill -0 "$pid" 2>/dev/null || return 1
|
|
61
|
+
comm="$(ps -o comm= -p "$pid" 2>/dev/null)"
|
|
62
|
+
case "$comm" in *claude*) return 0;; *) return 1;; esac
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# transcript_status <cwd> <sessionId> — activity fallback for sessions whose registry
|
|
66
|
+
# file has no status field (clients < ~2.1.211 never publish one). An active session
|
|
67
|
+
# appends to ~/.claude/projects/<flattened-cwd>/<sessionId>.jsonl on every turn, so:
|
|
68
|
+
# mtime within TRANSCRIPT_ACTIVE_SECS -> "busy"; older -> "idle"; no transcript -> "unknown".
|
|
69
|
+
TRANSCRIPT_ACTIVE_SECS=120
|
|
70
|
+
transcript_status() {
|
|
71
|
+
local cwd="${1:-}" sid="${2:-}" t mtime now
|
|
72
|
+
[ -n "$cwd" ] && [ -n "$sid" ] || { printf 'unknown\n'; return 0; }
|
|
73
|
+
t="$HOME/.claude/projects/$(printf '%s' "$cwd" | sed 's/[^A-Za-z0-9]/-/g')/$sid.jsonl"
|
|
74
|
+
[ -f "$t" ] || { printf 'unknown\n'; return 0; }
|
|
75
|
+
mtime="$(stat -f %m "$t" 2>/dev/null)"
|
|
76
|
+
case "$mtime" in ''|*[!0-9]*) printf 'unknown\n'; return 0;; esac
|
|
77
|
+
now="$(date +%s)"
|
|
78
|
+
if [ $(( now - mtime )) -le "$TRANSCRIPT_ACTIVE_SECS" ]; then
|
|
79
|
+
printf 'busy\n'
|
|
80
|
+
else
|
|
81
|
+
printf 'idle\n'
|
|
82
|
+
fi
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# sessions_json — JSON array of OTHER live sessions. status precedence: the value the
|
|
86
|
+
# session itself reports (statusSource "reported") > transcript-mtime fallback
|
|
87
|
+
# (statusSource "transcript-mtime") > "unknown" (statusSource "none").
|
|
88
|
+
sessions_json() {
|
|
89
|
+
local own_pid="" out="[]" new_out f pid entry st
|
|
90
|
+
own_pid="$(whoami_json 2>/dev/null | jq -r '.pid // empty' 2>/dev/null)" || own_pid=""
|
|
91
|
+
for f in "$SESSIONS_DIR"/*.json; do
|
|
92
|
+
[ -f "$f" ] || continue
|
|
93
|
+
pid="$(jq -r '.pid // empty' "$f" 2>/dev/null)" || continue
|
|
94
|
+
[ -n "$pid" ] || continue
|
|
95
|
+
[ "$pid" = "$own_pid" ] && continue
|
|
96
|
+
session_live "$pid" || continue
|
|
97
|
+
entry="$(jq -c '{pid, sessionId, cwd, kind, entrypoint, status: (.status // "unknown"),
|
|
98
|
+
statusSource: (if .status then "reported" else "none" end)}' "$f" 2>/dev/null)" || continue
|
|
99
|
+
[ -n "$entry" ] || continue
|
|
100
|
+
if [ "$(printf '%s' "$entry" | jq -r '.statusSource' 2>/dev/null)" = "none" ]; then
|
|
101
|
+
st="$(transcript_status "$(printf '%s' "$entry" | jq -r '.cwd // empty')" \
|
|
102
|
+
"$(printf '%s' "$entry" | jq -r '.sessionId // empty')")"
|
|
103
|
+
case "$st" in
|
|
104
|
+
busy|idle) entry="$(printf '%s' "$entry" | jq -c --arg s "$st" '.status = $s | .statusSource = "transcript-mtime"' 2>/dev/null)" || continue;;
|
|
105
|
+
esac
|
|
106
|
+
fi
|
|
107
|
+
new_out="$(printf '%s' "$out" | jq -c --argjson e "$entry" '. + [$e]' 2>/dev/null)" && [ -n "$new_out" ] && out="$new_out"
|
|
108
|
+
done
|
|
109
|
+
printf '%s\n' "$out"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# resolve_cwd [explicit] — precedence: explicit --cwd > whoami .cwd > $PWD
|
|
113
|
+
resolve_cwd() {
|
|
114
|
+
local c="${1:-}"
|
|
115
|
+
if [ -z "$c" ]; then
|
|
116
|
+
c="$(whoami_json 2>/dev/null | jq -r '.cwd // empty' 2>/dev/null)" || c=""
|
|
117
|
+
fi
|
|
118
|
+
[ -n "$c" ] || c="$PWD"
|
|
119
|
+
printf '%s\n' "$c"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# parse_cwd_opt "$@" — echo value of --cwd/--cwd= if present; return 1 if absent
|
|
123
|
+
parse_cwd_opt() {
|
|
124
|
+
while [ $# -gt 0 ]; do
|
|
125
|
+
case "$1" in
|
|
126
|
+
--cwd) shift; printf '%s\n' "${1:-}"; return 0;;
|
|
127
|
+
--cwd=*) printf '%s\n' "${1#--cwd=}"; return 0;;
|
|
128
|
+
esac
|
|
129
|
+
shift
|
|
130
|
+
done
|
|
131
|
+
return 1
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
cmd_whoami() {
|
|
135
|
+
local out
|
|
136
|
+
if out="$(whoami_json)"; then
|
|
137
|
+
printf '%s\n' "$out"
|
|
138
|
+
exit 0
|
|
139
|
+
fi
|
|
140
|
+
printf '%s\n' '{"error":"not-in-session"}'
|
|
141
|
+
exit 1
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
cmd_sessions() {
|
|
145
|
+
sessions_json
|
|
146
|
+
exit 0
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
cmd_preflight() {
|
|
150
|
+
local raw="${1:-}" busy busy_n list tgt slot="" verdict detail target_json number email us
|
|
151
|
+
busy="$(sessions_json | jq -c '[.[] | select(.status == "busy" or .status == "unknown")]' 2>/dev/null)" || busy="[]"
|
|
152
|
+
[ -n "$busy" ] || busy="[]"
|
|
153
|
+
busy_n="$(printf '%s' "$busy" | jq 'length' 2>/dev/null)" || busy_n=0
|
|
154
|
+
|
|
155
|
+
if [ -z "$raw" ]; then
|
|
156
|
+
jq -cn --argjson busy "$busy" \
|
|
157
|
+
'{verdict:"unknown-target",target:{number:null,email:null},busy:$busy,detail:"no target given"}'
|
|
158
|
+
exit 0
|
|
159
|
+
fi
|
|
160
|
+
|
|
161
|
+
list="$(cswap list --json 2>/dev/null)"
|
|
162
|
+
if [ -z "$list" ] || ! printf '%s' "$list" | jq -e . >/dev/null 2>&1; then
|
|
163
|
+
jq -cn --argjson busy "$busy" --arg t "$raw" \
|
|
164
|
+
'{verdict:"unknown-target",target:{number:null,email:null},busy:$busy,detail:("cswap list --json failed - cannot verify target " + ($t|tojson))}'
|
|
165
|
+
exit 0
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
# Resolve target: digits -> slot number; else alias in sequence.json; else email
|
|
169
|
+
case "$raw" in
|
|
170
|
+
*[!0-9]*)
|
|
171
|
+
slot="$(jq -r --arg a "$raw" '.accounts | to_entries[] | select((.value.alias // "") == $a) | .key' \
|
|
172
|
+
"$STATE_ROOT/sequence.json" 2>/dev/null | head -n1)" || slot=""
|
|
173
|
+
;;
|
|
174
|
+
*) slot="$raw";;
|
|
175
|
+
esac
|
|
176
|
+
if [ -n "$slot" ]; then
|
|
177
|
+
tgt="$(printf '%s' "$list" | jq -c --arg n "$slot" '[.accounts[]? | select((.number|tostring) == $n)] | first // empty' 2>/dev/null)"
|
|
178
|
+
else
|
|
179
|
+
tgt="$(printf '%s' "$list" | jq -c --arg e "$raw" '[.accounts[]? | select(((.email // "") | ascii_downcase) == ($e | ascii_downcase))] | first // empty' 2>/dev/null)"
|
|
180
|
+
fi
|
|
181
|
+
|
|
182
|
+
if [ -z "$tgt" ]; then
|
|
183
|
+
jq -cn --argjson busy "$busy" --arg t "$raw" \
|
|
184
|
+
'{verdict:"unknown-target",target:{number:null,email:null},busy:$busy,detail:("no account matches " + ($t|tojson) + " (not a slot number, alias, or email)")}'
|
|
185
|
+
exit 0
|
|
186
|
+
fi
|
|
187
|
+
|
|
188
|
+
target_json="$(printf '%s' "$tgt" | jq -c '{number, email}')"
|
|
189
|
+
number="$(printf '%s' "$tgt" | jq -r '.number')"
|
|
190
|
+
email="$(printf '%s' "$tgt" | jq -r '.email // ""')"
|
|
191
|
+
us="$(printf '%s' "$tgt" | jq -r '.usageStatus // ""' | tr '[:upper:]' '[:lower:]')"
|
|
192
|
+
|
|
193
|
+
case "$us" in
|
|
194
|
+
*login*|*quarantin*|*expired*|*invalid*|*revoked*)
|
|
195
|
+
verdict="relogin-required"
|
|
196
|
+
detail="account $number ($email) requires re-login (usageStatus: $us) - recover with /login then: cswap add --slot $number"
|
|
197
|
+
;;
|
|
198
|
+
*)
|
|
199
|
+
if [ "$busy_n" -gt 0 ] 2>/dev/null; then
|
|
200
|
+
verdict="busy"
|
|
201
|
+
detail="account $number ($email) is healthy, but $busy_n other session(s) are busy or possibly-busy - a switch flips ALL sessions within ~30s"
|
|
202
|
+
else
|
|
203
|
+
verdict="ok"
|
|
204
|
+
detail="account $number ($email) is healthy and no other session is busy"
|
|
205
|
+
fi
|
|
206
|
+
;;
|
|
207
|
+
esac
|
|
208
|
+
|
|
209
|
+
jq -cn --arg v "$verdict" --argjson t "$target_json" --argjson busy "$busy" --arg d "$detail" \
|
|
210
|
+
'{verdict:$v,target:$t,busy:$busy,detail:$d}'
|
|
211
|
+
exit 0
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
cmd_path() {
|
|
215
|
+
local kind="${1:-}"
|
|
216
|
+
[ $# -gt 0 ] && shift
|
|
217
|
+
local cwd_opt="" cwd
|
|
218
|
+
cwd_opt="$(parse_cwd_opt "$@")" || cwd_opt=""
|
|
219
|
+
cwd="$(resolve_cwd "$cwd_opt")"
|
|
220
|
+
case "$kind" in
|
|
221
|
+
flag) flag_path_for "$cwd";;
|
|
222
|
+
handoff) pending_path_for "$cwd";;
|
|
223
|
+
*) usage;;
|
|
224
|
+
esac
|
|
225
|
+
exit 0
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
cmd_flag() {
|
|
229
|
+
local input="${1:-}"
|
|
230
|
+
if [ -z "$input" ] || ! printf '%s' "$input" | jq -e . >/dev/null 2>&1; then
|
|
231
|
+
echo "swap-guard flag: input is not valid JSON" >&2
|
|
232
|
+
exit 1
|
|
233
|
+
fi
|
|
234
|
+
local mode
|
|
235
|
+
mode="$(printf '%s' "$input" | jq -r '.mode // empty')"
|
|
236
|
+
case "$mode" in
|
|
237
|
+
handoff|restart) ;;
|
|
238
|
+
*) echo 'swap-guard flag: .mode must be "handoff" or "restart"' >&2; exit 1;;
|
|
239
|
+
esac
|
|
240
|
+
local sid cwd created out p tmp
|
|
241
|
+
sid="$(printf '%s' "$input" | jq -r '.sessionId // ""')"
|
|
242
|
+
cwd="$(printf '%s' "$input" | jq -r '.cwd // empty')"
|
|
243
|
+
cwd="$(resolve_cwd "$cwd")"
|
|
244
|
+
created="$(printf '%s' "$input" | jq -r '.created // empty' 2>/dev/null)"
|
|
245
|
+
case "$created" in ''|*[!0-9]*) created="$(date +%s)";; esac
|
|
246
|
+
out="$(jq -cn --arg m "$mode" --arg s "$sid" --arg c "$cwd" --argjson t "$created" \
|
|
247
|
+
'{mode:$m,sessionId:$s,cwd:$c,created:$t}')"
|
|
248
|
+
p="$(flag_path_for "$cwd")"
|
|
249
|
+
mkdir -p "$STATE_ROOT" 2>/dev/null || { echo "swap-guard flag: cannot create $STATE_ROOT" >&2; exit 1; }
|
|
250
|
+
tmp="$p.tmp.$$"
|
|
251
|
+
if printf '%s\n' "$out" > "$tmp" 2>/dev/null && mv "$tmp" "$p" 2>/dev/null; then
|
|
252
|
+
printf '%s\n' "$p"
|
|
253
|
+
exit 0
|
|
254
|
+
fi
|
|
255
|
+
rm -f "$tmp" 2>/dev/null
|
|
256
|
+
echo "swap-guard flag: write failed" >&2
|
|
257
|
+
exit 1
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# _status_json <sessionId-or-empty> <cwd> — build the status JSON (factored for testability)
|
|
261
|
+
_status_json() {
|
|
262
|
+
local sid="${1:-}" cwd="${2:-$PWD}"
|
|
263
|
+
local pct="" fired=0 bannered=0
|
|
264
|
+
if [ -n "$sid" ] && [ -f "$CTX_DIR/$sid.json" ]; then
|
|
265
|
+
pct="$(jq -r 'if (.pct|type) == "number" then (.pct|tostring) else "" end' "$CTX_DIR/$sid.json" 2>/dev/null)" || pct=""
|
|
266
|
+
fi
|
|
267
|
+
if [ -n "$sid" ] && [ -f "$CTX_DIR/$sid.state" ]; then
|
|
268
|
+
fired="$(jq -r '.fired // 0' "$CTX_DIR/$sid.state" 2>/dev/null)" || fired=0
|
|
269
|
+
bannered="$(jq -r '.bannered // 0' "$CTX_DIR/$sid.state" 2>/dev/null)" || bannered=0
|
|
270
|
+
fi
|
|
271
|
+
case "$fired" in ''|*[!0-9]*) fired=0;; esac
|
|
272
|
+
case "$bannered" in ''|*[!0-9]*) bannered=0;; esac
|
|
273
|
+
|
|
274
|
+
local h pending flagf pending_out="" page="" flag_out="" arch="" created
|
|
275
|
+
h="$(hash_cwd "$cwd")"
|
|
276
|
+
pending="$STATE_ROOT/handoff-pending-$h.md"
|
|
277
|
+
flagf="$STATE_ROOT/relaunch-$h.json"
|
|
278
|
+
if [ -f "$pending" ]; then
|
|
279
|
+
pending_out="$pending"
|
|
280
|
+
created="$(sed -n '1s/.*created="\([0-9][0-9]*\)".*/\1/p' "$pending" 2>/dev/null)"
|
|
281
|
+
[ -n "$created" ] || created="$(stat -f %m "$pending" 2>/dev/null)"
|
|
282
|
+
case "$created" in
|
|
283
|
+
''|*[!0-9]*) page="";;
|
|
284
|
+
*) page="$(( $(date +%s) - created ))";;
|
|
285
|
+
esac
|
|
286
|
+
fi
|
|
287
|
+
[ -f "$flagf" ] && flag_out="$flagf"
|
|
288
|
+
if [ -d "$ARCHIVE_DIR" ]; then
|
|
289
|
+
arch="$(ls "$ARCHIVE_DIR" 2>/dev/null | grep -E -- "-$h(-expired)?\.md$" | sort | tail -n1)"
|
|
290
|
+
[ -n "$arch" ] && arch="$ARCHIVE_DIR/$arch"
|
|
291
|
+
fi
|
|
292
|
+
|
|
293
|
+
jq -cn --arg sid "$sid" --arg pct "$pct" --arg fired "$fired" --arg bannered "$bannered" \
|
|
294
|
+
--arg pending "$pending_out" --arg page "$page" --arg flagp "$flag_out" --arg arch "$arch" '
|
|
295
|
+
{sessionId: (if $sid == "" then null else $sid end),
|
|
296
|
+
pct: (if $pct == "" then null else ($pct|tonumber) end),
|
|
297
|
+
fired: ($fired|tonumber),
|
|
298
|
+
bannered: ($bannered|tonumber),
|
|
299
|
+
pendingHandoff: (if $pending == "" then null else $pending end),
|
|
300
|
+
pendingAgeSec: (if $page == "" then null else ($page|tonumber) end),
|
|
301
|
+
flag: (if $flagp == "" then null else $flagp end),
|
|
302
|
+
latestArchive: (if $arch == "" then null else $arch end)}'
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
cmd_status() {
|
|
306
|
+
local who sid="" cwd=""
|
|
307
|
+
if who="$(whoami_json 2>/dev/null)"; then
|
|
308
|
+
sid="$(printf '%s' "$who" | jq -r '.sessionId // empty' 2>/dev/null)" || sid=""
|
|
309
|
+
cwd="$(printf '%s' "$who" | jq -r '.cwd // empty' 2>/dev/null)" || cwd=""
|
|
310
|
+
fi
|
|
311
|
+
[ -n "$cwd" ] || cwd="$PWD"
|
|
312
|
+
_status_json "$sid" "$cwd"
|
|
313
|
+
exit 0
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
cmd_cancel() {
|
|
317
|
+
local cwd_opt="" cwd removed=0 f
|
|
318
|
+
cwd_opt="$(parse_cwd_opt "$@")" || cwd_opt=""
|
|
319
|
+
cwd="$(resolve_cwd "$cwd_opt")"
|
|
320
|
+
for f in "$(pending_path_for "$cwd")" "$(flag_path_for "$cwd")"; do
|
|
321
|
+
if [ -f "$f" ]; then
|
|
322
|
+
rm -f "$f" 2>/dev/null && { printf 'removed: %s\n' "$f"; removed=1; }
|
|
323
|
+
fi
|
|
324
|
+
done
|
|
325
|
+
[ "$removed" -eq 1 ] || printf 'nothing to remove for cwd %s\n' "$cwd"
|
|
326
|
+
exit 0
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
cmd_schedule_kill() {
|
|
330
|
+
echo "phase-2 feature not enabled" >&2
|
|
331
|
+
exit 1
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
main() {
|
|
335
|
+
local sub="${1:-}"
|
|
336
|
+
[ $# -gt 0 ] && shift
|
|
337
|
+
case "$sub" in
|
|
338
|
+
whoami) cmd_whoami "$@";;
|
|
339
|
+
sessions) cmd_sessions "$@";;
|
|
340
|
+
preflight) cmd_preflight "$@";;
|
|
341
|
+
path) cmd_path "$@";;
|
|
342
|
+
flag) cmd_flag "$@";;
|
|
343
|
+
status) cmd_status "$@";;
|
|
344
|
+
cancel) cmd_cancel "$@";;
|
|
345
|
+
schedule-kill) cmd_schedule_kill "$@";;
|
|
346
|
+
*) usage;;
|
|
347
|
+
esac
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if [ "${BASH_SOURCE[0]:-}" = "$0" ]; then
|
|
351
|
+
main "$@"
|
|
352
|
+
fi
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# ULTRACODE.md — Model & Effort Routing
|
|
2
|
+
|
|
3
|
+
## 1. Core principle
|
|
4
|
+
Spend fable only where judgment is the bottleneck, never where volume is: fable draws from a separate, much smaller rate-limit bucket, and omitting `opts.model` silently inherits it — every un-annotated `agent()` call is a fable spend.
|
|
5
|
+
Explicit down-routing is your default posture, not an optimization.
|
|
6
|
+
Route each agent by one question: "if this agent is quietly wrong, who catches it?" — a downstream agent means you may downgrade; nobody means top tier.
|
|
7
|
+
|
|
8
|
+
## 2. Routing table
|
|
9
|
+
| Task class | Model | Effort | Why |
|
|
10
|
+
|---|---|---|---|
|
|
11
|
+
| Scouting / file-listing | haiku | low | mechanical |
|
|
12
|
+
| Bulk code reading / fact extraction | haiku (sonnet if dense) | low | extraction |
|
|
13
|
+
| Mechanical transforms (rename, dedup, format) | haiku | low | deterministic |
|
|
14
|
+
| Classify / extract into fixed `schema` | sonnet | low | ambiguity |
|
|
15
|
+
| Finder sweeps (bug / perf) | sonnet | medium | recall |
|
|
16
|
+
| Security sweeps | opus | high | asymmetry |
|
|
17
|
+
| Implementation edits (well-scoped) | sonnet | medium | authorship |
|
|
18
|
+
| Implementation edits (cross-cutting / tricky) | opus | high | judgment |
|
|
19
|
+
| Adversarial verification / refutation votes | opus | high | precision |
|
|
20
|
+
| Judge panel voters | opus | high | arbitration |
|
|
21
|
+
| Judge chair / tie-break adjudicator | fable | xhigh | terminal |
|
|
22
|
+
| Completeness critics | opus | high | gaps |
|
|
23
|
+
| Final synthesis | fable (omit `model`) | xhigh | unrecoverable |
|
|
24
|
+
|
|
25
|
+
Pairing rules:
|
|
26
|
+
- Effort amplifies capability; it never substitutes. haiku@xhigh loses to sonnet@medium on judgment work; never pair xhigh with a fan-out stage.
|
|
27
|
+
- Multiply tier by fan-out width BEFORE picking a model — fine at width 3 is a budget event at width 20. Any `parallel()` wider than ~5 gets an explicit haiku/sonnet.
|
|
28
|
+
- Buy recall with width (more cheap finders over disjoint files); buy precision with tier (one strong verifier). Upstream misses get caught downstream; a false CONFIRM does not.
|
|
29
|
+
- Width unknown until runtime? Route for the upper end of plausible width.
|
|
30
|
+
|
|
31
|
+
## 3. Hard guardrails
|
|
32
|
+
|
|
33
|
+
Never downgrade (floors, not targets):
|
|
34
|
+
- Final synthesis, and ANY output the user sees without a downstream agent re-check: fable, never below opus. The trust boundary sets the floor, not the stage label.
|
|
35
|
+
- Adversarial verification, judge panels, security verdicts, completeness critics: opus floor. A false CONFIRM ends scrutiny — nothing checks the checker.
|
|
36
|
+
- Subtle-correctness verdicts (concurrency, auth/crypto, money math, data-loss migrations): fable. "Looks correct" and "is correct" diverge most here.
|
|
37
|
+
|
|
38
|
+
Escalation rule (what makes cheap-first safe):
|
|
39
|
+
- Give every finder/verifier a `schema` requiring `verdict` / `confidence` / `evidence`. Empty evidence IS low confidence, whatever number the agent reports.
|
|
40
|
+
- Re-run once at a higher tier on: confidence < 0.7, UNSURE, empty/malformed output, or contradiction between parallel agents.
|
|
41
|
+
- A CONFIRM/REFUTE split among voters escalates to one fable adjudicator — never majority-vote or average it; the split itself signals the question is hard.
|
|
42
|
+
- Still UNSURE at the top tier? Surface it to the user. An honest "unresolved" is correct output; a manufactured verdict is a defect.
|
|
43
|
+
|
|
44
|
+
Budget pressure (`budget.remaining()` thin, or fable bucket ~75%+ consumed):
|
|
45
|
+
- Cut fan-out width and batch more files per agent FIRST; floors are the last thing to fall.
|
|
46
|
+
- Treat fable as a read-only reserve: one highest-leverage call only (usually synthesis).
|
|
47
|
+
- If the budget cannot cover the fable/opus terminal stages, say so and propose the cut — never silently ship a downgraded final answer.
|
|
48
|
+
|
|
49
|
+
## 4. Example
|
|
50
|
+
```js
|
|
51
|
+
pipeline(files,
|
|
52
|
+
f => agent(`List exports and structure of ${f}`,
|
|
53
|
+
{ model: 'haiku', effort: 'low', label: 'scout (haiku/low)' }),
|
|
54
|
+
f => agent(`Find bugs and perf issues in ${f}`,
|
|
55
|
+
{ model: 'sonnet', effort: 'medium', schema: findingSchema,
|
|
56
|
+
label: 'find (sonnet/med)' }), // wide + cheap: recall
|
|
57
|
+
fs => agent(`Adversarially verify each finding; cite evidence; drop false positives`,
|
|
58
|
+
{ model: 'opus', effort: 'high', schema: verdictSchema,
|
|
59
|
+
label: 'verify (opus/high)' }) // narrow + strong: precision
|
|
60
|
+
);
|
|
61
|
+
// contested verdicts only — one strong judge beats five weak votes
|
|
62
|
+
const ruling = await agent(`Adjudicate the CONFIRM/REFUTE splits`,
|
|
63
|
+
{ model: 'fable', effort: 'xhigh', label: 'adjudicate (fable/xhigh)' });
|
|
64
|
+
// model omitted DELIBERATELY: terminal stage inherits fable
|
|
65
|
+
const report = await agent(`Synthesize the final review, ranked by severity`,
|
|
66
|
+
{ effort: 'xhigh', label: 'synthesize (fable/xhigh)' });
|
|
67
|
+
```
|
|
68
|
+
A workflow that never touches fable is a valid — often good — outcome. Omission of `opts.model` should be rare, counted, and load-bearing.
|
|
69
|
+
|
|
70
|
+
## 5. Routing lint (pre-flight — fix before dispatch, don't launch and patch)
|
|
71
|
+
1. Every `agent()` call has an explicit `model`, or its omission is a deliberate apex spend. More than 2 omissions in one script = under-routing, not an apex-heavy task.
|
|
72
|
+
2. No wide/parallel stage runs on opus or inherits fable; bulk stages sit at haiku/sonnet regardless of how the rest is routed.
|
|
73
|
+
3. Verifiers, judges, and synthesis are at their floors — even under budget pressure.
|
|
74
|
+
4. Every downgraded trusted-adjacent stage has a wired escalation trigger (confidence / UNSURE / contradiction → higher-tier re-run).
|
|
75
|
+
5. `label` and `phase()` names embed the model tag (e.g. `verify (opus/high)`) — the user never opens the script to learn a stage ran on fable.
|
|
76
|
+
|
|
77
|
+
## 6. Scope
|
|
78
|
+
The same table binds the Agent tool's `model` param for one-off dispatches outside Workflow scripts: a lone bulk-reading agent is still a haiku job; a lone terminal judgment still earns opus or an intentional fable inheritance.
|
|
79
|
+
The absence of a `pipeline()`/`parallel()` wrapper does not relax the discipline.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ctx-notify.sh — Claude Code Stop hook.
|
|
3
|
+
# Passive banner: when context usage sits in a threshold band that has not
|
|
4
|
+
# been bannered yet, emit {"systemMessage": ...}. NEVER emits a "decision"
|
|
5
|
+
# field. Contract: always exit 0, silent on every error path, defensive
|
|
6
|
+
# jq parsing, no-op when stop_hook_active.
|
|
7
|
+
exec 2>/dev/null
|
|
8
|
+
|
|
9
|
+
# Thresholds.
|
|
10
|
+
T1=60
|
|
11
|
+
T2=75
|
|
12
|
+
T3=85
|
|
13
|
+
|
|
14
|
+
STATE_ROOT="$HOME/.claude-swap-backup"
|
|
15
|
+
CTX_DIR="$STATE_ROOT/ctx"
|
|
16
|
+
|
|
17
|
+
INPUT="$(cat)" || INPUT=""
|
|
18
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
19
|
+
|
|
20
|
+
# Never act on our own stop cycle.
|
|
21
|
+
sha="$(jq -r '.stop_hook_active // false' <<<"$INPUT" 2>/dev/null)"
|
|
22
|
+
[ "$sha" = "true" ] && exit 0
|
|
23
|
+
|
|
24
|
+
session_id="$(jq -r '.session_id // empty' <<<"$INPUT" 2>/dev/null)"
|
|
25
|
+
[ -n "$session_id" ] || exit 0
|
|
26
|
+
|
|
27
|
+
# Relay pct (missing -> silent exit).
|
|
28
|
+
relay="$CTX_DIR/$session_id.json"
|
|
29
|
+
[ -f "$relay" ] || exit 0
|
|
30
|
+
pct_raw="$(jq -r 'if (.pct|type) == "number" then .pct else empty end' "$relay" 2>/dev/null)"
|
|
31
|
+
[ -n "$pct_raw" ] || exit 0
|
|
32
|
+
pct="$(awk -v p="$pct_raw" 'BEGIN { printf "%d", p }')"
|
|
33
|
+
case "$pct" in ''|*[!0-9]*) exit 0 ;; esac
|
|
34
|
+
|
|
35
|
+
# State (missing/corrupt -> fired=0 bannered=0).
|
|
36
|
+
state="$CTX_DIR/$session_id.state"
|
|
37
|
+
fired=0
|
|
38
|
+
bannered=0
|
|
39
|
+
if [ -f "$state" ]; then
|
|
40
|
+
f="$(jq -r '.fired // 0' "$state" 2>/dev/null)"
|
|
41
|
+
b="$(jq -r '.bannered // 0' "$state" 2>/dev/null)"
|
|
42
|
+
case "$f" in 0|60|75|85) fired=$f ;; esac
|
|
43
|
+
case "$b" in 0|60|75|85) bannered=$b ;; esac
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# band = highest threshold <= pct (0 if < 60).
|
|
47
|
+
band=0
|
|
48
|
+
for t in "$T1" "$T2" "$T3"; do
|
|
49
|
+
[ "$pct" -ge "$t" ] && band=$t
|
|
50
|
+
done
|
|
51
|
+
|
|
52
|
+
if [ "$pct" -ge "$T1" ] && [ "$band" -gt "$bannered" ]; then
|
|
53
|
+
printf '{"systemMessage":"context %d%% — /handoff available"}\n' "$pct"
|
|
54
|
+
mkdir -p "$CTX_DIR" || exit 0
|
|
55
|
+
tmp="$state.tmp.$$"
|
|
56
|
+
printf '{"fired":%d,"bannered":%d}\n' "$fired" "$band" > "$tmp" && mv -f "$tmp" "$state"
|
|
57
|
+
fi
|
|
58
|
+
exit 0
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ctx-watch.sh — Claude Code UserPromptSubmit hook.
|
|
3
|
+
# Reads the statusline relay for this session and, when context usage
|
|
4
|
+
# crosses a new threshold, injects the handoff-offer note into the turn.
|
|
5
|
+
# Contract: always exit 0, silent on every error path, defensive jq
|
|
6
|
+
# parsing, no state writes on the /handoff | /swap skip path.
|
|
7
|
+
exec 2>/dev/null
|
|
8
|
+
|
|
9
|
+
# Thresholds (contract: variables at top).
|
|
10
|
+
T1=60
|
|
11
|
+
T2=75
|
|
12
|
+
T3=85
|
|
13
|
+
|
|
14
|
+
STATE_ROOT="$HOME/.claude-swap-backup"
|
|
15
|
+
CTX_DIR="$STATE_ROOT/ctx"
|
|
16
|
+
|
|
17
|
+
INPUT="$(cat)" || INPUT=""
|
|
18
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
19
|
+
|
|
20
|
+
# Skip path FIRST — before any state read/write.
|
|
21
|
+
prompt="$(jq -r '.prompt // empty' <<<"$INPUT" 2>/dev/null)"
|
|
22
|
+
case "$prompt" in
|
|
23
|
+
"/handoff"*|"/swap"*) exit 0 ;;
|
|
24
|
+
esac
|
|
25
|
+
|
|
26
|
+
session_id="$(jq -r '.session_id // empty' <<<"$INPUT" 2>/dev/null)"
|
|
27
|
+
[ -n "$session_id" ] || exit 0
|
|
28
|
+
|
|
29
|
+
# Relay pct (missing file / null / non-number -> silent exit).
|
|
30
|
+
relay="$CTX_DIR/$session_id.json"
|
|
31
|
+
[ -f "$relay" ] || exit 0
|
|
32
|
+
pct_raw="$(jq -r 'if (.pct|type) == "number" then .pct else empty end' "$relay" 2>/dev/null)"
|
|
33
|
+
[ -n "$pct_raw" ] || exit 0
|
|
34
|
+
pct="$(awk -v p="$pct_raw" 'BEGIN { printf "%d", p }')"
|
|
35
|
+
case "$pct" in ''|*[!0-9]*) exit 0 ;; esac
|
|
36
|
+
|
|
37
|
+
# State (missing/corrupt -> fired=0 bannered=0).
|
|
38
|
+
state="$CTX_DIR/$session_id.state"
|
|
39
|
+
fired=0
|
|
40
|
+
bannered=0
|
|
41
|
+
if [ -f "$state" ]; then
|
|
42
|
+
f="$(jq -r '.fired // 0' "$state" 2>/dev/null)"
|
|
43
|
+
b="$(jq -r '.bannered // 0' "$state" 2>/dev/null)"
|
|
44
|
+
case "$f" in 0|60|75|85) fired=$f ;; esac
|
|
45
|
+
case "$b" in 0|60|75|85) bannered=$b ;; esac
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
changed=0
|
|
49
|
+
|
|
50
|
+
# Re-arm rule: if pct < fired-10 -> fired = highest threshold <= pct
|
|
51
|
+
# (else 0), and bannered = min(bannered, fired).
|
|
52
|
+
if [ "$pct" -lt $(( fired - 10 )) ]; then
|
|
53
|
+
new_fired=0
|
|
54
|
+
for t in "$T1" "$T2" "$T3"; do
|
|
55
|
+
[ "$pct" -ge "$t" ] && new_fired=$t
|
|
56
|
+
done
|
|
57
|
+
fired=$new_fired
|
|
58
|
+
[ "$bannered" -gt "$fired" ] && bannered=$fired
|
|
59
|
+
changed=1
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
# Fire: highest threshold T with pct >= T and T > fired.
|
|
63
|
+
T=0
|
|
64
|
+
for t in "$T1" "$T2" "$T3"; do
|
|
65
|
+
[ "$pct" -ge "$t" ] && T=$t
|
|
66
|
+
done
|
|
67
|
+
if [ "$T" -gt "$fired" ]; then
|
|
68
|
+
printf '%s\n' "[context-watch] Context is at ${pct}%. After fully completing the user's current request, tell them context is filling up and OFFER /handoff to continue in a fresh session. Do NOT invoke the handoff skill yourself unless the user explicitly accepts in their own message — an offer you made is not acceptance. If they decline or ignore the offer, drop the subject; this notice will re-appear at the next threshold."
|
|
69
|
+
fired=$T
|
|
70
|
+
changed=1
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
# Persist only when something changed (atomic write).
|
|
74
|
+
if [ "$changed" -eq 1 ]; then
|
|
75
|
+
mkdir -p "$CTX_DIR" || exit 0
|
|
76
|
+
tmp="$state.tmp.$$"
|
|
77
|
+
printf '{"fired":%d,"bannered":%d}\n' "$fired" "$bannered" > "$tmp" && mv -f "$tmp" "$state"
|
|
78
|
+
fi
|
|
79
|
+
exit 0
|