dsh-root-planner-preset 1.1.0 → 1.1.2
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/.github/CONTRIBUTING.md +30 -0
- package/INSTALL.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/docs/DEVELOPMENT.md +3 -1
- package/package.json +2 -2
- package/release.mjs +237 -0
- package/tools/extract-tool-inventory.mjs +65 -0
- package/tools/verify-session.mjs +57 -32
- package/tools/inspect-sessions.mjs +0 -71
package/.github/CONTRIBUTING.md
CHANGED
|
@@ -82,6 +82,36 @@ script prints `SKIP` and explains why. That is a pass, not a failure.
|
|
|
82
82
|
- Keep `main` linear — the branch protection refuses merge commits, so rebase
|
|
83
83
|
rather than merge.
|
|
84
84
|
|
|
85
|
+
## Releasing
|
|
86
|
+
|
|
87
|
+
`node release.mjs <patch|minor|major|X.Y.Z>` does the whole sequence. Every step in
|
|
88
|
+
it exists because the manual version went wrong at least once:
|
|
89
|
+
|
|
90
|
+
1. regenerates `cordis.patch.yml` and refuses to continue unless
|
|
91
|
+
`verify-patch.mjs` passes;
|
|
92
|
+
2. bumps `package.json` and commits with a local-time stamp;
|
|
93
|
+
3. pushes (the owner bypasses branch protection; a collaborator's push is refused,
|
|
94
|
+
so open a pull request instead);
|
|
95
|
+
4. publishes to `https://registry.npmjs.org/` **explicitly** — the configured
|
|
96
|
+
registry is a read-only mirror that also lags behind, which is how an install
|
|
97
|
+
right after a release fails with *No matching version found*;
|
|
98
|
+
5. **polls the registry for the exact version** rather than trusting the exit code,
|
|
99
|
+
because publishing on this account is staged and `npm publish` can print success
|
|
100
|
+
without the version ever appearing;
|
|
101
|
+
6. upgrades the local profile so the machine runs what was published.
|
|
102
|
+
|
|
103
|
+
`--dry-run` walks the whole thing without changing anything; `--skip-install` and
|
|
104
|
+
`--skip-gh` drop the last two steps.
|
|
105
|
+
|
|
106
|
+
Run it from a terminal. Inside DSH's own sandbox its `spawn` calls fail with
|
|
107
|
+
`EPERM` on Windows — that is the sandbox's piped-stdio boundary, not a defect in
|
|
108
|
+
the script.
|
|
109
|
+
|
|
110
|
+
**A release is not a verification.** The script can put a version on npm while the
|
|
111
|
+
preset stays unproven. Restart `dsh web`, open a new session on Root Planner, and
|
|
112
|
+
confirm the *root* actually composed with it — `node tools/verify-session.mjs
|
|
113
|
+
<session-dir>` — before believing anything about behaviour.
|
|
114
|
+
|
|
85
115
|
## How `main` is protected
|
|
86
116
|
|
|
87
117
|
`main` requires the `verify` status check (strict: the branch must be up to date),
|
package/INSTALL.md
CHANGED
|
@@ -114,7 +114,7 @@ git clone https://github.com/XiaoOu2024/dsh-root-planner-preset
|
|
|
114
114
|
| 委派立刻返回**深度错误** | `maxDepth` 没设或设得太小 | 见第三节,设为 `2` |
|
|
115
115
|
| 子代理**什么都不能读** | 白名单过窄(比如你删过头了) | 至少保留 `read`、`glob`、`grep`、`ops_inspect_local_file`、`ops_glob`、`ops_grep` |
|
|
116
116
|
| 子代理**还是能改文件** | 根会话没选中 Root Planner(见第四节),或白名单被改坏了 | 先查会话文件的 `agentPreset` 字段是否 `root-planner`;再确认 `cordis.patch.yml` 里两处 `toolFilter.allow` 都**不含** `write`、`edit`、`ops_replace`、`ops_bash`、`pwsh`。改完必须重开会话 |
|
|
117
|
-
| 行为与人设描述**对不上**(比如"子代理能写"、"规则没生效") | 最常见的原因就是**根会话跑在别的预设上**,而它的子代理跑在 root-planner 上——两者的规则和权限本来就不一样 |
|
|
117
|
+
| 行为与人设描述**对不上**(比如"子代理能写"、"规则没生效") | 最常见的原因就是**根会话跑在别的预设上**,而它的子代理跑在 root-planner 上——两者的规则和权限本来就不一样 | 跑 `node tools/verify-session.mjs <会话目录>`,它会判定根与全部子会话的预设、深度与父子关系 |
|
|
118
118
|
| 改了 `persona.txt` 但没生效 | 会话与子代理都锁定启动时的插件版本 | 开**新会话**;必要时把 `preset-root-planner` 在插件列表里关掉再打开 |
|
|
119
119
|
| Windows 上校验报**人设不一致** | 换行符被 Git 改写了 | 确认 `.gitattributes` 存在且含 `* text=auto eol=lf`,重新克隆一次 |
|
|
120
120
|
|
package/README.md
CHANGED
|
@@ -78,7 +78,7 @@ Then **one required step**: set **Settings → Subagents → depth limit** to **
|
|
|
78
78
|
|
|
79
79
|
Then **start a new session** and pick **Root Planner** in the preset selector. Full steps and troubleshooting are in [INSTALL.md](INSTALL.md).
|
|
80
80
|
|
|
81
|
-
> ⚠️ **The easiest mistake to make: the root session must actually be composed with Root Planner.** In one real trial the root session was still `standard`, so the root was bound by none of this preset while the subagents it spawned *were* `root-planner` — every rule and permission in the tree was mismatched, and a batch of confident conclusions about the preset followed from it. **To check:**
|
|
81
|
+
> ⚠️ **The easiest mistake to make: the root session must actually be composed with Root Planner.** In one real trial the root session was still `standard`, so the root was bound by none of this preset while the subagents it spawned *were* `root-planner` — every rule and permission in the tree was mismatched, and a batch of confident conclusions about the preset followed from it. **To check:** run `node tools/verify-session.mjs <session-dir>`, which reports the preset and depth of the root and every descendant, and fails loudly when the root is not `root-planner`.
|
|
82
82
|
|
|
83
83
|
## When it fits, and when it does not
|
|
84
84
|
|
package/README.zh.md
CHANGED
|
@@ -66,7 +66,7 @@ dsh plugin --profile web add github:XiaoOu2024/dsh-root-planner-preset
|
|
|
66
66
|
|
|
67
67
|
然后**开新会话**,在预设选择器里选 **Root Planner**。完整步骤与故障排查见 [INSTALL.md](INSTALL.md)。
|
|
68
68
|
|
|
69
|
-
> ⚠️ **最容易踩的坑:根会话必须真的选中 Root Planner。** 有人实测时根会话仍是 `standard`,于是根不受本预设约束、而它派出的子代理却是 `root-planner
|
|
69
|
+
> ⚠️ **最容易踩的坑:根会话必须真的选中 Root Planner。** 有人实测时根会话仍是 `standard`,于是根不受本预设约束、而它派出的子代理却是 `root-planner`——权限与规则整棵树错位,最后据此得出一批关于本预设的错误结论。**确认方法**:跑 `node tools/verify-session.mjs <会话目录>`,它会报出根与全部子会话的预设与深度,**根不是 root-planner 就直接判 FAIL**。
|
|
70
70
|
|
|
71
71
|
## 适合 / 不适合
|
|
72
72
|
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -27,7 +27,9 @@ Paths below are relative to the repository root; this file lives in `docs/`.
|
|
|
27
27
|
| `lib-yaml.mjs` | Resolves a YAML parser from the local DSH installation, so the scripts need no dependencies. |
|
|
28
28
|
| `verify-patch.mjs` | Parses the generated patch and checks persona integrity, role wiring, and the child mask. |
|
|
29
29
|
| `child-tools.mjs` | The child mask, defined once so the generator and the verifier cannot disagree. |
|
|
30
|
-
| `
|
|
30
|
+
| `release.mjs` | One-command release: regenerate, verify, bump, commit, push, publish to the **official** registry, confirm the version is live, upgrade the local profile. `--dry-run` changes nothing. Run it from a terminal, not from inside the DSH sandbox, whose piped-stdio boundary makes its `spawn` calls fail with `EPERM` on Windows. |
|
|
31
|
+
| `tools/verify-session.mjs` | Reads a real run's `.jsonl.zstd` logs (frame by frame — one decompress call returns 323 bytes of a 140 KB log) and checks that the **root** and every descendant composed with root-planner, at the expected depth. |
|
|
32
|
+
| `tools/extract-tool-inventory.mjs` | Pulls the runtime's own `known global tools:` line out of a log. That line, not memory, is what the child mask must be a subset of. |
|
|
31
33
|
| `verify-depth-override.mjs` | Checks that the profile patch layer still parses and that the `subagent` depth override landed in the composed profile. |
|
|
32
34
|
| `package.json` | Bundle manifest (`dsh.bundle.patch`). |
|
|
33
35
|
| `.gitattributes` | Pins LF; without it a Windows checkout breaks the byte-identity check. |
|
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.2",
|
|
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",
|
|
@@ -30,4 +30,4 @@
|
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"js-yaml": "^4.1.0"
|
|
32
32
|
}
|
|
33
|
-
}
|
|
33
|
+
}
|
package/release.mjs
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// One-command release for this bundle.
|
|
2
|
+
//
|
|
3
|
+
// It exists because the manual sequence has four places that bite, all of them
|
|
4
|
+
// discovered the hard way in this repository:
|
|
5
|
+
//
|
|
6
|
+
// 1. `cordis.patch.yml` is GENERATED. Editing it by hand, or forgetting to
|
|
7
|
+
// regenerate after a persona edit, is the most common mistake — and CI only
|
|
8
|
+
// catches it after the push.
|
|
9
|
+
// 2. The configured registry is registry.npmmirror.com, a read-only MIRROR. A
|
|
10
|
+
// plain `npm publish` goes to the wrong place, and the mirror can lag the
|
|
11
|
+
// official registry by hours, so an install right after a release fails with
|
|
12
|
+
// "No matching version found".
|
|
13
|
+
// 3. Publishing this account goes through npm's STAGED publishing. `npm publish`
|
|
14
|
+
// can print a success line while the version never reaches the registry, so
|
|
15
|
+
// the exit code proves nothing. The only reliable check is asking the registry
|
|
16
|
+
// for the exact version, with retries: propagation is not instant.
|
|
17
|
+
// 4. The local profile must be upgraded explicitly, or the machine keeps running
|
|
18
|
+
// the previous version while you believe you are testing the new one.
|
|
19
|
+
//
|
|
20
|
+
// Usage:
|
|
21
|
+
// node release.mjs patch # 1.1.1 -> 1.1.2, then release
|
|
22
|
+
// node release.mjs minor # 1.1.1 -> 1.2.0
|
|
23
|
+
// node release.mjs 2.0.0 # an explicit version
|
|
24
|
+
// node release.mjs patch -m "人设:…" # override the commit message body
|
|
25
|
+
//
|
|
26
|
+
// Flags:
|
|
27
|
+
// --dry-run do everything except commit, push, publish and install
|
|
28
|
+
// --skip-install do not upgrade the local DSH profile afterwards
|
|
29
|
+
// --skip-gh skip the GitHub CI wait (still pushes)
|
|
30
|
+
// --profile NAME DSH profile to upgrade (default: web)
|
|
31
|
+
|
|
32
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
33
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
34
|
+
import { homedir } from 'node:os';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
|
|
38
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const OFFICIAL_REGISTRY = 'https://registry.npmjs.org/';
|
|
40
|
+
const PROXY = process.env.HTTPS_PROXY ?? process.env.HTTP_PROXY ?? 'http://127.0.0.1:7897';
|
|
41
|
+
const GIT = process.env.DSH_RELEASE_GIT ?? 'git';
|
|
42
|
+
const NPM = process.env.DSH_RELEASE_NPM ?? 'npm';
|
|
43
|
+
const GH = process.env.DSH_RELEASE_GH ?? 'gh';
|
|
44
|
+
|
|
45
|
+
const argv = process.argv.slice(2);
|
|
46
|
+
const dryRun = argv.includes('--dry-run');
|
|
47
|
+
const skipInstall = argv.includes('--skip-install');
|
|
48
|
+
const skipGh = argv.includes('--skip-gh');
|
|
49
|
+
const profileIndex = argv.indexOf('--profile');
|
|
50
|
+
const profile = profileIndex === -1 ? 'web' : argv[profileIndex + 1];
|
|
51
|
+
const messageIndex = argv.indexOf('-m');
|
|
52
|
+
const body = messageIndex === -1 ? undefined : argv[messageIndex + 1];
|
|
53
|
+
|
|
54
|
+
/** The version argument: the first token that is neither a flag nor a flag's value. */
|
|
55
|
+
const flagValues = new Set([profileIndex + 1, messageIndex + 1].filter((index) => index > 0));
|
|
56
|
+
const bump = argv.find((arg, index) => !arg.startsWith('-') && !flagValues.has(index));
|
|
57
|
+
|
|
58
|
+
if (bump === undefined) {
|
|
59
|
+
process.stderr.write('usage: node release.mjs <patch|minor|major|X.Y.Z> [-m "commit body"] [--dry-run] [--skip-install]\n');
|
|
60
|
+
process.exit(2);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const env = { ...process.env, HTTPS_PROXY: PROXY, HTTP_PROXY: PROXY, GIT_TERMINAL_PROMPT: '0' };
|
|
64
|
+
const step = (text) => process.stdout.write(`\n\u25b6 ${text}\n`);
|
|
65
|
+
const run = (command, args, options = {}) => {
|
|
66
|
+
const result = spawnSync(command, args, { cwd: here, env, encoding: 'utf8', ...options });
|
|
67
|
+
if (result.status !== 0 && options.allowFailure !== true) {
|
|
68
|
+
throw new Error(`${command} ${args.join(' ')} failed (${result.status})\n${result.stdout ?? ''}${result.stderr ?? ''}`);
|
|
69
|
+
}
|
|
70
|
+
return `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
|
71
|
+
};
|
|
72
|
+
const git = (...args) => run(GIT, args).trim();
|
|
73
|
+
|
|
74
|
+
/** Bump a semver string by one of patch/minor/major. */
|
|
75
|
+
function nextVersion(current, kind) {
|
|
76
|
+
if (/^\d+\.\d+\.\d+$/.test(kind)) return kind;
|
|
77
|
+
const [major, minor, patch] = current.split('.').map(Number);
|
|
78
|
+
if (kind === 'patch') return `${major}.${minor}.${patch + 1}`;
|
|
79
|
+
if (kind === 'minor') return `${major}.${minor + 1}.0`;
|
|
80
|
+
if (kind === 'major') return `${major + 1}.0.0`;
|
|
81
|
+
throw new Error(`unknown bump "${kind}" — use patch, minor, major, or an explicit X.Y.Z`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Preconditions ────────────────────────────────────────────────────────────
|
|
85
|
+
step('Preconditions');
|
|
86
|
+
const manifestPath = join(here, 'package.json');
|
|
87
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
88
|
+
const current = manifest.version;
|
|
89
|
+
const version = nextVersion(current, bump);
|
|
90
|
+
|
|
91
|
+
const dirty = git('status', '--porcelain');
|
|
92
|
+
if (dirty.length > 0) {
|
|
93
|
+
process.stdout.write(` uncommitted changes are present; they will be included in this release:\n${dirty.split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
94
|
+
}
|
|
95
|
+
const branch = git('rev-parse', '--abbrev-ref', 'HEAD');
|
|
96
|
+
if (branch !== 'main') throw new Error(`on branch "${branch}", expected main`);
|
|
97
|
+
process.stdout.write(` ${manifest.name} ${current} -> ${version}\n`);
|
|
98
|
+
|
|
99
|
+
// ── Build and verify ─────────────────────────────────────────────────────────
|
|
100
|
+
step('Regenerate the patch from persona.txt');
|
|
101
|
+
process.stdout.write(` ${run(process.execPath, ['build-patch.mjs']).trim()}\n`);
|
|
102
|
+
|
|
103
|
+
step('Verify the patch, the persona and the mask');
|
|
104
|
+
const patchReport = run(process.execPath, ['verify-patch.mjs']);
|
|
105
|
+
if (!patchReport.includes('ALL CHECKS PASSED')) throw new Error(`verify-patch.mjs did not pass:\n${patchReport}`);
|
|
106
|
+
process.stdout.write(' verify-patch.mjs: ALL CHECKS PASSED\n');
|
|
107
|
+
const depthReport = run(process.execPath, ['verify-depth-override.mjs'], { allowFailure: true });
|
|
108
|
+
process.stdout.write(
|
|
109
|
+
depthReport.includes('ALL CHECKS PASSED')
|
|
110
|
+
? ' verify-depth-override.mjs: ALL CHECKS PASSED\n'
|
|
111
|
+
: ' verify-depth-override.mjs: SKIPped (no depth override on this machine)\n',
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// ── Version and commit ───────────────────────────────────────────────────────
|
|
115
|
+
step(`Set version ${version}`);
|
|
116
|
+
if (dryRun) {
|
|
117
|
+
process.stdout.write(' --dry-run: not writing package.json\n');
|
|
118
|
+
} else {
|
|
119
|
+
const updated = readFileSync(manifestPath, 'utf8').replace(/"version": "[^"]+"/, `"version": "${version}"`);
|
|
120
|
+
if (updated === readFileSync(manifestPath, 'utf8')) throw new Error('could not find the version field to replace');
|
|
121
|
+
run(process.execPath, ['-e', `require('node:fs').writeFileSync(${JSON.stringify(manifestPath)}, ${JSON.stringify(updated)})`]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `yyyy-MM-dd HH:mm` in LOCAL time — the project's commit-message convention. */
|
|
125
|
+
function localStamp() {
|
|
126
|
+
const now = new Date();
|
|
127
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
128
|
+
return (
|
|
129
|
+
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
|
|
130
|
+
`${pad(now.getHours())}:${pad(now.getMinutes())}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const commitMessage = `[Agent任务] ${localStamp()} | ${body ?? `发布 v${version}`}`;
|
|
135
|
+
|
|
136
|
+
step('Commit and push');
|
|
137
|
+
if (dryRun) {
|
|
138
|
+
process.stdout.write(` --dry-run: would commit "${commitMessage}" and push to origin/main\n`);
|
|
139
|
+
} else {
|
|
140
|
+
git('add', '-A');
|
|
141
|
+
git('commit', '-m', commitMessage);
|
|
142
|
+
process.stdout.write(` committed ${git('rev-parse', '--short', 'HEAD')}\n`);
|
|
143
|
+
const pushed = run(GIT, ['push', 'origin', 'main'], { allowFailure: true });
|
|
144
|
+
// The owner bypasses branch protection; collaborators cannot, and will need a PR.
|
|
145
|
+
if (!/main -> main|Everything up-to-date/.test(pushed)) {
|
|
146
|
+
throw new Error(`push failed — if branch protection refused it, open a pull request instead:\n${pushed}`);
|
|
147
|
+
}
|
|
148
|
+
process.stdout.write(' pushed to origin/main\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Publish ──────────────────────────────────────────────────────────────────
|
|
152
|
+
step(`Publish to the official registry (the configured one is a read-only mirror)`);
|
|
153
|
+
if (dryRun) {
|
|
154
|
+
process.stdout.write(` --dry-run: would run npm publish --registry ${OFFICIAL_REGISTRY}\n`);
|
|
155
|
+
} else {
|
|
156
|
+
const published = run(NPM, ['publish', '--registry', OFFICIAL_REGISTRY, '--access', 'public'], { allowFailure: true });
|
|
157
|
+
process.stdout.write(` ${published.split('\n').filter((line) => /\+ |error|Publishing/.test(line)).join('\n ') || '(no output)'}\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Confirm on the registry ──────────────────────────────────────────────────
|
|
161
|
+
step('Confirm the version is actually live (the exit code does not prove it)');
|
|
162
|
+
if (dryRun) {
|
|
163
|
+
process.stdout.write(' --dry-run: skipped\n');
|
|
164
|
+
} else {
|
|
165
|
+
// curl, not fetch: this machine reaches the network through a proxy that Node's
|
|
166
|
+
// global fetch does not read, so a fetch here would fail for a reason that has
|
|
167
|
+
// nothing to do with the release.
|
|
168
|
+
const probe = (url) =>
|
|
169
|
+
spawnSync('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '-m', '20', '-x', PROXY, url], {
|
|
170
|
+
encoding: 'utf8',
|
|
171
|
+
}).stdout?.trim();
|
|
172
|
+
|
|
173
|
+
let live = false;
|
|
174
|
+
for (let attempt = 1; attempt <= 20 && !live; attempt += 1) {
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 10_000));
|
|
176
|
+
const code = probe(`${OFFICIAL_REGISTRY}${manifest.name}/${version}`);
|
|
177
|
+
if (code === '200') {
|
|
178
|
+
live = true;
|
|
179
|
+
process.stdout.write(` ${manifest.name}@${version} is live on the registry\n`);
|
|
180
|
+
} else if (attempt % 5 === 0) {
|
|
181
|
+
process.stdout.write(` still not visible after ${attempt * 10}s (last HTTP ${code})\n`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!live) {
|
|
185
|
+
process.stderr.write(
|
|
186
|
+
'\n NOT VISIBLE. On this account publishing is STAGED: check for a pending\n' +
|
|
187
|
+
' approval on npmjs.com — `npm stage list` can report nothing while the\n' +
|
|
188
|
+
' registry still refuses the version.\n',
|
|
189
|
+
);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Upgrade the local profile ────────────────────────────────────────────────
|
|
195
|
+
step(`Upgrade the local "${profile}" profile so the machine runs what was published`);
|
|
196
|
+
if (dryRun || skipInstall) {
|
|
197
|
+
process.stdout.write(' skipped\n');
|
|
198
|
+
} else {
|
|
199
|
+
const spec = `${manifest.name}@${version}`;
|
|
200
|
+
const output = run('dsh', ['plugin', '--profile', profile, 'add', spec, '--registry', OFFICIAL_REGISTRY], {
|
|
201
|
+
allowFailure: true,
|
|
202
|
+
});
|
|
203
|
+
const installed = (() => {
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(
|
|
206
|
+
readFileSync(join(homedir(), '.dsh', 'profiles', profile, 'node_modules', manifest.name, 'package.json'), 'utf8'),
|
|
207
|
+
).version;
|
|
208
|
+
} catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
})();
|
|
212
|
+
if (installed === version) {
|
|
213
|
+
process.stdout.write(` profile now runs ${manifest.name}@${installed}\n`);
|
|
214
|
+
} else {
|
|
215
|
+
process.stdout.write(` install reported ${installed ?? 'unknown'} — run it by hand if this matters:\n${output.trim()}\n`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── CI ───────────────────────────────────────────────────────────────────────
|
|
220
|
+
if (!dryRun && !skipGh) {
|
|
221
|
+
step('Wait for CI on the pushed commit');
|
|
222
|
+
const sha = git('rev-parse', 'HEAD');
|
|
223
|
+
const waited = run(
|
|
224
|
+
GH,
|
|
225
|
+
['run', 'watch', sha, '--repo', 'XiaoOu2024/dsh-root-planner-preset', '--exit-status'],
|
|
226
|
+
{ allowFailure: true },
|
|
227
|
+
);
|
|
228
|
+
process.stdout.write(` ${waited.trim().split('\n').slice(-2).join('\n ') || '(no output)'}\n`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
step(dryRun ? 'Dry run complete — nothing was changed' : `Released ${manifest.name}@${version}`);
|
|
232
|
+
if (!dryRun) {
|
|
233
|
+
process.stdout.write(
|
|
234
|
+
'\n Next: restart `dsh web`, open a new session on the Root Planner preset, then\n' +
|
|
235
|
+
' confirm it with tools/verify-session.mjs before trusting any behaviour.\n',
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Extracts the runtime's own global-tool inventory from a session log.
|
|
2
|
+
//
|
|
3
|
+
// `tools.restrict()` prints every known global tool when it refuses a name, so a
|
|
4
|
+
// session that hit `names unknown global tool` carries the authoritative list for
|
|
5
|
+
// its own environment. That list is what the child mask has to be a subset of, and
|
|
6
|
+
// it is the only trustworthy source — a mask built from memory breaks every child
|
|
7
|
+
// creation, which is exactly what happened twice in this repository.
|
|
8
|
+
//
|
|
9
|
+
// Usage: node tools/extract-tool-inventory.mjs <session.jsonl.zstd> [...]
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { createZstdDecompress } from 'node:zlib';
|
|
13
|
+
|
|
14
|
+
/** Locate the end of the first zstd frame, where the next one begins. */
|
|
15
|
+
function firstFrameEnd(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
|
+
/**
|
|
23
|
+
* Decompress a concatenated-zstd log frame by frame. A single decompress call
|
|
24
|
+
* stops at the first frame, which is why a naive read of these logs returns a few
|
|
25
|
+
* hundred bytes out of tens of kilobytes and hides every event.
|
|
26
|
+
* @param {string} path - the .jsonl.zstd log.
|
|
27
|
+
* @returns {Promise<string>} the whole decompressed text.
|
|
28
|
+
*/
|
|
29
|
+
async function decompressAll(path) {
|
|
30
|
+
let rest = readFileSync(path);
|
|
31
|
+
let text = '';
|
|
32
|
+
while (rest.length > 0) {
|
|
33
|
+
const slice = rest.subarray(0, firstFrameEnd(rest));
|
|
34
|
+
try {
|
|
35
|
+
text += await new Promise((resolve, reject) => {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
const decoder = createZstdDecompress();
|
|
38
|
+
decoder.on('data', (chunk) => chunks.push(chunk));
|
|
39
|
+
decoder.on('error', reject);
|
|
40
|
+
decoder.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
41
|
+
decoder.end(slice);
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
if (slice.length >= rest.length) break;
|
|
47
|
+
rest = rest.subarray(slice.length);
|
|
48
|
+
}
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const path of process.argv.slice(2)) {
|
|
53
|
+
const text = await decompressAll(path);
|
|
54
|
+
const match = /known global tools: ([^"\\\]]+)/.exec(text);
|
|
55
|
+
process.stdout.write(`\n=== ${path.split(/[\\/]/).pop()} (${text.length} chars decompressed) ===\n`);
|
|
56
|
+
if (match === null) {
|
|
57
|
+
process.stdout.write(`no inventory line found. mentions "unknown global tool": ${text.includes('unknown global tool')}\n`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const tools = match[1]
|
|
61
|
+
.split(',')
|
|
62
|
+
.map((name) => name.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
process.stdout.write(`${tools.length} global tools:\n ${tools.join('\n ')}\n`);
|
|
65
|
+
}
|
package/tools/verify-session.mjs
CHANGED
|
@@ -17,42 +17,45 @@ import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { createZstdDecompress } from 'node:zlib';
|
|
19
19
|
|
|
20
|
+
/** Locate the end of the first zstd frame, where the next one begins. */
|
|
21
|
+
function frameBytes(buf) {
|
|
22
|
+
for (let i = 4; i + 4 <= buf.length; i += 1) {
|
|
23
|
+
if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) return i;
|
|
24
|
+
}
|
|
25
|
+
return buf.length;
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
/**
|
|
21
|
-
* Decompress a concatenated-zstd file
|
|
22
|
-
*
|
|
23
|
-
*
|
|
29
|
+
* Decompress a concatenated-zstd file frame by frame.
|
|
30
|
+
*
|
|
31
|
+
* A single decompress call stops at the first frame, which is why a naive read of
|
|
32
|
+
* these logs returns 323 bytes out of 139 KB and silently hides every event.
|
|
33
|
+
* Measured on a real log: frame-by-frame reads 143 654 characters where one call
|
|
34
|
+
* read 323.
|
|
24
35
|
* @param {Buffer} buf - whole file.
|
|
25
36
|
* @returns {Promise<string>} the complete decompressed text.
|
|
26
37
|
*/
|
|
27
38
|
async function decompressAll(buf) {
|
|
28
|
-
const out = [];
|
|
29
39
|
let rest = buf;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return out.join('');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Locate the end of the first zstd frame by magic-number scanning. */
|
|
51
|
-
function frameBytes(buf, ) {
|
|
52
|
-
for (let i = 4; i + 4 <= buf.length; i += 1) {
|
|
53
|
-
if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) return i;
|
|
40
|
+
let text = '';
|
|
41
|
+
while (rest.length > 0) {
|
|
42
|
+
const slice = rest.subarray(0, frameBytes(rest));
|
|
43
|
+
try {
|
|
44
|
+
text += await new Promise((resolve, reject) => {
|
|
45
|
+
const chunks = [];
|
|
46
|
+
const dec = createZstdDecompress();
|
|
47
|
+
dec.on('data', (chunk) => chunks.push(chunk));
|
|
48
|
+
dec.on('error', reject);
|
|
49
|
+
dec.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
50
|
+
dec.end(slice);
|
|
51
|
+
});
|
|
52
|
+
} catch {
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
if (slice.length >= rest.length) break;
|
|
56
|
+
rest = rest.subarray(slice.length);
|
|
54
57
|
}
|
|
55
|
-
return
|
|
58
|
+
return text;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/** Read one session directory into {header, events}. */
|
|
@@ -86,6 +89,7 @@ const base = join(rootDir, '..');
|
|
|
86
89
|
const root = await readSession(rootDir);
|
|
87
90
|
|
|
88
91
|
const sessions = [{ dir: rootDir, ...root }];
|
|
92
|
+
const candidates = [];
|
|
89
93
|
for (const name of readdirSync(base)) {
|
|
90
94
|
if (name === rootDir.split(/[\\/]/).pop()) continue;
|
|
91
95
|
const candidate = join(base, name);
|
|
@@ -96,10 +100,31 @@ for (const name of readdirSync(base)) {
|
|
|
96
100
|
}
|
|
97
101
|
const session = await readSession(candidate);
|
|
98
102
|
// Only real children count. Sibling ROOT sessions of other conversations live in
|
|
99
|
-
// the same directory and have no parentSession — including them made
|
|
100
|
-
// claim
|
|
103
|
+
// the same directory and have no parentSession — including them made this report
|
|
104
|
+
// claim a run had failed when it had not.
|
|
101
105
|
if (session.header?.origin !== 'subagent') continue;
|
|
102
|
-
|
|
106
|
+
candidates.push({ dir: candidate, ...session });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Keep only the root's real descendants. Other presets delegate too, and their
|
|
111
|
+
* subagent sessions sit in the same directory; without this walk they appear as
|
|
112
|
+
* children of this run and fail the preset check for the wrong reason.
|
|
113
|
+
*/
|
|
114
|
+
const rootId = root.header?.id;
|
|
115
|
+
const childrenOf = new Map();
|
|
116
|
+
for (const session of candidates) {
|
|
117
|
+
const parent = session.header?.parentSession;
|
|
118
|
+
if (parent === undefined) continue;
|
|
119
|
+
const list = childrenOf.get(parent) ?? [];
|
|
120
|
+
list.push(session);
|
|
121
|
+
childrenOf.set(parent, list);
|
|
122
|
+
}
|
|
123
|
+
const queue = [...(childrenOf.get(rootId) ?? [])];
|
|
124
|
+
while (queue.length > 0) {
|
|
125
|
+
const session = queue.shift();
|
|
126
|
+
sessions.push(session);
|
|
127
|
+
queue.push(...(childrenOf.get(session.header?.id) ?? []));
|
|
103
128
|
}
|
|
104
129
|
|
|
105
130
|
const failures = [];
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
// Forensics on the real root-planner test run: which preset did the root session
|
|
2
|
-
// compose with, and what tools did each level actually receive?
|
|
3
|
-
//
|
|
4
|
-
// Usage: node inspect-sessions.mjs <rootSessionDir> [childSessionDir ...]
|
|
5
|
-
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { join } from 'node:path';
|
|
8
|
-
import { zstdDecompressSync } from 'node:zlib';
|
|
9
|
-
|
|
10
|
-
/** Read one .jsonl.zstd session log into parsed events. */
|
|
11
|
-
function readSession(dir) {
|
|
12
|
-
const raw = zstdDecompressSync(readFileSync(join(dir, 'session.v4.jsonl.zstd')));
|
|
13
|
-
return raw
|
|
14
|
-
.toString('utf8')
|
|
15
|
-
.split('\n')
|
|
16
|
-
.filter((line) => line.trim().length > 0)
|
|
17
|
-
.map((line) => {
|
|
18
|
-
try {
|
|
19
|
-
return JSON.parse(line);
|
|
20
|
-
} catch {
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
})
|
|
24
|
-
.filter(Boolean);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
for (const dir of process.argv.slice(2)) {
|
|
28
|
-
const events = readSession(dir);
|
|
29
|
-
const types = new Map();
|
|
30
|
-
for (const event of events) types.set(event.type, (types.get(event.type) ?? 0) + 1);
|
|
31
|
-
|
|
32
|
-
process.stdout.write(`\n===== ${dir.split(/[\\/]/).pop()} — ${events.length} events =====\n`);
|
|
33
|
-
process.stdout.write(`event types: ${[...types.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([t, n]) => `${t}×${n}`).join(', ')}\n`);
|
|
34
|
-
|
|
35
|
-
// Session header: the composition that was actually in force.
|
|
36
|
-
for (const event of events) {
|
|
37
|
-
if (event.type === 'session/header' || event.type === 'session/start') {
|
|
38
|
-
const h = event.data ?? event;
|
|
39
|
-
process.stdout.write(
|
|
40
|
-
`header: origin=${h.header?.origin ?? h.origin} parent=${h.header?.parentSession ?? h.parentSession} ` +
|
|
41
|
-
`depth=${h.header?.delegationDepth ?? h.delegationDepth} preset=${h.preset ?? h.presetId ?? '(not in header)'}\n`,
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Tool names actually offered to this session, from its request payloads.
|
|
47
|
-
const offered = new Set();
|
|
48
|
-
for (const event of events) {
|
|
49
|
-
const message = event.data?.request?.messages ?? event.request?.messages;
|
|
50
|
-
if (!Array.isArray(message)) continue;
|
|
51
|
-
for (const entry of message) {
|
|
52
|
-
if (entry?.role !== 'system') continue;
|
|
53
|
-
const text = typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content);
|
|
54
|
-
for (const match of text.matchAll(/"name":\s*"([a-z_0-9]+)"/g)) offered.add(match[1]);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
if (offered.size > 0) {
|
|
58
|
-
process.stdout.write(`tools seen in request payloads (${offered.size}): ${[...offered].sort().join(', ')}\n`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Any tool error mentioning a mask or an executor.
|
|
62
|
-
for (const event of events) {
|
|
63
|
-
const text = JSON.stringify(event);
|
|
64
|
-
for (const needle of ['unknown global tool', 'EXECUTOR_UNAVAILABLE', 'SubagentDepthError', 'exceeds maxDepth']) {
|
|
65
|
-
if (text.includes(needle)) {
|
|
66
|
-
const start = Math.max(0, text.indexOf(needle) - 220);
|
|
67
|
-
process.stdout.write(` [${needle}] …${text.slice(start, text.indexOf(needle) + 220)}…\n`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|