@polygraph/claude-plugin 0.5.0 → 0.5.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/.claude-plugin/plugin.json +1 -1
- package/hooks/CAPTURE_CONTRACT.md +178 -0
- package/hooks/agent-session-capture.mjs +214 -0
- package/hooks/agent-session-finalize.mjs +115 -30
- package/hooks/agent-session-link.mjs +35 -13
- package/hooks/capture-cli.mjs +294 -0
- package/hooks/ensure-agent-session-capture-worker.mjs +63 -0
- package/hooks/ensure-agent-session-capture.mjs +79 -0
- package/hooks/finalize-agent-session-worker.mjs +63 -0
- package/hooks/finalize-agent-session.mjs +24 -10
- package/hooks/hooks.json +23 -1
- package/hooks/record-session-mapping.mjs +17 -15
- package/package.json +1 -1
- package/skills/polygraph/SKILL.md +4 -2
- package/skills/polygraph/reference/delegation.md +14 -2
- package/skills/polygraph/reference/publish-changes.md +48 -1
- package/skills/polygraph/reference/session-description.md +1 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { spawn as spawnChild, spawnSync } from 'node:child_process';
|
|
2
|
+
import { closeSync, mkdirSync, openSync, renameSync, statSync } from 'node:fs';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const JS_CLI_ENTRY = /\.[cm]?js$/i;
|
|
7
|
+
export const HOOK_WORKER_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export function nonEmptyString(value) {
|
|
10
|
+
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isManagedChildEnvironment(env) {
|
|
14
|
+
return Boolean(env && Object.hasOwn(env, 'POLYGRAPH_CHILD_AGENT'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function captureCommandEnvironment(env = process.env) {
|
|
18
|
+
const commandEnv = { ...env };
|
|
19
|
+
delete commandEnv.POLYGRAPH_SESSION_ID;
|
|
20
|
+
delete commandEnv.POLYGRAPH_CAPTURE_TOKEN;
|
|
21
|
+
return commandEnv;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The directory a capture process is launched from. A claim carries the
|
|
26
|
+
* harness working directory, but that directory can be gone by the time a
|
|
27
|
+
* delayed hook or detached worker runs (an archived session worktree, a
|
|
28
|
+
* removed temp dir), and a spawn from a missing cwd fails with ENOENT before
|
|
29
|
+
* the CLI ever starts. Working-directory evidence reaches the CLI as an
|
|
30
|
+
* explicit `--cwd` argument where it matters, so the launch itself only
|
|
31
|
+
* needs a directory that exists: the claim's own when it does, else the home
|
|
32
|
+
* directory, else the temp directory.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveLaunchDirectory(preferred, env = process.env) {
|
|
35
|
+
const candidates = [preferred, nonEmptyString(env?.HOME) ?? homedir(), tmpdir()];
|
|
36
|
+
for (const candidate of candidates) {
|
|
37
|
+
const directory = nonEmptyString(candidate);
|
|
38
|
+
if (!directory) continue;
|
|
39
|
+
try {
|
|
40
|
+
if (statSync(directory).isDirectory()) return directory;
|
|
41
|
+
} catch {
|
|
42
|
+
// Missing or unreadable: try the next candidate.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The hook process's own working directory, or undefined when it no longer
|
|
50
|
+
* has one. A harness can start a hook in a directory removed moments earlier
|
|
51
|
+
* or remove it while the hook runs, and `process.cwd()` then throws
|
|
52
|
+
* `uv_cwd`. Callers use this only as the last-resort claim directory, so a
|
|
53
|
+
* missing answer degrades to the launch fallback instead of a crash.
|
|
54
|
+
*/
|
|
55
|
+
export function processWorkingDirectory() {
|
|
56
|
+
try {
|
|
57
|
+
return process.cwd();
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The working directory a claim may record when the payload carries none.
|
|
65
|
+
* Claude and Codex run command hooks in the session's own directory, so the
|
|
66
|
+
* hook process's cwd is genuine harness evidence there. Cursor runs plugin
|
|
67
|
+
* hooks from the plugin root, which is never the repository: a Cursor claim
|
|
68
|
+
* without workspace_roots records no directory at all, and the launch
|
|
69
|
+
* fallback (home, then temp) stays a spawn detail rather than evidence.
|
|
70
|
+
*/
|
|
71
|
+
export function fallbackClaimDirectory(agentType, hookCwd) {
|
|
72
|
+
if (agentType === 'cursor') return undefined;
|
|
73
|
+
return nonEmptyString(hookCwd) ?? processWorkingDirectory();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A hook-captured observation time: a positive epoch-millisecond integer, or
|
|
78
|
+
* undefined for anything else. Wakes and finalizations both carry one, and
|
|
79
|
+
* neither ever substitutes a worker's own clock for it.
|
|
80
|
+
*/
|
|
81
|
+
export function observedAtValue(value) {
|
|
82
|
+
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function nodeRuntime(execPath) {
|
|
86
|
+
const base = basename(execPath).toLowerCase();
|
|
87
|
+
return base === 'node' || base === 'node.exe' ? execPath : 'node';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function portableReexec(env, platform) {
|
|
91
|
+
const raw = nonEmptyString(env.POLYGRAPH_CLI_REEXEC);
|
|
92
|
+
if (!raw || platform !== 'win32') return undefined;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(raw);
|
|
96
|
+
if (
|
|
97
|
+
Array.isArray(parsed) &&
|
|
98
|
+
parsed.length > 0 &&
|
|
99
|
+
parsed.every((part) => nonEmptyString(part))
|
|
100
|
+
) {
|
|
101
|
+
return parsed;
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// An invalid portability hint is ignored in favor of the normal launch.
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function deadlineExceededResult() {
|
|
110
|
+
const error = new Error('Polygraph capture command timed out before launch');
|
|
111
|
+
error.code = 'ETIMEDOUT';
|
|
112
|
+
return { error, status: null, signal: 'SIGTERM' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function withRemainingTimeout(options, deadline, now) {
|
|
116
|
+
if (deadline === undefined) return options;
|
|
117
|
+
const remaining = Math.floor(deadline - now());
|
|
118
|
+
if (remaining < 1) return undefined;
|
|
119
|
+
const configured = Number.isFinite(options.timeout) ? options.timeout : remaining;
|
|
120
|
+
return { ...options, timeout: Math.min(configured, remaining) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runBeforeDeadline(spawn, command, args, options, deadline, now) {
|
|
124
|
+
const boundedOptions = withRemainingTimeout(options, deadline, now);
|
|
125
|
+
if (!boundedOptions) return deadlineExceededResult();
|
|
126
|
+
// Some runtimes hosting these hooks in-process (OpenCode runs under Bun)
|
|
127
|
+
// THROW launch errors from spawnSync instead of returning them in
|
|
128
|
+
// result.error. Both shapes must land in the same error path.
|
|
129
|
+
try {
|
|
130
|
+
return spawn(command, args, boundedOptions);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return { error, status: null, signal: null };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function runCaptureCliSync(
|
|
137
|
+
args,
|
|
138
|
+
{
|
|
139
|
+
env = process.env,
|
|
140
|
+
spawn = spawnSync,
|
|
141
|
+
options = {},
|
|
142
|
+
cwd,
|
|
143
|
+
deadline,
|
|
144
|
+
now = Date.now,
|
|
145
|
+
platform = process.platform,
|
|
146
|
+
execPath = process.execPath,
|
|
147
|
+
} = {}
|
|
148
|
+
) {
|
|
149
|
+
const command = nonEmptyString(env.POLYGRAPH_CLI) ?? 'polygraph';
|
|
150
|
+
const reexec = portableReexec(env, platform);
|
|
151
|
+
const launchDirectory = resolveLaunchDirectory(cwd, env);
|
|
152
|
+
const spawnOptions = {
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
155
|
+
...options,
|
|
156
|
+
...(launchDirectory ? { cwd: launchDirectory } : {}),
|
|
157
|
+
env: captureCommandEnvironment(env),
|
|
158
|
+
shell: false,
|
|
159
|
+
windowsHide: true,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// A plain JavaScript CLI entry (a local package install, a dev build without
|
|
163
|
+
// the executable bit) is never executed directly: direct execution fails
|
|
164
|
+
// EACCES/ENOEXEC on most platforms and Bun surfaces that as a synchronous
|
|
165
|
+
// throw. Running it through Node up front means exactly one process ever
|
|
166
|
+
// launches per wake — there is no ambiguity about which attempt ran.
|
|
167
|
+
let executable;
|
|
168
|
+
let prefixArgs;
|
|
169
|
+
if (reexec) {
|
|
170
|
+
[executable, ...prefixArgs] = reexec;
|
|
171
|
+
} else if (JS_CLI_ENTRY.test(command)) {
|
|
172
|
+
executable = nodeRuntime(execPath);
|
|
173
|
+
prefixArgs = [command];
|
|
174
|
+
} else {
|
|
175
|
+
executable = command;
|
|
176
|
+
prefixArgs = [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return runBeforeDeadline(
|
|
180
|
+
spawn,
|
|
181
|
+
executable,
|
|
182
|
+
[...prefixArgs, ...args],
|
|
183
|
+
spawnOptions,
|
|
184
|
+
deadline,
|
|
185
|
+
now
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function reportWorkerLaunchFailure(onFailure, error) {
|
|
190
|
+
try {
|
|
191
|
+
const pending = onFailure(error);
|
|
192
|
+
if (pending && typeof pending.catch === 'function') {
|
|
193
|
+
pending.catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// A detached handoff must never turn diagnostics into a hook failure.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A worker's inherited stdout/stderr land in one append-only log per worker
|
|
201
|
+
// kind, rotated to `.1` past the same bound as hooks.log so a chatty CLI can
|
|
202
|
+
// never grow it without limit.
|
|
203
|
+
export function openHookWorkerLog(env, logName) {
|
|
204
|
+
const home = nonEmptyString(env?.HOME) ?? homedir();
|
|
205
|
+
const logsDir = join(home, '.polygraph', 'logs');
|
|
206
|
+
mkdirSync(logsDir, { recursive: true });
|
|
207
|
+
const logFile = join(logsDir, logName);
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
if (statSync(logFile).size > HOOK_WORKER_LOG_MAX_BYTES) {
|
|
211
|
+
renameSync(logFile, `${logFile}.1`);
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// There may be no prior log, and rotation must stay best-effort.
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return openSync(logFile, 'a', 0o600);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Hand a serialized claim to a detached Node worker and return immediately.
|
|
222
|
+
* The worker owns the complete CLI invocation and its durable failure
|
|
223
|
+
* logging; the short-lived parent hook observes launch errors only, because
|
|
224
|
+
* it cannot outlive the harness event that spawned it.
|
|
225
|
+
*
|
|
226
|
+
* The worker is a plain JS module, so it always launches through a Node
|
|
227
|
+
* runtime: process.execPath is the host binary, and under OpenCode that is
|
|
228
|
+
* the compiled Bun executable rather than Node.
|
|
229
|
+
*/
|
|
230
|
+
export function launchDetachedHookWorker({
|
|
231
|
+
workerPath,
|
|
232
|
+
claim,
|
|
233
|
+
logName,
|
|
234
|
+
spawn = spawnChild,
|
|
235
|
+
env = process.env,
|
|
236
|
+
execPath = process.execPath,
|
|
237
|
+
onFailure = () => {},
|
|
238
|
+
openLog = openHookWorkerLog,
|
|
239
|
+
closeLog = closeSync,
|
|
240
|
+
}) {
|
|
241
|
+
// The log is diagnostic only. If it cannot be opened (unwritable home,
|
|
242
|
+
// exhausted descriptors) the worker still launches with its output
|
|
243
|
+
// discarded; its own durable hooks.log write does not depend on it.
|
|
244
|
+
let logFd;
|
|
245
|
+
try {
|
|
246
|
+
logFd = openLog(env, logName);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
reportWorkerLaunchFailure(onFailure, error);
|
|
249
|
+
}
|
|
250
|
+
const output = logFd === undefined ? 'ignore' : logFd;
|
|
251
|
+
|
|
252
|
+
// The serialized claim keeps the harness cwd as evidence even when the
|
|
253
|
+
// launch has to happen elsewhere.
|
|
254
|
+
const launchDirectory = resolveLaunchDirectory(claim.cwd, env);
|
|
255
|
+
|
|
256
|
+
let child;
|
|
257
|
+
try {
|
|
258
|
+
child = spawn(nodeRuntime(execPath), [workerPath, JSON.stringify(claim)], {
|
|
259
|
+
...(launchDirectory ? { cwd: launchDirectory } : {}),
|
|
260
|
+
detached: true,
|
|
261
|
+
env: captureCommandEnvironment(env),
|
|
262
|
+
shell: false,
|
|
263
|
+
stdio: ['ignore', output, output],
|
|
264
|
+
windowsHide: true,
|
|
265
|
+
});
|
|
266
|
+
} finally {
|
|
267
|
+
if (logFd !== undefined) {
|
|
268
|
+
try {
|
|
269
|
+
closeLog(logFd);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
reportWorkerLaunchFailure(onFailure, error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
child.once('error', (error) => reportWorkerLaunchFailure(onFailure, error));
|
|
277
|
+
child.unref();
|
|
278
|
+
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function cliFailure(commandName, result) {
|
|
283
|
+
if (result?.error) return result.error;
|
|
284
|
+
const stderr = nonEmptyString(result?.stderr);
|
|
285
|
+
const stdout = nonEmptyString(result?.stdout);
|
|
286
|
+
const detail = stderr ?? stdout;
|
|
287
|
+
const outcome = result?.signal
|
|
288
|
+
? `terminated by signal ${result.signal}`
|
|
289
|
+
: `exited with status ${String(result?.status)}`;
|
|
290
|
+
return new Error(
|
|
291
|
+
`polygraph ${commandName} ${outcome}` +
|
|
292
|
+
(detail ? `: ${detail}` : '')
|
|
293
|
+
);
|
|
294
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { ensureAgentSessionCapture } from './agent-session-capture.mjs';
|
|
5
|
+
import { logHookFailure } from './agent-session-link.mjs';
|
|
6
|
+
|
|
7
|
+
function writeWorkerFailure(error, claim) {
|
|
8
|
+
try {
|
|
9
|
+
const entry = {
|
|
10
|
+
time: new Date().toISOString(),
|
|
11
|
+
hook: `${claim?.agentType ?? 'unknown'}:ensure-agent-session-capture-worker`,
|
|
12
|
+
pid: process.pid,
|
|
13
|
+
agentSessionId: claim?.agentSessionId,
|
|
14
|
+
error: error instanceof Error ? error.message : String(error),
|
|
15
|
+
...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
|
|
16
|
+
};
|
|
17
|
+
process.stderr.write(JSON.stringify(entry) + '\n');
|
|
18
|
+
} catch {
|
|
19
|
+
// The inherited log stream is diagnostic only.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function main({
|
|
24
|
+
serializedClaim = process.argv[2],
|
|
25
|
+
env = process.env,
|
|
26
|
+
spawn,
|
|
27
|
+
logFailure = logHookFailure,
|
|
28
|
+
writeFailure = writeWorkerFailure,
|
|
29
|
+
} = {}) {
|
|
30
|
+
let claim;
|
|
31
|
+
try {
|
|
32
|
+
claim = JSON.parse(serializedClaim);
|
|
33
|
+
return ensureAgentSessionCapture(claim, spawn, env);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
writeFailure(error, claim);
|
|
36
|
+
try {
|
|
37
|
+
logFailure(
|
|
38
|
+
`${claim?.agentType ?? 'unknown'}:ensure-agent-session-capture-worker`,
|
|
39
|
+
error,
|
|
40
|
+
{
|
|
41
|
+
agentSessionId: claim?.agentSessionId,
|
|
42
|
+
cli: env.POLYGRAPH_CLI || 'polygraph',
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
} catch {
|
|
46
|
+
// The worker is already detached; diagnostics cannot be allowed to crash it.
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isMainModule() {
|
|
53
|
+
if (!process.argv[1]) return false;
|
|
54
|
+
try {
|
|
55
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isMainModule()) {
|
|
62
|
+
process.exitCode = main() ? 0 : 1;
|
|
63
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildCommandHookEnsureCapture,
|
|
6
|
+
ensureAgentSessionCapture,
|
|
7
|
+
launchAgentSessionCaptureWake,
|
|
8
|
+
} from './agent-session-capture.mjs';
|
|
9
|
+
import { hookPayloadSessionId, logHookFailure } from './agent-session-link.mjs';
|
|
10
|
+
import { fallbackClaimDirectory } from './capture-cli.mjs';
|
|
11
|
+
|
|
12
|
+
function readPayload() {
|
|
13
|
+
try {
|
|
14
|
+
const raw = readFileSync(0, 'utf8');
|
|
15
|
+
return raw ? JSON.parse(raw) : undefined;
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function main({
|
|
22
|
+
payload = readPayload(),
|
|
23
|
+
agentType = process.argv[2],
|
|
24
|
+
// Harness manifests without an async hook flag pass --detach so the hook
|
|
25
|
+
// returns immediately; a detached worker then owns the bounded wake. This
|
|
26
|
+
// matters most for cursor's blocking beforeSubmitPrompt, which would
|
|
27
|
+
// otherwise stall every prompt on a slow CLI.
|
|
28
|
+
detach = process.argv.includes('--detach'),
|
|
29
|
+
env = process.env,
|
|
30
|
+
spawn,
|
|
31
|
+
logFailure = logHookFailure,
|
|
32
|
+
// The hook's own directory is read lazily, inside the protected path, and
|
|
33
|
+
// only when the payload carries none and the harness runs hooks in the
|
|
34
|
+
// session directory: a default evaluated at entry would throw uv_cwd from
|
|
35
|
+
// an already-deleted cwd before any fallback could run, and Cursor's hook
|
|
36
|
+
// cwd is the plugin root rather than the repository.
|
|
37
|
+
cwd,
|
|
38
|
+
launcherOptions = {},
|
|
39
|
+
now = Date.now,
|
|
40
|
+
} = {}) {
|
|
41
|
+
const reportFailure = (error) =>
|
|
42
|
+
logFailure(`${agentType || 'unknown'}:ensure-agent-session-capture`, error, {
|
|
43
|
+
hookEventName: payload?.hook_event_name,
|
|
44
|
+
agentSessionId: hookPayloadSessionId(payload),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const built = buildCommandHookEnsureCapture(payload, agentType, env, now);
|
|
49
|
+
if (!built) return false;
|
|
50
|
+
|
|
51
|
+
const claim = {
|
|
52
|
+
...built,
|
|
53
|
+
cwd: built.cwd ?? fallbackClaimDirectory(agentType, cwd),
|
|
54
|
+
};
|
|
55
|
+
if (detach) {
|
|
56
|
+
return launchAgentSessionCaptureWake(claim, spawn, env, {
|
|
57
|
+
...launcherOptions,
|
|
58
|
+
onFailure: reportFailure,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return ensureAgentSessionCapture(claim, spawn, env);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
reportFailure(error);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isMainModule() {
|
|
69
|
+
if (!process.argv[1]) return false;
|
|
70
|
+
try {
|
|
71
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (isMainModule()) {
|
|
78
|
+
main();
|
|
79
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { finalizeAgentSession } from './agent-session-finalize.mjs';
|
|
5
|
+
import { logHookFailure } from './agent-session-link.mjs';
|
|
6
|
+
|
|
7
|
+
function writeWorkerFailure(error, claim) {
|
|
8
|
+
try {
|
|
9
|
+
const entry = {
|
|
10
|
+
time: new Date().toISOString(),
|
|
11
|
+
hook: `${claim?.agentType ?? 'unknown'}:finalize-agent-session-worker`,
|
|
12
|
+
pid: process.pid,
|
|
13
|
+
agentSessionId: claim?.agentSessionId,
|
|
14
|
+
error: error instanceof Error ? error.message : String(error),
|
|
15
|
+
...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
|
|
16
|
+
};
|
|
17
|
+
process.stderr.write(JSON.stringify(entry) + '\n');
|
|
18
|
+
} catch {
|
|
19
|
+
// The inherited log stream is diagnostic only.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function main({
|
|
24
|
+
serializedClaim = process.argv[2],
|
|
25
|
+
env = process.env,
|
|
26
|
+
spawn,
|
|
27
|
+
logFailure = logHookFailure,
|
|
28
|
+
writeFailure = writeWorkerFailure,
|
|
29
|
+
} = {}) {
|
|
30
|
+
let claim;
|
|
31
|
+
try {
|
|
32
|
+
claim = JSON.parse(serializedClaim);
|
|
33
|
+
return finalizeAgentSession(claim, spawn, env);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
writeFailure(error, claim);
|
|
36
|
+
try {
|
|
37
|
+
logFailure(
|
|
38
|
+
`${claim?.agentType ?? 'unknown'}:finalize-agent-session-worker`,
|
|
39
|
+
error,
|
|
40
|
+
{
|
|
41
|
+
agentSessionId: claim?.agentSessionId,
|
|
42
|
+
cli: env.POLYGRAPH_CLI || 'polygraph',
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
} catch {
|
|
46
|
+
// The worker is already detached; diagnostics cannot be allowed to crash it.
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isMainModule() {
|
|
53
|
+
if (!process.argv[1]) return false;
|
|
54
|
+
try {
|
|
55
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isMainModule()) {
|
|
62
|
+
process.exitCode = main() ? 0 : 1;
|
|
63
|
+
}
|
|
@@ -3,9 +3,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
buildCommandHookFinalize,
|
|
6
|
-
|
|
6
|
+
launchAgentSessionFinalize,
|
|
7
7
|
} from './agent-session-finalize.mjs';
|
|
8
|
-
import { logHookFailure } from './agent-session-link.mjs';
|
|
8
|
+
import { hookPayloadSessionId, logHookFailure } from './agent-session-link.mjs';
|
|
9
|
+
import { fallbackClaimDirectory } from './capture-cli.mjs';
|
|
9
10
|
|
|
10
11
|
function readPayload() {
|
|
11
12
|
try {
|
|
@@ -21,23 +22,36 @@ export function main({
|
|
|
21
22
|
agentType = process.argv[2],
|
|
22
23
|
env = process.env,
|
|
23
24
|
spawn,
|
|
25
|
+
logFailure = logHookFailure,
|
|
26
|
+
// The hook's own directory is read lazily, inside the protected path, and
|
|
27
|
+
// only when the payload carries none and the harness runs hooks in the
|
|
28
|
+
// session directory: a default evaluated at entry would throw uv_cwd from
|
|
29
|
+
// an already-deleted cwd before any fallback could run, and Cursor's hook
|
|
30
|
+
// cwd is the plugin root rather than the repository.
|
|
31
|
+
cwd,
|
|
32
|
+
launcherOptions = {},
|
|
33
|
+
now = Date.now,
|
|
24
34
|
} = {}) {
|
|
35
|
+
const reportFailure = (error) =>
|
|
36
|
+
logFailure(`${agentType || 'unknown'}:finalize-agent-session`, error, {
|
|
37
|
+
hookEventName: payload?.hook_event_name,
|
|
38
|
+
agentSessionId: hookPayloadSessionId(payload),
|
|
39
|
+
});
|
|
40
|
+
|
|
25
41
|
try {
|
|
26
|
-
const finalize = buildCommandHookFinalize(payload, agentType, env);
|
|
42
|
+
const finalize = buildCommandHookFinalize(payload, agentType, env, now);
|
|
27
43
|
if (!finalize) return false;
|
|
28
|
-
return
|
|
44
|
+
return launchAgentSessionFinalize(
|
|
29
45
|
{
|
|
30
46
|
...finalize,
|
|
31
|
-
cwd: finalize.cwd ??
|
|
47
|
+
cwd: finalize.cwd ?? fallbackClaimDirectory(agentType, cwd),
|
|
32
48
|
},
|
|
33
49
|
spawn,
|
|
34
|
-
env
|
|
50
|
+
env,
|
|
51
|
+
{ ...launcherOptions, onFailure: reportFailure }
|
|
35
52
|
);
|
|
36
53
|
} catch (error) {
|
|
37
|
-
|
|
38
|
-
hookEventName: payload?.hook_event_name,
|
|
39
|
-
agentSessionId: payload?.session_id,
|
|
40
|
-
});
|
|
54
|
+
reportFailure(error);
|
|
41
55
|
return false;
|
|
42
56
|
}
|
|
43
57
|
}
|
package/hooks/hooks.json
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
"SessionStart": [
|
|
27
27
|
{
|
|
28
|
-
"matcher": "startup|resume|compact",
|
|
28
|
+
"matcher": "startup|resume|clear|compact|fork",
|
|
29
29
|
"hooks": [
|
|
30
30
|
{
|
|
31
31
|
"type": "command",
|
|
@@ -39,6 +39,28 @@
|
|
|
39
39
|
]
|
|
40
40
|
}
|
|
41
41
|
],
|
|
42
|
+
"UserPromptSubmit": [
|
|
43
|
+
{
|
|
44
|
+
"hooks": [
|
|
45
|
+
{
|
|
46
|
+
"type": "command",
|
|
47
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ensure-agent-session-capture.mjs claude",
|
|
48
|
+
"async": true
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"Stop": [
|
|
54
|
+
{
|
|
55
|
+
"hooks": [
|
|
56
|
+
{
|
|
57
|
+
"type": "command",
|
|
58
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ensure-agent-session-capture.mjs claude",
|
|
59
|
+
"async": true
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
],
|
|
42
64
|
"SessionEnd": [
|
|
43
65
|
{
|
|
44
66
|
"hooks": [
|
|
@@ -3,9 +3,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
buildCommandHookLink,
|
|
6
|
+
hookPayloadSessionId,
|
|
6
7
|
linkAgentSession,
|
|
7
8
|
logHookFailure,
|
|
8
9
|
} from './agent-session-link.mjs';
|
|
10
|
+
import { fallbackClaimDirectory } from './capture-cli.mjs';
|
|
9
11
|
|
|
10
12
|
function readPayload() {
|
|
11
13
|
try {
|
|
@@ -20,29 +22,29 @@ export function main({
|
|
|
20
22
|
payload = readPayload(),
|
|
21
23
|
agentType = process.argv[2],
|
|
22
24
|
env = process.env,
|
|
23
|
-
pid = process.ppid,
|
|
24
25
|
spawn,
|
|
26
|
+
logFailure = logHookFailure,
|
|
25
27
|
} = {}) {
|
|
26
28
|
try {
|
|
27
29
|
const link = buildCommandHookLink(payload, agentType, env);
|
|
28
30
|
if (!link) return false;
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
// Claude and Codex run this hook in the session directory, so the hook's
|
|
33
|
+
// own cwd stands in when the payload carries none; Cursor's hook cwd is
|
|
34
|
+
// the plugin root and is never recorded. Read lazily, inside the
|
|
35
|
+
// protected path, because a deleted cwd makes process.cwd() throw.
|
|
36
|
+
return linkAgentSession(
|
|
37
|
+
{
|
|
38
|
+
...link,
|
|
39
|
+
cwd: link.cwd ?? fallbackClaimDirectory(agentType),
|
|
40
|
+
},
|
|
41
|
+
spawn,
|
|
42
|
+
env
|
|
43
|
+
);
|
|
42
44
|
} catch (error) {
|
|
43
|
-
|
|
45
|
+
logFailure(`${agentType || 'unknown'}:link-agent-session`, error, {
|
|
44
46
|
hookEventName: payload?.hook_event_name,
|
|
45
|
-
agentSessionId: payload
|
|
47
|
+
agentSessionId: hookPayloadSessionId(payload),
|
|
46
48
|
});
|
|
47
49
|
return false;
|
|
48
50
|
}
|
package/package.json
CHANGED
|
@@ -35,6 +35,7 @@ Polygraph functionality is available via both MCP tools and CLI commands. Use wh
|
|
|
35
35
|
| `stop_agent` | — | Cancel an in-progress child by delegation id; its session is preserved for later read-only context restoration. |
|
|
36
36
|
| `push_branch` | — | Push a local git branch to the remote repository. For the repo you are in, this pushes from your current checkout. Requires a session description. |
|
|
37
37
|
| `create_pr` | — | Create draft PRs with session metadata linking related PRs |
|
|
38
|
+
| `update_pr` | — | Update title, user-authored body, labels, or assignees on one PR associated with a session |
|
|
38
39
|
| `show_session` | `polygraph session show <id> [--details]` | Query status of the current session. Use details when session summary, repo IDs, PR URLs, and PR descriptions are needed. |
|
|
39
40
|
| `update_session` | `polygraph session update --session <id> [--title] [--description]` | Update the session title and/or description (at least one required); metadata only, independent of PR creation or mark-ready. |
|
|
40
41
|
| `link_reference` | — | Link an external reference to a session. |
|
|
@@ -211,9 +212,9 @@ The `allow_agent` and `deny_agent` tools exist for parents whose MCP clients do
|
|
|
211
212
|
|
|
212
213
|
### Publish Changes (Push Branches, Create PRs, Mark Ready)
|
|
213
214
|
|
|
214
|
-
Publishing covers the branch-to-PR flow: `push_branch` (push local commits; must precede PR creation), `create_pr` (linked draft PRs, including fork PRs via `targetRepository`), `mark_pr_ready` (transition drafts to OPEN),
|
|
215
|
+
Publishing covers the branch-to-PR flow: `push_branch` (push local commits; must precede PR creation), `create_pr` (linked draft PRs, including fork PRs via `targetRepository`), `mark_pr_ready` (transition drafts to OPEN), `associate_pr` (link PRs created outside Polygraph), and `update_pr` (update metadata on an associated PR).
|
|
215
216
|
|
|
216
|
-
**Whenever you push a branch, create or
|
|
217
|
+
**Whenever you push a branch, create, associate, or update a PR, or mark PRs ready, read [`reference/publish-changes.md`](reference/publish-changes.md) first.** That reference file holds the full flow.
|
|
217
218
|
|
|
218
219
|
### Session Description Policy
|
|
219
220
|
|
|
@@ -295,6 +296,7 @@ If the session has a description timeline, also display:
|
|
|
295
296
|
1. **Wait in background subagents** — `spawn_agent` is fine to call directly, but every waited `show_agent` poll belongs in a `Task(run_in_background: true)`; inline polling floods the context with status noise.
|
|
296
297
|
|
|
297
298
|
1. **Read each result once** — when a poller exits, read that child with a single unwaited `show_agent(sessionId, id)`; `result.text` is the child's final message. Only reach for an explicit `tail` if that is not enough.
|
|
299
|
+
1. **State the output in every brief** — children are told to be concise, so the instruction must say what to return: the shape, a cap where one makes sense, and the exact token for "nothing to report". See [`reference/delegation.md`](reference/delegation.md).
|
|
298
300
|
1. **Poll child status before proceeding** — Always verify child agents have reached a terminal `child.status` (`'completed'`, `'failed'`, or `'cancelled'`) before pushing branches or creating PRs
|
|
299
301
|
1. **Link PRs in descriptions** - Reference related PRs in each PR body
|
|
300
302
|
1. **Keep PRs as drafts** until all repos are ready
|