acstack 0.0.0-stage → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +329 -0
  27. package/assets/terminal-workspace/setup.sh +4317 -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 +1774 -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,723 @@
1
+ /**
2
+ * ACStack on pi.
3
+ *
4
+ * The pi port of the Claude Code hooks in hooks/. Claude Code and Codex never
5
+ * load this file, and nothing in hooks/ depends on it.
6
+ *
7
+ * session_start symlink the CLI, warn once if pi-subagents is
8
+ * missing, and print the setup reminder outside a
9
+ * acstack project (hooks/session-start.sh)
10
+ * before_agent_start list ACStack's skills in the system prompt in every
11
+ * session, and add orchestrator/AGENTS.md in a
12
+ * acstack project (hooks/session-start.sh)
13
+ * add the writing style and its per-turn reminder when
14
+ * the user turned it on (hooks/ac-writing-style-reminder.sh)
15
+ * agent_before_settle nudge with a continuity-versus-freshness checkpoint
16
+ * as context fills (hooks/context-pressure.sh)
17
+ * session_compact re-inject acstack/HANDOFF.md after a compaction, and
18
+ * restart the nudge ladder (hooks/session-start-handoff.sh,
19
+ * hooks/context-pressure.sh)
20
+ *
21
+ * It also registers /acstack <skill>, a /acstack:<skill> command per skill,
22
+ * and /acstack-writing-style on|off. Those two forms are the only way a user
23
+ * runs an ACStack skill on pi: package.json lists no skills, so pi adds no
24
+ * /skill:<name> commands for them.
25
+ *
26
+ * Only type imports come from pi, so Node can strip the types and load this
27
+ * file in tests/test-pi-extension.sh with a stub API.
28
+ */
29
+
30
+ import * as fs from "node:fs";
31
+ import * as os from "node:os";
32
+ import * as path from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
35
+
36
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
37
+ const troubleshoot = path.join(packageRoot, "TROUBLESHOOTING.md");
38
+
39
+ /** pi-subagents' background runner sets this in every child process. */
40
+ const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
41
+ /** pi-subagents sets this instead in a child it places in its own herdr pane. */
42
+ const HERDR_BRIDGE_CHILD_ENV = "PI_SUBAGENTS_HERDR_BRIDGE";
43
+
44
+ function agentDir(): string {
45
+ return process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
46
+ }
47
+
48
+ function readText(file: string): string | undefined {
49
+ try {
50
+ return fs.readFileSync(file, "utf-8");
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ function report(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
57
+ if (ctx.hasUI) ctx.ui.notify(message, level);
58
+ else process.stderr.write(`${message}\n`);
59
+ }
60
+
61
+ // --------------------------------------------------------------------------
62
+ // CLI symlinks
63
+ // --------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Point `link` at `src`, with the non-clobber rules of link_cli in
67
+ * hooks/session-start.sh: create when absent, retarget a stale symlink, and
68
+ * never touch a real file.
69
+ */
70
+ function linkCli(ctx: ExtensionContext, src: string, link: string, label: string): void {
71
+ let current: string | undefined;
72
+ let exists = false;
73
+ try {
74
+ const stat = fs.lstatSync(link);
75
+ exists = true;
76
+ if (stat.isSymbolicLink()) current = fs.readlinkSync(link);
77
+ } catch {
78
+ // Absent.
79
+ }
80
+
81
+ if (!exists) {
82
+ try {
83
+ fs.mkdirSync(path.dirname(link), { recursive: true });
84
+ fs.symlinkSync(src, link);
85
+ report(ctx, `acstack: linked ${label} at ${link} -> ${src}`);
86
+ } catch {
87
+ report(ctx, `acstack: failed to create symlink at ${link} – see ${troubleshoot} (section: command not found).`, "warning");
88
+ }
89
+ return;
90
+ }
91
+
92
+ if (current === undefined) {
93
+ report(ctx, `acstack: ${link} exists but is not a symlink – not overwriting. See ${troubleshoot} (section: command not found).`, "warning");
94
+ return;
95
+ }
96
+
97
+ if (current === src) return;
98
+ try {
99
+ const tmp = `${link}.acstack-${process.pid}`;
100
+ fs.symlinkSync(src, tmp);
101
+ fs.renameSync(tmp, link);
102
+ // A repoint is routine after every upgrade, so it stays off the UI.
103
+ process.stderr.write(`acstack: updated ${label} at ${link} -> ${src}\n`);
104
+ } catch {
105
+ report(ctx, `acstack: failed to update stale symlink at ${link} (was: ${current}) – see ${troubleshoot} (section: stale cache).`, "warning");
106
+ }
107
+ }
108
+
109
+ /** The first executable `acstack` on PATH, like `command -v acstack`. */
110
+ function acstackOnPath(): string | undefined {
111
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
112
+ if (!dir) continue;
113
+ const candidate = path.join(dir, "acstack");
114
+ try {
115
+ fs.accessSync(candidate, fs.constants.X_OK);
116
+ if (fs.statSync(candidate).isFile()) return candidate;
117
+ } catch {
118
+ // Not here.
119
+ }
120
+ }
121
+ return undefined;
122
+ }
123
+
124
+ function realpathOrSelf(file: string): string {
125
+ try {
126
+ return fs.realpathSync(file);
127
+ } catch {
128
+ return file;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * The same two symlinks hooks/session-start.sh refreshes.
134
+ *
135
+ * Every skill and agent calls the CLI as ~/.claude/acstack/bin/acstack, so the
136
+ * agent path keeps its Claude-flavoured location on pi too. With both Claude
137
+ * Code and pi installed, each one points the symlink at its own copy on every
138
+ * session start, so it flips between the two caches. Both copies are a
139
+ * released CLI, so either target works.
140
+ */
141
+ function linkClis(ctx: ExtensionContext): void {
142
+ const wrapper = path.join(packageRoot, "cli", "bin", "acstack.js");
143
+ const dist = path.join(packageRoot, "cli", "dist", "index.js");
144
+ if (!fs.existsSync(wrapper)) return;
145
+ if (!fs.existsSync(dist)) {
146
+ report(ctx, `acstack: CLI is missing its built output (${dist}) – see ${troubleshoot} (section: ERR_MODULE_NOT_FOUND).`, "error");
147
+ return;
148
+ }
149
+
150
+ const home = os.homedir();
151
+ linkCli(ctx, wrapper, path.join(home, ".claude", "acstack", "bin", "acstack"), "agent CLI");
152
+
153
+ // Defer to an acstack on PATH that someone else installed. The agent path
154
+ // above is absolute, so it is never affected.
155
+ const humanLink = path.join(home, ".local", "bin", "acstack");
156
+ const onPath = acstackOnPath();
157
+ // The shell hook says so on every session. pi shows each report as a
158
+ // notification, so this stays silent instead.
159
+ if (!onPath || onPath === humanLink || realpathOrSelf(onPath) === realpathOrSelf(humanLink)) {
160
+ linkCli(ctx, wrapper, humanLink, "human CLI");
161
+ }
162
+
163
+ if (process.platform === "linux" || process.platform === "darwin") {
164
+ const vmFreePort = path.join(packageRoot, "assets", "playwriter-bridge", "vm-free-port.sh");
165
+ if (fs.existsSync(vmFreePort)) {
166
+ linkCli(ctx, vmFreePort, path.join(home, ".claude", "acstack", "bin", "vm-free-port.sh"), "vm-free-port.sh");
167
+ }
168
+ }
169
+ }
170
+
171
+ // --------------------------------------------------------------------------
172
+ // Writing style
173
+ // --------------------------------------------------------------------------
174
+
175
+ const settingsFile = (): string => path.join(agentDir(), "acstack.json");
176
+
177
+ function readSettings(): { writingStyle?: boolean } {
178
+ try {
179
+ const parsed = JSON.parse(readText(settingsFile()) ?? "{}");
180
+ return parsed && typeof parsed === "object" ? parsed : {};
181
+ } catch {
182
+ return {};
183
+ }
184
+ }
185
+
186
+ function writeSettings(settings: { writingStyle?: boolean }): void {
187
+ fs.mkdirSync(path.dirname(settingsFile()), { recursive: true });
188
+ fs.writeFileSync(settingsFile(), `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
189
+ }
190
+
191
+ function stripFrontmatter(text: string): string {
192
+ const match = /^---\n[\s\S]*?\n---\n/.exec(text);
193
+ return (match ? text.slice(match[0].length) : text).trim();
194
+ }
195
+
196
+ /**
197
+ * The per-turn reminder, extracted from the style file on every call so it
198
+ * cannot drift. Mirrors the awk in hooks/ac-writing-style-reminder.sh line for
199
+ * line: the top-level lines of "## Who you are", then "## Before you send" in
200
+ * full. tests/test-pi-extension.sh checks the two produce the same text.
201
+ */
202
+ export function extractWritingReminder(style: string): string | undefined {
203
+ const out: string[] = [];
204
+ let persona = false;
205
+ let checklist = false;
206
+ // awk sees no line after the file's final newline, so neither does this.
207
+ for (const line of style.replace(/\n$/, "").split("\n")) {
208
+ if (checklist) {
209
+ out.push(line);
210
+ continue;
211
+ }
212
+ if (line === "## Who you are") {
213
+ persona = true;
214
+ continue;
215
+ }
216
+ if (line === "## Shape: Minto pyramid") persona = false;
217
+ if (persona && line.startsWith("### ")) {
218
+ out.push("", line);
219
+ continue;
220
+ }
221
+ if (persona && /^[*-]/.test(line)) {
222
+ out.push(line);
223
+ continue;
224
+ }
225
+ if (line === "## Before you send") {
226
+ checklist = true;
227
+ out.push("", line);
228
+ }
229
+ }
230
+ if (out.length === 0) return undefined;
231
+ return `ac-writing-style reminder\n\n${out.join("\n")}\n`;
232
+ }
233
+
234
+ // --------------------------------------------------------------------------
235
+ // Context pressure
236
+ // --------------------------------------------------------------------------
237
+
238
+ const DEFAULT_WARN = 200000;
239
+ const DEFAULT_SEVERE = 500000;
240
+ const DEFAULT_STEP = 100000;
241
+ const LARGE_CONTEXT_WINDOW = 1000000;
242
+
243
+ const AGENCY =
244
+ "The call is yours. If you have a strong view either way, act on it " +
245
+ "without asking. Either keep continuity in this session, or choose " +
246
+ "freshness with /acstack:handoff. Ask the user with the host's question " +
247
+ "tool if you don't know which route would make the work go better.";
248
+
249
+ const STANDING_AGREEMENT =
250
+ "If you are partway through a long run of tasks and the user already " +
251
+ "agreed to a handoff earlier in this session, including in a handoff " +
252
+ "or compaction summary you resumed from, use /acstack:handoff without " +
253
+ "asking again.";
254
+
255
+ const DECIDE_TAIL =
256
+ `${AGENCY} ${STANDING_AGREEMENT}` +
257
+ " If you do ask and the user chooses continuity, drop it for now" +
258
+ " and revisit freshness once the current run of work wraps up.";
259
+
260
+ interface HandoffConfig {
261
+ nudge_warn?: number;
262
+ nudge_severe?: number;
263
+ nudge_step?: number;
264
+ context_window?: number;
265
+ context_thresholds?: string[];
266
+ }
267
+
268
+ function flowList(value: string): string[] {
269
+ let inner = value.trim();
270
+ if (inner.startsWith("[")) inner = inner.slice(1);
271
+ if (inner.endsWith("]")) inner = inner.slice(0, -1);
272
+ return inner
273
+ .split(",")
274
+ .map((part) => part.trim().replace(/^["']+|["']+$/g, ""))
275
+ .filter(Boolean);
276
+ }
277
+
278
+ /**
279
+ * The workflow.handoff keys from acstack/project.yaml. A port of read_config
280
+ * in hooks/context-pressure.sh, hand-rolled for the same reason: no YAML
281
+ * library is guaranteed to be installed.
282
+ */
283
+ export function readHandoffConfig(yaml: string | undefined): HandoffConfig {
284
+ const cfg: HandoffConfig = {};
285
+ if (yaml === undefined) return cfg;
286
+ const stack: Array<[number, string]> = [];
287
+ let collecting: number | undefined;
288
+ for (const raw of yaml.split("\n")) {
289
+ if (!raw.trim() || raw.trimStart().startsWith("#")) continue;
290
+ const stripped = raw.replace(/^ +/, "");
291
+ const indent = raw.length - stripped.length;
292
+
293
+ if (collecting !== undefined) {
294
+ if (stripped.startsWith("- ") && indent >= collecting) {
295
+ const item = stripped.slice(2).split("#")[0].trim().replace(/^["']+|["']+$/g, "");
296
+ if (item) cfg.context_thresholds?.push(item);
297
+ continue;
298
+ }
299
+ collecting = undefined;
300
+ }
301
+
302
+ if (stripped.startsWith("- ") || !stripped.includes(":")) continue;
303
+ const sep = stripped.indexOf(":");
304
+ const key = stripped.slice(0, sep).trim().replace(/^["']+|["']+$/g, "");
305
+ const value = stripped.slice(sep + 1).split("#")[0].trim().replace(/^["']+|["']+$/g, "");
306
+
307
+ while (stack.length && stack[stack.length - 1][0] >= indent) stack.pop();
308
+ const keyPath = [...stack.map(([, k]) => k), key];
309
+
310
+ if (!value) {
311
+ stack.push([indent, key]);
312
+ if (keyPath.join(".") === "workflow.handoff.context_thresholds") {
313
+ cfg.context_thresholds = [];
314
+ collecting = indent;
315
+ }
316
+ continue;
317
+ }
318
+
319
+ if (keyPath.length !== 3 || keyPath[0] !== "workflow" || keyPath[1] !== "handoff") continue;
320
+ if (key === "context_thresholds") {
321
+ cfg.context_thresholds = flowList(value);
322
+ } else if (key === "nudge_warn" || key === "nudge_severe" || key === "nudge_step" || key === "context_window") {
323
+ const n = Number.parseInt(value, 10);
324
+ if (/^-?\d+$/.test(value) && Number.isFinite(n)) cfg[key] = n;
325
+ }
326
+ }
327
+ return cfg;
328
+ }
329
+
330
+ /**
331
+ * The nudge for `tokens`, or undefined when no new rung was crossed. Same
332
+ * ladder as hooks/context-pressure.sh. pi reports the model's real context
333
+ * window, so a percentage rung resolves against that instead of a guess from
334
+ * the model id.
335
+ */
336
+ export function contextNudge(
337
+ tokens: number,
338
+ contextWindow: number,
339
+ cfg: HandoffConfig,
340
+ lastRung: number | undefined,
341
+ _inHerdr: boolean,
342
+ ): { rung: number; reason: string } | undefined {
343
+ const window = cfg.context_window && cfg.context_window > 0 ? cfg.context_window : contextWindow;
344
+ // Unlike Claude Code, pi auto-compacts about 16k tokens short of the
345
+ // window, so a 200k mark on a 200k model never fires. An unset mark
346
+ // therefore also stays at or below half (warn) and 80% (severe) of the
347
+ // window.
348
+ const warnAt = Math.max(cfg.nudge_warn ?? Math.min(DEFAULT_WARN, Math.floor(window * 0.5)), 1);
349
+ const severeAt = Math.max(cfg.nudge_severe ?? Math.min(DEFAULT_SEVERE, Math.floor(window * 0.8)), warnAt);
350
+ const rawStep = cfg.nudge_step ?? DEFAULT_STEP;
351
+ const step = rawStep < 1 ? undefined : rawStep;
352
+
353
+ const resolve = (item: string): number | undefined => {
354
+ const s = item.trim().replace(/^["']+|["']+$/g, "");
355
+ if (s.endsWith("%")) {
356
+ const percent = Number.parseFloat(s.slice(0, -1));
357
+ return Number.isFinite(percent) ? Math.round((window * percent) / 100) : undefined;
358
+ }
359
+ return /^-?\d+$/.test(s) ? Number.parseInt(s, 10) : undefined;
360
+ };
361
+
362
+ let rungs = (cfg.context_thresholds ?? [])
363
+ .map(resolve)
364
+ .filter((r): r is number => r !== undefined && r >= 1);
365
+ if (rungs.length === 0) rungs = [warnAt, severeAt];
366
+ rungs = [...new Set(rungs)].sort((a, b) => a - b);
367
+
368
+ const top = rungs[rungs.length - 1];
369
+ let rung: number | undefined;
370
+ if (tokens >= top) rung = step === undefined ? top : top + Math.floor((tokens - top) / step) * step;
371
+ else rung = rungs.filter((r) => r <= tokens).pop();
372
+ if (rung === undefined) return undefined;
373
+ if (lastRung !== undefined && rung <= lastRung) return undefined;
374
+
375
+ const used = tokens.toLocaleString("en-US");
376
+ const limit = rung.toLocaleString("en-US");
377
+ let continuity = "Continuity: if your working context is useful, keep going in this same session";
378
+ if (window >= LARGE_CONTEXT_WINDOW) continuity += " and rely on billion-context";
379
+ continuity += ", with ACP compression as an in-place option if the host offers it.";
380
+ const freshness = "Freshness: if a fresh context window or an independent read would help more, use /acstack:handoff.";
381
+ const head =
382
+ rung >= severeAt
383
+ ? `Context check (urgent): this session has used about ${used} tokens, ` +
384
+ `past the ${limit} severe mark, so it is close to the wall. Treat high ` +
385
+ `context as a continuity-versus-freshness checkpoint. ${continuity} ` +
386
+ `${freshness} If you ask the user, say plainly that context is nearly ` +
387
+ "gone so they can decide quickly."
388
+ : `Context check: this session has used about ${used} tokens, past the ` +
389
+ `${limit} mark. Treat high context as a continuity-versus-freshness ` +
390
+ `checkpoint. ${continuity} ${freshness}`;
391
+ return { rung, reason: `${head} ${DECIDE_TAIL}` };
392
+ }
393
+
394
+ /**
395
+ * Ladder state shares its directory and file names with
396
+ * hooks/context-pressure.sh. The handoff skill writes <session id>.quiet
397
+ * there once the work is handed over.
398
+ */
399
+ function pressureStateDir(): string {
400
+ return process.env.ACSTACK_CONTEXT_PRESSURE_DIR || path.join(process.env.TMPDIR || "/tmp", "acstack-context-pressure");
401
+ }
402
+
403
+ function sessionKey(ctx: ExtensionContext): string | undefined {
404
+ const id = ctx.sessionManager.getSessionId();
405
+ if (!id || id.includes("\0")) return undefined;
406
+ const key = path.basename(id.replace(/[\\/]/g, "_"));
407
+ return key && key !== "." && key !== ".." ? key : undefined;
408
+ }
409
+
410
+ // --------------------------------------------------------------------------
411
+ // Handoff reload
412
+ // --------------------------------------------------------------------------
413
+
414
+ const HANDOFF_PREAMBLE =
415
+ "The session was just compacted. The following ACStack handoff captures " +
416
+ "load-bearing work state the summary may have compressed. Treat it as " +
417
+ "authoritative for what to do next.\n\n" +
418
+ 'Resume now: when the user\'s next message is "continue" (or any short ' +
419
+ "nudge), pick up the work immediately from the Next action below - do not " +
420
+ "wait for further instruction. If anything in this handoff or the " +
421
+ "compaction summary is ambiguous or underspecified, ask the user " +
422
+ "to resolve it before acting rather than guessing. Ask liberally: a " +
423
+ "wrong assumption taken after compaction is expensive to unwind.\n\n";
424
+
425
+ /**
426
+ * Consume acstack/HANDOFF.md the way hooks/session-start-handoff.sh does:
427
+ * only a file carrying the acstack_handoff marker, moved into a per-map
428
+ * archive so it is never injected twice. Returns the text to inject.
429
+ */
430
+ export function consumeHandoff(cwd: string, now: Date = new Date()): string | undefined {
431
+ const active = path.join(cwd, "acstack", "HANDOFF.md");
432
+ const raw = readText(active);
433
+ if (!raw?.trim()) return undefined;
434
+
435
+ if (!raw.startsWith("---")) return undefined;
436
+ const lines = raw.split("\n");
437
+ const end = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
438
+ if (end === -1) return undefined;
439
+ const meta: Record<string, string> = {};
440
+ for (const line of lines.slice(1, end)) {
441
+ const sep = line.indexOf(":");
442
+ if (sep !== -1) meta[line.slice(0, sep).trim()] = line.slice(sep + 1).trim();
443
+ }
444
+ const body = lines.slice(end + 1).join("\n").replace(/^\n+/, "");
445
+ if (!("acstack_handoff" in meta) || !body.trim()) return undefined;
446
+
447
+ let map = (meta.map || meta.change || "ad-hoc").replace(/[^A-Za-z0-9._-]/g, "-");
448
+ if (!map.replace(/^\.+|\.+$/g, "")) map = "ad-hoc";
449
+ let stamp = (meta.created || "").replace(/[^A-Za-z0-9._-]/g, "-");
450
+ if (!stamp) stamp = now.toISOString().replace(/\.\d+Z$/, "Z").replace(/:/g, "-");
451
+
452
+ const changeDir = path.join(cwd, "acstack", "changes", map);
453
+ const archiveDir =
454
+ map !== "ad-hoc" && fs.existsSync(changeDir)
455
+ ? path.join(changeDir, "handoffs")
456
+ : path.join(cwd, "acstack", "handoffs", map);
457
+ try {
458
+ fs.mkdirSync(archiveDir, { recursive: true });
459
+ let dest = path.join(archiveDir, `${stamp}.md`);
460
+ for (let n = 2; fs.existsSync(dest); n++) dest = path.join(archiveDir, `${stamp}-${n}.md`);
461
+ fs.renameSync(active, dest);
462
+ } catch {
463
+ // Best effort: inject anyway so the resume is not lost.
464
+ }
465
+ return HANDOFF_PREAMBLE + body;
466
+ }
467
+
468
+ // --------------------------------------------------------------------------
469
+ // Skill commands
470
+ // --------------------------------------------------------------------------
471
+
472
+ interface SkillInfo {
473
+ file: string;
474
+ description: string;
475
+ /** disable-model-invocation: true – runs only when the user asks for it. */
476
+ userOnly: boolean;
477
+ }
478
+
479
+ /** One frontmatter value: every SKILL.md keeps its fields on one line. */
480
+ function frontmatterField(text: string, key: string): string | undefined {
481
+ const frontmatter = /^---\n([\s\S]*?)\n---\n/.exec(text)?.[1] ?? "";
482
+ const raw = new RegExp(`^${key}:[ \t]*(.*)$`, "m").exec(frontmatter)?.[1].trim();
483
+ if (raw === undefined) return undefined;
484
+ if (raw.startsWith('"') && raw.endsWith('"')) {
485
+ try {
486
+ return JSON.parse(raw);
487
+ } catch {
488
+ return raw.slice(1, -1);
489
+ }
490
+ }
491
+ if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1).replace(/''/g, "'");
492
+ return raw;
493
+ }
494
+
495
+ /** This package's skills, by name, from skills/<dir>/SKILL.md. */
496
+ export function listSkills(root: string = packageRoot): Map<string, SkillInfo> {
497
+ const skills = new Map<string, SkillInfo>();
498
+ const dir = path.join(root, "skills");
499
+ let entries: fs.Dirent[] = [];
500
+ try {
501
+ entries = fs.readdirSync(dir, { withFileTypes: true });
502
+ } catch {
503
+ return skills;
504
+ }
505
+ for (const entry of entries.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
506
+ const file = path.join(dir, entry.name, "SKILL.md");
507
+ const text = readText(file) ?? "";
508
+ const name = frontmatterField(text, "name");
509
+ if (!name) continue;
510
+ skills.set(name, {
511
+ file,
512
+ description: frontmatterField(text, "description") ?? "",
513
+ userOnly: frontmatterField(text, "disable-model-invocation") === "true",
514
+ });
515
+ }
516
+ return skills;
517
+ }
518
+
519
+ function escapeXml(text: string): string {
520
+ return text
521
+ .replace(/&/g, "&amp;")
522
+ .replace(/</g, "&lt;")
523
+ .replace(/>/g, "&gt;")
524
+ .replace(/"/g, "&quot;")
525
+ .replace(/'/g, "&apos;");
526
+ }
527
+
528
+ /**
529
+ * The skills section of the system prompt. ACStack's skills are not pi skills,
530
+ * because pi would turn each one into a /skill:<name> command, so this lists
531
+ * them the way pi's formatSkillsForPrompt lists its own. Like pi, it leaves
532
+ * out a skill only the user may run.
533
+ */
534
+ export function skillsSection(skills: Map<string, SkillInfo>): string | undefined {
535
+ const visible = [...skills].filter(([, skill]) => !skill.userOnly);
536
+ if (visible.length === 0) return undefined;
537
+ const lines = [
538
+ "The following ACStack skills provide specialized instructions for specific tasks.",
539
+ "Use the read tool to load a skill's file when the task matches its description.",
540
+ "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
541
+ "When you tell the user to run an ACStack skill, write /acstack:<name>, never /skill:<name>.",
542
+ "",
543
+ "<available_skills>",
544
+ ];
545
+ for (const [name, skill] of visible) {
546
+ lines.push(
547
+ " <skill>",
548
+ ` <name>${escapeXml(name)}</name>`,
549
+ ` <description>${escapeXml(skill.description)}</description>`,
550
+ ` <location>${escapeXml(skill.file)}</location>`,
551
+ " </skill>",
552
+ );
553
+ }
554
+ lines.push("</available_skills>");
555
+ return lines.join("\n");
556
+ }
557
+
558
+ /**
559
+ * The user message for one ACStack skill, in the same shape pi's own
560
+ * /skill:<name> expansion produces. Built from this package's file, so a
561
+ * generic name such as map or commit that another package's skill took
562
+ * first still runs the ACStack skill.
563
+ */
564
+ export function skillInvocation(name: string, file: string, args: string): string | undefined {
565
+ const content = readText(file);
566
+ if (content === undefined) return undefined;
567
+ const block = `<skill name="${name}" location="${file}">\nReferences are relative to ${path.dirname(file)}.\n\n${stripFrontmatter(content)}\n</skill>`;
568
+ return args.trim() ? `${block}\n\n${args.trim()}` : block;
569
+ }
570
+
571
+ // --------------------------------------------------------------------------
572
+ // The extension
573
+ // --------------------------------------------------------------------------
574
+
575
+ export default function acstack(pi: ExtensionAPI): void {
576
+ // A background subagent loads every ambient extension. The orchestrator
577
+ // prompt and the nudges belong to the lead session, never to a subagent.
578
+ if (process.env[SUBAGENT_CHILD_ENV] === "1" || process.env[HERDR_BRIDGE_CHILD_ENV] === "1") return;
579
+
580
+ const orchestratorFile = path.join(packageRoot, "orchestrator", "AGENTS.md");
581
+ const styleFile = path.join(packageRoot, "output-styles", "ac-writing-style.md");
582
+ const isProject = (cwd: string) => fs.existsSync(path.join(cwd, "acstack", "project.yaml"));
583
+ let warnedSubagents = false;
584
+
585
+ pi.on("session_start", async (event, ctx) => {
586
+ linkClis(ctx);
587
+
588
+ if (!warnedSubagents && !pi.getAllTools().some((tool) => tool.name === "subagent")) {
589
+ warnedSubagents = true;
590
+ report(
591
+ ctx,
592
+ "acstack: pi-subagents is not loaded, so the test-writer, task-executor, task-checker and frontend-verifier agents cannot run. Install it with `pi install npm:pi-subagents`.",
593
+ "warning",
594
+ );
595
+ }
596
+
597
+ if (event.reason === "startup" && !isProject(ctx.cwd)) {
598
+ report(ctx, "acstack: no project config found. Run /acstack:setup to set up.");
599
+ }
600
+ });
601
+
602
+ // /acstack map <args> and /acstack:map <args> both run ACStack's own map
603
+ // skill, matching the /acstack:map names Claude Code shows.
604
+ const skills = listSkills();
605
+ const skillsPrompt = skillsSection(skills);
606
+
607
+ pi.on("before_agent_start", async (event, ctx) => {
608
+ const sections = event.systemPromptOptions.sections;
609
+
610
+ // Every session, like the plugin's skills on Claude Code.
611
+ if (skillsPrompt) sections["acstack-skills"] = skillsPrompt;
612
+
613
+ if (isProject(ctx.cwd)) {
614
+ const orchestrator = readText(orchestratorFile);
615
+ if (orchestrator) sections["acstack-orchestrator"] = orchestrator;
616
+ else report(ctx, `acstack: could not read ${orchestratorFile}`, "warning");
617
+ }
618
+
619
+ if (!readSettings().writingStyle) return;
620
+ const style = readText(styleFile);
621
+ if (!style) return;
622
+ sections["acstack-writing-style"] = stripFrontmatter(style);
623
+ const reminder = extractWritingReminder(style);
624
+ if (!reminder) return;
625
+ return { message: { customType: "acstack-writing-style-reminder", content: reminder, display: false } };
626
+ });
627
+
628
+ pi.on("agent_before_settle", async (event, ctx) => {
629
+ if (event.outcome !== "completed") return;
630
+ const usage = ctx.getContextUsage();
631
+ if (!usage || usage.tokens === null) return;
632
+ const key = sessionKey(ctx);
633
+ if (!key) return;
634
+
635
+ const dir = pressureStateDir();
636
+ if (fs.existsSync(path.join(dir, `${key}.quiet`))) return;
637
+ const lastFile = path.join(dir, `${key}.last`);
638
+ const lastText = readText(lastFile)?.trim();
639
+ const lastRung = lastText && /^\d+$/.test(lastText) ? Number.parseInt(lastText, 10) : undefined;
640
+
641
+ const cfg = readHandoffConfig(readText(path.join(ctx.cwd, "acstack", "project.yaml")));
642
+ const nudge = contextNudge(usage.tokens, usage.contextWindow, cfg, lastRung, process.env.HERDR_ENV === "1");
643
+ if (!nudge) return;
644
+
645
+ // Record the rung before speaking. A nudge it cannot remember firing
646
+ // would repeat on every settle.
647
+ try {
648
+ fs.mkdirSync(dir, { recursive: true });
649
+ fs.writeFileSync(lastFile, `${nudge.rung}\n`, "utf-8");
650
+ } catch {
651
+ return;
652
+ }
653
+ return {
654
+ entries: [{ type: "custom_message", customType: "acstack-context-pressure", content: nudge.reason, display: true }],
655
+ continue: true,
656
+ };
657
+ });
658
+
659
+ pi.on("session_compact", async (_event, ctx) => {
660
+ // The compaction threw the old context away, so the ladder starts over.
661
+ const key = sessionKey(ctx);
662
+ if (key) {
663
+ for (const suffix of [".last", ".quiet"]) {
664
+ try {
665
+ fs.rmSync(path.join(pressureStateDir(), key + suffix), { force: true });
666
+ } catch {
667
+ // Unremovable: the handoff below still has to load.
668
+ }
669
+ }
670
+ }
671
+
672
+ const handoff = consumeHandoff(ctx.cwd);
673
+ if (handoff) {
674
+ pi.sendMessage({ customType: "acstack-handoff", content: handoff, display: true }, { deliverAs: "nextTurn" });
675
+ }
676
+ });
677
+
678
+ const runSkill = (name: string, args: string, ctx: ExtensionContext): void => {
679
+ const file = skills.get(name)?.file;
680
+ const message = file && skillInvocation(name, file, args);
681
+ if (!message) {
682
+ report(ctx, `acstack: no skill named "${name}". Skills: ${[...skills.keys()].join(", ")}`, "warning");
683
+ return;
684
+ }
685
+ if (ctx.isIdle()) pi.sendUserMessage(message);
686
+ else pi.sendUserMessage(message, { deliverAs: "followUp" });
687
+ };
688
+
689
+ pi.registerCommand("acstack", {
690
+ description: "Run an ACStack skill: /acstack <skill> [args], e.g. /acstack map",
691
+ handler: async (args, ctx) => {
692
+ const [name = "", ...rest] = args.trim().split(/\s+/);
693
+ if (!name) {
694
+ report(ctx, `acstack: usage is /acstack <skill> [args]. Skills: ${[...skills.keys()].join(", ")}`);
695
+ return;
696
+ }
697
+ runSkill(name, rest.join(" "), ctx);
698
+ },
699
+ });
700
+
701
+ for (const name of skills.keys()) {
702
+ pi.registerCommand(`acstack:${name}`, {
703
+ description: `Run the ACStack ${name} skill`,
704
+ handler: async (args, ctx) => runSkill(name, args, ctx),
705
+ });
706
+ }
707
+
708
+ pi.registerCommand("acstack-writing-style", {
709
+ description: "Turn the ACStack writing style on or off for every pi session (on | off)",
710
+ handler: async (args, ctx) => {
711
+ const choice = args.trim().toLowerCase();
712
+ const settings = readSettings();
713
+ if (choice === "on" || choice === "off") {
714
+ settings.writingStyle = choice === "on";
715
+ writeSettings(settings);
716
+ } else if (choice) {
717
+ report(ctx, "acstack: usage is /acstack-writing-style on|off", "warning");
718
+ return;
719
+ }
720
+ report(ctx, `acstack: writing style is ${settings.writingStyle ? "on" : "off"} (${settingsFile()}).`);
721
+ },
722
+ });
723
+ }