dsh-root-planner-preset 1.1.6 → 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/cordis.patch.yml +3 -2
- package/package.json +1 -1
- package/persona.txt +3 -2
- package/release.mjs +26 -6
- package/tools/measure-read-overlap.mjs +134 -0
- package/verify-patch.mjs +24 -0
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
)
|
|
280
|
-
|
|
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,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 ?? [];
|