acstack 0.52.5 → 0.53.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.
- package/.claude-plugin/plugin.json +1 -1
- package/cli/dist/index.js +4 -0
- package/cli/dist/index.js.map +2 -2
- package/cli/package.json +1 -1
- package/docs/config-reference.md +25 -1
- package/hooks/hooks.json +11 -0
- package/hooks/lib/read-guard.mjs +190 -0
- package/hooks/read-guard.mjs +31 -0
- package/hooks/session-start.sh +4 -1
- package/orchestrator/AGENTS.md +59 -6
- package/package.json +1 -1
- package/pi/extension.ts +50 -8
package/cli/package.json
CHANGED
package/docs/config-reference.md
CHANGED
|
@@ -305,7 +305,7 @@ Three refusals protect a file neither command can handle.
|
|
|
305
305
|
|
|
306
306
|
## 3. workflow: the switches
|
|
307
307
|
|
|
308
|
-
*
|
|
308
|
+
*Six booleans sit in the block. Each one changes how much discipline ACStack applies. A byte count tunes the large-read guard, and one more key decides how the agent asks you a question.*
|
|
309
309
|
|
|
310
310
|
| Key | Values | Default | What changes |
|
|
311
311
|
| --- | --- | --- | --- |
|
|
@@ -314,6 +314,8 @@ Three refusals protect a file neither command can handle.
|
|
|
314
314
|
| `workflow.tdd.orchestrator_strict` | `true`, `false` | none | deprecated compatibility boolean; no delegation effect, including explicit `true` |
|
|
315
315
|
| `workflow.frontend_verification` | `true`, `false` | `false` | the frontend-verifier runs only when this is `true`, the file configures a `frontend` block, and the change touched frontend files |
|
|
316
316
|
| `workflow.design_review` | `true`, `false` | `false` | a design review runs on a UI change only when this is `true`; it needs the impeccable plugin installed and switched on, which `acstack config check` reports on |
|
|
317
|
+
| `workflow.read_guard` | `true`, `false` | `true` | `false` turns off the large-read guard; section 3.3 describes it |
|
|
318
|
+
| `workflow.read_guard_bytes` | a whole number, 1 or more | `32768` | the smallest file, in bytes, the large-read guard stops |
|
|
317
319
|
|
|
318
320
|
How a boolean reads:
|
|
319
321
|
|
|
@@ -361,6 +363,26 @@ How a boolean reads:
|
|
|
361
363
|
- `tool` falls back to inline prose for any round it cannot hold
|
|
362
364
|
- Two things exceed it: a round of more than four questions, and any question with no discrete options
|
|
363
365
|
|
|
366
|
+
### 3.3. The large-read guard
|
|
367
|
+
|
|
368
|
+
*The guard stops a main agent's first whole read of a large file once, and points it to a cheaper way to read.*
|
|
369
|
+
|
|
370
|
+
- The guard runs on Claude Code and Codex as a `PreToolUse` hook (`hooks/read-guard.mjs`), and on pi in the extension's `tool_call` handler
|
|
371
|
+
- Both share one policy, `hooks/lib/read-guard.mjs`
|
|
372
|
+
- It stops a whole-file read of a file of at least `workflow.read_guard_bytes` bytes
|
|
373
|
+
- A whole-file read is the read tool with no offset or limit, or a single-file `cat`, `less`, `more`, `nl`, or `bat` with no pipe, redirect, or variable
|
|
374
|
+
- The block message suggests a ranged read, a search, or a read-only subagent
|
|
375
|
+
- Repeating the same read passes, and so does any later whole read of the same unchanged file
|
|
376
|
+
- The guard is a nudge, not a security boundary
|
|
377
|
+
- A ranged read, a pipe, or `head` always passes
|
|
378
|
+
- The guard watches only a main agent, meaning a session the user started
|
|
379
|
+
- Claude Code and Codex subagents carry `agent_id` in the hook input, so they pass
|
|
380
|
+
- ac-teams members (`ACSTACK_AGENT_ROLE` set to anything but `main`, or `AC_TEAMS_MEMBER` set) and pi-subagents children (`PI_SUBAGENT_CHILD`) pass
|
|
381
|
+
- On pi, a session with a `parentSession` header or no session file passes, so the guard skips a forked session too
|
|
382
|
+
- When the guard cannot tell, it lets the read through
|
|
383
|
+
- Any error inside the guard lets the read through
|
|
384
|
+
- Claude Code and Codex record passed reads in `${TMPDIR:-/tmp}/acstack-read-guard-<uid>/<session_id>.json`, a folder only its owner can open; pi keeps them in memory
|
|
385
|
+
|
|
364
386
|
## 4. workflow.maps
|
|
365
387
|
|
|
366
388
|
*Two keys record where a map's nodes live, and what happens to those nodes at the end.*
|
|
@@ -634,6 +656,8 @@ workflow:
|
|
|
634
656
|
# An absent key reads as false. Section 3 says why.
|
|
635
657
|
frontend_verification: true
|
|
636
658
|
design_review: false
|
|
659
|
+
read_guard: true
|
|
660
|
+
read_guard_bytes: 32768
|
|
637
661
|
maps:
|
|
638
662
|
# tracker has no default: the map skill picks and writes it
|
|
639
663
|
persist: false
|
package/hooks/hooks.json
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The large-read guard's policy, shared by hooks/read-guard.mjs (Claude Code
|
|
2
|
+
// and Codex) and pi/extension.ts. It knows nothing about any harness: callers
|
|
3
|
+
// say whether the agent is a main agent and pass a retry ledger.
|
|
4
|
+
//
|
|
5
|
+
// A main agent's first whole-file read of a large file is blocked with a
|
|
6
|
+
// redirect message; an identical repeat passes. Every error allows the read,
|
|
7
|
+
// because the guard saves context and must never trap an agent.
|
|
8
|
+
//
|
|
9
|
+
// Design: .acstack/designs/large-read-guard/overview.md
|
|
10
|
+
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as os from 'node:os';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_MIN_BYTES = 32768;
|
|
17
|
+
|
|
18
|
+
const SHELL_READERS = new Set(['cat', 'less', 'more', 'nl', 'bat']);
|
|
19
|
+
|
|
20
|
+
/** True when a launcher marked this process as anything but a main agent. */
|
|
21
|
+
export function envSaysNotMain(env = process.env) {
|
|
22
|
+
const role = env.ACSTACK_AGENT_ROLE;
|
|
23
|
+
return Boolean(role && role !== 'main') || Boolean(env.AC_TEAMS_MEMBER);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The one settings function: workflow.read_guard and
|
|
28
|
+
* workflow.read_guard_bytes from the nearest
|
|
29
|
+
* .acstack/project.yaml above cwd. Hand-rolled like the other hooks' readers,
|
|
30
|
+
* because no YAML library is guaranteed at hook time.
|
|
31
|
+
*/
|
|
32
|
+
export function readGuardSettings(cwd) {
|
|
33
|
+
const settings = { enabled: true, minBytes: DEFAULT_MIN_BYTES };
|
|
34
|
+
const yaml = projectYaml(cwd);
|
|
35
|
+
if (yaml === undefined) return settings;
|
|
36
|
+
const stack = [];
|
|
37
|
+
for (const raw of yaml.split('\n')) {
|
|
38
|
+
if (!raw.trim() || raw.trimStart().startsWith('#')) continue;
|
|
39
|
+
const stripped = raw.replace(/^ +/, '');
|
|
40
|
+
const indent = raw.length - stripped.length;
|
|
41
|
+
if (stripped.startsWith('- ') || !stripped.includes(':')) continue;
|
|
42
|
+
const sep = stripped.indexOf(':');
|
|
43
|
+
const key = stripped.slice(0, sep).trim().replace(/^["']+|["']+$/g, '');
|
|
44
|
+
const value = stripped.slice(sep + 1).split('#')[0].trim().replace(/^["']+|["']+$/g, '');
|
|
45
|
+
while (stack.length && stack[stack.length - 1][0] >= indent) stack.pop();
|
|
46
|
+
const keyPath = [...stack.map(([, k]) => k), key].join('.');
|
|
47
|
+
if (!value) {
|
|
48
|
+
stack.push([indent, key]);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (keyPath === 'workflow.read_guard') settings.enabled = !['false', 'no', 'off'].includes(value.toLowerCase());
|
|
52
|
+
if (keyPath === 'workflow.read_guard_bytes' && /^\d+$/.test(value)) settings.minBytes = Number(value);
|
|
53
|
+
}
|
|
54
|
+
return settings;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function projectYaml(cwd) {
|
|
58
|
+
let dir = path.resolve(cwd);
|
|
59
|
+
while (true) {
|
|
60
|
+
const file = path.join(dir, '.acstack', 'project.yaml');
|
|
61
|
+
if (fs.existsSync(file)) {
|
|
62
|
+
try {
|
|
63
|
+
return fs.readFileSync(file, 'utf8');
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const parent = path.dirname(dir);
|
|
69
|
+
if (parent === dir) return undefined;
|
|
70
|
+
dir = parent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The file a tool call reads whole, or undefined when the call is not a
|
|
76
|
+
* whole-file read: a ranged read, a pipe, a script, or anything unrecognised.
|
|
77
|
+
*/
|
|
78
|
+
export function wholeReadTarget(toolName, input, cwd) {
|
|
79
|
+
if (!input || typeof input !== 'object') return undefined;
|
|
80
|
+
const name = String(toolName).toLowerCase();
|
|
81
|
+
if (name === 'read') {
|
|
82
|
+
if (input.offset != null || input.limit != null) return undefined;
|
|
83
|
+
const file = input.file_path ?? input.path;
|
|
84
|
+
return typeof file === 'string' && file ? resolvePath(file, cwd) : undefined;
|
|
85
|
+
}
|
|
86
|
+
if (name === 'bash') {
|
|
87
|
+
const file = shellReadTarget(input.command);
|
|
88
|
+
return file === undefined ? undefined : resolvePath(file, cwd);
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The single file of `cat|less|more|nl|bat [-flags] <file>`, nothing more. */
|
|
94
|
+
export function shellReadTarget(command) {
|
|
95
|
+
if (typeof command !== 'string') return undefined;
|
|
96
|
+
// Pipes, redirects, chains, substitutions, globs, and variables all pass.
|
|
97
|
+
if (/[|;&<>`$()*?\n\\]/.test(command)) return undefined;
|
|
98
|
+
const words = [];
|
|
99
|
+
for (const match of command.trim().matchAll(/'([^']*)'|"([^"]*)"|(\S+)/g)) {
|
|
100
|
+
words.push(match[1] ?? match[2] ?? match[3]);
|
|
101
|
+
}
|
|
102
|
+
const [reader, ...args] = words;
|
|
103
|
+
if (!SHELL_READERS.has(reader)) return undefined;
|
|
104
|
+
const flags = args.filter((arg) => arg.startsWith('-'));
|
|
105
|
+
const files = args.filter((arg) => !arg.startsWith('-'));
|
|
106
|
+
// bat -r / --line-range reads a range.
|
|
107
|
+
if (reader === 'bat' && flags.some((flag) => flag === '-r' || flag.startsWith('--line-range'))) return undefined;
|
|
108
|
+
return files.length === 1 ? files[0] : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolvePath(file, cwd) {
|
|
112
|
+
if (file === '~' || file.startsWith('~/')) return path.join(os.homedir(), file.slice(1));
|
|
113
|
+
return path.resolve(cwd, file);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Decide one tool call. Returns { block: false } or { block: true, message }.
|
|
118
|
+
* `ledger` has has(key) and add(key); add throws when it cannot record, and
|
|
119
|
+
* then the read is allowed so a repeat can never be trapped.
|
|
120
|
+
*/
|
|
121
|
+
export function decide({ toolName, toolInput, cwd, main, ledger, settings = readGuardSettings(cwd) }) {
|
|
122
|
+
try {
|
|
123
|
+
if (!main || !settings.enabled || !ledger) return { block: false };
|
|
124
|
+
const file = wholeReadTarget(toolName, toolInput, cwd);
|
|
125
|
+
if (!file) return { block: false };
|
|
126
|
+
const stat = fs.statSync(file);
|
|
127
|
+
if (!stat.isFile() || stat.size < settings.minBytes) return { block: false };
|
|
128
|
+
const key = `${fs.realpathSync(file)}\0${stat.size}\0${stat.mtimeMs}`;
|
|
129
|
+
if (ledger.has(key)) return { block: false };
|
|
130
|
+
ledger.add(key);
|
|
131
|
+
return { block: true, message: blockMessage(stat.size) };
|
|
132
|
+
} catch {
|
|
133
|
+
return { block: false };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function blockMessage(bytes) {
|
|
138
|
+
const kb = Math.round(bytes / 1024);
|
|
139
|
+
const tokens = Math.round(bytes / 4 / 1000) * 1000;
|
|
140
|
+
return [
|
|
141
|
+
`This file is ${kb} KB (about ${tokens.toLocaleString('en-US')} tokens), and reading all of it is usually unnecessary. Instead:`,
|
|
142
|
+
'',
|
|
143
|
+
'- Search for what you need, then read only that range',
|
|
144
|
+
"- Or hand the question to an investigator, as ACStack's Delegate investigation section describes",
|
|
145
|
+
'',
|
|
146
|
+
'If you really need the whole file, repeat this exact read and it will go through.',
|
|
147
|
+
].join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A per-session ledger file in a folder only this user can open:
|
|
152
|
+
* ${TMPDIR:-/tmp}/acstack-read-guard-<uid>/<session_id>.json.
|
|
153
|
+
* Returns undefined without a session id, so the guard stays off.
|
|
154
|
+
*/
|
|
155
|
+
export function fileLedger(sessionId, tmpdir = os.tmpdir()) {
|
|
156
|
+
if (typeof sessionId !== 'string' || !sessionId) return undefined;
|
|
157
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
158
|
+
const dir = path.join(tmpdir, `acstack-read-guard-${uid ?? os.userInfo().username}`);
|
|
159
|
+
const name = /^[A-Za-z0-9._-]{1,128}$/.test(sessionId) && !sessionId.startsWith('.')
|
|
160
|
+
? sessionId
|
|
161
|
+
: createHash('sha256').update(sessionId).digest('hex');
|
|
162
|
+
const file = path.join(dir, `${name}.json`);
|
|
163
|
+
|
|
164
|
+
const ownedDir = () => {
|
|
165
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
166
|
+
const stat = fs.lstatSync(dir);
|
|
167
|
+
// Another user's folder could hold planted entries that switch the guard off.
|
|
168
|
+
if (!stat.isDirectory() || (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) {
|
|
169
|
+
throw new Error(`refusing ledger folder ${dir}`);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const load = () => {
|
|
173
|
+
ownedDir();
|
|
174
|
+
try {
|
|
175
|
+
const keys = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
176
|
+
return new Set(Array.isArray(keys) ? keys : []);
|
|
177
|
+
} catch {
|
|
178
|
+
return new Set();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
has: (key) => load().has(key),
|
|
183
|
+
add: (key) => {
|
|
184
|
+
const keys = load().add(key);
|
|
185
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
186
|
+
fs.writeFileSync(tmp, JSON.stringify([...keys]), { mode: 0o600 });
|
|
187
|
+
fs.renameSync(tmp, file);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// PreToolUse hook for Claude Code and Codex: the large-read guard.
|
|
2
|
+
// Reads the hook input on stdin and prints a deny decision, or nothing.
|
|
3
|
+
// Any failure allows the call, so the guard can never trap an agent.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { decide, envSaysNotMain, fileLedger } from './lib/read-guard.mjs';
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
10
|
+
// Subagents and in-process teammates carry agent_id in both harnesses.
|
|
11
|
+
const main = !input.agent_id && !envSaysNotMain();
|
|
12
|
+
const cwd = input.cwd || process.cwd();
|
|
13
|
+
const result = decide({
|
|
14
|
+
toolName: input.tool_name,
|
|
15
|
+
toolInput: input.tool_input,
|
|
16
|
+
cwd,
|
|
17
|
+
main,
|
|
18
|
+
ledger: main ? fileLedger(input.session_id) : undefined,
|
|
19
|
+
});
|
|
20
|
+
if (result.block) {
|
|
21
|
+
console.log(JSON.stringify({
|
|
22
|
+
hookSpecificOutput: {
|
|
23
|
+
hookEventName: 'PreToolUse',
|
|
24
|
+
permissionDecision: 'deny',
|
|
25
|
+
permissionDecisionReason: result.message,
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Allow.
|
|
31
|
+
}
|
package/hooks/session-start.sh
CHANGED
|
@@ -206,10 +206,13 @@ if ! command -v python3 >/dev/null 2>&1; then
|
|
|
206
206
|
fi
|
|
207
207
|
|
|
208
208
|
python3 - "$claude_md" <<'PY'
|
|
209
|
-
import json, sys
|
|
209
|
+
import json, re, sys
|
|
210
210
|
path = sys.argv[1]
|
|
211
211
|
with open(path, 'r', encoding='utf-8') as f:
|
|
212
212
|
content = f.read()
|
|
213
|
+
# SessionStart reaches the lead session, so it keeps the lead block and drops the delegated one.
|
|
214
|
+
content = re.sub(r'<!-- acstack:delegated:start -->[\s\S]*?<!-- acstack:delegated:end -->\n?', '', content)
|
|
215
|
+
content = re.sub(r'<!-- acstack:lead:(start|end) -->\n?', '', content)
|
|
213
216
|
print(json.dumps({
|
|
214
217
|
"hookSpecificOutput": {
|
|
215
218
|
"hookEventName": "SessionStart",
|
package/orchestrator/AGENTS.md
CHANGED
|
@@ -2,13 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
ACStack keeps meaningful tests and chooses verification effort to fit the change.
|
|
4
4
|
|
|
5
|
+
<!-- acstack:lead:start -->
|
|
5
6
|
## Your role
|
|
6
7
|
|
|
7
8
|
- Understand the request, investigate the code, implement, and verify the result
|
|
8
9
|
- Work directly on straightforward tasks, including research, tests, code, and checks
|
|
9
|
-
- Delegate when another agent materially improves confidence
|
|
10
|
+
- Delegate when another agent materially improves confidence, handles an independent scope, or keeps broad discovery out of your context
|
|
10
11
|
- Honor explicit user requests for stronger process, including separate agents and tests-first work
|
|
11
12
|
- Follow the shared Implementation discipline below across implement, quick-fix, and map work
|
|
13
|
+
- Keep the user's session continuous
|
|
14
|
+
- Use `/acstack:handoff` to transfer work to a visible agent
|
|
15
|
+
- Use `/acstack:compact-and-continue` to preserve session state before compaction
|
|
16
|
+
<!-- acstack:lead:end -->
|
|
17
|
+
<!-- acstack:delegated:start -->
|
|
18
|
+
## Your role
|
|
19
|
+
|
|
20
|
+
- You are a delegated session: the brief your parent gave at launch sets your role, scope, and access
|
|
21
|
+
- You may be an investigator, an implementer, or a coordinator of your own scope
|
|
22
|
+
- The shared guidance below applies wherever the brief does not narrow it
|
|
23
|
+
- Stay inside the assigned scope and access
|
|
24
|
+
- Report work outside them to your parent instead of doing it
|
|
25
|
+
- Treat a no-edit instruction as binding even when the launcher does not enforce it
|
|
26
|
+
- Delegate further only when the brief allows it, and never grant a child more access than you have
|
|
27
|
+
- Report results, evidence, and blockers to your parent
|
|
28
|
+
- Your parent owns synthesis and completion unless the brief assigns them to you
|
|
29
|
+
<!-- acstack:delegated:end -->
|
|
12
30
|
|
|
13
31
|
## Non-negotiable rules
|
|
14
32
|
|
|
@@ -40,12 +58,15 @@ ACStack runs on Claude Code, Codex, and pi. The skills name each capability once
|
|
|
40
58
|
|
|
41
59
|
- **Launch a subagent** – start a child agent with its own context, by its ACStack name (`test-writer`, `task-executor`, `task-checker`, `frontend-verifier`)
|
|
42
60
|
- Claude Code: the Agent tool, with `subagent_type` set to that name
|
|
43
|
-
- pi: `Agent` from `@tintinweb/pi-subagents` (`subagent_type`),
|
|
61
|
+
- pi: `Agent` from `@tintinweb/pi-subagents` (`subagent_type`), `subagent` from `pi-subagents` (`agent`), or `team_agent` from ac-teams
|
|
62
|
+
- `team_agent` does not load ACStack agents by name, so put the role's instructions in the brief
|
|
63
|
+
- ac-teams describes its member access controls in its own `docs/access.md`
|
|
44
64
|
- **Read-only codebase-search subagent** – `Explore` on Claude Code and pi's `Agent`, or `scout` on pi's `subagent`
|
|
45
65
|
- `debugger` names a debugging agent
|
|
46
66
|
- On a host without a matching built-in agent, launch a general subagent with that brief
|
|
47
67
|
- **Agent team** – parallel full sessions, each owning one scope and launching its own subagents
|
|
48
68
|
- Claude Code has agent teams, with messaging between teammates and plan approval by the lead
|
|
69
|
+
- pi has ac-teams, through `team_agent`
|
|
49
70
|
- On a host without agent teams, work directly or delegate scopes sequentially
|
|
50
71
|
- When the task assigns separate test ownership, keep it separate from implementation
|
|
51
72
|
- **Ask the user a structured question** – the host's question tool (AskUserQuestion on Claude Code)
|
|
@@ -215,6 +236,40 @@ flowchart TD
|
|
|
215
236
|
- Check the design gate and load design context once per task when it is on
|
|
216
237
|
- Follow the same procedure for direct work and delegated implementation
|
|
217
238
|
|
|
239
|
+
### Delegate investigation
|
|
240
|
+
|
|
241
|
+
- Prefer delegated investigation when discovery means broad searches, substantial reading, independent hypotheses, or separable research questions
|
|
242
|
+
- Delegation keeps raw discovery out of your context, even when the investigator runs your own model
|
|
243
|
+
- Work directly when the paths are few and known, or when briefing costs more than searching
|
|
244
|
+
- Launch one investigator per separable question, with no fixed count
|
|
245
|
+
- Launch the host's read-only search agent for navigation questions (see Host tools)
|
|
246
|
+
- For other questions, launch a general agent with the investigator brief below
|
|
247
|
+
- With no launcher available, investigate directly and say that delegation was unavailable
|
|
248
|
+
- Choose the model separately from the role
|
|
249
|
+
- Prefer a configured cheaper, capable model for navigation and fact gathering
|
|
250
|
+
- Use a stronger model for hard causal or ambiguous analysis
|
|
251
|
+
- Never hardcode a provider or model; use what the host or user configured
|
|
252
|
+
- Request enforced read-only access when the launcher reports that it can enforce it
|
|
253
|
+
- Expect prompt-only protection on most hosts today
|
|
254
|
+
- A read-only search agent such as `Explore` lacks edit tools; that limits its tools but is not operating-system enforcement
|
|
255
|
+
- Without enforcement, instruct the investigator not to edit project files, and call that protection prompt-only
|
|
256
|
+
- If an enforced launch fails, report the failure and ask before launching without enforcement
|
|
257
|
+
- Own the synthesis: verify the decisive evidence a conclusion rests on, without repeating the whole investigation
|
|
258
|
+
|
|
259
|
+
The investigator brief states:
|
|
260
|
+
|
|
261
|
+
- The question, and why the answer matters
|
|
262
|
+
- The scope: paths, repositories, or sources to search, and what is out of scope
|
|
263
|
+
- The access: enforced read-only, or a prompt-only instruction not to edit project files
|
|
264
|
+
- The report the parent needs back
|
|
265
|
+
|
|
266
|
+
The investigator reports:
|
|
267
|
+
|
|
268
|
+
- Findings, with file paths and line references
|
|
269
|
+
- Which findings rest on evidence and which are hypotheses
|
|
270
|
+
- What it searched, so the parent knows what it did not cover
|
|
271
|
+
- Uncertainties, and the next checks worth running
|
|
272
|
+
|
|
218
273
|
### Use independent review where needed
|
|
219
274
|
|
|
220
275
|
- Require independent review for security boundaries, money, irreversible data changes, or unresolved correctness or impact uncertainty
|
|
@@ -414,8 +469,8 @@ Launch each agent by the name below (see Host tools for how your host takes it).
|
|
|
414
469
|
| Implement task | `task-executor` | Source and tests unless tests have a separate owner |
|
|
415
470
|
| Verify implementation | `task-checker` | Independent review, coverage adequacy, regressions |
|
|
416
471
|
| Verify frontend UI | `frontend-verifier` | Real browser, screenshots, non-negotiable |
|
|
417
|
-
|
|
|
418
|
-
| Debug
|
|
472
|
+
| Investigate a question | `Explore` / `scout`, or a general agent | Read-only; brief per Delegate investigation |
|
|
473
|
+
| Debug | `debugger` | Built-in debugging agent |
|
|
419
474
|
|
|
420
475
|
---
|
|
421
476
|
|
|
@@ -436,5 +491,3 @@ Launch each agent by the name below (see Host tools for how your host takes it).
|
|
|
436
491
|
- Keep living specs current through commit-time sync
|
|
437
492
|
- Create a map when unresolved questions need durable tracking
|
|
438
493
|
- Invoke `bridge` before cross-device browser or tunnel work
|
|
439
|
-
- Use `/acstack:handoff` to transfer work to a visible agent
|
|
440
|
-
- Use `/acstack:compact-and-continue` to preserve session state before compaction
|
package/package.json
CHANGED
package/pi/extension.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* session_compact re-inject .acstack/HANDOFF.md after a compaction, and
|
|
18
18
|
* restart the nudge ladder (hooks/session-start-handoff.sh,
|
|
19
19
|
* hooks/context-pressure.sh)
|
|
20
|
+
* tool_call block a main agent's first whole read of a large
|
|
21
|
+
* file (hooks/read-guard.mjs)
|
|
20
22
|
*
|
|
21
23
|
* It also registers /acstack <skill>, a /acstack:<skill> command per skill,
|
|
22
24
|
* and /acstack-writing-style for the bundled/default/custom style choice.
|
|
@@ -33,6 +35,8 @@ import * as path from "node:path";
|
|
|
33
35
|
import { fileURLToPath } from "node:url";
|
|
34
36
|
import { execFileSync } from "node:child_process";
|
|
35
37
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
38
|
+
// @ts-ignore -- plain ESM shared with hooks/read-guard.mjs, no type declarations
|
|
39
|
+
import { decide, envSaysNotMain } from "../hooks/lib/read-guard.mjs";
|
|
36
40
|
|
|
37
41
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
38
42
|
const troubleshoot = path.join(packageRoot, "TROUBLESHOOTING.md");
|
|
@@ -80,6 +84,8 @@ function ensureProjectData(ctx: ExtensionContext, root: string): boolean {
|
|
|
80
84
|
const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
|
|
81
85
|
/** pi-subagents sets this instead in a child it places in its own herdr pane. */
|
|
82
86
|
const HERDR_BRIDGE_CHILD_ENV = "PI_SUBAGENTS_HERDR_BRIDGE";
|
|
87
|
+
/** ac-teams sets this in every member it launches. */
|
|
88
|
+
const AC_TEAMS_MEMBER_ENV = "AC_TEAMS_MEMBER";
|
|
83
89
|
|
|
84
90
|
function agentDir(): string {
|
|
85
91
|
return process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
|
|
@@ -622,6 +628,18 @@ export function skillsSection(skills: Map<string, SkillInfo>): string | undefine
|
|
|
622
628
|
return lines.join("\n");
|
|
623
629
|
}
|
|
624
630
|
|
|
631
|
+
/**
|
|
632
|
+
* The orchestrator text for one kind of session. orchestrator/AGENTS.md marks
|
|
633
|
+
* the lead-only and delegated-only blocks; each session keeps its own block
|
|
634
|
+
* and drops the other.
|
|
635
|
+
*/
|
|
636
|
+
export function orchestratorFor(text: string, role: "lead" | "delegated"): string {
|
|
637
|
+
const other = role === "lead" ? "delegated" : "lead";
|
|
638
|
+
return text
|
|
639
|
+
.replace(new RegExp(`<!-- acstack:${other}:start -->[\\s\\S]*?<!-- acstack:${other}:end -->\\n?`, "g"), "")
|
|
640
|
+
.replace(new RegExp(`<!-- acstack:${role}:(start|end) -->\\n?`, "g"), "");
|
|
641
|
+
}
|
|
642
|
+
|
|
625
643
|
/**
|
|
626
644
|
* The user message for one ACStack skill, in the same shape pi's own
|
|
627
645
|
* /skill:<name> expansion produces. Built from this package's file, so a
|
|
@@ -640,9 +658,9 @@ export function skillInvocation(name: string, file: string, args: string): strin
|
|
|
640
658
|
// --------------------------------------------------------------------------
|
|
641
659
|
|
|
642
660
|
export default function acstack(pi: ExtensionAPI): void {
|
|
643
|
-
// A
|
|
644
|
-
//
|
|
645
|
-
|
|
661
|
+
// A delegated session keeps the shared guidance but not the lead's role,
|
|
662
|
+
// CLI symlinks, launcher warning, context nudges, or handoff reload.
|
|
663
|
+
const delegated = process.env[SUBAGENT_CHILD_ENV] === "1" || process.env[HERDR_BRIDGE_CHILD_ENV] === "1" || Boolean(process.env[AC_TEAMS_MEMBER_ENV]);
|
|
646
664
|
|
|
647
665
|
const orchestratorFile = path.join(packageRoot, "orchestrator", "AGENTS.md");
|
|
648
666
|
const styleFile = path.join(packageRoot, "output-styles", "ac-writing-style.md");
|
|
@@ -651,7 +669,7 @@ export default function acstack(pi: ExtensionAPI): void {
|
|
|
651
669
|
let warnedSubagents = false;
|
|
652
670
|
|
|
653
671
|
pi.on("session_start", async (event, ctx) => {
|
|
654
|
-
linkClis(ctx);
|
|
672
|
+
if (!delegated) linkClis(ctx);
|
|
655
673
|
const root = projectRoot(ctx.cwd);
|
|
656
674
|
if (ensureProjectData(ctx, root)) unsafeProjects.delete(root);
|
|
657
675
|
else {
|
|
@@ -659,6 +677,7 @@ export default function acstack(pi: ExtensionAPI): void {
|
|
|
659
677
|
return;
|
|
660
678
|
}
|
|
661
679
|
|
|
680
|
+
if (delegated) return;
|
|
662
681
|
if (!warnedSubagents && !pi.getAllTools().some((tool) => tool.name === "subagent" || tool.name === "Agent" || tool.name === "team_agent")) {
|
|
663
682
|
warnedSubagents = true;
|
|
664
683
|
report(
|
|
@@ -686,14 +705,19 @@ export default function acstack(pi: ExtensionAPI): void {
|
|
|
686
705
|
|
|
687
706
|
if (isProject(ctx.cwd)) {
|
|
688
707
|
const orchestrator = readText(orchestratorFile);
|
|
689
|
-
if (orchestrator) sections["acstack-orchestrator"] = orchestrator;
|
|
708
|
+
if (orchestrator) sections["acstack-orchestrator"] = orchestratorFor(orchestrator, delegated ? "delegated" : "lead");
|
|
690
709
|
else report(ctx, `acstack: could not read ${orchestratorFile}`, "warning");
|
|
691
710
|
}
|
|
692
711
|
|
|
693
712
|
const styleCfg = writingStyleSettings();
|
|
694
713
|
if (styleCfg.mode === "off") return;
|
|
695
714
|
const resolvedStyleFile = styleCfg.mode === "custom" && styleCfg.path ? expandUserPath(styleCfg.path) : styleFile;
|
|
696
|
-
|
|
715
|
+
let style = readText(resolvedStyleFile);
|
|
716
|
+
// A sandboxed delegated session may not see a custom file outside its read roots.
|
|
717
|
+
if (!style && delegated && resolvedStyleFile !== styleFile) {
|
|
718
|
+
report(ctx, `acstack: could not read writing style at ${resolvedStyleFile}; using the bundled style`, "warning");
|
|
719
|
+
style = readText(styleFile);
|
|
720
|
+
}
|
|
697
721
|
if (!style) {
|
|
698
722
|
report(ctx, `acstack: could not read writing style at ${resolvedStyleFile}`, "warning");
|
|
699
723
|
return;
|
|
@@ -705,7 +729,7 @@ export default function acstack(pi: ExtensionAPI): void {
|
|
|
705
729
|
return { message: { customType: "acstack-writing-style-reminder", content: reminder, display: false } };
|
|
706
730
|
});
|
|
707
731
|
|
|
708
|
-
pi.on("agent_before_settle", async (event, ctx) => {
|
|
732
|
+
if (!delegated) pi.on("agent_before_settle", async (event, ctx) => {
|
|
709
733
|
if (event.outcome !== "completed") return;
|
|
710
734
|
const usage = ctx.getContextUsage();
|
|
711
735
|
if (!usage || usage.tokens === null) return;
|
|
@@ -736,7 +760,7 @@ export default function acstack(pi: ExtensionAPI): void {
|
|
|
736
760
|
};
|
|
737
761
|
});
|
|
738
762
|
|
|
739
|
-
pi.on("session_compact", async (_event, ctx) => {
|
|
763
|
+
if (!delegated) pi.on("session_compact", async (_event, ctx) => {
|
|
740
764
|
// The compaction threw the old context away, so the ladder starts over.
|
|
741
765
|
const key = sessionKey(ctx);
|
|
742
766
|
if (key) {
|
|
@@ -757,6 +781,24 @@ export default function acstack(pi: ExtensionAPI): void {
|
|
|
757
781
|
}
|
|
758
782
|
});
|
|
759
783
|
|
|
784
|
+
// The large-read guard. Environment markers are read once, at load, which
|
|
785
|
+
// is when pi-subagents sets them for an in-process child. A session with a
|
|
786
|
+
// parentSession header (a tintinweb/pi-subagents child, or a user's /fork)
|
|
787
|
+
// or with no session file is not treated as main: unclear means off.
|
|
788
|
+
const readGuardOff = delegated || envSaysNotMain();
|
|
789
|
+
const readLedgers = new Map<string, Set<string>>();
|
|
790
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
791
|
+
if (readGuardOff || (event.toolName !== "read" && event.toolName !== "bash")) return;
|
|
792
|
+
const sessions = ctx.sessionManager;
|
|
793
|
+
const sessionId = sessions.getSessionId();
|
|
794
|
+
const main = Boolean(sessionId && sessions.getSessionFile?.() && !sessions.getHeader?.()?.parentSession);
|
|
795
|
+
if (!main) return;
|
|
796
|
+
let ledger = readLedgers.get(sessionId);
|
|
797
|
+
if (!ledger) readLedgers.set(sessionId, (ledger = new Set()));
|
|
798
|
+
const result = decide({ toolName: event.toolName, toolInput: event.input, cwd: ctx.cwd, main, ledger });
|
|
799
|
+
if (result.block) return { block: true, reason: result.message };
|
|
800
|
+
});
|
|
801
|
+
|
|
760
802
|
const runSkill = (name: string, args: string, ctx: ExtensionContext): void => {
|
|
761
803
|
const file = skills.get(name)?.file;
|
|
762
804
|
const message = file && skillInvocation(name, file, args);
|