@polygraph/opencode-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/agent-session-link.mjs +202 -36
- package/ensure-agent-session-capture-worker.mjs +304 -0
- package/package.json +2 -1
- package/server.js +20 -0
- 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
package/agent-session-link.mjs
CHANGED
|
@@ -1,20 +1,123 @@
|
|
|
1
1
|
// source/hooks/agent-session-link.mjs
|
|
2
|
-
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
2
|
+
import { appendFileSync, mkdirSync as mkdirSync2, renameSync as renameSync2, statSync as statSync2 } from "node:fs";
|
|
3
|
+
import { homedir as homedir2 } from "node:os";
|
|
4
|
+
import { basename as basename2, join as join2 } from "node:path";
|
|
5
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
// source/hooks/capture-cli.mjs
|
|
8
|
+
import { spawn as spawnChild, spawnSync } from "node:child_process";
|
|
9
|
+
import { closeSync, mkdirSync, openSync, renameSync, statSync } from "node:fs";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
11
|
import { basename, join } from "node:path";
|
|
5
|
-
|
|
12
|
+
var HOOK_WORKER_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
13
|
+
function nonEmptyString(value) {
|
|
14
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
15
|
+
}
|
|
16
|
+
function isManagedChildEnvironment(env) {
|
|
17
|
+
return Boolean(env && Object.hasOwn(env, "POLYGRAPH_CHILD_AGENT"));
|
|
18
|
+
}
|
|
19
|
+
function captureCommandEnvironment(env = process.env) {
|
|
20
|
+
const commandEnv = { ...env };
|
|
21
|
+
delete commandEnv.POLYGRAPH_SESSION_ID;
|
|
22
|
+
delete commandEnv.POLYGRAPH_CAPTURE_TOKEN;
|
|
23
|
+
return commandEnv;
|
|
24
|
+
}
|
|
25
|
+
function resolveLaunchDirectory(preferred, env = process.env) {
|
|
26
|
+
const candidates = [preferred, nonEmptyString(env?.HOME) ?? homedir(), tmpdir()];
|
|
27
|
+
for (const candidate of candidates) {
|
|
28
|
+
const directory = nonEmptyString(candidate);
|
|
29
|
+
if (!directory) continue;
|
|
30
|
+
try {
|
|
31
|
+
if (statSync(directory).isDirectory()) return directory;
|
|
32
|
+
} catch {
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return void 0;
|
|
36
|
+
}
|
|
37
|
+
function nodeRuntime(execPath) {
|
|
38
|
+
const base = basename(execPath).toLowerCase();
|
|
39
|
+
return base === "node" || base === "node.exe" ? execPath : "node";
|
|
40
|
+
}
|
|
41
|
+
function reportWorkerLaunchFailure(onFailure, error) {
|
|
42
|
+
try {
|
|
43
|
+
const pending = onFailure(error);
|
|
44
|
+
if (pending && typeof pending.catch === "function") {
|
|
45
|
+
pending.catch(() => {
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function openHookWorkerLog(env, logName) {
|
|
52
|
+
const home = nonEmptyString(env?.HOME) ?? homedir();
|
|
53
|
+
const logsDir = join(home, ".polygraph", "logs");
|
|
54
|
+
mkdirSync(logsDir, { recursive: true });
|
|
55
|
+
const logFile = join(logsDir, logName);
|
|
56
|
+
try {
|
|
57
|
+
if (statSync(logFile).size > HOOK_WORKER_LOG_MAX_BYTES) {
|
|
58
|
+
renameSync(logFile, `${logFile}.1`);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
return openSync(logFile, "a", 384);
|
|
63
|
+
}
|
|
64
|
+
function launchDetachedHookWorker({
|
|
65
|
+
workerPath,
|
|
66
|
+
claim,
|
|
67
|
+
logName,
|
|
68
|
+
spawn = spawnChild,
|
|
69
|
+
env = process.env,
|
|
70
|
+
execPath = process.execPath,
|
|
71
|
+
onFailure = () => {
|
|
72
|
+
},
|
|
73
|
+
openLog = openHookWorkerLog,
|
|
74
|
+
closeLog = closeSync
|
|
75
|
+
}) {
|
|
76
|
+
let logFd;
|
|
77
|
+
try {
|
|
78
|
+
logFd = openLog(env, logName);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
reportWorkerLaunchFailure(onFailure, error);
|
|
81
|
+
}
|
|
82
|
+
const output = logFd === void 0 ? "ignore" : logFd;
|
|
83
|
+
const launchDirectory = resolveLaunchDirectory(claim.cwd, env);
|
|
84
|
+
let child;
|
|
85
|
+
try {
|
|
86
|
+
child = spawn(nodeRuntime(execPath), [workerPath, JSON.stringify(claim)], {
|
|
87
|
+
...launchDirectory ? { cwd: launchDirectory } : {},
|
|
88
|
+
detached: true,
|
|
89
|
+
env: captureCommandEnvironment(env),
|
|
90
|
+
shell: false,
|
|
91
|
+
stdio: ["ignore", output, output],
|
|
92
|
+
windowsHide: true
|
|
93
|
+
});
|
|
94
|
+
} finally {
|
|
95
|
+
if (logFd !== void 0) {
|
|
96
|
+
try {
|
|
97
|
+
closeLog(logFd);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
reportWorkerLaunchFailure(onFailure, error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
child.once("error", (error) => reportWorkerLaunchFailure(onFailure, error));
|
|
104
|
+
child.unref();
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// source/hooks/agent-session-link.mjs
|
|
6
109
|
var HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
7
110
|
var AGENT_TYPES = /* @__PURE__ */ new Set(["claude", "codex", "opencode", "cursor"]);
|
|
8
111
|
var COMMAND_HOOK_TOOL = /^mcp__(?:plugin_polygraph_)?polygraph[-_]mcp__/;
|
|
9
112
|
var OPENCODE_TOOL = /^polygraph(?:(?:-|_)mcp)?_/;
|
|
10
|
-
function
|
|
113
|
+
function nonEmptyString2(value) {
|
|
11
114
|
return typeof value === "string" && value.trim() ? value : void 0;
|
|
12
115
|
}
|
|
13
|
-
function
|
|
116
|
+
function isManagedChildEnvironment2(env) {
|
|
14
117
|
return Boolean(env && Object.hasOwn(env, "POLYGRAPH_CHILD_AGENT"));
|
|
15
118
|
}
|
|
16
119
|
function isPolygraphMcpToolName(toolName) {
|
|
17
|
-
const name =
|
|
120
|
+
const name = nonEmptyString2(toolName);
|
|
18
121
|
return Boolean(name && (COMMAND_HOOK_TOOL.test(name) || OPENCODE_TOOL.test(name)));
|
|
19
122
|
}
|
|
20
123
|
function buildLinkAgentSessionArgs({
|
|
@@ -23,26 +126,22 @@ function buildLinkAgentSessionArgs({
|
|
|
23
126
|
agentSessionId,
|
|
24
127
|
cwd,
|
|
25
128
|
transcriptPath,
|
|
26
|
-
pid,
|
|
27
129
|
source,
|
|
28
130
|
hookOperation
|
|
29
131
|
}) {
|
|
30
|
-
const session =
|
|
31
|
-
const harnessSession =
|
|
32
|
-
const claimSource =
|
|
132
|
+
const session = nonEmptyString2(polygraphSessionId);
|
|
133
|
+
const harnessSession = nonEmptyString2(agentSessionId);
|
|
134
|
+
const claimSource = nonEmptyString2(source);
|
|
33
135
|
if (!AGENT_TYPES.has(agentType)) throw new Error(`Unsupported agent type: ${agentType}`);
|
|
34
136
|
if (!harnessSession) throw new Error("agentSessionId is required");
|
|
35
137
|
if (!claimSource) throw new Error("source is required");
|
|
36
138
|
const args = ["_link-agent-session"];
|
|
37
139
|
if (session) args.push("--session", session);
|
|
38
140
|
args.push("--agent-type", agentType, "--agent-session-id", harnessSession);
|
|
39
|
-
const workingDirectory =
|
|
141
|
+
const workingDirectory = nonEmptyString2(cwd);
|
|
40
142
|
if (workingDirectory) args.push("--cwd", workingDirectory);
|
|
41
|
-
const transcript =
|
|
143
|
+
const transcript = nonEmptyString2(transcriptPath);
|
|
42
144
|
if (transcript) args.push("--transcript-path", transcript);
|
|
43
|
-
if (Number.isSafeInteger(pid) && pid > 0) {
|
|
44
|
-
args.push("--pid", String(pid));
|
|
45
|
-
}
|
|
46
145
|
let input;
|
|
47
146
|
if (hookOperation && typeof hookOperation === "object") {
|
|
48
147
|
args.push("--hook-operation-stdin");
|
|
@@ -51,46 +150,51 @@ function buildLinkAgentSessionArgs({
|
|
|
51
150
|
args.push("--source", claimSource);
|
|
52
151
|
return { args, input };
|
|
53
152
|
}
|
|
54
|
-
function
|
|
55
|
-
const base =
|
|
153
|
+
function nodeRuntime2() {
|
|
154
|
+
const base = basename2(process.execPath).toLowerCase();
|
|
56
155
|
return base === "node" || base === "node.exe" ? process.execPath : "node";
|
|
57
156
|
}
|
|
58
|
-
function linkAgentSession(claim, spawn =
|
|
59
|
-
if (
|
|
157
|
+
function linkAgentSession(claim, spawn = spawnSync2, env = process.env) {
|
|
158
|
+
if (isManagedChildEnvironment2(env)) return false;
|
|
60
159
|
const { args, input } = buildLinkAgentSessionArgs(claim);
|
|
61
|
-
const command =
|
|
62
|
-
const commandEnv =
|
|
160
|
+
const command = nonEmptyString2(env?.POLYGRAPH_CLI) ?? "polygraph";
|
|
161
|
+
const commandEnv = nonEmptyString2(claim.polygraphSessionId) ? env : { ...env };
|
|
63
162
|
if (commandEnv !== env) {
|
|
64
163
|
delete commandEnv.POLYGRAPH_SESSION_ID;
|
|
65
164
|
delete commandEnv.POLYGRAPH_CAPTURE_TOKEN;
|
|
66
165
|
}
|
|
166
|
+
const launchDirectory = resolveLaunchDirectory(claim.cwd, env);
|
|
67
167
|
const spawnOptions = {
|
|
68
168
|
encoding: "utf8",
|
|
69
169
|
env: commandEnv,
|
|
70
170
|
stdio: [input === void 0 ? "ignore" : "pipe", "ignore", "pipe"],
|
|
71
|
-
...input === void 0 ? {} : { input }
|
|
171
|
+
...input === void 0 ? {} : { input },
|
|
172
|
+
...launchDirectory ? { cwd: launchDirectory } : {}
|
|
72
173
|
};
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
174
|
+
const jsEntry = /\.[cm]?js$/i.test(command);
|
|
175
|
+
let result;
|
|
176
|
+
try {
|
|
177
|
+
result = jsEntry ? spawn(nodeRuntime2(), [command, ...args], spawnOptions) : spawn(command, args, spawnOptions);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
76
180
|
}
|
|
77
181
|
if (result?.error) throw result.error;
|
|
78
182
|
if (result?.status !== 0) {
|
|
79
|
-
const detail =
|
|
183
|
+
const detail = nonEmptyString2(result?.stderr);
|
|
80
184
|
throw new Error(
|
|
81
185
|
`polygraph _link-agent-session exited with status ${String(result?.status)}` + (detail ? `: ${detail}` : "")
|
|
82
186
|
);
|
|
83
187
|
}
|
|
84
188
|
return true;
|
|
85
189
|
}
|
|
86
|
-
function logHookFailure(hook, error, meta = {}, home = process.env.HOME?.trim() ||
|
|
190
|
+
function logHookFailure(hook, error, meta = {}, home = process.env.HOME?.trim() || homedir2()) {
|
|
87
191
|
try {
|
|
88
|
-
const logsDir =
|
|
89
|
-
|
|
90
|
-
const logFile =
|
|
192
|
+
const logsDir = join2(home, ".polygraph", "logs");
|
|
193
|
+
mkdirSync2(logsDir, { recursive: true });
|
|
194
|
+
const logFile = join2(logsDir, "hooks.log");
|
|
91
195
|
try {
|
|
92
|
-
if (
|
|
93
|
-
|
|
196
|
+
if (statSync2(logFile).size > HOOK_LOG_MAX_BYTES) {
|
|
197
|
+
renameSync2(logFile, `${logFile}.1`);
|
|
94
198
|
}
|
|
95
199
|
} catch {
|
|
96
200
|
}
|
|
@@ -107,6 +211,25 @@ function logHookFailure(hook, error, meta = {}, home = process.env.HOME?.trim()
|
|
|
107
211
|
}
|
|
108
212
|
}
|
|
109
213
|
|
|
214
|
+
// source/hooks/agent-session-capture.mjs
|
|
215
|
+
import { fileURLToPath } from "node:url";
|
|
216
|
+
var ENSURE_WAKE_WORKER_PATH = fileURLToPath(
|
|
217
|
+
new URL("./ensure-agent-session-capture-worker.mjs", import.meta.url)
|
|
218
|
+
);
|
|
219
|
+
function launchAgentSessionCaptureWake(claim, spawn, env = process.env, { onFailure = () => {
|
|
220
|
+
}, ...workerOptions } = {}) {
|
|
221
|
+
if (isManagedChildEnvironment(env)) return false;
|
|
222
|
+
return launchDetachedHookWorker({
|
|
223
|
+
workerPath: ENSURE_WAKE_WORKER_PATH,
|
|
224
|
+
logName: "capture-wake.log",
|
|
225
|
+
...workerOptions,
|
|
226
|
+
claim,
|
|
227
|
+
...spawn ? { spawn } : {},
|
|
228
|
+
env,
|
|
229
|
+
onFailure
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
110
233
|
// source/opencode/agent-session-link.mjs
|
|
111
234
|
function sessionRecord(result) {
|
|
112
235
|
if (result?.error) {
|
|
@@ -149,22 +272,48 @@ async function linkOpenCodeSessionCreatedEvent(input, sessionLinker) {
|
|
|
149
272
|
if (event?.type !== "session.created") return false;
|
|
150
273
|
return sessionLinker.fromSessionCreated(event.properties?.info);
|
|
151
274
|
}
|
|
275
|
+
function openCodeChatMessageSessionId(input, output) {
|
|
276
|
+
return sessionIdValue(input?.sessionID) ?? sessionIdValue(output?.message?.sessionID);
|
|
277
|
+
}
|
|
278
|
+
function sessionIdValue(value) {
|
|
279
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
280
|
+
}
|
|
152
281
|
function deferOpenCodeToolActivity(input, sessionLinker, onError, schedule = setTimeout) {
|
|
153
282
|
schedule(() => {
|
|
154
|
-
Promise.resolve(sessionLinker.fromToolActivity(input)).catch(
|
|
283
|
+
return Promise.resolve().then(() => sessionLinker.fromToolActivity(input)).catch((error) => {
|
|
284
|
+
try {
|
|
285
|
+
return onError(error);
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
}).catch(() => {
|
|
289
|
+
});
|
|
290
|
+
}, 0);
|
|
291
|
+
}
|
|
292
|
+
function deferOpenCodeCaptureWake(sessionId, sessionLinker, onError, schedule = setTimeout, now = Date.now) {
|
|
293
|
+
const observedAt = now();
|
|
294
|
+
schedule(() => {
|
|
295
|
+
return Promise.resolve().then(() => sessionLinker.wakeCapture(sessionId, { observedAt })).catch((error) => {
|
|
296
|
+
try {
|
|
297
|
+
return onError(error);
|
|
298
|
+
} catch {
|
|
299
|
+
}
|
|
300
|
+
}).catch(() => {
|
|
301
|
+
});
|
|
155
302
|
}, 0);
|
|
156
303
|
}
|
|
157
304
|
function createOpenCodeSessionLinker({
|
|
158
305
|
client,
|
|
159
306
|
directory,
|
|
160
307
|
env = process.env,
|
|
161
|
-
pid = process.pid,
|
|
162
308
|
link,
|
|
163
|
-
|
|
309
|
+
ensure,
|
|
310
|
+
spawn,
|
|
311
|
+
now = Date.now
|
|
164
312
|
} = {}) {
|
|
165
313
|
const roots = /* @__PURE__ */ new Map();
|
|
166
314
|
const linkedLifecycleSessions = /* @__PURE__ */ new Set();
|
|
167
315
|
const submitLink = link ?? ((claim) => linkAgentSession(claim, spawn, env));
|
|
316
|
+
const submitEnsure = ensure ?? ((claim) => launchAgentSessionCaptureWake(claim, spawn, env));
|
|
168
317
|
async function rootSessionId(sessionId) {
|
|
169
318
|
if (!sessionId) return void 0;
|
|
170
319
|
if (roots.has(sessionId)) return roots.get(sessionId);
|
|
@@ -185,7 +334,6 @@ function createOpenCodeSessionLinker({
|
|
|
185
334
|
agentType: "opencode",
|
|
186
335
|
agentSessionId,
|
|
187
336
|
cwd: cwd || directory || process.cwd(),
|
|
188
|
-
pid,
|
|
189
337
|
source: "hook"
|
|
190
338
|
});
|
|
191
339
|
if (linked && lifecycleKey) linkedLifecycleSessions.add(lifecycleKey);
|
|
@@ -208,13 +356,31 @@ function createOpenCodeSessionLinker({
|
|
|
208
356
|
async fromToolActivity(input) {
|
|
209
357
|
if (!isPolygraphMcpToolName(input?.tool)) return false;
|
|
210
358
|
return submit(input?.sessionID);
|
|
359
|
+
},
|
|
360
|
+
// Identity-only capture liveness. Resolves the root session (subagent
|
|
361
|
+
// activity wakes the parent's capture) and pokes the sidecar; it records
|
|
362
|
+
// no mapping, no provenance, and no step boundary.
|
|
363
|
+
async wakeCapture(openCodeSessionId, { cwd, observedAt = now() } = {}) {
|
|
364
|
+
if (!openCodeSessionId || env && Object.hasOwn(env, "POLYGRAPH_CHILD_AGENT")) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
const agentSessionId = await rootSessionId(openCodeSessionId);
|
|
368
|
+
if (!agentSessionId) return false;
|
|
369
|
+
return submitEnsure({
|
|
370
|
+
agentType: "opencode",
|
|
371
|
+
agentSessionId,
|
|
372
|
+
cwd: cwd || directory || process.cwd(),
|
|
373
|
+
observedAt
|
|
374
|
+
});
|
|
211
375
|
}
|
|
212
376
|
};
|
|
213
377
|
}
|
|
214
378
|
export {
|
|
215
379
|
createOpenCodeSessionLinker,
|
|
380
|
+
deferOpenCodeCaptureWake,
|
|
216
381
|
deferOpenCodeToolActivity,
|
|
217
382
|
linkOpenCodeSessionCreatedEvent,
|
|
218
383
|
logHookFailure,
|
|
384
|
+
openCodeChatMessageSessionId,
|
|
219
385
|
resolveOpenCodeRootSessionId
|
|
220
386
|
};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// source/hooks/ensure-agent-session-capture-worker.mjs
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
4
|
+
|
|
5
|
+
// source/hooks/agent-session-capture.mjs
|
|
6
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
// source/hooks/capture-cli.mjs
|
|
10
|
+
import { spawn as spawnChild, spawnSync } from "node:child_process";
|
|
11
|
+
import { closeSync, mkdirSync, openSync, renameSync, statSync } from "node:fs";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import { basename, join } from "node:path";
|
|
14
|
+
var JS_CLI_ENTRY = /\.[cm]?js$/i;
|
|
15
|
+
var HOOK_WORKER_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
16
|
+
function nonEmptyString(value) {
|
|
17
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
18
|
+
}
|
|
19
|
+
function isManagedChildEnvironment(env) {
|
|
20
|
+
return Boolean(env && Object.hasOwn(env, "POLYGRAPH_CHILD_AGENT"));
|
|
21
|
+
}
|
|
22
|
+
function captureCommandEnvironment(env = process.env) {
|
|
23
|
+
const commandEnv = { ...env };
|
|
24
|
+
delete commandEnv.POLYGRAPH_SESSION_ID;
|
|
25
|
+
delete commandEnv.POLYGRAPH_CAPTURE_TOKEN;
|
|
26
|
+
return commandEnv;
|
|
27
|
+
}
|
|
28
|
+
function resolveLaunchDirectory(preferred, env = process.env) {
|
|
29
|
+
const candidates = [preferred, nonEmptyString(env?.HOME) ?? homedir(), tmpdir()];
|
|
30
|
+
for (const candidate of candidates) {
|
|
31
|
+
const directory = nonEmptyString(candidate);
|
|
32
|
+
if (!directory) continue;
|
|
33
|
+
try {
|
|
34
|
+
if (statSync(directory).isDirectory()) return directory;
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return void 0;
|
|
39
|
+
}
|
|
40
|
+
function observedAtValue(value) {
|
|
41
|
+
return Number.isSafeInteger(value) && value > 0 ? value : void 0;
|
|
42
|
+
}
|
|
43
|
+
function nodeRuntime(execPath) {
|
|
44
|
+
const base = basename(execPath).toLowerCase();
|
|
45
|
+
return base === "node" || base === "node.exe" ? execPath : "node";
|
|
46
|
+
}
|
|
47
|
+
function portableReexec(env, platform) {
|
|
48
|
+
const raw = nonEmptyString(env.POLYGRAPH_CLI_REEXEC);
|
|
49
|
+
if (!raw || platform !== "win32") return void 0;
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(raw);
|
|
52
|
+
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((part) => nonEmptyString(part))) {
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
return void 0;
|
|
58
|
+
}
|
|
59
|
+
function deadlineExceededResult() {
|
|
60
|
+
const error = new Error("Polygraph capture command timed out before launch");
|
|
61
|
+
error.code = "ETIMEDOUT";
|
|
62
|
+
return { error, status: null, signal: "SIGTERM" };
|
|
63
|
+
}
|
|
64
|
+
function withRemainingTimeout(options, deadline, now) {
|
|
65
|
+
if (deadline === void 0) return options;
|
|
66
|
+
const remaining = Math.floor(deadline - now());
|
|
67
|
+
if (remaining < 1) return void 0;
|
|
68
|
+
const configured = Number.isFinite(options.timeout) ? options.timeout : remaining;
|
|
69
|
+
return { ...options, timeout: Math.min(configured, remaining) };
|
|
70
|
+
}
|
|
71
|
+
function runBeforeDeadline(spawn, command, args, options, deadline, now) {
|
|
72
|
+
const boundedOptions = withRemainingTimeout(options, deadline, now);
|
|
73
|
+
if (!boundedOptions) return deadlineExceededResult();
|
|
74
|
+
try {
|
|
75
|
+
return spawn(command, args, boundedOptions);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return { error, status: null, signal: null };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function runCaptureCliSync(args, {
|
|
81
|
+
env = process.env,
|
|
82
|
+
spawn = spawnSync,
|
|
83
|
+
options = {},
|
|
84
|
+
cwd,
|
|
85
|
+
deadline,
|
|
86
|
+
now = Date.now,
|
|
87
|
+
platform = process.platform,
|
|
88
|
+
execPath = process.execPath
|
|
89
|
+
} = {}) {
|
|
90
|
+
const command = nonEmptyString(env.POLYGRAPH_CLI) ?? "polygraph";
|
|
91
|
+
const reexec = portableReexec(env, platform);
|
|
92
|
+
const launchDirectory = resolveLaunchDirectory(cwd, env);
|
|
93
|
+
const spawnOptions = {
|
|
94
|
+
encoding: "utf8",
|
|
95
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
96
|
+
...options,
|
|
97
|
+
...launchDirectory ? { cwd: launchDirectory } : {},
|
|
98
|
+
env: captureCommandEnvironment(env),
|
|
99
|
+
shell: false,
|
|
100
|
+
windowsHide: true
|
|
101
|
+
};
|
|
102
|
+
let executable;
|
|
103
|
+
let prefixArgs;
|
|
104
|
+
if (reexec) {
|
|
105
|
+
[executable, ...prefixArgs] = reexec;
|
|
106
|
+
} else if (JS_CLI_ENTRY.test(command)) {
|
|
107
|
+
executable = nodeRuntime(execPath);
|
|
108
|
+
prefixArgs = [command];
|
|
109
|
+
} else {
|
|
110
|
+
executable = command;
|
|
111
|
+
prefixArgs = [];
|
|
112
|
+
}
|
|
113
|
+
return runBeforeDeadline(
|
|
114
|
+
spawn,
|
|
115
|
+
executable,
|
|
116
|
+
[...prefixArgs, ...args],
|
|
117
|
+
spawnOptions,
|
|
118
|
+
deadline,
|
|
119
|
+
now
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
function cliFailure(commandName, result) {
|
|
123
|
+
if (result?.error) return result.error;
|
|
124
|
+
const stderr = nonEmptyString(result?.stderr);
|
|
125
|
+
const stdout = nonEmptyString(result?.stdout);
|
|
126
|
+
const detail = stderr ?? stdout;
|
|
127
|
+
const outcome = result?.signal ? `terminated by signal ${result.signal}` : `exited with status ${String(result?.status)}`;
|
|
128
|
+
return new Error(
|
|
129
|
+
`polygraph ${commandName} ${outcome}` + (detail ? `: ${detail}` : "")
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// source/hooks/agent-session-capture.mjs
|
|
134
|
+
var ENSURE_CAPTURE_TIMEOUT_MS = 2e4;
|
|
135
|
+
var ENSURE_CAPTURE_UNSUPPORTED_MARKER = "POLYGRAPH_ENSURE_AGENT_SESSION_CAPTURE_UNSUPPORTED";
|
|
136
|
+
var WAKE_AGENT_TYPES = /* @__PURE__ */ new Set(["claude", "codex", "opencode", "cursor"]);
|
|
137
|
+
var ENSURE_WAKE_WORKER_PATH = fileURLToPath(
|
|
138
|
+
new URL("./ensure-agent-session-capture-worker.mjs", import.meta.url)
|
|
139
|
+
);
|
|
140
|
+
function boundedEnsureTimeout(timeoutMs) {
|
|
141
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1) {
|
|
142
|
+
return ENSURE_CAPTURE_TIMEOUT_MS;
|
|
143
|
+
}
|
|
144
|
+
return Math.min(Math.floor(timeoutMs), ENSURE_CAPTURE_TIMEOUT_MS);
|
|
145
|
+
}
|
|
146
|
+
function wakeIdentityArgs({ agentType, agentSessionId }) {
|
|
147
|
+
const harnessSession = nonEmptyString(agentSessionId);
|
|
148
|
+
if (!WAKE_AGENT_TYPES.has(agentType)) {
|
|
149
|
+
throw new Error(`Unsupported agent type: ${agentType}`);
|
|
150
|
+
}
|
|
151
|
+
if (!harnessSession) throw new Error("agentSessionId is required");
|
|
152
|
+
return ["--agent-type", agentType, "--agent-session-id", harnessSession];
|
|
153
|
+
}
|
|
154
|
+
function buildEnsureAgentSessionCaptureArgs({
|
|
155
|
+
agentType,
|
|
156
|
+
agentSessionId,
|
|
157
|
+
observedAt
|
|
158
|
+
}) {
|
|
159
|
+
const identity = wakeIdentityArgs({ agentType, agentSessionId });
|
|
160
|
+
const observed = observedAtValue(observedAt);
|
|
161
|
+
if (observed === void 0) {
|
|
162
|
+
throw new Error("observedAt is required: a wake must carry the hook-captured time");
|
|
163
|
+
}
|
|
164
|
+
return [
|
|
165
|
+
"_ensure-agent-session-capture",
|
|
166
|
+
...identity,
|
|
167
|
+
"--observed-at",
|
|
168
|
+
String(observed)
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
function buildLegacyCaptureWakeArgs(claim) {
|
|
172
|
+
const args = ["_link-agent-session", ...wakeIdentityArgs(claim)];
|
|
173
|
+
const workingDirectory = nonEmptyString(claim.cwd);
|
|
174
|
+
if (workingDirectory) args.push("--cwd", workingDirectory);
|
|
175
|
+
const transcript = nonEmptyString(claim.transcriptPath);
|
|
176
|
+
if (transcript) args.push("--transcript-path", transcript);
|
|
177
|
+
args.push("--source", "hook");
|
|
178
|
+
return args;
|
|
179
|
+
}
|
|
180
|
+
function commandUnavailable(result) {
|
|
181
|
+
if (result?.error) return false;
|
|
182
|
+
const output = `${result?.stdout ?? ""}
|
|
183
|
+
${result?.stderr ?? ""}`;
|
|
184
|
+
if (output.includes(ENSURE_CAPTURE_UNSUPPORTED_MARKER)) return true;
|
|
185
|
+
const stdout = typeof result?.stdout === "string" ? result.stdout.replace(/\r\n/g, "\n") : "";
|
|
186
|
+
return result?.status === 1 && !nonEmptyString(result?.stderr) && stdout.startsWith("Usage: polygraph\n") && stdout.includes("\nValidation failed for one or more options\n") && stdout.includes("\n - Unknown argument: _ensure-agent-session-capture\n");
|
|
187
|
+
}
|
|
188
|
+
function ensureAgentSessionCapture(claim, spawn = spawnSync2, env = process.env, {
|
|
189
|
+
timeoutMs = ENSURE_CAPTURE_TIMEOUT_MS,
|
|
190
|
+
now = Date.now,
|
|
191
|
+
platform = process.platform,
|
|
192
|
+
execPath = process.execPath
|
|
193
|
+
} = {}) {
|
|
194
|
+
if (isManagedChildEnvironment(env)) return false;
|
|
195
|
+
const deadline = now() + boundedEnsureTimeout(timeoutMs);
|
|
196
|
+
const options = {
|
|
197
|
+
killSignal: "SIGKILL",
|
|
198
|
+
maxBuffer: 256 * 1024
|
|
199
|
+
};
|
|
200
|
+
const run = (args) => runCaptureCliSync(args, {
|
|
201
|
+
env,
|
|
202
|
+
spawn,
|
|
203
|
+
options,
|
|
204
|
+
cwd: claim.cwd,
|
|
205
|
+
deadline,
|
|
206
|
+
now,
|
|
207
|
+
platform,
|
|
208
|
+
execPath
|
|
209
|
+
});
|
|
210
|
+
const result = run(buildEnsureAgentSessionCaptureArgs(claim));
|
|
211
|
+
if (!result?.error && result?.status === 0) return true;
|
|
212
|
+
if (commandUnavailable(result)) {
|
|
213
|
+
const fallback = run(buildLegacyCaptureWakeArgs(claim));
|
|
214
|
+
if (!fallback?.error && fallback?.status === 0) return true;
|
|
215
|
+
throw cliFailure("_link-agent-session compatibility fallback", fallback);
|
|
216
|
+
}
|
|
217
|
+
throw cliFailure("_ensure-agent-session-capture", result);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// source/hooks/agent-session-link.mjs
|
|
221
|
+
import { appendFileSync, mkdirSync as mkdirSync2, renameSync as renameSync2, statSync as statSync2 } from "node:fs";
|
|
222
|
+
import { homedir as homedir2 } from "node:os";
|
|
223
|
+
import { basename as basename2, join as join2 } from "node:path";
|
|
224
|
+
var HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
225
|
+
function logHookFailure(hook, error, meta = {}, home = process.env.HOME?.trim() || homedir2()) {
|
|
226
|
+
try {
|
|
227
|
+
const logsDir = join2(home, ".polygraph", "logs");
|
|
228
|
+
mkdirSync2(logsDir, { recursive: true });
|
|
229
|
+
const logFile = join2(logsDir, "hooks.log");
|
|
230
|
+
try {
|
|
231
|
+
if (statSync2(logFile).size > HOOK_LOG_MAX_BYTES) {
|
|
232
|
+
renameSync2(logFile, `${logFile}.1`);
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
}
|
|
236
|
+
const entry = {
|
|
237
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
238
|
+
hook,
|
|
239
|
+
pid: process.pid,
|
|
240
|
+
...meta,
|
|
241
|
+
error: error instanceof Error ? error.message : String(error),
|
|
242
|
+
...error instanceof Error && error.stack ? { stack: error.stack } : {}
|
|
243
|
+
};
|
|
244
|
+
appendFileSync(logFile, JSON.stringify(entry) + "\n");
|
|
245
|
+
} catch {
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// source/hooks/ensure-agent-session-capture-worker.mjs
|
|
250
|
+
function writeWorkerFailure(error, claim) {
|
|
251
|
+
try {
|
|
252
|
+
const entry = {
|
|
253
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
254
|
+
hook: `${claim?.agentType ?? "unknown"}:ensure-agent-session-capture-worker`,
|
|
255
|
+
pid: process.pid,
|
|
256
|
+
agentSessionId: claim?.agentSessionId,
|
|
257
|
+
error: error instanceof Error ? error.message : String(error),
|
|
258
|
+
...error instanceof Error && error.stack ? { stack: error.stack } : {}
|
|
259
|
+
};
|
|
260
|
+
process.stderr.write(JSON.stringify(entry) + "\n");
|
|
261
|
+
} catch {
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function main({
|
|
265
|
+
serializedClaim = process.argv[2],
|
|
266
|
+
env = process.env,
|
|
267
|
+
spawn,
|
|
268
|
+
logFailure = logHookFailure,
|
|
269
|
+
writeFailure = writeWorkerFailure
|
|
270
|
+
} = {}) {
|
|
271
|
+
let claim;
|
|
272
|
+
try {
|
|
273
|
+
claim = JSON.parse(serializedClaim);
|
|
274
|
+
return ensureAgentSessionCapture(claim, spawn, env);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
writeFailure(error, claim);
|
|
277
|
+
try {
|
|
278
|
+
logFailure(
|
|
279
|
+
`${claim?.agentType ?? "unknown"}:ensure-agent-session-capture-worker`,
|
|
280
|
+
error,
|
|
281
|
+
{
|
|
282
|
+
agentSessionId: claim?.agentSessionId,
|
|
283
|
+
cli: env.POLYGRAPH_CLI || "polygraph"
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function isMainModule() {
|
|
292
|
+
if (!process.argv[1]) return false;
|
|
293
|
+
try {
|
|
294
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath2(import.meta.url));
|
|
295
|
+
} catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (isMainModule()) {
|
|
300
|
+
process.exitCode = main() ? 0 : 1;
|
|
301
|
+
}
|
|
302
|
+
export {
|
|
303
|
+
main
|
|
304
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polygraph/opencode-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "AI agent skills and subagents for Polygraph sessions, repository context, and coordination",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
"server.js",
|
|
27
27
|
"agent-session-link.mjs",
|
|
28
|
+
"ensure-agent-session-capture-worker.mjs",
|
|
28
29
|
"frontmatter.mjs",
|
|
29
30
|
"skills/",
|
|
30
31
|
"agents/",
|
package/server.js
CHANGED
|
@@ -15,9 +15,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
createOpenCodeSessionLinker,
|
|
18
|
+
deferOpenCodeCaptureWake,
|
|
18
19
|
deferOpenCodeToolActivity,
|
|
19
20
|
linkOpenCodeSessionCreatedEvent,
|
|
20
21
|
logHookFailure,
|
|
22
|
+
openCodeChatMessageSessionId,
|
|
21
23
|
} from './agent-session-link.mjs';
|
|
22
24
|
import { parseFrontmatter } from './frontmatter.mjs';
|
|
23
25
|
|
|
@@ -40,6 +42,24 @@ export const PolygraphPlugin = async ({ client, directory } = {}) => {
|
|
|
40
42
|
sessionID: input?.event?.properties?.info?.id,
|
|
41
43
|
});
|
|
42
44
|
}
|
|
45
|
+
|
|
46
|
+
// session.idle is OpenCode's agent-done signal. It is a capture
|
|
47
|
+
// liveness wake only — the transcript decides step boundaries.
|
|
48
|
+
if (input?.event?.type === 'session.idle') {
|
|
49
|
+
const sessionID = input.event.properties?.sessionID;
|
|
50
|
+
deferOpenCodeCaptureWake(sessionID, sessionLinker, (error) => {
|
|
51
|
+
logHookFailure('opencode:session.idle', error, { sessionID });
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// Fires when the user submits a prompt; wakes capture so the prompt is
|
|
57
|
+
// available promptly even if the sidecar died. Liveness only.
|
|
58
|
+
'chat.message': async (input, output) => {
|
|
59
|
+
const sessionID = openCodeChatMessageSessionId(input, output);
|
|
60
|
+
deferOpenCodeCaptureWake(sessionID, sessionLinker, (error) => {
|
|
61
|
+
logHookFailure('opencode:chat.message', error, { sessionID });
|
|
62
|
+
});
|
|
43
63
|
},
|
|
44
64
|
|
|
45
65
|
config: async (cfg) => {
|
|
@@ -26,6 +26,7 @@ Polygraph functionality is available via both MCP tools and CLI commands. Use wh
|
|
|
26
26
|
| `stop_agent` | — | Cancel an in-progress child by delegation id; its session is preserved for later read-only context restoration. |
|
|
27
27
|
| `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. |
|
|
28
28
|
| `create_pr` | — | Create draft PRs with session metadata linking related PRs |
|
|
29
|
+
| `update_pr` | — | Update title, user-authored body, labels, or assignees on one PR associated with a session |
|
|
29
30
|
| `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. |
|
|
30
31
|
| `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. |
|
|
31
32
|
| `link_reference` | — | Link an external reference to a session. |
|
|
@@ -223,9 +224,9 @@ When polling `show_agent`, treat `permission-required` like `input-required`:
|
|
|
223
224
|
|
|
224
225
|
### Publish Changes (Push Branches, Create PRs, Mark Ready)
|
|
225
226
|
|
|
226
|
-
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),
|
|
227
|
+
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).
|
|
227
228
|
|
|
228
|
-
**Whenever you push a branch, create or
|
|
229
|
+
**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.
|
|
229
230
|
|
|
230
231
|
### Session Description Policy
|
|
231
232
|
|
|
@@ -307,6 +308,7 @@ If the session has a description timeline, also display:
|
|
|
307
308
|
1. **Wait in background subagents** — `spawn_agent` is fine to call directly, but every waited `show_agent` poll MUST go through `@polygraph-delegate-subagent`; inline polling floods the context window with status noise.
|
|
308
309
|
|
|
309
310
|
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.
|
|
311
|
+
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).
|
|
310
312
|
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
|
|
311
313
|
1. **Link PRs in descriptions** - Reference related PRs in each PR body
|
|
312
314
|
1. **Keep PRs as drafts** until all repos are ready
|
|
@@ -28,12 +28,24 @@ spawn_agent(
|
|
|
28
28
|
|
|
29
29
|
`agent` picks the child's harness and `model` overrides its default model; include either only when the user named one.
|
|
30
30
|
|
|
31
|
-
Write the instruction as if to a competent engineer who cannot see your conversation: state the goal, the constraints,
|
|
31
|
+
Write the instruction as if to a competent engineer who cannot see your conversation: state the goal, the constraints, what "done" looks like, and what to report back. The child has its own repo and its own context; it inherits nothing from yours.
|
|
32
32
|
|
|
33
33
|
Delegate to several repos in parallel by calling `spawn_agent` once per repo before waiting on any of them.
|
|
34
34
|
|
|
35
35
|
**Own-repo rule.** With the default role, `repo` must be a repository other than the one you are working in — never delegate into your own repo with the default role; work on it directly (ordinary local subagents are fine for that). Delegating into your own repo IS allowed with an explicit non-default `role`, because each (repo, role) pair is a separate agent slot and the child then runs alongside your own default-role work without colliding with it.
|
|
36
36
|
|
|
37
|
+
## The output contract
|
|
38
|
+
|
|
39
|
+
Children are told to be concise: another agent reads their final message and pays for it on every turn that carries it. Expect terse reports; brevity is not less work done.
|
|
40
|
+
|
|
41
|
+
Your half is the brief: state the output as well as the input — shape (fields, order), a cap where useful, the exact token for "nothing to report", what to omit. In communicating with child agents, maintain extremely high information density while being concise - describe everything needed in the fewest words possible.
|
|
42
|
+
|
|
43
|
+
Investigation is where it matters most: a fan-out leaves most repos with nothing to report, and without a named empty answer (`NONE`, `no matches`) each writes several thousand characters to say so.
|
|
44
|
+
|
|
45
|
+
Implementation still wants concision, but lost information is the worse failure: a missed detail costs a round trip, costlier than the prose. Cut narration, recap, hedging — never branch names, files touched, decisions taken, or anything contradicting the brief.
|
|
46
|
+
|
|
47
|
+
Prose only where necessary. Consumers are agents first, humans second: dense and structural, not narrative.
|
|
48
|
+
|
|
37
49
|
## Waiting
|
|
38
50
|
|
|
39
51
|
For each id, launch one background poller subagent whose entire job is to block until that child stops moving. Give it the `sessionId` and the `id`, and nothing else.
|
|
@@ -59,7 +71,7 @@ When a poller exits, read the child's answer yourself with a single **unwaited**
|
|
|
59
71
|
show_agent(sessionId: "<sessionId>", id: "<id>")
|
|
60
72
|
```
|
|
61
73
|
|
|
62
|
-
`result.text` is the child's final message: what it did
|
|
74
|
+
`result.text` is the child's final message: what it did and what it found, in the shape the instruction asked for. This is the payload. Read it once, in the main conversation, and act on it.
|
|
63
75
|
|
|
64
76
|
One-off unwaited reads like this are cheap and expected inline. It is the *waiting* that belongs in a subagent, not the reading.
|
|
65
77
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Publishing Changes Reference
|
|
2
2
|
|
|
3
|
-
The branch-to-PR flow: push branches, create draft PRs, mark them ready,
|
|
3
|
+
The branch-to-PR flow: push branches, create draft PRs, mark them ready, associate PRs created outside Polygraph, and update associated PRs. `push_branch`, `create_pr`, and `associate_pr` all require a `description` following the Session Description Policy — read [`session-description.md`](session-description.md) before writing one. `update_pr` does not require a session timeline description.
|
|
4
4
|
|
|
5
5
|
## Push Branches
|
|
6
6
|
|
|
@@ -151,3 +151,50 @@ associate_pr(
|
|
|
151
151
|
```
|
|
152
152
|
|
|
153
153
|
**Returns** the list of PRs now associated with the session.
|
|
154
|
+
|
|
155
|
+
## Update an Associated PR
|
|
156
|
+
|
|
157
|
+
Use the MCP `update_pr` tool to update one PR already associated with the named Polygraph session. Do not use `gh` or call Ocean HTTP directly. `mark_pr_ready` remains a separate operation.
|
|
158
|
+
|
|
159
|
+
**Parameters:**
|
|
160
|
+
|
|
161
|
+
- `sessionId` (required): The Polygraph session ID.
|
|
162
|
+
- `prUrl` (required): The URL of a PR already associated with the session.
|
|
163
|
+
- `title` (optional): Replacement PR title.
|
|
164
|
+
- `body` (optional): Replacement user-authored PR body. Pass an empty string to clear it. The managed Polygraph session footer remains server-owned.
|
|
165
|
+
- `labels` (optional): A collection update with `mode` and `values`.
|
|
166
|
+
- `assignees` (optional): A collection update with `mode` and `values`.
|
|
167
|
+
|
|
168
|
+
Omitted fields remain unchanged. For `labels` and `assignees`:
|
|
169
|
+
|
|
170
|
+
- `{ mode: "set", values: [...] }` replaces the complete collection. An empty `values` list clears it. Use `set` only when you intend to replace every value because it can remove labels or assignees applied by humans.
|
|
171
|
+
- `add` and `remove` preserve unrelated values and require a non-empty `values` list.
|
|
172
|
+
|
|
173
|
+
Set the complete label collection and clear the user-authored body:
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
update_pr(
|
|
177
|
+
sessionId: "<session-id>",
|
|
178
|
+
prUrl: "https://github.com/org/repo/pull/123",
|
|
179
|
+
body: "",
|
|
180
|
+
labels: {
|
|
181
|
+
mode: "set",
|
|
182
|
+
values: ["documentation", "release-note"]
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Add an assignee while leaving the title, body, labels, and other assignees unchanged:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
update_pr(
|
|
191
|
+
sessionId: "<session-id>",
|
|
192
|
+
prUrl: "https://github.com/org/repo/pull/123",
|
|
193
|
+
assignees: {
|
|
194
|
+
mode: "add",
|
|
195
|
+
values: ["octocat"]
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Metadata updates do not require a session timeline `description`.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
`description` is user-facing Polygraph session context.
|
|
6
6
|
|
|
7
|
-
`description` is required for `push_branch`, `create_pr`, and `associate_pr`, and is the primary input to `update_session` (which takes `title` and/or `description`).
|
|
7
|
+
`description` is required for `push_branch`, `create_pr`, and `associate_pr`, and is the primary input to `update_session` (which takes `title` and/or `description`). Metadata updates through `update_pr` do not require a session timeline description, and `mark_pr_ready` does not take one. The Polygraph web app renders the description as Markdown, so use real Markdown headings — not flat `Label:` lines. Use the canonical structured format:
|
|
8
8
|
|
|
9
9
|
```markdown
|
|
10
10
|
## Goal
|