@pareto4/pp-core 1.22.0 → 1.23.1
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/bin/install.js +205 -28
- package/commands/pp-help.md +2 -1
- package/gemini-extension.json +1 -1
- package/hooks/dist/pp-check-update-worker.js +1 -1
- package/hooks/dist/pp-context-monitor.js +3 -3
- package/hooks/dist/pp-statusline.js +88 -14
- package/hooks/dist/pp-update-banner.js +44 -4
- package/hooks/dist/pp-workflow-guard.js +3 -3
- package/hooks/pp-check-update-worker.js +1 -1
- package/hooks/pp-context-monitor.js +3 -3
- package/hooks/pp-statusline.js +88 -14
- package/hooks/pp-update-banner.js +44 -4
- package/hooks/pp-workflow-guard.js +3 -3
- package/package.json +1 -1
- package/pp-core/bin/lib/audience-command.cjs +271 -0
- package/pp-core/bin/lib/audience.cjs +293 -0
- package/pp-core/bin/lib/commands.cjs +26 -17
- package/pp-core/bin/lib/discovery-charter.cjs +13 -1
- package/pp-core/bin/lib/discovery-input-check.cjs +11 -11
- package/pp-core/bin/lib/discovery-session-check.cjs +7 -7
- package/pp-core/bin/lib/discovery-status.cjs +419 -0
- package/pp-core/bin/lib/explain-command.cjs +48 -0
- package/pp-core/bin/lib/gate-card.cjs +97 -0
- package/pp-core/bin/lib/hook-messages.cjs +132 -0
- package/pp-core/bin/lib/init-contract.cjs +116 -76
- package/pp-core/bin/lib/init-interactive.cjs +110 -49
- package/pp-core/bin/lib/init.cjs +44 -3
- package/pp-core/bin/lib/io.cjs +33 -0
- package/pp-core/bin/lib/message-labels.cjs +75 -0
- package/pp-core/bin/lib/messages/core.cjs +20 -0
- package/pp-core/bin/lib/messages/discovery.cjs +267 -0
- package/pp-core/bin/lib/messages/gate.cjs +257 -0
- package/pp-core/bin/lib/messages/hook.cjs +114 -0
- package/pp-core/bin/lib/messages/init.cjs +513 -0
- package/pp-core/bin/lib/messages/progress.cjs +110 -0
- package/pp-core/bin/lib/messages.cjs +208 -0
- package/pp-core/bin/lib/vault-command-router.cjs +16 -3
- package/pp-core/bin/lib/violation-render.cjs +78 -0
- package/pp-core/bin/pp-tools.cjs +68 -23
- package/pp-core/bin/shared/config-schema.manifest.json +1 -0
- package/pp-core/references/audience-moments.md +119 -0
- package/pp-core/references/audience.md +114 -0
- package/pp-core/references/discovery-charter.md +2 -2
- package/pp-core/templates/project-charter.md +3 -4
- package/pp-core/workflows/add-phase.md +2 -0
- package/pp-core/workflows/add-tests.md +2 -0
- package/pp-core/workflows/add-todo.md +2 -0
- package/pp-core/workflows/ai-integration-phase.md +2 -0
- package/pp-core/workflows/audit-milestone.md +2 -0
- package/pp-core/workflows/autonomous.md +2 -0
- package/pp-core/workflows/check-todos.md +2 -0
- package/pp-core/workflows/code-review-fix.md +2 -0
- package/pp-core/workflows/code-review.md +2 -0
- package/pp-core/workflows/complete-milestone.md +10 -1
- package/pp-core/workflows/discovery-sprint/charter.md +3 -2
- package/pp-core/workflows/discovery-sprint/prefill.md +3 -0
- package/pp-core/workflows/discovery-sprint/prototype.md +14 -0
- package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +4 -1
- package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -1
- package/pp-core/workflows/discovery-sprint/vault-context.md +15 -0
- package/pp-core/workflows/discovery-sprint.md +14 -0
- package/pp-core/workflows/discovery-to-build.md +8 -2
- package/pp-core/workflows/discuss-phase-assumptions.md +2 -0
- package/pp-core/workflows/discuss-phase.md +1 -1
- package/pp-core/workflows/edit-phase.md +2 -0
- package/pp-core/workflows/eval-review.md +2 -0
- package/pp-core/workflows/execute-phase/steps/check-query-gate.md +25 -1
- package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -0
- package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +2 -0
- package/pp-core/workflows/execute-phase.md +1 -1
- package/pp-core/workflows/execute-plan.md +2 -0
- package/pp-core/workflows/extract-learnings.md +2 -0
- package/pp-core/workflows/help/modes/business.md +56 -0
- package/pp-core/workflows/help.md +12 -2
- package/pp-core/workflows/import.md +10 -1
- package/pp-core/workflows/ingest-docs.md +2 -0
- package/pp-core/workflows/init.md +17 -1
- package/pp-core/workflows/insert-phase.md +2 -0
- package/pp-core/workflows/list-workspaces.md +2 -0
- package/pp-core/workflows/manager.md +2 -0
- package/pp-core/workflows/map-codebase.md +2 -0
- package/pp-core/workflows/milestone-summary.md +2 -0
- package/pp-core/workflows/new-milestone.md +10 -1
- package/pp-core/workflows/new-project.md +12 -2
- package/pp-core/workflows/new-workspace.md +2 -0
- package/pp-core/workflows/next.md +8 -0
- package/pp-core/workflows/plan-milestone-gaps.md +10 -1
- package/pp-core/workflows/plan-phase.md +2 -1
- package/pp-core/workflows/plan-review-convergence.md +1 -1
- package/pp-core/workflows/progress.md +10 -0
- package/pp-core/workflows/quick.md +11 -1
- package/pp-core/workflows/remove-phase.md +2 -0
- package/pp-core/workflows/remove-workspace.md +2 -0
- package/pp-core/workflows/resume-project.md +25 -0
- package/pp-core/workflows/review.md +2 -0
- package/pp-core/workflows/scan.md +2 -0
- package/pp-core/workflows/secure-phase.md +2 -0
- package/pp-core/workflows/settings.md +41 -0
- package/pp-core/workflows/ship.md +2 -0
- package/pp-core/workflows/spec-phase.md +1 -1
- package/pp-core/workflows/ui-phase.md +2 -0
- package/pp-core/workflows/ui-review.md +2 -0
- package/pp-core/workflows/ultraplan-phase.md +2 -0
- package/pp-core/workflows/validate-phase.md +2 -0
- package/pp-core/workflows/vault-review.md +2 -0
- package/pp-core/workflows/verify-phase.md +2 -0
- package/pp-core/workflows/verify-work.md +2 -0
- package/scripts/affected-tests-lib.cjs +26 -12
- package/scripts/ci-release-only.cjs +158 -0
- package/scripts/live-runs/driver.cjs +326 -0
- package/scripts/live-runs/run.cjs +906 -0
- package/scripts/live-runs/scenarios.cjs +326 -0
- package/scripts/live-runs/scratch-project.cjs +132 -0
- package/scripts/live-runs/scrub.cjs +291 -0
- package/scripts/live-runs/turn-guard.cjs +107 -0
- package/scripts/live-runs/walk-setup.cjs +198 -0
- package/scripts/run-tests.cjs +151 -135
- package/scripts/test-env.cjs +64 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* driver.cjs - Phase 44 REACH-02 turn driver (D-01, D-04).
|
|
5
|
+
*
|
|
6
|
+
* One user turn is one `claude -p <turn> --output-format stream-json --verbose`
|
|
7
|
+
* process. Later turns resume the conversation with `--resume <session_id>` taken
|
|
8
|
+
* from the previous turn's result event. The child runs as the operator (login
|
|
9
|
+
* needs HOME) inside a scratch project. What the harness narrows here:
|
|
10
|
+
* - the env is an allow list (ENV_ALLOW): tokens, cloud keys, the SSH agent,
|
|
11
|
+
* ANTHROPIC_MODEL, GSD_* test switches and GIT_DIR-style variables never reach (gsd-allow-legacy-name: runtime env contract)
|
|
12
|
+
* the child, and the parent session's markers are gone;
|
|
13
|
+
* - the operator's user settings and MCP servers are left out, but the deny rules
|
|
14
|
+
* in their user settings.json are carried over through --disallowedTools;
|
|
15
|
+
* - the tools that reach the network, schedule work or push (DENIED_TOOLS) are
|
|
16
|
+
* denied, next to the auto-approved working set (ALLOWED_TOOLS);
|
|
17
|
+
* - every turn has a wall-time kill and a dollar cap.
|
|
18
|
+
* What it does not do: it is not a sandbox. Bash, Write and Edit are auto-approved,
|
|
19
|
+
* so a model that ignores the scenario can still run any other command as the
|
|
20
|
+
* operator. The scrub (scrub.cjs) is the last line before evidence is written.
|
|
21
|
+
*
|
|
22
|
+
* Never passes --model (the run gets the CLI's default model, D-04) or --bare
|
|
23
|
+
* (it needs an API key and skips the installed commands). The spawn is an argv
|
|
24
|
+
* array with no shell: the user turn is data.
|
|
25
|
+
*
|
|
26
|
+
* The binary comes from PP_LIVE_CLAUDE_BIN (a script path runs through node), so the
|
|
27
|
+
* offline tests drive a fake. Nothing here is required by `npm test` except that
|
|
28
|
+
* test (D-02).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const { spawnSync } = require('node:child_process');
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
/** The parent Claude Code session's markers. A child that inherits them refuses to start. */
|
|
36
|
+
const CLAUDE_ENV_SCRUB_RE = /^CLAUDE_CODE_|^CLAUDECODE$|^CLAUDE_PID$|^CLAUDE_EFFORT$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The only env keys a child session inherits: what the CLI needs to start, log in and
|
|
40
|
+
* reach the API. Everything else (GH_TOKEN, AWS_*, SSH_AUTH_SOCK, NPM_TOKEN,
|
|
41
|
+
* ANTHROPIC_MODEL, GSD_*, GIT_DIR, ...) is dropped. ANTHROPIC_API_KEY stays because it (gsd-allow-legacy-name)
|
|
42
|
+
* is a login path; the scrub removes its value from any evidence.
|
|
43
|
+
*/
|
|
44
|
+
const ENV_ALLOW = Object.freeze([
|
|
45
|
+
'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM', 'TMPDIR', 'TZ',
|
|
46
|
+
'CLAUDE_CONFIG_DIR', 'ANTHROPIC_API_KEY',
|
|
47
|
+
'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'https_proxy', 'http_proxy', 'no_proxy', 'NODE_EXTRA_CA_CERTS',
|
|
48
|
+
'SYSTEMROOT', 'SystemRoot', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PATHEXT', 'COMSPEC', 'ComSpec',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/** The offline fake CLI is driven by these; they never exist in a real run. */
|
|
52
|
+
const ENV_ALLOW_PREFIX_RE = /^PP_FAKE_CLAUDE_/;
|
|
53
|
+
|
|
54
|
+
/** An explicit allow list replaces bypass mode; Agent is there so a workflow can spawn pp-core agents. */
|
|
55
|
+
const ALLOWED_TOOLS = 'Bash Read Write Edit Glob Grep Agent';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Denied outright: tools that reach the network, schedule or trigger work outside the
|
|
59
|
+
* turn, or notify a person, and the shell commands that push, call remote services or
|
|
60
|
+
* read credential folders. A deny rule wins over an auto-approval.
|
|
61
|
+
*/
|
|
62
|
+
const DENIED_TOOLS = Object.freeze([
|
|
63
|
+
'WebFetch', 'WebSearch', 'RemoteTrigger', 'CronCreate', 'CronDelete', 'CronList', 'ScheduleWakeup', 'PushNotification',
|
|
64
|
+
'Bash(git push *)', 'Bash(gh *)', 'Bash(ssh *)', 'Bash(scp *)', 'Bash(curl *)', 'Bash(wget *)',
|
|
65
|
+
'Read(~/.ssh/**)', 'Read(~/.aws/**)', 'Read(~/.config/gh/**)', 'Read(~/.npmrc)',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The deny rules from the operator's user settings.json (`permissions.deny`). The child
|
|
70
|
+
* runs with --setting-sources project,local, which leaves that file out, so its deny
|
|
71
|
+
* rules are passed on explicitly. A missing or unreadable file gives [].
|
|
72
|
+
*/
|
|
73
|
+
function userDenyRules(env = process.env) {
|
|
74
|
+
const configDir = env.CLAUDE_CONFIG_DIR || (env.HOME ? path.join(env.HOME, '.claude') : null);
|
|
75
|
+
if (!configDir) return [];
|
|
76
|
+
try {
|
|
77
|
+
const data = JSON.parse(fs.readFileSync(path.join(configDir, 'settings.json'), 'utf8'));
|
|
78
|
+
const deny = data && data.permissions && data.permissions.deny;
|
|
79
|
+
return Array.isArray(deny) ? deny.filter((r) => typeof r === 'string' && r.trim() !== '') : [];
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The throwaway identity every scratch repo and child process commits under. */
|
|
86
|
+
const GIT_IDENTITY = Object.freeze({ name: 'Live Run', email: 'live-run@example.invalid' });
|
|
87
|
+
|
|
88
|
+
const MAX_BUFFER = 256 * 1024 * 1024;
|
|
89
|
+
/** The process-group guard every turn runs under (WR-06), and its timings. */
|
|
90
|
+
const TURN_GUARD = path.join(__dirname, 'turn-guard.cjs');
|
|
91
|
+
const KILL_GRACE_MS = 5000;
|
|
92
|
+
const GUARD_MARGIN_MS = 10000;
|
|
93
|
+
const VERSION_TIMEOUT_MS = 30000;
|
|
94
|
+
const DETAIL_MAX = 300;
|
|
95
|
+
const SCRIPT_EXTENSIONS = new Set(['.cjs', '.js', '.mjs']);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Env keys that point git at another repository. Inherited from a git hook or other
|
|
99
|
+
* tooling they would make the scratch repo's git calls act on the operator's repo.
|
|
100
|
+
*/
|
|
101
|
+
const GIT_REPO_ENV_KEYS = Object.freeze([
|
|
102
|
+
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
103
|
+
'GIT_COMMON_DIR', 'GIT_CEILING_DIRECTORIES', 'GIT_NAMESPACE', 'GIT_PREFIX',
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
/** A copy of `env` without the GIT_REPO_ENV_KEYS. */
|
|
107
|
+
function withoutGitRepoEnv(env = process.env) {
|
|
108
|
+
const out = { ...env };
|
|
109
|
+
for (const key of GIT_REPO_ENV_KEYS) delete out[key];
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function gitIdentityEnv() {
|
|
114
|
+
return {
|
|
115
|
+
GIT_AUTHOR_NAME: GIT_IDENTITY.name,
|
|
116
|
+
GIT_AUTHOR_EMAIL: GIT_IDENTITY.email,
|
|
117
|
+
GIT_COMMITTER_NAME: GIT_IDENTITY.name,
|
|
118
|
+
GIT_COMMITTER_EMAIL: GIT_IDENTITY.email,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Which binary to run: PP_LIVE_CLAUDE_BIN when set (a script path runs through node), else `claude`. */
|
|
123
|
+
function resolveBin(env = process.env) {
|
|
124
|
+
const bin = env && env.PP_LIVE_CLAUDE_BIN;
|
|
125
|
+
if (!bin) return { command: 'claude', prefixArgs: [] };
|
|
126
|
+
if (SCRIPT_EXTENSIONS.has(path.extname(bin))) return { command: process.execPath, prefixArgs: [bin] };
|
|
127
|
+
return { command: bin, prefixArgs: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The env a child session gets: only the ENV_ALLOW keys of `baseEnv` (HOME stays, login
|
|
132
|
+
* needs it). `personHome` becomes the person home (where person.json lives); `audience`
|
|
133
|
+
* null leaves PP_AUDIENCE unset so a walk can test the person-file path instead of the
|
|
134
|
+
* variable.
|
|
135
|
+
*/
|
|
136
|
+
function childEnv(baseEnv, { personHome, audience = 'business' } = {}) {
|
|
137
|
+
const env = {};
|
|
138
|
+
for (const [key, value] of Object.entries(baseEnv || {})) {
|
|
139
|
+
if (value === undefined || CLAUDE_ENV_SCRUB_RE.test(key)) continue;
|
|
140
|
+
if (ENV_ALLOW.includes(key) || ENV_ALLOW_PREFIX_RE.test(key)) env[key] = value;
|
|
141
|
+
}
|
|
142
|
+
env.GSD_HOME = personHome;
|
|
143
|
+
if (audience !== null) env.PP_AUDIENCE = audience;
|
|
144
|
+
return { ...env, ...gitIdentityEnv() };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The exact argv for one turn. `extraDeny` adds deny rules (the operator's own, see userDenyRules). */
|
|
148
|
+
function buildArgs({ prompt, sessionId, budgetUsd, extraDeny = [] }) {
|
|
149
|
+
const deny = [...new Set([...DENIED_TOOLS, ...extraDeny])].join(' ');
|
|
150
|
+
const args = [
|
|
151
|
+
'-p', prompt,
|
|
152
|
+
'--output-format', 'stream-json',
|
|
153
|
+
'--verbose',
|
|
154
|
+
'--setting-sources', 'project,local',
|
|
155
|
+
'--strict-mcp-config',
|
|
156
|
+
'--permission-mode', 'acceptEdits',
|
|
157
|
+
'--allowedTools', ALLOWED_TOOLS,
|
|
158
|
+
'--disallowedTools', deny,
|
|
159
|
+
'--max-budget-usd', String(budgetUsd),
|
|
160
|
+
];
|
|
161
|
+
if (sessionId) args.push('--resume', sessionId);
|
|
162
|
+
return args;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The guard's report from fd 3, or null when it did not write one. */
|
|
166
|
+
function guardReport(res) {
|
|
167
|
+
const raw = res && Array.isArray(res.output) ? String(res.output[3] || '') : '';
|
|
168
|
+
const last = raw.split('\n').filter((l) => l.trim() !== '').pop();
|
|
169
|
+
if (!last) return null;
|
|
170
|
+
try {
|
|
171
|
+
const r = JSON.parse(last);
|
|
172
|
+
return r && typeof r === 'object' ? r : null;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Run one turn. Never throws for a CLI problem: the caller classifies the result
|
|
180
|
+
* (`classifyTurn`) and records "not run" with the reason (D-03). The CLI runs under
|
|
181
|
+
* turn-guard.cjs, which kills its whole process group at the wall time and when it
|
|
182
|
+
* exits; the spawnSync timeout here is only a backstop for the guard itself.
|
|
183
|
+
*/
|
|
184
|
+
function runTurn({ cwd, personHome, prompt, sessionId, wallMs, budgetUsd, env = process.env, audience = 'business' }) {
|
|
185
|
+
const { command, prefixArgs } = resolveBin(env);
|
|
186
|
+
const args = [...prefixArgs, ...buildArgs({ prompt, sessionId, budgetUsd, extraDeny: userDenyRules(env) })];
|
|
187
|
+
const started = Date.now();
|
|
188
|
+
const res = spawnSync(process.execPath, [TURN_GUARD, String(wallMs), String(KILL_GRACE_MS), command, ...args], {
|
|
189
|
+
cwd,
|
|
190
|
+
env: childEnv(env, { personHome, audience }),
|
|
191
|
+
encoding: 'utf8',
|
|
192
|
+
timeout: wallMs + KILL_GRACE_MS + GUARD_MARGIN_MS,
|
|
193
|
+
killSignal: 'SIGKILL',
|
|
194
|
+
maxBuffer: MAX_BUFFER,
|
|
195
|
+
stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
|
|
196
|
+
});
|
|
197
|
+
const report = guardReport(res);
|
|
198
|
+
let status;
|
|
199
|
+
let signal;
|
|
200
|
+
let timedOut;
|
|
201
|
+
let spawnError = null;
|
|
202
|
+
if (report) {
|
|
203
|
+
status = report.status === undefined ? null : report.status;
|
|
204
|
+
signal = report.signal === undefined ? null : report.signal;
|
|
205
|
+
timedOut = report.timedOut === true;
|
|
206
|
+
spawnError = report.spawnError || null;
|
|
207
|
+
} else {
|
|
208
|
+
status = res.status;
|
|
209
|
+
signal = res.signal;
|
|
210
|
+
timedOut = Boolean(res.error && res.error.code === 'ETIMEDOUT');
|
|
211
|
+
if (res.error && !timedOut) spawnError = res.error.code || res.error.message;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
status,
|
|
215
|
+
signal,
|
|
216
|
+
timedOut,
|
|
217
|
+
spawnError,
|
|
218
|
+
stdout: res.stdout || '',
|
|
219
|
+
stderr: res.stderr || '',
|
|
220
|
+
durationMs: Date.now() - started,
|
|
221
|
+
args,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Split stream-json stdout into events; a line that is not JSON is reported, not thrown. */
|
|
226
|
+
function parseLines(stdout) {
|
|
227
|
+
const events = [];
|
|
228
|
+
const parseErrors = [];
|
|
229
|
+
for (const raw of String(stdout || '').split('\n')) {
|
|
230
|
+
if (raw.trim() === '') continue;
|
|
231
|
+
try {
|
|
232
|
+
events.push(JSON.parse(raw));
|
|
233
|
+
} catch (err) {
|
|
234
|
+
parseErrors.push({ line: raw, error: err.message });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { events, parseErrors };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The session id the next turn resumes: the result event's, else the last event that carries one. */
|
|
241
|
+
function sessionIdOf(events) {
|
|
242
|
+
const withId = (events || []).filter((e) => e && typeof e.session_id === 'string' && e.session_id !== '');
|
|
243
|
+
const result = [...withId].reverse().find((e) => e.type === 'result');
|
|
244
|
+
if (result) return result.session_id;
|
|
245
|
+
return withId.length > 0 ? withId.at(-1).session_id : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const AUTH_RE = /not logged in|please run \/login|invalid api key|authentication_error|oauth token (?:has )?expired|expired oauth/i;
|
|
249
|
+
const LAUNCH_REFUSED_RE = /cannot be launched inside another claude code session|nested (?:claude code )?session/i;
|
|
250
|
+
|
|
251
|
+
const cut = (s) => String(s).trim().slice(0, DETAIL_MAX);
|
|
252
|
+
const firstMatchingLine = (text, re) => String(text).split('\n').find((l) => re.test(l)) || '';
|
|
253
|
+
|
|
254
|
+
/** Text a login problem would show: stderr, an error result, or a stdout line that is not an event. */
|
|
255
|
+
function authEvidence(turn, events) {
|
|
256
|
+
const parts = [turn.stderr || ''];
|
|
257
|
+
for (const e of events) {
|
|
258
|
+
if (e && e.type === 'result' && e.is_error === true) parts.push(String(e.result || ''));
|
|
259
|
+
}
|
|
260
|
+
if (events.length === 0) parts.push(turn.stdout || '');
|
|
261
|
+
return parts.join('\n');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Decide what became of a turn, in a fixed order: the CLI is missing, it ran out of
|
|
266
|
+
* wall time, the login failed, the account is rate limited, the CLI refused a nested
|
|
267
|
+
* launch, it crashed, or it is fine. `detail` is the matching line, cut to 300 characters.
|
|
268
|
+
*/
|
|
269
|
+
function classifyTurn(turn, events = []) {
|
|
270
|
+
if (turn.spawnError === 'ENOENT') return { kind: 'cli_missing', detail: cut('the claude binary was not found') };
|
|
271
|
+
if (turn.timedOut) return { kind: 'timeout', detail: cut('the turn ran past its wall time and was killed') };
|
|
272
|
+
|
|
273
|
+
const auth = authEvidence(turn, events);
|
|
274
|
+
if (AUTH_RE.test(auth)) return { kind: 'auth', detail: cut(firstMatchingLine(auth, AUTH_RE)) };
|
|
275
|
+
|
|
276
|
+
const limited = events.find((e) => {
|
|
277
|
+
if (!e || e.type !== 'rate_limit_event') return false;
|
|
278
|
+
const status = e.rate_limit_info && e.rate_limit_info.status;
|
|
279
|
+
return typeof status === 'string' && !status.startsWith('allowed');
|
|
280
|
+
});
|
|
281
|
+
if (limited) return { kind: 'rate_limited', detail: cut(JSON.stringify(limited.rate_limit_info)) };
|
|
282
|
+
|
|
283
|
+
const err = turn.stderr || '';
|
|
284
|
+
if (LAUNCH_REFUSED_RE.test(err)) return { kind: 'launch_refused', detail: cut(firstMatchingLine(err, LAUNCH_REFUSED_RE)) };
|
|
285
|
+
|
|
286
|
+
const hasResult = events.some((e) => e && e.type === 'result');
|
|
287
|
+
if (turn.spawnError || turn.status !== 0 || !hasResult) {
|
|
288
|
+
const why = turn.spawnError || (err.trim() !== '' ? err : `exit ${turn.status === null ? 'by signal ' + turn.signal : turn.status} with no result event`);
|
|
289
|
+
return { kind: 'crashed', detail: cut(why) };
|
|
290
|
+
}
|
|
291
|
+
return { kind: 'ok', detail: '' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The CLI's version string, or null when it cannot run. */
|
|
295
|
+
function cliVersion({ env = process.env } = {}) {
|
|
296
|
+
const { command, prefixArgs } = resolveBin(env);
|
|
297
|
+
const res = spawnSync(command, [...prefixArgs, '--version'], {
|
|
298
|
+
env,
|
|
299
|
+
encoding: 'utf8',
|
|
300
|
+
timeout: VERSION_TIMEOUT_MS,
|
|
301
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
302
|
+
});
|
|
303
|
+
if (res.error || res.status !== 0) return null;
|
|
304
|
+
const out = String(res.stdout || '').trim();
|
|
305
|
+
return out === '' ? null : out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
CLAUDE_ENV_SCRUB_RE,
|
|
310
|
+
ENV_ALLOW,
|
|
311
|
+
ALLOWED_TOOLS,
|
|
312
|
+
DENIED_TOOLS,
|
|
313
|
+
GIT_IDENTITY,
|
|
314
|
+
GIT_REPO_ENV_KEYS,
|
|
315
|
+
withoutGitRepoEnv,
|
|
316
|
+
gitIdentityEnv,
|
|
317
|
+
resolveBin,
|
|
318
|
+
userDenyRules,
|
|
319
|
+
childEnv,
|
|
320
|
+
buildArgs,
|
|
321
|
+
runTurn,
|
|
322
|
+
parseLines,
|
|
323
|
+
sessionIdOf,
|
|
324
|
+
classifyTurn,
|
|
325
|
+
cliVersion,
|
|
326
|
+
};
|