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.
Files changed (143) hide show
  1. package/.claude-plugin/plugin.json +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +389 -2
  4. package/THIRD_PARTY_NOTICES +14 -0
  5. package/TROUBLESHOOTING.md +225 -0
  6. package/agents/codex/frontend-verifier.toml +352 -0
  7. package/agents/codex/task-checker.toml +345 -0
  8. package/agents/codex/task-executor.toml +157 -0
  9. package/agents/codex/test-writer.toml +149 -0
  10. package/agents/frontend-verifier.md +351 -0
  11. package/agents/task-checker.md +344 -0
  12. package/agents/task-executor.md +156 -0
  13. package/agents/test-writer.md +148 -0
  14. package/assets/playwriter-bridge/build-launcher.ps1 +32 -0
  15. package/assets/playwriter-bridge/doctor.ps1 +450 -0
  16. package/assets/playwriter-bridge/launcher-src.cs +131 -0
  17. package/assets/playwriter-bridge/opener.js +330 -0
  18. package/assets/playwriter-bridge/opener.ps1 +63 -0
  19. package/assets/playwriter-bridge/register-tasks.ps1 +328 -0
  20. package/assets/playwriter-bridge/relay.ps1 +50 -0
  21. package/assets/playwriter-bridge/stop.ps1 +125 -0
  22. package/assets/playwriter-bridge/sync.ps1 +271 -0
  23. package/assets/playwriter-bridge/tunnel.ps1 +325 -0
  24. package/assets/playwriter-bridge/vm-free-port.sh +166 -0
  25. package/assets/terminal-workspace/client-keybindings.md +87 -0
  26. package/assets/terminal-workspace/config.example.yaml +334 -0
  27. package/assets/terminal-workspace/setup.sh +4404 -0
  28. package/cli/README.md +47 -0
  29. package/cli/bin/acstack.js +3 -0
  30. package/cli/dist/index.js +25984 -0
  31. package/cli/dist/index.js.map +7 -0
  32. package/cli/package.json +59 -0
  33. package/docs/adr/0001-ste-writing-standard.md +16 -0
  34. package/docs/adr/0002-version-gate-not-auto-bump.md +35 -0
  35. package/docs/adr/0003-handoff-ack-command.md +19 -0
  36. package/docs/adr/0004-dev-setup-per-host.md +13 -0
  37. package/docs/adr/0005-two-orchestrators-per-host.md +25 -0
  38. package/docs/adr/0006-document-opener-service.md +89 -0
  39. package/docs/adr/0007-lead-check-reads-the-transcript.md +87 -0
  40. package/docs/adr/0008-cli-audits-skill-interviews.md +37 -0
  41. package/docs/adr/0009-verify-the-write-not-the-writer.md +51 -0
  42. package/docs/adr/0010-promote-dev-to-main-by-fast-forward.md +115 -0
  43. package/docs/adr/0011-node-header-is-the-edge-encoding.md +20 -0
  44. package/docs/adr/0012-node-kind-is-a-closed-set-of-five.md +31 -0
  45. package/docs/adr/0013-the-diagram-renderer-is-pure.md +23 -0
  46. package/docs/adr/0014-reading-a-node-hides-its-generated-diagram.md +29 -0
  47. package/docs/adr/0015-a-node-id-links-to-its-issue-through-an-anchor.md +37 -0
  48. package/docs/adr/0016-the-map-root-carries-no-status-of-its-own.md +21 -0
  49. package/docs/adr/0017-call-impeccable-copy-only-its-questions.md +13 -0
  50. package/docs/adr/index.md +17 -0
  51. package/docs/config-reference.md +572 -0
  52. package/docs/dev-setups.md +164 -0
  53. package/docs/migrate-0.8.md +28 -0
  54. package/docs/playwriter-bridge-windows.md +15 -0
  55. package/docs/terminal-workspace-keys.md +137 -0
  56. package/docs/terminal-workspace.md +1780 -0
  57. package/docs/workflows.md +309 -0
  58. package/hooks/ac-writing-style-reminder.sh +86 -0
  59. package/hooks/context-pressure.sh +583 -0
  60. package/hooks/hooks.json +50 -0
  61. package/hooks/session-start-handoff.sh +165 -0
  62. package/hooks/session-start.sh +214 -0
  63. package/orchestrator/AGENTS.md +532 -0
  64. package/output-styles/ac-writing-style.md +286 -0
  65. package/package.json +50 -4
  66. package/pi/agents/frontend-verifier.md +357 -0
  67. package/pi/agents/task-checker.md +348 -0
  68. package/pi/agents/task-executor.md +160 -0
  69. package/pi/agents/test-writer.md +154 -0
  70. package/pi/extension.ts +723 -0
  71. package/profiles/fullstack-python.yaml +29 -0
  72. package/profiles/node-typescript.yaml +23 -0
  73. package/profiles/python.yaml +24 -0
  74. package/skills/archive/SKILL.md +160 -0
  75. package/skills/bootstrap/SKILL.md +109 -0
  76. package/skills/bridge/HANDOFF.md +93 -0
  77. package/skills/bridge/SKILL-VM.md +292 -0
  78. package/skills/bridge/SKILL-WINDOWS.md +526 -0
  79. package/skills/bridge/SKILL.md +121 -0
  80. package/skills/browser-verify/SKILL.md +229 -0
  81. package/skills/code-review/SKILL.md +44 -0
  82. package/skills/commit/SKILL.md +106 -0
  83. package/skills/compact-and-continue/SKILL.md +241 -0
  84. package/skills/explore/SKILL.md +149 -0
  85. package/skills/grilling/SKILL.md +134 -0
  86. package/skills/handoff/SKILL.md +250 -0
  87. package/skills/handoff/launch-herdr.md +197 -0
  88. package/skills/handoff/launch-without-herdr.md +30 -0
  89. package/skills/handoff/lead-check.md +59 -0
  90. package/skills/handoff/outcomes.md +162 -0
  91. package/skills/handoff/running-agent.md +50 -0
  92. package/skills/handoff/watch.md +115 -0
  93. package/skills/host/SKILL.md +247 -0
  94. package/skills/host/detect-host.sh +297 -0
  95. package/skills/host/herdr.md +79 -0
  96. package/skills/host/install-herdr-block.sh +467 -0
  97. package/skills/host/install-orca.sh +512 -0
  98. package/skills/host/notes.md +97 -0
  99. package/skills/host/orca.md +160 -0
  100. package/skills/host/questions.md +163 -0
  101. package/skills/host/report.md +42 -0
  102. package/skills/implement/SKILL.md +230 -0
  103. package/skills/implement/decision-log.md +62 -0
  104. package/skills/implement/design-pass.md +32 -0
  105. package/skills/implement/relaxed-tdd.md +28 -0
  106. package/skills/map/SKILL.md +250 -0
  107. package/skills/map/frontend.md +227 -0
  108. package/skills/map/trackers/files.md +74 -0
  109. package/skills/map/trackers/github.md +180 -0
  110. package/skills/map/visuals.md +387 -0
  111. package/skills/new-worktree/SKILL.md +155 -0
  112. package/skills/new-worktree/detect-orchestrator.sh +328 -0
  113. package/skills/new-worktree/detect-orchestrator.test.sh +894 -0
  114. package/skills/new-worktree/herdr.md +84 -0
  115. package/skills/new-worktree/orca.md +101 -0
  116. package/skills/pre-commit-review/SKILL.md +242 -0
  117. package/skills/pre-commit-review/auto-fix.md +19 -0
  118. package/skills/pre-commit-review/interactive.md +35 -0
  119. package/skills/quick-fix/SKILL.md +125 -0
  120. package/skills/record-context/SKILL.md +106 -0
  121. package/skills/setup/SKILL.md +250 -0
  122. package/skills/setup/browser.md +123 -0
  123. package/skills/setup/design-review.md +97 -0
  124. package/skills/setup/first-run.md +114 -0
  125. package/skills/setup/report.md +68 -0
  126. package/skills/setup/row-fixes.md +196 -0
  127. package/skills/setup/terminal-workspace.md +153 -0
  128. package/skills/sync/SKILL.md +171 -0
  129. package/skills/teardown-worktree/SKILL.md +245 -0
  130. package/skills/teardown-worktree/detector.md +39 -0
  131. package/skills/teardown-worktree/herdr.md +113 -0
  132. package/skills/teardown-worktree/orca.md +61 -0
  133. package/skills/terminal-workspace/SKILL.md +249 -0
  134. package/skills/terminal-workspace/components.md +36 -0
  135. package/skills/terminal-workspace/config-choices.md +69 -0
  136. package/skills/terminal-workspace/emulator-keys.md +48 -0
  137. package/skills/terminal-workspace/headless-clipboard.md +175 -0
  138. package/skills/terminal-workspace/turn-off-and-update.md +104 -0
  139. package/skills/test-conventions/SKILL.md +59 -0
  140. package/skills/visual-docs/SKILL.md +246 -0
  141. package/skills/writing/SKILL.md +231 -0
  142. package/skills/writing/examples.md +153 -0
  143. 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
@@ -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
+ }