acstack 0.0.0-stage → 0.47.1
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.
- package/.claude-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/README.md +389 -2
- package/THIRD_PARTY_NOTICES +14 -0
- package/TROUBLESHOOTING.md +225 -0
- package/agents/codex/frontend-verifier.toml +352 -0
- package/agents/codex/task-checker.toml +345 -0
- package/agents/codex/task-executor.toml +157 -0
- package/agents/codex/test-writer.toml +149 -0
- package/agents/frontend-verifier.md +351 -0
- package/agents/task-checker.md +344 -0
- package/agents/task-executor.md +156 -0
- package/agents/test-writer.md +148 -0
- package/assets/playwriter-bridge/build-launcher.ps1 +32 -0
- package/assets/playwriter-bridge/doctor.ps1 +450 -0
- package/assets/playwriter-bridge/launcher-src.cs +131 -0
- package/assets/playwriter-bridge/opener.js +330 -0
- package/assets/playwriter-bridge/opener.ps1 +63 -0
- package/assets/playwriter-bridge/register-tasks.ps1 +328 -0
- package/assets/playwriter-bridge/relay.ps1 +50 -0
- package/assets/playwriter-bridge/stop.ps1 +125 -0
- package/assets/playwriter-bridge/sync.ps1 +271 -0
- package/assets/playwriter-bridge/tunnel.ps1 +325 -0
- package/assets/playwriter-bridge/vm-free-port.sh +166 -0
- package/assets/terminal-workspace/client-keybindings.md +87 -0
- package/assets/terminal-workspace/config.example.yaml +334 -0
- package/assets/terminal-workspace/setup.sh +4404 -0
- package/cli/README.md +47 -0
- package/cli/bin/acstack.js +3 -0
- package/cli/dist/index.js +25984 -0
- package/cli/dist/index.js.map +7 -0
- package/cli/package.json +59 -0
- package/docs/adr/0001-ste-writing-standard.md +16 -0
- package/docs/adr/0002-version-gate-not-auto-bump.md +35 -0
- package/docs/adr/0003-handoff-ack-command.md +19 -0
- package/docs/adr/0004-dev-setup-per-host.md +13 -0
- package/docs/adr/0005-two-orchestrators-per-host.md +25 -0
- package/docs/adr/0006-document-opener-service.md +89 -0
- package/docs/adr/0007-lead-check-reads-the-transcript.md +87 -0
- package/docs/adr/0008-cli-audits-skill-interviews.md +37 -0
- package/docs/adr/0009-verify-the-write-not-the-writer.md +51 -0
- package/docs/adr/0010-promote-dev-to-main-by-fast-forward.md +115 -0
- package/docs/adr/0011-node-header-is-the-edge-encoding.md +20 -0
- package/docs/adr/0012-node-kind-is-a-closed-set-of-five.md +31 -0
- package/docs/adr/0013-the-diagram-renderer-is-pure.md +23 -0
- package/docs/adr/0014-reading-a-node-hides-its-generated-diagram.md +29 -0
- package/docs/adr/0015-a-node-id-links-to-its-issue-through-an-anchor.md +37 -0
- package/docs/adr/0016-the-map-root-carries-no-status-of-its-own.md +21 -0
- package/docs/adr/0017-call-impeccable-copy-only-its-questions.md +13 -0
- package/docs/adr/index.md +17 -0
- package/docs/config-reference.md +572 -0
- package/docs/dev-setups.md +164 -0
- package/docs/migrate-0.8.md +28 -0
- package/docs/playwriter-bridge-windows.md +15 -0
- package/docs/terminal-workspace-keys.md +137 -0
- package/docs/terminal-workspace.md +1780 -0
- package/docs/workflows.md +309 -0
- package/hooks/ac-writing-style-reminder.sh +86 -0
- package/hooks/context-pressure.sh +583 -0
- package/hooks/hooks.json +50 -0
- package/hooks/session-start-handoff.sh +165 -0
- package/hooks/session-start.sh +214 -0
- package/orchestrator/AGENTS.md +532 -0
- package/output-styles/ac-writing-style.md +286 -0
- package/package.json +50 -4
- package/pi/agents/frontend-verifier.md +357 -0
- package/pi/agents/task-checker.md +348 -0
- package/pi/agents/task-executor.md +160 -0
- package/pi/agents/test-writer.md +154 -0
- package/pi/extension.ts +723 -0
- package/profiles/fullstack-python.yaml +29 -0
- package/profiles/node-typescript.yaml +23 -0
- package/profiles/python.yaml +24 -0
- package/skills/archive/SKILL.md +160 -0
- package/skills/bootstrap/SKILL.md +109 -0
- package/skills/bridge/HANDOFF.md +93 -0
- package/skills/bridge/SKILL-VM.md +292 -0
- package/skills/bridge/SKILL-WINDOWS.md +526 -0
- package/skills/bridge/SKILL.md +121 -0
- package/skills/browser-verify/SKILL.md +229 -0
- package/skills/code-review/SKILL.md +44 -0
- package/skills/commit/SKILL.md +106 -0
- package/skills/compact-and-continue/SKILL.md +241 -0
- package/skills/explore/SKILL.md +149 -0
- package/skills/grilling/SKILL.md +134 -0
- package/skills/handoff/SKILL.md +250 -0
- package/skills/handoff/launch-herdr.md +197 -0
- package/skills/handoff/launch-without-herdr.md +30 -0
- package/skills/handoff/lead-check.md +59 -0
- package/skills/handoff/outcomes.md +162 -0
- package/skills/handoff/running-agent.md +50 -0
- package/skills/handoff/watch.md +115 -0
- package/skills/host/SKILL.md +247 -0
- package/skills/host/detect-host.sh +297 -0
- package/skills/host/herdr.md +79 -0
- package/skills/host/install-herdr-block.sh +467 -0
- package/skills/host/install-orca.sh +512 -0
- package/skills/host/notes.md +97 -0
- package/skills/host/orca.md +160 -0
- package/skills/host/questions.md +163 -0
- package/skills/host/report.md +42 -0
- package/skills/implement/SKILL.md +230 -0
- package/skills/implement/decision-log.md +62 -0
- package/skills/implement/design-pass.md +32 -0
- package/skills/implement/relaxed-tdd.md +28 -0
- package/skills/map/SKILL.md +250 -0
- package/skills/map/frontend.md +227 -0
- package/skills/map/trackers/files.md +74 -0
- package/skills/map/trackers/github.md +180 -0
- package/skills/map/visuals.md +387 -0
- package/skills/new-worktree/SKILL.md +155 -0
- package/skills/new-worktree/detect-orchestrator.sh +328 -0
- package/skills/new-worktree/detect-orchestrator.test.sh +894 -0
- package/skills/new-worktree/herdr.md +84 -0
- package/skills/new-worktree/orca.md +101 -0
- package/skills/pre-commit-review/SKILL.md +242 -0
- package/skills/pre-commit-review/auto-fix.md +19 -0
- package/skills/pre-commit-review/interactive.md +35 -0
- package/skills/quick-fix/SKILL.md +125 -0
- package/skills/record-context/SKILL.md +106 -0
- package/skills/setup/SKILL.md +250 -0
- package/skills/setup/browser.md +123 -0
- package/skills/setup/design-review.md +97 -0
- package/skills/setup/first-run.md +114 -0
- package/skills/setup/report.md +68 -0
- package/skills/setup/row-fixes.md +196 -0
- package/skills/setup/terminal-workspace.md +153 -0
- package/skills/sync/SKILL.md +171 -0
- package/skills/teardown-worktree/SKILL.md +245 -0
- package/skills/teardown-worktree/detector.md +39 -0
- package/skills/teardown-worktree/herdr.md +113 -0
- package/skills/teardown-worktree/orca.md +61 -0
- package/skills/terminal-workspace/SKILL.md +249 -0
- package/skills/terminal-workspace/components.md +36 -0
- package/skills/terminal-workspace/config-choices.md +69 -0
- package/skills/terminal-workspace/emulator-keys.md +48 -0
- package/skills/terminal-workspace/headless-clipboard.md +175 -0
- package/skills/terminal-workspace/turn-off-and-update.md +104 -0
- package/skills/test-conventions/SKILL.md +59 -0
- package/skills/visual-docs/SKILL.md +246 -0
- package/skills/writing/SKILL.md +231 -0
- package/skills/writing/examples.md +153 -0
- package/skills/writing/vocabulary.md +96 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ACStack context-pressure nudge (Stop / SessionStart hook).
|
|
3
|
+
#
|
|
4
|
+
# Measures how much context the session has consumed and, past a threshold,
|
|
5
|
+
# blocks the stop with a short continuity-versus-freshness checkpoint before
|
|
6
|
+
# context runs out.
|
|
7
|
+
#
|
|
8
|
+
# Contract: JSON payload on stdin, JSON (or nothing) on stdout, nothing on
|
|
9
|
+
# stderr, ALWAYS exit 0. A hook that errors or writes junk to stdout breaks the
|
|
10
|
+
# stop, so every failure path here is a silent no-op.
|
|
11
|
+
#
|
|
12
|
+
# Two events, and only two:
|
|
13
|
+
#
|
|
14
|
+
# Stop the session itself finished a turn - the only event that can
|
|
15
|
+
# produce a nudge.
|
|
16
|
+
# SessionStart used solely to reset this session's ladder state when the
|
|
17
|
+
# source is "compact"; it never emits anything.
|
|
18
|
+
#
|
|
19
|
+
# A SubagentStop, should one ever arrive, is a silent no-op that creates no
|
|
20
|
+
# state. Neither a plain subagent nor an in-process teammate can hand the
|
|
21
|
+
# user's work over, so nudging one just wastes a turn - which is why the hook
|
|
22
|
+
# is not registered for that event at all.
|
|
23
|
+
#
|
|
24
|
+
# How the measurement works:
|
|
25
|
+
#
|
|
26
|
+
# The transcript is JSONL. Each assistant record carries message.usage, whose
|
|
27
|
+
# input_tokens + cache_read_input_tokens + cache_creation_input_tokens is the
|
|
28
|
+
# context that turn actually sent to the model. cache_read already contains
|
|
29
|
+
# the whole prior conversation, so the LAST such record is the current
|
|
30
|
+
# context size - summing across records would multiply-count it.
|
|
31
|
+
#
|
|
32
|
+
# Records with isSidechain true belong to a subagent running under this
|
|
33
|
+
# session, not to this session's own context, so they are skipped. Malformed
|
|
34
|
+
# lines are skipped rather than fatal; transcripts are appended live and the
|
|
35
|
+
# tail can be a partial write.
|
|
36
|
+
#
|
|
37
|
+
# The threshold ladder:
|
|
38
|
+
#
|
|
39
|
+
# The hook fires at most once per rung per session, so a long session gets a
|
|
40
|
+
# nudge when it crosses each rung rather than on every single stop.
|
|
41
|
+
#
|
|
42
|
+
# The default rungs are workflow.handoff.nudge_warn and nudge_severe from
|
|
43
|
+
# acstack/project.yaml (defaulting to 200000 / 500000), and then one rung
|
|
44
|
+
# every workflow.handoff.nudge_step tokens (default 100000) above the last
|
|
45
|
+
# one. Setting workflow.handoff.context_thresholds replaces the default rungs
|
|
46
|
+
# entirely with the listed ones - written either block style (one "- item"
|
|
47
|
+
# per line) or flow style ("[a, b]") - while nudge_step still extends the
|
|
48
|
+
# ladder past the last listed rung. A listed rung is either a token count or
|
|
49
|
+
# a percentage string such as "40%", which resolves against
|
|
50
|
+
# workflow.handoff.context_window when that is set. With no context_window
|
|
51
|
+
# configured, the window is inferred from the model id on the transcript
|
|
52
|
+
# record: an id carrying the [1m] marker means 1,000,000; the haiku line and
|
|
53
|
+
# the 4.x families (claude-opus-4-8, claude-haiku-4-5-..., claude-sonnet-4-5)
|
|
54
|
+
# mean 200,000; anything else - the 5.x families, or no model id at all -
|
|
55
|
+
# means 1,000,000. With an explicit ladder, nudge_severe no longer places a
|
|
56
|
+
# rung: it only chooses the wording, which reads as severe once the rung that
|
|
57
|
+
# fired is at or above it.
|
|
58
|
+
#
|
|
59
|
+
# The hook fires when the highest rung at or below the current token count is
|
|
60
|
+
# higher than the rung it last fired for this session, and then records that
|
|
61
|
+
# rung.
|
|
62
|
+
#
|
|
63
|
+
# Per-session state:
|
|
64
|
+
#
|
|
65
|
+
# State lives in ACSTACK_CONTEXT_PRESSURE_DIR, defaulting to a
|
|
66
|
+
# acstack-context-pressure directory under TMPDIR (or /tmp), created when it
|
|
67
|
+
# is missing. The session key is the payload's session_id, falling back to the
|
|
68
|
+
# transcript's basename without its .jsonl suffix. Two files per key:
|
|
69
|
+
#
|
|
70
|
+
# <key>.last the rung this hook last fired, written only by this hook.
|
|
71
|
+
# Absent or unreadable means nothing has fired yet.
|
|
72
|
+
# <key>.quiet a marker the handoff and compact-and-continue skills leave
|
|
73
|
+
# behind once they finish. The hook only ever reads it: while
|
|
74
|
+
# it exists the hook stays silent for the rest of the session
|
|
75
|
+
# and leaves <key>.last untouched, because the work has already
|
|
76
|
+
# been handed over and there is nothing left to nudge about.
|
|
77
|
+
#
|
|
78
|
+
# A state directory that cannot be created or written stays a silent no-op:
|
|
79
|
+
# a nudge the hook cannot remember firing would repeat on every stop.
|
|
80
|
+
#
|
|
81
|
+
# Both files are deleted when the session compacts. A compaction throws the
|
|
82
|
+
# session's context away and rebuilds it much smaller, so the rung the hook
|
|
83
|
+
# last fired describes context that no longer exists, and the quiet marker
|
|
84
|
+
# records a handover the fresh context knows nothing about. The ladder
|
|
85
|
+
# therefore starts over from the bottom after every compaction.
|
|
86
|
+
#
|
|
87
|
+
# Output is deliberately flat {"decision": "block", "reason": ...}: it is the
|
|
88
|
+
# one Stop-hook shape whose text reliably reaches the model across harnesses.
|
|
89
|
+
# hookSpecificOutput/additionalContext are SessionStart-shaped and are dropped
|
|
90
|
+
# here; systemMessage reaches only the user.
|
|
91
|
+
|
|
92
|
+
set -u
|
|
93
|
+
|
|
94
|
+
command -v python3 >/dev/null 2>&1 || exit 0
|
|
95
|
+
|
|
96
|
+
# Capture the hook payload from stdin before running python. The heredoc below
|
|
97
|
+
# becomes python's stdin (the program source), so the payload must travel via an
|
|
98
|
+
# env var, not sys.stdin.
|
|
99
|
+
ACSTACK_HOOK_INPUT="$(cat)"
|
|
100
|
+
export ACSTACK_HOOK_INPUT
|
|
101
|
+
|
|
102
|
+
# Resolve the state directory here so python only has to read one variable.
|
|
103
|
+
ACSTACK_CONTEXT_PRESSURE_DIR="${ACSTACK_CONTEXT_PRESSURE_DIR:-${TMPDIR:-/tmp}/acstack-context-pressure}"
|
|
104
|
+
export ACSTACK_CONTEXT_PRESSURE_DIR
|
|
105
|
+
|
|
106
|
+
# stderr goes nowhere: a hook that writes to stderr is noise at best, and a
|
|
107
|
+
# traceback from an unforeseen payload must never reach the user. Every failure
|
|
108
|
+
# path inside is already a silent no-op; this is the backstop.
|
|
109
|
+
python3 2>/dev/null <<'PY'
|
|
110
|
+
import json, math, os, re, sys
|
|
111
|
+
|
|
112
|
+
DEFAULT_WARN = 200000
|
|
113
|
+
DEFAULT_SEVERE = 500000
|
|
114
|
+
DEFAULT_STEP = 100000
|
|
115
|
+
|
|
116
|
+
# What a percentage rung resolves against when project.yaml names no
|
|
117
|
+
# context_window. The model id on the transcript record is the only clue
|
|
118
|
+
# available: see window_for_model below for the rule these feed.
|
|
119
|
+
WINDOW_1M = 1000000
|
|
120
|
+
WINDOW_SMALL = 200000
|
|
121
|
+
WINDOW_DEFAULT = 1000000
|
|
122
|
+
|
|
123
|
+
# Model ids whose window is 200k unless the [1m] marker says otherwise: the
|
|
124
|
+
# haiku line, and the 4.x families (claude-opus-4-8, claude-sonnet-4-5, and so
|
|
125
|
+
# on). Anything else - the 5.x families, or no model id at all - takes the
|
|
126
|
+
# 1,000,000 default.
|
|
127
|
+
SMALL_WINDOW_MODEL = re.compile(r"haiku|-4-\d|-4\b")
|
|
128
|
+
|
|
129
|
+
# The agency clause is the load-bearing instruction, and it is identical
|
|
130
|
+
# across tiers: continuity or freshness is the agent's own call. The question
|
|
131
|
+
# tool is for the case where it genuinely cannot tell. Only the head above it
|
|
132
|
+
# differs, and only in urgency, so a severe nudge never halts an unattended run
|
|
133
|
+
# that the agent could have resolved itself.
|
|
134
|
+
AGENCY = (
|
|
135
|
+
"The call is yours. If you have a strong view either way, act on it "
|
|
136
|
+
"without asking. Either keep continuity in this session, or choose "
|
|
137
|
+
"freshness with /acstack:handoff. Ask the user with the host's question "
|
|
138
|
+
"tool if you don't know which route would make the work go better."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# The standing-agreement clause settles the one case the agent cannot read
|
|
142
|
+
# off its own context: a handoff the user already agreed to, which needs no
|
|
143
|
+
# second ask.
|
|
144
|
+
STANDING_AGREEMENT = (
|
|
145
|
+
"If you are partway through a long run of tasks and the user already "
|
|
146
|
+
"agreed to a handoff earlier in this session, including in a handoff "
|
|
147
|
+
"or compaction summary you resumed from, use /acstack:handoff without "
|
|
148
|
+
"asking again."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Shared tail appended after each tier's head: the agency clause, the
|
|
152
|
+
# standing agreement, and what to do when an answer does come back. Both
|
|
153
|
+
# tiers render the same tail, so a future edit to any of it cannot drift
|
|
154
|
+
# between warn and severe.
|
|
155
|
+
DECIDE_TAIL = (
|
|
156
|
+
AGENCY
|
|
157
|
+
+ " " + STANDING_AGREEMENT
|
|
158
|
+
+ " If you do ask and the user chooses continuity, drop it for now"
|
|
159
|
+
+ " and revisit freshness once the current run of work wraps up."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def bail():
|
|
164
|
+
"""Every failure path is a silent, successful no-op."""
|
|
165
|
+
sys.exit(0)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
data = json.loads(os.environ.get("ACSTACK_HOOK_INPUT", ""))
|
|
170
|
+
except Exception:
|
|
171
|
+
bail()
|
|
172
|
+
|
|
173
|
+
if not isinstance(data, dict):
|
|
174
|
+
bail()
|
|
175
|
+
|
|
176
|
+
# Our own injection caused this stop; nudging again would loop. The value is
|
|
177
|
+
# usually the JSON boolean true, but some hosts round-trip it through a
|
|
178
|
+
# string - treat any case-insensitive "true" the same way. Plain truthiness
|
|
179
|
+
# is wrong here: the string "false" is truthy.
|
|
180
|
+
_stop_hook_active = data.get("stop_hook_active")
|
|
181
|
+
if _stop_hook_active is True or (
|
|
182
|
+
isinstance(_stop_hook_active, str)
|
|
183
|
+
and _stop_hook_active.strip().lower() == "true"
|
|
184
|
+
):
|
|
185
|
+
bail()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --- state directory and session key ---------------------------------------
|
|
189
|
+
|
|
190
|
+
state_dir = os.environ.get("ACSTACK_CONTEXT_PRESSURE_DIR", "").strip()
|
|
191
|
+
if not state_dir:
|
|
192
|
+
bail()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def key_part(value):
|
|
196
|
+
"""One component of the state key, safe to paste into a file name.
|
|
197
|
+
|
|
198
|
+
Returns "" when nothing was supplied, and None when something was supplied
|
|
199
|
+
that cannot name a file: a NUL byte, or a name that sanitises away to
|
|
200
|
+
nothing. None is not a fallback case - a key the filesystem would reject
|
|
201
|
+
means the hook could never record what it fired, so the caller gives up
|
|
202
|
+
quietly rather than nudge on every stop from then on."""
|
|
203
|
+
if not isinstance(value, str) or not value.strip():
|
|
204
|
+
return ""
|
|
205
|
+
if "\x00" in value:
|
|
206
|
+
return None
|
|
207
|
+
value = value.strip()
|
|
208
|
+
# The host chooses these strings, so nothing may walk out of the state
|
|
209
|
+
# directory: the key stays a single path component.
|
|
210
|
+
for separator in ("/", "\\", os.sep, os.altsep or "/"):
|
|
211
|
+
value = value.replace(separator, "_")
|
|
212
|
+
value = os.path.basename(value)
|
|
213
|
+
if not value or value in (".", ".."):
|
|
214
|
+
return None
|
|
215
|
+
return value
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def transcript_key(path):
|
|
219
|
+
"""The fallback key: the transcript's basename without its .jsonl suffix."""
|
|
220
|
+
if not isinstance(path, str) or not path:
|
|
221
|
+
return ""
|
|
222
|
+
basename = os.path.basename(path)
|
|
223
|
+
if basename.endswith(".jsonl"):
|
|
224
|
+
basename = basename[: -len(".jsonl")]
|
|
225
|
+
return key_part(basename)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def session_key_for(path):
|
|
229
|
+
"""This session's state key, or None when nothing usable names a file."""
|
|
230
|
+
key = key_part(data.get("session_id"))
|
|
231
|
+
if key is None:
|
|
232
|
+
return None
|
|
233
|
+
if not key:
|
|
234
|
+
key = transcript_key(path)
|
|
235
|
+
return key or None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# --- events that never nudge -----------------------------------------------
|
|
239
|
+
|
|
240
|
+
# A subagent or an in-process teammate cannot hand the user's work over, so
|
|
241
|
+
# nudging one would only waste a turn. The hook is not registered for this
|
|
242
|
+
# event; should a payload arrive anyway it is a silent no-op that creates no
|
|
243
|
+
# state - in particular it never spends a rung on the session's own ladder.
|
|
244
|
+
if data.get("hook_event_name") == "SubagentStop":
|
|
245
|
+
bail()
|
|
246
|
+
|
|
247
|
+
# A compaction throws this session's context away and rebuilds it much
|
|
248
|
+
# smaller, so the rung the hook last fired and the quiet marker a handover
|
|
249
|
+
# left behind no longer describe anything: delete both and start the ladder
|
|
250
|
+
# over. The hook stays silent on this event whatever happens - SessionStart
|
|
251
|
+
# output reaches the model as extra context, which is exactly what the
|
|
252
|
+
# compaction just cleared - and every other source leaves the state alone.
|
|
253
|
+
if data.get("hook_event_name") == "SessionStart":
|
|
254
|
+
if data.get("source") == "compact":
|
|
255
|
+
reset_key = session_key_for(data.get("transcript_path"))
|
|
256
|
+
if reset_key:
|
|
257
|
+
for suffix in (".last", ".quiet"):
|
|
258
|
+
try:
|
|
259
|
+
os.remove(os.path.join(state_dir, reset_key + suffix))
|
|
260
|
+
except Exception:
|
|
261
|
+
# Absent, unwritable, or a name the filesystem rejects:
|
|
262
|
+
# there is nothing to say about any of them.
|
|
263
|
+
pass
|
|
264
|
+
bail()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# --- which transcript? -----------------------------------------------------
|
|
268
|
+
|
|
269
|
+
transcript = data.get("transcript_path")
|
|
270
|
+
if not isinstance(transcript, str) or not transcript:
|
|
271
|
+
bail()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# --- measure ---------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def measure(path):
|
|
277
|
+
"""Context size and model id from the last qualifying assistant record.
|
|
278
|
+
|
|
279
|
+
Returns (tokens, model), or (None, None) when the transcript holds no
|
|
280
|
+
qualifying record. The model id is whatever that record reported, and is
|
|
281
|
+
None when it reported none."""
|
|
282
|
+
try:
|
|
283
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
284
|
+
lines = f.readlines()
|
|
285
|
+
except OSError:
|
|
286
|
+
return None, None
|
|
287
|
+
|
|
288
|
+
for line in reversed(lines):
|
|
289
|
+
line = line.strip()
|
|
290
|
+
if not line:
|
|
291
|
+
continue
|
|
292
|
+
try:
|
|
293
|
+
rec = json.loads(line)
|
|
294
|
+
except Exception:
|
|
295
|
+
continue
|
|
296
|
+
if not isinstance(rec, dict):
|
|
297
|
+
continue
|
|
298
|
+
if rec.get("type") != "assistant":
|
|
299
|
+
continue
|
|
300
|
+
if rec.get("isSidechain") is True:
|
|
301
|
+
continue
|
|
302
|
+
message = rec.get("message")
|
|
303
|
+
if not isinstance(message, dict):
|
|
304
|
+
continue
|
|
305
|
+
usage = message.get("usage")
|
|
306
|
+
if not isinstance(usage, dict):
|
|
307
|
+
continue
|
|
308
|
+
total = 0
|
|
309
|
+
for key in ("input_tokens", "cache_read_input_tokens",
|
|
310
|
+
"cache_creation_input_tokens"):
|
|
311
|
+
value = usage.get(key, 0)
|
|
312
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
313
|
+
value = 0
|
|
314
|
+
elif isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
|
|
315
|
+
# json.loads accepts bare NaN/Infinity, and a huge exponent
|
|
316
|
+
# like 1e400 parses to inf. Both pass the float check above
|
|
317
|
+
# and then int() raises - coerce to 0 instead of crashing.
|
|
318
|
+
value = 0
|
|
319
|
+
total += int(value)
|
|
320
|
+
model = message.get("model")
|
|
321
|
+
if not isinstance(model, str):
|
|
322
|
+
model = None
|
|
323
|
+
return total, model
|
|
324
|
+
return None, None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
tokens, model_id = measure(transcript)
|
|
328
|
+
if tokens is None:
|
|
329
|
+
bail()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# --- configuration ---------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
def flow_list(value):
|
|
335
|
+
"""Split a flow-style YAML list ("[a, b]") into its raw item strings."""
|
|
336
|
+
inner = value.strip()
|
|
337
|
+
if inner.startswith("["):
|
|
338
|
+
inner = inner[1:]
|
|
339
|
+
if inner.endswith("]"):
|
|
340
|
+
inner = inner[:-1]
|
|
341
|
+
items = []
|
|
342
|
+
for part in inner.split(","):
|
|
343
|
+
part = part.strip().strip('"\'')
|
|
344
|
+
if part:
|
|
345
|
+
items.append(part)
|
|
346
|
+
return items
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def read_config():
|
|
350
|
+
"""Read the workflow.handoff keys this hook understands from project.yaml.
|
|
351
|
+
|
|
352
|
+
Hand-rolled because pyyaml is not guaranteed to be installed. Walks
|
|
353
|
+
indentation to find the workflow: -> handoff: block and reads scalars from
|
|
354
|
+
it, plus the block-style items under context_thresholds. Anything
|
|
355
|
+
unexpected is left as None so the caller falls back to its defaults."""
|
|
356
|
+
cfg = {
|
|
357
|
+
"nudge_warn": None,
|
|
358
|
+
"nudge_severe": None,
|
|
359
|
+
"nudge_step": None,
|
|
360
|
+
"context_window": None,
|
|
361
|
+
"context_thresholds": None,
|
|
362
|
+
}
|
|
363
|
+
try:
|
|
364
|
+
with open(os.path.join("acstack", "project.yaml"), encoding="utf-8", errors="replace") as f:
|
|
365
|
+
lines = f.read().split("\n")
|
|
366
|
+
except OSError:
|
|
367
|
+
return cfg
|
|
368
|
+
|
|
369
|
+
stack = [] # (indent, key) for the enclosing mappings
|
|
370
|
+
# Indent of the context_thresholds key while its block-style items ("- x"
|
|
371
|
+
# on their own lines) are being collected; None when not collecting.
|
|
372
|
+
collecting = None
|
|
373
|
+
for raw in lines:
|
|
374
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
375
|
+
continue
|
|
376
|
+
stripped = raw.lstrip(" ")
|
|
377
|
+
indent = len(raw) - len(stripped)
|
|
378
|
+
|
|
379
|
+
if collecting is not None:
|
|
380
|
+
if stripped.startswith("- ") and indent >= collecting:
|
|
381
|
+
item = stripped[2:].split("#", 1)[0].strip().strip('"\'')
|
|
382
|
+
if item:
|
|
383
|
+
cfg["context_thresholds"].append(item)
|
|
384
|
+
continue
|
|
385
|
+
collecting = None
|
|
386
|
+
|
|
387
|
+
if stripped.startswith("- ") or ":" not in stripped:
|
|
388
|
+
continue
|
|
389
|
+
key, _, value = stripped.partition(":")
|
|
390
|
+
key = key.strip().strip('"\'')
|
|
391
|
+
value = value.split("#", 1)[0].strip().strip('"\'')
|
|
392
|
+
|
|
393
|
+
while stack and stack[-1][0] >= indent:
|
|
394
|
+
stack.pop()
|
|
395
|
+
path = [k for _, k in stack] + [key]
|
|
396
|
+
|
|
397
|
+
if not value:
|
|
398
|
+
stack.append((indent, key))
|
|
399
|
+
if path == ["workflow", "handoff", "context_thresholds"]:
|
|
400
|
+
cfg["context_thresholds"] = []
|
|
401
|
+
collecting = indent
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
if path[:2] != ["workflow", "handoff"] or len(path) != 3:
|
|
405
|
+
continue
|
|
406
|
+
if key == "context_thresholds":
|
|
407
|
+
cfg["context_thresholds"] = flow_list(value)
|
|
408
|
+
elif key in ("nudge_warn", "nudge_severe", "nudge_step", "context_window"):
|
|
409
|
+
try:
|
|
410
|
+
cfg[key] = int(value)
|
|
411
|
+
except ValueError:
|
|
412
|
+
pass
|
|
413
|
+
return cfg
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
cfg = read_config()
|
|
417
|
+
|
|
418
|
+
warn_at = cfg["nudge_warn"] if cfg["nudge_warn"] is not None else DEFAULT_WARN
|
|
419
|
+
severe_at = cfg["nudge_severe"] if cfg["nudge_severe"] is not None else DEFAULT_SEVERE
|
|
420
|
+
step = cfg["nudge_step"] if cfg["nudge_step"] is not None else DEFAULT_STEP
|
|
421
|
+
|
|
422
|
+
# Floor both at 1 (a configured 0 would block every single turn), and never
|
|
423
|
+
# let severe sit below warn (severe < warn would create a dead zone where the
|
|
424
|
+
# severe nudge never fires, since the severe check only runs once tokens have
|
|
425
|
+
# already cleared warn).
|
|
426
|
+
warn_at = max(warn_at, 1)
|
|
427
|
+
severe_at = max(severe_at, warn_at)
|
|
428
|
+
# A step of 0 or less would either loop forever or place a rung on top of the
|
|
429
|
+
# last one, so it means "do not extend the ladder past the last rung".
|
|
430
|
+
if step < 1:
|
|
431
|
+
step = None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# --- the ladder ------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
def window_for_model(model):
|
|
437
|
+
"""The context window a model id implies.
|
|
438
|
+
|
|
439
|
+
The [1m] marker wins outright: a model running the million-token window
|
|
440
|
+
says so in its id. Otherwise the haiku line and the 4.x families run
|
|
441
|
+
200,000, and everything else - the 5.x families, or no model id at all -
|
|
442
|
+
takes the 1,000,000 default."""
|
|
443
|
+
if not model:
|
|
444
|
+
return WINDOW_DEFAULT
|
|
445
|
+
if "[1m]" in model:
|
|
446
|
+
return WINDOW_1M
|
|
447
|
+
if SMALL_WINDOW_MODEL.search(model.lower()):
|
|
448
|
+
return WINDOW_SMALL
|
|
449
|
+
return WINDOW_DEFAULT
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def context_window():
|
|
453
|
+
"""What a percentage rung is a percentage of."""
|
|
454
|
+
if cfg["context_window"] is not None and cfg["context_window"] > 0:
|
|
455
|
+
return cfg["context_window"]
|
|
456
|
+
return window_for_model(model_id)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def resolve_rung(item):
|
|
460
|
+
"""One configured rung as a token count, or None when it is unreadable."""
|
|
461
|
+
item = str(item).strip().strip('"\'')
|
|
462
|
+
if item.endswith("%"):
|
|
463
|
+
try:
|
|
464
|
+
percent = float(item[:-1])
|
|
465
|
+
except ValueError:
|
|
466
|
+
return None
|
|
467
|
+
return int(round(context_window() * percent / 100.0))
|
|
468
|
+
try:
|
|
469
|
+
return int(item)
|
|
470
|
+
except ValueError:
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
listed = cfg["context_thresholds"]
|
|
475
|
+
rungs = []
|
|
476
|
+
if listed:
|
|
477
|
+
for item in listed:
|
|
478
|
+
resolved = resolve_rung(item)
|
|
479
|
+
if resolved is not None and resolved >= 1:
|
|
480
|
+
rungs.append(resolved)
|
|
481
|
+
if not rungs:
|
|
482
|
+
# No usable list: the warn and severe marks are the ladder's own rungs.
|
|
483
|
+
rungs = [warn_at, severe_at]
|
|
484
|
+
rungs = sorted(set(rungs))
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def highest_rung(count):
|
|
488
|
+
"""The highest rung at or below count, or None when count clears none.
|
|
489
|
+
|
|
490
|
+
Above the last listed rung the ladder carries on in nudge_step strides, so
|
|
491
|
+
a session that keeps growing keeps getting nudged."""
|
|
492
|
+
top = rungs[-1]
|
|
493
|
+
if count >= top:
|
|
494
|
+
if step is None:
|
|
495
|
+
return top
|
|
496
|
+
return top + ((count - top) // step) * step
|
|
497
|
+
below = [rung for rung in rungs if rung <= count]
|
|
498
|
+
return below[-1] if below else None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
rung = highest_rung(tokens)
|
|
502
|
+
if rung is None:
|
|
503
|
+
bail()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# --- per-session ladder state ----------------------------------------------
|
|
507
|
+
|
|
508
|
+
session_key = session_key_for(transcript)
|
|
509
|
+
if not session_key:
|
|
510
|
+
bail()
|
|
511
|
+
|
|
512
|
+
# The handoff and compact-and-continue skills leave this marker behind when
|
|
513
|
+
# they finish. The work is already handed over, so there is nothing to nudge
|
|
514
|
+
# about for the rest of the session - and .last stays as it was.
|
|
515
|
+
try:
|
|
516
|
+
handed_over = os.path.exists(os.path.join(state_dir, session_key + ".quiet"))
|
|
517
|
+
except Exception:
|
|
518
|
+
# A key the filesystem will not even be asked about: stay silent.
|
|
519
|
+
bail()
|
|
520
|
+
if handed_over:
|
|
521
|
+
bail()
|
|
522
|
+
|
|
523
|
+
last_file = os.path.join(state_dir, session_key + ".last")
|
|
524
|
+
try:
|
|
525
|
+
with open(last_file, encoding="utf-8", errors="replace") as f:
|
|
526
|
+
last_rung = int(f.read().strip())
|
|
527
|
+
except Exception:
|
|
528
|
+
# No record, or a record this hook cannot read: nothing has fired yet.
|
|
529
|
+
last_rung = None
|
|
530
|
+
|
|
531
|
+
if last_rung is not None and rung <= last_rung:
|
|
532
|
+
bail()
|
|
533
|
+
|
|
534
|
+
# Record the rung BEFORE speaking. A nudge the hook cannot remember firing
|
|
535
|
+
# would repeat on every stop, which is the thing this ladder exists to stop.
|
|
536
|
+
try:
|
|
537
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
538
|
+
with open(last_file, "w", encoding="utf-8") as f:
|
|
539
|
+
f.write("{}\n".format(rung))
|
|
540
|
+
except Exception:
|
|
541
|
+
# Unwritable, or a key the filesystem rejects: stay silent rather than
|
|
542
|
+
# nudge without a record, and never let a traceback reach stderr.
|
|
543
|
+
bail()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# --- the nudge -------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
used = "{:,}".format(tokens)
|
|
549
|
+
limit = "{:,}".format(rung)
|
|
550
|
+
large_context = context_window() >= WINDOW_1M
|
|
551
|
+
continuity = (
|
|
552
|
+
"Continuity: if your working context is useful, keep going in this same "
|
|
553
|
+
"session"
|
|
554
|
+
)
|
|
555
|
+
if large_context:
|
|
556
|
+
continuity += " and rely on billion-context"
|
|
557
|
+
continuity += ", with ACP compression as an in-place option if the host offers it."
|
|
558
|
+
freshness = (
|
|
559
|
+
"Freshness: if a fresh context window or an independent read would help "
|
|
560
|
+
"more, use /acstack:handoff."
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
if rung >= severe_at:
|
|
564
|
+
head = (
|
|
565
|
+
"Context check (urgent): this session has used about {used} tokens, "
|
|
566
|
+
"past the {limit} severe mark, so it is close to the wall. Treat high "
|
|
567
|
+
"context as a continuity-versus-freshness checkpoint. {continuity} "
|
|
568
|
+
"{freshness} If you ask the user, say plainly that context is nearly "
|
|
569
|
+
"gone so they can decide quickly."
|
|
570
|
+
).format(used=used, limit=limit, continuity=continuity, freshness=freshness)
|
|
571
|
+
else:
|
|
572
|
+
head = (
|
|
573
|
+
"Context check: this session has used about {used} tokens, past the "
|
|
574
|
+
"{limit} mark. Treat high context as a continuity-versus-freshness "
|
|
575
|
+
"checkpoint. {continuity} {freshness}"
|
|
576
|
+
).format(used=used, limit=limit, continuity=continuity, freshness=freshness)
|
|
577
|
+
|
|
578
|
+
reason = head + " " + DECIDE_TAIL
|
|
579
|
+
|
|
580
|
+
print(json.dumps({"decision": "block", "reason": reason}))
|
|
581
|
+
PY
|
|
582
|
+
|
|
583
|
+
exit 0
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh\""
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"hooks": [
|
|
14
|
+
{
|
|
15
|
+
"type": "command",
|
|
16
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start-handoff.sh\""
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"hooks": [
|
|
22
|
+
{
|
|
23
|
+
"type": "command",
|
|
24
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/context-pressure.sh\""
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"Stop": [
|
|
30
|
+
{
|
|
31
|
+
"hooks": [
|
|
32
|
+
{
|
|
33
|
+
"type": "command",
|
|
34
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/context-pressure.sh\""
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"UserPromptSubmit": [
|
|
40
|
+
{
|
|
41
|
+
"hooks": [
|
|
42
|
+
{
|
|
43
|
+
"type": "command",
|
|
44
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/ac-writing-style-reminder.sh\""
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
}
|