dsh-root-planner-preset 1.1.5 → 1.1.7

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/README.md CHANGED
@@ -122,9 +122,27 @@ Then **start a new session** and pick **Root Planner** in the preset selector. F
122
122
 
123
123
  **Verified**: the patch parses as loader YAML; the parsed persona is **byte-identical** to `persona.txt`; both delegation masks exclude every mutation entry point and name only tools the runtime reports as global; a clean checkout passes verification (`.gitattributes` pins LF, without which a Windows clone breaks the byte-identity check); the depth override reached the composed profile.
124
124
 
125
- **Fixed after a real failure**: a subagent spawn on a live `root-planner` session failed with `tools.restrict() names unknown global tool "subagent"`. The cause was a design error in this bundle, not a misconfiguration — `subagent` is registered inside each composed Agent's own scope, and a `toolFilter` takes global names only, so the mask was naming something it could never contain. The mask no longer names it, `verify-patch.mjs` asserts its absence by intent, and the mask is defined once in `child-tools.mjs` so the generator and the verifier cannot drift apart.
125
+ **Verified end to end on a real task** — a 24-descendant run over a large reverse-engineering directory tree. Every line below was read from the session logs' own persisted tool schemas, which are the registry's output rather than a model's recollection:
126
126
 
127
- **Still not verified**: **no subagent has been spawned successfully under this preset yet.** Recursion is expected to work for the reason given above — the child inherits the preset and therefore its own scoped `subagent`, and a restriction does not hide scoped registrations — but that reasoning is exactly the kind that this bundle has already been wrong about once. Treat it as unconfirmed until someone reports a two-level delegation working.
127
+ | Property | Evidence |
128
+ |---|---|
129
+ | The mask reaches every child | The root is offered **31** tools; depth-1 and depth-2 agents are offered **25**. The difference is exactly the six the mask omits: `workflow`, `present`, `job_output`, `job_list`, `job_kill`, `exit_plan_mode`. |
130
+ | Subagents really are workers | `write`, `edit`, `ops_replace` and `pwsh` are present at every depth and were used: 10 `write` and 60 `edit` from the root, 14 `write` from one depth-1 agent, 20 `write` from a depth-2 agent. |
131
+ | No subagent can leave a process running | `run_in_background` is absent from the `pwsh` schema at all three depths. (A naive log search says otherwise — the phrase also occurs in `ops_bash`'s own description, which is why `tools/check-pwsh-background.mjs` inspects the schema window instead.) |
132
+ | Recursion works | 12 direct children, 4 of which delegated onward to 12 grandchildren; the root itself made 14 `subagent` calls. |
133
+ | The depth cap holds | The tree stops at depth 2; no depth-3 session exists anywhere in the log directory. |
134
+ | The live-child cap is soft, not fatal | One `subagent limit reached (active child limit: 8)` during an 8-wide fan-out. Settlements returned slots as children finished, and every later delegation succeeded. |
135
+
136
+ **Corrected after the fact**: an earlier version of this file claimed the mask made
137
+ children leaf agents. It does not. `subagent` is registered per composed agent rather
138
+ than globally, so the mask can never name it — and because a restriction filters
139
+ globals only, a child keeps it. Recursion is bounded by `maxDepth`, not by the mask.
140
+
141
+ **How to check it yourself**: `tools/verify-session.mjs` proves which preset each
142
+ session ran and at what depth, but it does not read tool schemas. Use
143
+ `tools/extract-offered-tools.mjs` for the per-level tool list,
144
+ `tools/check-pwsh-background.mjs` for the process bound, and
145
+ `tools/count-tool-calls.mjs` for what a level actually called.
128
146
 
129
147
  ## Contributing
130
148
 
package/README.zh.md CHANGED
@@ -102,9 +102,20 @@ dsh plugin --profile web add github:XiaoOu2024/dsh-root-planner-preset
102
102
 
103
103
  **已验证**:补丁可被 loader 解析;解析出的人设与 `persona.txt` **逐字节相同**;两个委派工具的白名单排除了所有写入入口、且只包含**运行时报告的全局工具**;干净检出后校验通过(`.gitattributes` 已钉死 LF,否则 Windows 克隆会破坏逐字节校验);深度覆盖已落到合成配置。
104
104
 
105
- **因一次真实故障而修复**:在跑着 `root-planner` 的会话里创建子代理失败,报 `tools.restrict() names unknown global tool "subagent"`。根因是本包**设计上的错误**,不是配置错误——`subagent` 注册在每个被组合 Agent 自身的作用域里,而 `toolFilter` 只接受全局工具名,白名单写了一个它永远不可能合法包含的名字。现在白名单不再写它,`verify-patch.mjs` 会**按意图断言它必须缺席**,白名单也收敛到 `child-tools.mjs` 单一真源,生成器与校验脚本不可能再各说各话。
105
+ **已在真实任务上端到端验证**——那次运行在一个大型逆向工程目录树上产生了 **24 个后代会话**。下面每一条都读自**会话日志自己持久化的工具 schema**,那是注册表的输出,不是模型的回忆:
106
106
 
107
- **仍未验证**:**还没有一次成功的子代理派出。** 递归按上面的理由**应当**可用——子代理继承预设、因此拥有自己的作用域 `subagent`,而限制不会隐藏作用域注册——但这个推理恰好就是本包已经错过一次的那类推理。在有人报告"两层委派成功"之前,请当作未确认。
107
+ | 性质 | 证据 |
108
+ |---|---|
109
+ | 白名单覆盖每一层 | 根被提供 **31** 个工具;深度 1 与深度 2 的 agent 都是 **25** 个。差额正好是白名单排除的那 6 个:`workflow`、`present`、`job_output`、`job_list`、`job_kill`、`exit_plan_mode`。 |
110
+ | 子代理确实是工人 | `write`、`edit`、`ops_replace`、`pwsh` 在每一层都在,而且真的被用了:根 10 次 `write` + 60 次 `edit`,某个深度 1 的子代理 14 次 `write`,某个深度 2 的孙代理 20 次 `write`。 |
111
+ | 子代理留不下运行中的进程 | `run_in_background` 在三个层级的 `pwsh` schema 里**都不存在**。(朴素的全日志搜索会给出相反答案——`ops_bash` 自己的描述里也有这个词,所以要用 `tools/check-pwsh-background.mjs` 看 schema 窗口。) |
112
+ | 递归成立 | 12 个直接子代理,其中 4 个继续派出了 12 个孙代理;根自己做了 14 次 `subagent` 调用。 |
113
+ | 深度上限有效 | 树停在深度 2;整个日志目录里不存在任何深度 3 的会话。 |
114
+ | 在世上限是软拒绝而非致命 | 8 路扇出时出现一次 `subagent limit reached (active child limit: 8)`;子代理完结时结算通知归还了槽位,此后每次委派都成功。 |
115
+
116
+ **一处事后更正**:本文件早期版本称"白名单让子代理成为叶子"。**并非如此。** `subagent` 是按每个被组合 Agent 注册、而非全局注册,所以白名单**永远无法写下它的名字**;而限制只过滤全局工具,因此子代理**保有此工具**。递归的边界是 `maxDepth`,不是白名单。
117
+
118
+ **想自己核**:`tools/verify-session.mjs` 能证明每个会话跑的是哪个预设、深度多少,但它不读工具 schema。逐层工具清单用 `tools/extract-offered-tools.mjs`,进程边界用 `tools/check-pwsh-background.mjs`,实际调用用 `tools/count-tool-calls.mjs`。
108
119
 
109
120
  ## 参与维护
110
121
 
package/cordis.patch.yml CHANGED
@@ -52,8 +52,9 @@
52
52
  9. Treat a summary as evidence, not a verdict. Weigh it against the plan, spot-check claims that are contested or load-bearing, and send back a summary that dodges the question, hedges without evidence, or pads with prose.
53
53
  10. **Leave nothing running.** No servers, watchers, daemons, or detached processes — for you as much as for a subagent. Background execution is your shell's decision to offer, not a convenience to reach for: if a task seems to need a long-lived process, do it in the foreground, bound it with a timeout, or ask the user before starting anything that outlives the work.
54
54
  11. Answer a subagent that reports a problem or an out-of-scope finding. Decide it, then reply with `send_message` so the child can continue: either the change is approved and who makes it, or it is out of scope and why, or what to do instead. An unanswered report leaves the child blocked on a decision only you can make. **A child that reports a clean result needs no reply** — silence is the correct response, so do not invent an acknowledgement.
55
- 12. Give concurrent subagents disjoint work. Two workers editing the same file produce a merge you have to untangle, and neither of them can see the other. Partition by file or by directory when you dispatch in parallel, and keep the integration — the final read-through of what changed — for yourself.
56
- 13. Decide who runs it, and do not hand a command down out of habit. Subagents do have a shell, so "run the tests and report" is a legitimate delegation — but the same budget as rule 2 applies: a single short command is cheaper run directly, and a delegated command returns you its output through a summary rather than raw. Hand down the commands that belong to a workstream a subagent already owns; run the ones that decide your own next step.
55
+ 12. Give concurrent subagents disjoint work. Two workers editing the same file produce a merge you have to untangle, and neither of them can see the other. Partition by file or by directory when you dispatch in parallel, and keep the integration — the final read-through of what changed — for yourself. **One writer per path, always:** if two subtasks genuinely need to modify the same file, run them one after the other rather than together, and never let two agents own the same output directory.
56
+ 13. Reuse a child before spawning a new one. A finished child still holds everything it read, and `send_message` starts another turn in that same context — so a follow-up about the same material belongs there, not in a fresh agent that must read it all again. Sessions are isolated and a sibling cannot be told what another one already read, so overlapping reads across agents are a real cost you pay twice: once in tokens and once in the chance that two agents report the same file differently. When several subtasks need the same document, give them to one agent, or read it yourself and pass the conclusion down.
57
+ 14. Decide who runs it, and do not hand a command down out of habit. Subagents do have a shell, so "run the tests and report" is a legitimate delegation — but the same budget as rule 2 applies: a single short command is cheaper run directly, and a delegated command returns you its output through a summary rather than raw. Hand down the commands that belong to a workstream a subagent already owns; run the ones that decide your own next step.
57
58
 
58
59
  ## Subagent rules
59
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-root-planner-preset",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "type": "module",
5
5
  "description": "DSH agent preset that outsources file reading to strictly read-only subagents, while the root agent keeps the plan and owns every file mutation.",
6
6
  "license": "MIT",
package/persona.txt CHANGED
@@ -31,8 +31,9 @@ Reading is not planning. Raw file contents and raw query results belong in the a
31
31
  9. Treat a summary as evidence, not a verdict. Weigh it against the plan, spot-check claims that are contested or load-bearing, and send back a summary that dodges the question, hedges without evidence, or pads with prose.
32
32
  10. **Leave nothing running.** No servers, watchers, daemons, or detached processes — for you as much as for a subagent. Background execution is your shell's decision to offer, not a convenience to reach for: if a task seems to need a long-lived process, do it in the foreground, bound it with a timeout, or ask the user before starting anything that outlives the work.
33
33
  11. Answer a subagent that reports a problem or an out-of-scope finding. Decide it, then reply with `send_message` so the child can continue: either the change is approved and who makes it, or it is out of scope and why, or what to do instead. An unanswered report leaves the child blocked on a decision only you can make. **A child that reports a clean result needs no reply** — silence is the correct response, so do not invent an acknowledgement.
34
- 12. Give concurrent subagents disjoint work. Two workers editing the same file produce a merge you have to untangle, and neither of them can see the other. Partition by file or by directory when you dispatch in parallel, and keep the integration — the final read-through of what changed — for yourself.
35
- 13. Decide who runs it, and do not hand a command down out of habit. Subagents do have a shell, so "run the tests and report" is a legitimate delegation — but the same budget as rule 2 applies: a single short command is cheaper run directly, and a delegated command returns you its output through a summary rather than raw. Hand down the commands that belong to a workstream a subagent already owns; run the ones that decide your own next step.
34
+ 12. Give concurrent subagents disjoint work. Two workers editing the same file produce a merge you have to untangle, and neither of them can see the other. Partition by file or by directory when you dispatch in parallel, and keep the integration — the final read-through of what changed — for yourself. **One writer per path, always:** if two subtasks genuinely need to modify the same file, run them one after the other rather than together, and never let two agents own the same output directory.
35
+ 13. Reuse a child before spawning a new one. A finished child still holds everything it read, and `send_message` starts another turn in that same context — so a follow-up about the same material belongs there, not in a fresh agent that must read it all again. Sessions are isolated and a sibling cannot be told what another one already read, so overlapping reads across agents are a real cost you pay twice: once in tokens and once in the chance that two agents report the same file differently. When several subtasks need the same document, give them to one agent, or read it yourself and pass the conclusion down.
36
+ 14. Decide who runs it, and do not hand a command down out of habit. Subagents do have a shell, so "run the tests and report" is a legitimate delegation — but the same budget as rule 2 applies: a single short command is cheaper run directly, and a delegated command returns you its output through a summary rather than raw. Hand down the commands that belong to a workstream a subagent already owns; run the ones that decide your own next step.
36
37
 
37
38
  ## Subagent rules
38
39
 
package/release.mjs CHANGED
@@ -271,13 +271,33 @@ if (dryRun || skipInstall) {
271
271
  // ── CI ───────────────────────────────────────────────────────────────────────
272
272
  if (!dryRun && !skipGh) {
273
273
  step('Wait for CI on the pushed commit');
274
+ const repo = 'XiaoOu2024/dsh-root-planner-preset';
274
275
  const sha = git('rev-parse', 'HEAD');
275
- const waited = run(
276
- GH,
277
- ['run', 'watch', sha, '--repo', 'XiaoOu2024/dsh-root-planner-preset', '--exit-status'],
278
- { allowFailure: true },
279
- );
280
- process.stdout.write(` ${waited.trim().split('\n').slice(-2).join('\n ') || '(no output)'}\n`);
276
+ // `gh run watch <sha>` is wrong: the bare argument is a run ID, so a commit hash
277
+ // returns HTTP 404. Select the run by commit, then watch it by ID; if no run has
278
+ // registered yet, say so rather than reporting a failure that never happened.
279
+ let runId;
280
+ for (let attempt = 1; attempt <= 12 && runId === undefined; attempt += 1) {
281
+ const listed = run(
282
+ GH,
283
+ ['run', 'list', '--repo', repo, '--commit', sha, '--limit', '1', '--json', 'databaseId'],
284
+ { allowFailure: true },
285
+ );
286
+ try {
287
+ const [first] = JSON.parse(listed.trim() || '[]');
288
+ if (first?.databaseId !== undefined) runId = String(first.databaseId);
289
+ } catch {
290
+ /* the run has not registered yet */
291
+ }
292
+ if (runId === undefined) await new Promise((resolve) => setTimeout(resolve, 10_000));
293
+ }
294
+ if (runId === undefined) {
295
+ process.stdout.write(` no CI run registered for ${sha.slice(0, 8)} within 2 minutes; check it by hand\n`);
296
+ } else {
297
+ const waited = run(GH, ['run', 'watch', runId, '--repo', repo, '--exit-status'], { allowFailure: true });
298
+ const tail = waited.trim().split('\n').filter((line) => line.trim().length > 0).slice(-2);
299
+ process.stdout.write(` ${tail.join('\n ') || `run ${runId} finished`}\n`);
300
+ }
281
301
  }
282
302
 
283
303
  step(dryRun ? 'Dry run complete — nothing was changed' : `Released ${manifest.name}@${version}`);
@@ -0,0 +1,91 @@
1
+ // Checks whether `run_in_background` still exists inside a session's `pwsh` tool
2
+ // schema — the one property this preset enforces structurally rather than by asking
3
+ // the model nicely.
4
+ //
5
+ // A whole-log substring search cannot answer it: the phrase also appears in
6
+ // `ops_bash`'s own description on this machine, so the naive check reports "present"
7
+ // for a session where the parameter has genuinely been removed.
8
+ //
9
+ // Usage: node tools/check-pwsh-background.mjs <session-dir> [...]
10
+
11
+ import { readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { createZstdDecompress } from 'node:zlib';
14
+
15
+ /** Locate the end of the first zstd frame, where the next one begins. */
16
+ function frameEnd(buf) {
17
+ for (let i = 4; i + 4 <= buf.length; i += 1) {
18
+ if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) return i;
19
+ }
20
+ return buf.length;
21
+ }
22
+
23
+ /** Decompress a concatenated-zstd log frame by frame. */
24
+ async function decompressAll(buf) {
25
+ let rest = buf;
26
+ let text = '';
27
+ while (rest.length > 0) {
28
+ const slice = rest.subarray(0, frameEnd(rest));
29
+ try {
30
+ text += await new Promise((resolve, reject) => {
31
+ const chunks = [];
32
+ const dec = createZstdDecompress();
33
+ dec.on('data', (chunk) => chunks.push(chunk));
34
+ dec.on('error', reject);
35
+ dec.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
36
+ dec.end(slice);
37
+ });
38
+ } catch {
39
+ break;
40
+ }
41
+ if (slice.length >= rest.length) break;
42
+ rest = rest.subarray(slice.length);
43
+ }
44
+ return text;
45
+ }
46
+
47
+ /**
48
+ * Find every `"name":"<tool>"` occurrence and return the window of text that follows
49
+ * it — close enough to hold that tool's own parameters object.
50
+ * @param text - whole decompressed log.
51
+ * @param tool - tool name to locate.
52
+ * @returns the following windows, longest first.
53
+ */
54
+ function windowsAfter(text, tool) {
55
+ const needle = `\\"name\\":\\"${tool}\\"`;
56
+ const plain = `"name":"${tool}"`;
57
+ const out = [];
58
+ for (const pattern of [needle, plain]) {
59
+ let from = 0;
60
+ for (;;) {
61
+ const at = text.indexOf(pattern, from);
62
+ if (at === -1) break;
63
+ out.push(text.slice(at, at + 4000));
64
+ from = at + pattern.length;
65
+ }
66
+ }
67
+ return out.sort((a, b) => b.length - a.length);
68
+ }
69
+
70
+ for (const dir of process.argv.slice(2)) {
71
+ const text = await decompressAll(readFileSync(join(dir, 'session.v4.jsonl.zstd')));
72
+ const header = JSON.parse(text.split('\n')[0]);
73
+
74
+ const pwshWindows = windowsAfter(text, 'pwsh');
75
+ // The parameters object is the one that mentions the tool's own argument names.
76
+ const schemaWindow = pwshWindows.find((w) => w.includes('workdir') || w.includes('command')) ?? pwshWindows[0] ?? '';
77
+ const backgroundInSchema = /run_in_background/.test(schemaWindow);
78
+ const backgroundAnywhere = text.includes('run_in_background');
79
+
80
+ process.stdout.write(
81
+ `\n${dir.split(/[\\/]/).pop().slice(0, 20)} depth=${header?.delegationDepth}\n` +
82
+ ` "run_in_background" anywhere in the log : ${backgroundAnywhere}\n` +
83
+ ` inside the pwsh schema window : ${backgroundInSchema}` +
84
+ `${backgroundInSchema ? ' <-- the parameter is still offered' : ' <-- removed, as intended'}\n` +
85
+ ` pwsh schema window size : ${schemaWindow.length} chars\n`,
86
+ );
87
+ if (backgroundAnywhere && !backgroundInSchema) {
88
+ const at = text.indexOf('run_in_background');
89
+ process.stdout.write(` first mention context: …${text.slice(Math.max(0, at - 120), at + 60)}…\n`);
90
+ }
91
+ }
@@ -0,0 +1,102 @@
1
+ // Reports whether each child session in a run is finished or still active, from its
2
+ // own log — the question behind "subagent limit reached": are slots being held by
3
+ // children that already stopped, or by children that are genuinely still working?
4
+ //
5
+ // Usage: node tools/child-status.mjs <root-session-dir>
6
+
7
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { createZstdDecompress } from 'node:zlib';
10
+
11
+ /** Locate the end of the first zstd frame, where the next one begins. */
12
+ function frameEnd(buf) {
13
+ for (let i = 4; i + 4 <= buf.length; i += 1) {
14
+ if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) return i;
15
+ }
16
+ return buf.length;
17
+ }
18
+
19
+ /** Decompress a concatenated-zstd log frame by frame. */
20
+ async function decompressAll(buf) {
21
+ let rest = buf;
22
+ let text = '';
23
+ while (rest.length > 0) {
24
+ const slice = rest.subarray(0, frameEnd(rest));
25
+ try {
26
+ text += await new Promise((resolve, reject) => {
27
+ const chunks = [];
28
+ const dec = createZstdDecompress();
29
+ dec.on('data', (chunk) => chunks.push(chunk));
30
+ dec.on('error', reject);
31
+ dec.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
32
+ dec.end(slice);
33
+ });
34
+ } catch {
35
+ break;
36
+ }
37
+ if (slice.length >= rest.length) break;
38
+ rest = rest.subarray(slice.length);
39
+ }
40
+ return text;
41
+ }
42
+
43
+ const rootDir = process.argv[2];
44
+ if (rootDir === undefined) {
45
+ process.stderr.write('usage: node tools/child-status.mjs <root-session-dir>\n');
46
+ process.exit(2);
47
+ }
48
+
49
+ const base = join(rootDir, '..');
50
+ const rootId = rootDir.split(/[\\/]/).pop();
51
+ const rows = [];
52
+
53
+ for (const name of readdirSync(base)) {
54
+ const file = join(base, name, 'session.v4.jsonl.zstd');
55
+ try {
56
+ if (!statSync(file).isFile()) continue;
57
+ } catch {
58
+ continue;
59
+ }
60
+ const text = await decompressAll(readFileSync(file));
61
+ const events = text
62
+ .split('\n')
63
+ .filter((line) => line.trim().length > 0)
64
+ .flatMap((line) => {
65
+ try {
66
+ return [JSON.parse(line)];
67
+ } catch {
68
+ return [];
69
+ }
70
+ });
71
+ const header = events[0];
72
+ if (name !== rootId && header?.origin !== 'subagent') continue;
73
+
74
+ const types = new Map();
75
+ for (const event of events) types.set(event.type, (types.get(event.type) ?? 0) + 1);
76
+
77
+ const last = events[events.length - 1];
78
+ rows.push({
79
+ id: name,
80
+ depth: header?.delegationDepth,
81
+ mtime: statSync(file).mtime,
82
+ events: events.length,
83
+ lastType: last?.type,
84
+ lastTime: last?.time,
85
+ // A turn that ended and left nothing pending is the shape of a settled child.
86
+ turnEnds: types.get('turn/end') ?? 0,
87
+ hasInboxPending: types.has('agent/inbox/spliced') && !types.has('agent/inbox/claimed'),
88
+ cancels: types.get('agent/cancel') ?? 0,
89
+ });
90
+ }
91
+
92
+ rows.sort((a, b) => (a.lastTime ?? 0) - (b.lastTime ?? 0));
93
+ const fmt = (ms) => (ms === undefined ? '?' : new Date(ms).toISOString().slice(11, 19));
94
+
95
+ process.stdout.write(`${rows.length} agent session(s) under ${base}\n\n`);
96
+ process.stdout.write('depth turnEnds events last event type last event time session\n');
97
+ for (const row of rows) {
98
+ process.stdout.write(
99
+ `${String(row.depth ?? '-').padEnd(6)} ${String(row.turnEnds).padEnd(9)} ${String(row.events).padEnd(7)} ` +
100
+ `${String(row.lastType ?? '-').padEnd(25)} ${fmt(row.lastTime).padEnd(16)} ${row.id.slice(0, 20)}\n`,
101
+ );
102
+ }
@@ -0,0 +1,78 @@
1
+ // Counts the tool calls actually made in one session, by name.
2
+ //
3
+ // Written to settle a question that string counting cannot: which delegation tool a
4
+ // given level really called, and therefore which tools the child mask actually left
5
+ // visible. A raw occurrence count of "subagent" matches "subagent_fork" too, and the
6
+ // preset's own prose mentions both.
7
+ //
8
+ // Usage: node tools/count-tool-calls.mjs <session-dir> [...]
9
+
10
+ import { readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { createZstdDecompress } from 'node:zlib';
13
+
14
+ /** Locate the end of the first zstd frame, where the next one begins. */
15
+ function frameEnd(buf) {
16
+ for (let i = 4; i + 4 <= buf.length; i += 1) {
17
+ if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) return i;
18
+ }
19
+ return buf.length;
20
+ }
21
+
22
+ /** Decompress a concatenated-zstd log frame by frame. */
23
+ async function decompressAll(buf) {
24
+ let rest = buf;
25
+ let text = '';
26
+ while (rest.length > 0) {
27
+ const slice = rest.subarray(0, frameEnd(rest));
28
+ try {
29
+ text += await new Promise((resolve, reject) => {
30
+ const chunks = [];
31
+ const dec = createZstdDecompress();
32
+ dec.on('data', (chunk) => chunks.push(chunk));
33
+ dec.on('error', reject);
34
+ dec.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
35
+ dec.end(slice);
36
+ });
37
+ } catch {
38
+ break;
39
+ }
40
+ if (slice.length >= rest.length) break;
41
+ rest = rest.subarray(slice.length);
42
+ }
43
+ return text;
44
+ }
45
+
46
+ for (const dir of process.argv.slice(2)) {
47
+ const text = await decompressAll(readFileSync(join(dir, 'session.v4.jsonl.zstd')));
48
+ const events = text
49
+ .split('\n')
50
+ .filter((line) => line.trim().length > 0)
51
+ .flatMap((line) => {
52
+ try {
53
+ return [JSON.parse(line)];
54
+ } catch {
55
+ return [];
56
+ }
57
+ });
58
+
59
+ const header = events[0];
60
+ const calls = new Map();
61
+ for (const event of events) {
62
+ if (event.type !== 'tool/call') continue;
63
+ const name = event.data?.name ?? event.data?.message?.name;
64
+ if (typeof name !== 'string') continue;
65
+ calls.set(name, (calls.get(name) ?? 0) + 1);
66
+ }
67
+
68
+ const name = dir.split(/[\\/]/).pop().slice(0, 20);
69
+ process.stdout.write(
70
+ `\n${name} depth=${header?.delegationDepth} origin=${header?.origin} ${events.length} events\n`,
71
+ );
72
+ const rows = [...calls.entries()].sort((a, b) => b[1] - a[1]);
73
+ process.stdout.write(` ${rows.length} distinct tools called: ${rows.map(([t, n]) => `${t}×${n}`).join(', ') || '(none)'}\n`);
74
+ const delegation = rows.filter(([t]) => t.startsWith('subagent'));
75
+ process.stdout.write(
76
+ ` delegation calls: ${delegation.map(([t, n]) => `${t}×${n}`).join(', ') || '(none)'}\n`,
77
+ );
78
+ }
@@ -0,0 +1,69 @@
1
+ // Extracts the tool names a session was actually offered, from the tool schemas the
2
+ // session log persists.
3
+ //
4
+ // Why this exists: a subagent's own account of its tools is a language-model
5
+ // recollection, and it can be wrong in both directions (the first such report
6
+ // omitted `write` from its list while claiming to discuss it). The persisted
7
+ // schemas are the registry's own output, so they settle questions like "does the
8
+ // child mask really apply?" and "is run_in_background gone?" without guessing.
9
+ //
10
+ // Usage: node tools/extract-offered-tools.mjs <session-dir> [...]
11
+
12
+ import { readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { createZstdDecompress } from 'node:zlib';
15
+
16
+ /** Locate the end of the first zstd frame, where the next one begins. */
17
+ function frameEnd(buf) {
18
+ for (let i = 4; i + 4 <= buf.length; i += 1) {
19
+ if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) return i;
20
+ }
21
+ return buf.length;
22
+ }
23
+
24
+ /** Decompress a concatenated-zstd log frame by frame. */
25
+ async function decompressAll(buf) {
26
+ let rest = buf;
27
+ let text = '';
28
+ while (rest.length > 0) {
29
+ const slice = rest.subarray(0, frameEnd(rest));
30
+ try {
31
+ text += await new Promise((resolve, reject) => {
32
+ const chunks = [];
33
+ const dec = createZstdDecompress();
34
+ dec.on('data', (chunk) => chunks.push(chunk));
35
+ dec.on('error', reject);
36
+ dec.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
37
+ dec.end(slice);
38
+ });
39
+ } catch {
40
+ break;
41
+ }
42
+ if (slice.length >= rest.length) break;
43
+ rest = rest.subarray(slice.length);
44
+ }
45
+ return text;
46
+ }
47
+
48
+ for (const dir of process.argv.slice(2)) {
49
+ const text = await decompressAll(readFileSync(join(dir, 'session.v4.jsonl.zstd')));
50
+
51
+ // A tool schema is an object carrying `name` plus `parameters` (and `description`).
52
+ // Collect every name that appears next to a parameters object anywhere in the log.
53
+ const names = new Set();
54
+ for (const match of text.matchAll(/\{\\?"name\\?":\\?"([a-zA-Z_][a-zA-Z0-9_]*)\\?":\{[^{}]*\\?"type\\?":\\?"object\\?"/g)) {
55
+ names.add(match[1]);
56
+ }
57
+ for (const match of text.matchAll(/\\?"name\\?":\\?"([a-zA-Z_][a-zA-Z0-9_]*)\\?",\\?"description\\?":/g)) {
58
+ names.add(match[1]);
59
+ }
60
+
61
+ const header = JSON.parse(text.split('\n')[0]);
62
+ const listed = [...names].sort();
63
+ process.stdout.write(
64
+ `\n${dir.split(/[\\/]/).pop().slice(0, 20)} depth=${header?.delegationDepth}\n ${listed.length} names: ${listed.join(', ')}\n`,
65
+ );
66
+ for (const probe of ['workflow', 'present', 'job_output', 'exit_plan_mode', 'subagent', 'subagent_fork', 'pwsh', 'write']) {
67
+ process.stdout.write(` ${probe.padEnd(16)} ${names.has(probe) ? 'present' : 'ABSENT'}\n`);
68
+ }
69
+ }
@@ -0,0 +1,134 @@
1
+ // Measures cross-agent read overlap in a real run: how many files were read by more
2
+ // than one agent, and how much redundant reading that represents.
3
+ //
4
+ // The question this answers: sessions are isolated, so a parent cannot pass "what I
5
+ // already read" to a sibling. Is that redundancy a rounding error or the dominant
6
+ // cost of the fan-out? Only the logs can say.
7
+ //
8
+ // Usage: node tools/measure-read-overlap.mjs <session-dir> [...]
9
+
10
+ import { readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { createZstdDecompress } from 'node:zlib';
13
+
14
+ /** Locate the end of the first zstd frame, where the next one begins. */
15
+ function frameEnd(buf) {
16
+ for (let i = 4; i + 4 <= buf.length; i += 1) {
17
+ if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) return i;
18
+ }
19
+ return buf.length;
20
+ }
21
+
22
+ /** Decompress a concatenated-zstd log frame by frame. */
23
+ async function decompressAll(buf) {
24
+ let rest = buf;
25
+ let text = '';
26
+ while (rest.length > 0) {
27
+ const slice = rest.subarray(0, frameEnd(rest));
28
+ try {
29
+ text += await new Promise((resolve, reject) => {
30
+ const chunks = [];
31
+ const dec = createZstdDecompress();
32
+ dec.on('data', (chunk) => chunks.push(chunk));
33
+ dec.on('error', reject);
34
+ dec.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
35
+ dec.end(slice);
36
+ });
37
+ } catch {
38
+ break;
39
+ }
40
+ if (slice.length >= rest.length) break;
41
+ rest = rest.subarray(slice.length);
42
+ }
43
+ return text;
44
+ }
45
+
46
+ const READ_TOOLS = new Set(['read', 'ops_inspect_local_file']);
47
+
48
+ /** Normalize a path argument so the same file spelled two ways counts once. */
49
+ function normalize(value) {
50
+ if (typeof value !== 'string') return undefined;
51
+ return value.replace(/\\/g, '/').toLowerCase().trim();
52
+ }
53
+
54
+ /**
55
+ * Read one tool call's arguments. The log stores them as a JSON *string*, not as an
56
+ * object — reading `arguments.path` off it silently yields nothing, which made the
57
+ * first version of this tool report zero overlap on a run full of reads.
58
+ * @param data - the `tool/call` event's data.
59
+ * @returns the parsed arguments, or an empty object.
60
+ */
61
+ function parseArguments(data) {
62
+ const raw = data?.arguments ?? data?.message?.arguments;
63
+ if (typeof raw === 'object' && raw !== null) return raw;
64
+ if (typeof raw !== 'string') return {};
65
+ try {
66
+ const parsed = JSON.parse(raw);
67
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
68
+ } catch {
69
+ return {};
70
+ }
71
+ }
72
+
73
+ /** Pull (agent, path) read pairs out of one session log. */
74
+ async function readsOf(dir) {
75
+ const text = await decompressAll(readFileSync(join(dir, 'session.v4.jsonl.zstd')));
76
+ const events = text
77
+ .split('\n')
78
+ .filter((line) => line.trim().length > 0)
79
+ .flatMap((line) => {
80
+ try {
81
+ return [JSON.parse(line)];
82
+ } catch {
83
+ return [];
84
+ }
85
+ });
86
+ const header = events[0];
87
+ const reads = [];
88
+ for (const event of events) {
89
+ if (event.type !== 'tool/call') continue;
90
+ const name = event.data?.name ?? event.data?.message?.name;
91
+ if (!READ_TOOLS.has(name)) continue;
92
+ const args = parseArguments(event.data);
93
+ const path = args.path ?? args.file_path;
94
+ const norm = normalize(path);
95
+ if (norm !== undefined) reads.push({ path: norm, depth: header?.delegationDepth });
96
+ }
97
+ return { id: dir.split(/[\\/]/).pop(), depth: header?.delegationDepth, reads };
98
+ }
99
+
100
+ const sessions = [];
101
+ for (const dir of process.argv.slice(2)) sessions.push(await readsOf(dir));
102
+
103
+ /** How many distinct agents touched each file. */
104
+ const byPath = new Map();
105
+ for (const session of sessions) {
106
+ for (const path of new Set(session.reads.map((r) => r.path))) {
107
+ const list = byPath.get(path) ?? [];
108
+ list.push(session.id.slice(0, 8));
109
+ byPath.set(path, list);
110
+ }
111
+ }
112
+
113
+ const totalReads = sessions.reduce((sum, s) => sum + s.reads.length, 0);
114
+ const shared = [...byPath.entries()].filter(([, agents]) => agents.length > 1);
115
+ const sharedReads = sessions.reduce(
116
+ (sum, s) => sum + s.reads.filter((r) => (byPath.get(r.path)?.length ?? 0) > 1).length,
117
+ 0,
118
+ );
119
+
120
+ process.stdout.write(`\n${sessions.length} session(s), ${totalReads} read calls, ${byPath.size} distinct paths\n`);
121
+ process.stdout.write(
122
+ `paths read by more than one agent: ${shared.length} (${((shared.length / Math.max(1, byPath.size)) * 100).toFixed(1)}% of paths)\n`,
123
+ );
124
+ process.stdout.write(
125
+ `read calls that hit a path another agent also read: ${sharedReads} (${((sharedReads / Math.max(1, totalReads)) * 100).toFixed(1)}% of reads)\n`,
126
+ );
127
+
128
+ const worst = shared.sort((a, b) => b[1].length - a[1].length).slice(0, 12);
129
+ if (worst.length > 0) {
130
+ process.stdout.write('\nmost shared paths:\n');
131
+ for (const [path, agents] of worst) {
132
+ process.stdout.write(` ${String(agents.length)}× ${path.slice(-96)}\n`);
133
+ }
134
+ }
package/verify-patch.mjs CHANGED
@@ -62,6 +62,30 @@ check(
62
62
  'persona forbids long-running processes for subagents',
63
63
  );
64
64
 
65
+ /**
66
+ * Rule count, asserted on purpose.
67
+ *
68
+ * Byte-identity above only proves the patch matches `persona.txt` in the working
69
+ * tree. It says nothing about what was PUBLISHED: 1.1.6 shipped a package.json bump
70
+ * while the two collaboration rules added in the same session stayed in the working
71
+ * tree, so npm carried a 13-rule persona while the repository had 14. The byte check
72
+ * could not see that, because both files agreed with each other.
73
+ *
74
+ * Update this number in the same commit as the rule it counts, so the release is the
75
+ * act that carries it.
76
+ */
77
+ const ROOT_RULE_COUNT = 14;
78
+ const rootSection = (personaRow?.config?.prefix ?? '').split('## Root agent rules')[1]?.split('## Subagent rules')[0] ?? '';
79
+ const rootRules = [...rootSection.matchAll(/^(\d+)\. /gm)].map((match) => Number(match[1]));
80
+ check(
81
+ rootRules.length === ROOT_RULE_COUNT,
82
+ `persona carries ${ROOT_RULE_COUNT} root rules (found ${rootRules.length})`,
83
+ );
84
+ check(
85
+ rootRules.every((value, index) => value === index + 1),
86
+ `root rule numbering is 1..${rootRules.length} with no gaps or repeats (found ${rootRules.join(',')})`,
87
+ );
88
+
65
89
  const delegation = plugins.find((row) => row.id === 'delegation');
66
90
  check(delegation?.group === true, 'delegation is a group row');
67
91
  const delegationRows = delegation?.config ?? [];