@polygraph/claude-plugin 0.4.44 → 0.4.46

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygraph",
3
- "version": "0.4.44",
3
+ "version": "0.4.46",
4
4
  "description": "AI agent skills and subagents for Polygraph sessions, repository context, and coordination",
5
5
  "author": {
6
6
  "name": "Narwhal Technologies Inc",
@@ -0,0 +1,146 @@
1
+ import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ const HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024;
7
+
8
+ const AGENT_TYPES = new Set(['claude', 'codex', 'opencode']);
9
+ const COMMAND_HOOK_TOOL = /^mcp__(?:plugin_polygraph_)?polygraph[-_]mcp__/;
10
+ const OPENCODE_TOOL = /^polygraph(?:(?:-|_)mcp)?_/;
11
+
12
+ function nonEmptyString(value) {
13
+ return typeof value === 'string' && value.trim() ? value : undefined;
14
+ }
15
+
16
+ function isManagedChildEnvironment(env) {
17
+ return Boolean(env && Object.hasOwn(env, 'POLYGRAPH_CHILD_AGENT'));
18
+ }
19
+
20
+ export function isPolygraphMcpToolName(toolName) {
21
+ const name = nonEmptyString(toolName);
22
+ return Boolean(name && (COMMAND_HOOK_TOOL.test(name) || OPENCODE_TOOL.test(name)));
23
+ }
24
+
25
+ export function buildLinkAgentSessionArgs({
26
+ polygraphSessionId,
27
+ agentType,
28
+ agentSessionId,
29
+ cwd,
30
+ transcriptPath,
31
+ pid,
32
+ source,
33
+ }) {
34
+ const session = nonEmptyString(polygraphSessionId);
35
+ const harnessSession = nonEmptyString(agentSessionId);
36
+ const claimSource = nonEmptyString(source);
37
+ if (!AGENT_TYPES.has(agentType)) throw new Error(`Unsupported agent type: ${agentType}`);
38
+ if (!harnessSession) throw new Error('agentSessionId is required');
39
+ if (!claimSource) throw new Error('source is required');
40
+
41
+ const args = ['_link-agent-session'];
42
+ if (session) args.push('--session', session);
43
+ args.push('--agent-type', agentType, '--agent-session-id', harnessSession);
44
+
45
+ const workingDirectory = nonEmptyString(cwd);
46
+ if (workingDirectory) args.push('--cwd', workingDirectory);
47
+
48
+ const transcript = nonEmptyString(transcriptPath);
49
+ if (transcript) args.push('--transcript-path', transcript);
50
+
51
+ if (Number.isSafeInteger(pid) && pid > 0) {
52
+ args.push('--pid', String(pid));
53
+ }
54
+
55
+ args.push('--source', claimSource);
56
+ return args;
57
+ }
58
+
59
+ export function linkAgentSession(claim, spawn = spawnSync, env = process.env) {
60
+ if (isManagedChildEnvironment(env)) return false;
61
+
62
+ const args = buildLinkAgentSessionArgs(claim);
63
+ const command = nonEmptyString(env?.POLYGRAPH_CLI) ?? 'polygraph';
64
+ const commandEnv = nonEmptyString(claim.polygraphSessionId) ? env : { ...env };
65
+ if (commandEnv !== env) {
66
+ delete commandEnv.POLYGRAPH_SESSION_ID;
67
+ delete commandEnv.POLYGRAPH_CAPTURE_TOKEN;
68
+ }
69
+
70
+ const result = spawn(command, args, {
71
+ encoding: 'utf8',
72
+ env: commandEnv,
73
+ stdio: ['ignore', 'ignore', 'pipe'],
74
+ });
75
+
76
+ if (result?.error) throw result.error;
77
+ if (result?.status !== 0) {
78
+ const detail = nonEmptyString(result?.stderr);
79
+ throw new Error(
80
+ `polygraph _link-agent-session exited with status ${String(result?.status)}` +
81
+ (detail ? `: ${detail}` : '')
82
+ );
83
+ }
84
+
85
+ return true;
86
+ }
87
+
88
+ export function buildCommandHookLink(payload, agentType, env = process.env) {
89
+ if (!payload || typeof payload !== 'object') return undefined;
90
+ if (isManagedChildEnvironment(env)) return undefined;
91
+
92
+ const agentSessionId = nonEmptyString(payload.session_id);
93
+ if (!agentSessionId) return undefined;
94
+
95
+ const common = {
96
+ agentType,
97
+ agentSessionId,
98
+ cwd: nonEmptyString(payload.cwd),
99
+ transcriptPath: nonEmptyString(payload.transcript_path),
100
+ source: 'hook',
101
+ };
102
+
103
+ if (payload.hook_event_name === 'SessionStart') {
104
+ const polygraphSessionId = nonEmptyString(env.POLYGRAPH_SESSION_ID);
105
+ return polygraphSessionId ? { ...common, polygraphSessionId } : undefined;
106
+ }
107
+
108
+ if (payload.hook_event_name === 'PostToolUse') {
109
+ return isPolygraphMcpToolName(payload.tool_name) ? common : undefined;
110
+ }
111
+
112
+ return undefined;
113
+ }
114
+
115
+ export function logHookFailure(
116
+ hook,
117
+ error,
118
+ meta = {},
119
+ home = process.env.HOME?.trim() || homedir()
120
+ ) {
121
+ try {
122
+ const logsDir = join(home, '.polygraph', 'logs');
123
+ mkdirSync(logsDir, { recursive: true });
124
+ const logFile = join(logsDir, 'hooks.log');
125
+
126
+ try {
127
+ if (statSync(logFile).size > HOOK_LOG_MAX_BYTES) {
128
+ renameSync(logFile, `${logFile}.1`);
129
+ }
130
+ } catch {
131
+ // There may be no prior log, and logging must stay best-effort.
132
+ }
133
+
134
+ const entry = {
135
+ time: new Date().toISOString(),
136
+ hook,
137
+ pid: process.pid,
138
+ ...meta,
139
+ error: error instanceof Error ? error.message : String(error),
140
+ ...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
141
+ };
142
+ appendFileSync(logFile, JSON.stringify(entry) + '\n');
143
+ } catch {
144
+ // Hook diagnostics must never break the harness event that triggered them.
145
+ }
146
+ }
package/hooks/hooks.json CHANGED
@@ -11,6 +11,18 @@
11
11
  ]
12
12
  }
13
13
  ],
14
+ "PostToolUse": [
15
+ {
16
+ "matcher": "mcp__plugin_polygraph_.*|mcp__polygraph.*",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/record-session-mapping.mjs claude",
21
+ "async": true
22
+ }
23
+ ]
24
+ }
25
+ ],
14
26
  "SessionStart": [
15
27
  {
16
28
  "matcher": "startup|resume|compact",
@@ -24,15 +36,6 @@
24
36
  "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/record-session-mapping.mjs claude"
25
37
  }
26
38
  ]
27
- },
28
- {
29
- "matcher": "startup|resume",
30
- "hooks": [
31
- {
32
- "type": "command",
33
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/check-plugin-version.mjs claude"
34
- }
35
- ]
36
39
  }
37
40
  ]
38
41
  }
@@ -1,263 +1,54 @@
1
- // Hidden SessionStart hook records an agent-capture mapping file that binds
2
- // this agent's session id to the Polygraph session id in the environment.
3
- // Used by both the Claude Code plugin (agentType=claude) and the Codex plugin
4
- // (agentType=codex). The agentType is passed as the first CLI argument so the
5
- // same script ships in both plugin artifacts.
6
- //
7
- // File contract (must match the Polygraph CLI reader exactly):
8
- // <sessionsRoot>/<POLYGRAPH_SESSION_ID>/sidecars/mapping-<agentType>-<agentSessionId>.json
9
- // where sessionsRoot = $POLYGRAPH_ROOT, else `globalRoot` from
10
- // ~/.polygraph/config.json, else ~/.polygraph/sessions
11
- // Legacy fallback, used ONLY when <sessionsRoot>/<POLYGRAPH_SESSION_ID>
12
- // does not exist (for real sessions nothing new is written here):
13
- // ~/.polygraph/sidecars/<POLYGRAPH_SESSION_ID>/mapping-<agentType>-<agentSessionId>.json
14
- //
15
- // The session folder is a trustworthy location for this parent-transcript
16
- // binding because the Polygraph CLI's child-agent sandboxes exclude the
17
- // session root — children cannot write there. The CLI reads mappings from
18
- // the session folder first, with the flat dir as a read-only fallback.
19
- //
20
- // Behaviour:
21
- // - Silent no-op when POLYGRAPH_SESSION_ID is unset.
22
- // - Silent no-op when POLYGRAPH_CHILD_AGENT is set (child agents must not
23
- // register themselves as parents).
24
- // - Atomic write: write to <path>.tmp-<pid>, then rename over final path.
25
- // - Refresh: when a valid prior mapping for the same session already exists
26
- // (checked in the new location first, then the legacy flat dir), preserve
27
- // its firstSeenAt and only update lastSeenAt + mutable fields — so
28
- // migrating a mapping from the legacy dir keeps firstSeenAt continuity.
29
- // - All failures are silently swallowed; never writes to stdout (Claude Code
30
- // injects hook stdout into the model context); never exits non-zero.
31
-
32
- import {
33
- appendFileSync,
34
- existsSync,
35
- mkdirSync,
36
- readFileSync,
37
- realpathSync,
38
- renameSync,
39
- statSync,
40
- writeFileSync,
41
- } from 'node:fs';
42
- import { homedir } from 'node:os';
43
- import { join } from 'node:path';
1
+ import { readFileSync, realpathSync } from 'node:fs';
44
2
  import { fileURLToPath } from 'node:url';
45
3
 
46
- const HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024;
47
-
48
- // Append a one-line JSON record of a hook failure to ~/.polygraph/logs/hooks.log.
49
- // This hook swallows its errors silently and must never write to stdout (Claude
50
- // Code injects hook stdout into the model context), so this on-disk log is the
51
- // only record that something went wrong. The logger is itself failure-proof.
52
- function logHookFailure(
53
- hook,
54
- error,
55
- meta = {},
56
- home = process.env.HOME?.trim() || homedir()
57
- ) {
58
- try {
59
- const logsDir = join(home, '.polygraph', 'logs');
60
- mkdirSync(logsDir, { recursive: true });
61
- const logFile = join(logsDir, 'hooks.log');
62
-
63
- try {
64
- if (statSync(logFile).size > HOOK_LOG_MAX_BYTES) {
65
- renameSync(logFile, `${logFile}.1`);
66
- }
67
- } catch {
68
- // no prior log, or rotation failed — ignore
69
- }
70
-
71
- const entry = {
72
- time: new Date().toISOString(),
73
- hook,
74
- pid: process.pid,
75
- ...meta,
76
- error: error instanceof Error ? error.message : String(error),
77
- ...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
78
- };
79
- appendFileSync(logFile, JSON.stringify(entry) + '\n');
80
- } catch {
81
- // Logging must never throw — a failing logger must not break the hook.
82
- }
83
- }
84
-
85
- function readStdin() {
86
- try {
87
- return readFileSync(0, 'utf8');
88
- } catch {
89
- return '';
90
- }
91
- }
4
+ import {
5
+ buildCommandHookLink,
6
+ linkAgentSession,
7
+ logHookFailure,
8
+ } from './agent-session-link.mjs';
92
9
 
93
- function tryParseJson(str) {
10
+ function readPayload() {
94
11
  try {
95
- return JSON.parse(str);
12
+ const raw = readFileSync(0, 'utf8');
13
+ return raw ? JSON.parse(raw) : undefined;
96
14
  } catch {
97
- return null;
15
+ return undefined;
98
16
  }
99
17
  }
100
18
 
101
- function sanitizeFilename(str) {
102
- return str.replace(/[^A-Za-z0-9._-]/g, '_');
103
- }
104
-
105
- // Resolve the root directory that holds per-session folders:
106
- // $POLYGRAPH_ROOT, else `globalRoot` from ~/.polygraph/config.json, else
107
- // ~/.polygraph/sessions. Must match the Polygraph CLI's own resolution.
108
- export function sessionsRoot(home = process.env.HOME?.trim() || homedir()) {
109
- const fromEnv = process.env.POLYGRAPH_ROOT?.trim();
110
- if (fromEnv) return fromEnv;
111
-
19
+ export function main({
20
+ payload = readPayload(),
21
+ agentType = process.argv[2],
22
+ env = process.env,
23
+ pid = process.ppid,
24
+ spawn,
25
+ } = {}) {
112
26
  try {
113
- const config = tryParseJson(
114
- readFileSync(join(home, '.polygraph', 'config.json'), 'utf8')
27
+ const link = buildCommandHookLink(payload, agentType, env);
28
+ if (!link) return false;
29
+
30
+ return linkAgentSession(
31
+ {
32
+ ...link,
33
+ pid,
34
+ cwd: link.cwd ?? process.cwd(),
35
+ },
36
+ spawn,
37
+ env
115
38
  );
116
- if (typeof config?.globalRoot === 'string' && config.globalRoot.trim()) {
117
- return config.globalRoot.trim();
118
- }
119
- } catch {
120
- // no config — use the default
121
- }
122
-
123
- return join(home, '.polygraph', 'sessions');
124
- }
125
-
126
- /**
127
- * Write (or refresh) the agent-capture mapping file.
128
- *
129
- * Written into the session folder (`<sessionsRoot>/<sessionId>/sidecars/`)
130
- * when the session directory exists; only when it does not exist does the
131
- * write fall back to the legacy flat `~/.polygraph/sidecars/<sessionId>/`
132
- * dir — for real sessions nothing new lands under the flat dir.
133
- *
134
- * @param {object} opts
135
- * @param {string} opts.agentType 'claude' | 'codex'
136
- * @param {string} opts.agentSessionId The harness's own session id.
137
- * @param {string} opts.polygraphSessionId Value of POLYGRAPH_SESSION_ID.
138
- * @param {string} opts.cwd Agent working directory.
139
- * @param {string} [opts.transcriptPath] Absolute transcript path; omit when unknown.
140
- * @param {number} [opts.pid] Harness process id; omit when not knowable.
141
- * @param {string} [home] Override HOME for testing.
142
- */
143
- export function writeCaptureMapping(
144
- { agentType, agentSessionId, polygraphSessionId, cwd, transcriptPath, pid },
145
- home = process.env.HOME?.trim() || homedir()
146
- ) {
147
- const filenamePart = sanitizeFilename(`${agentType}-${agentSessionId}`);
148
- const fileName = `mapping-${filenamePart}.json`;
149
-
150
- const sessionDir = join(sessionsRoot(home), polygraphSessionId);
151
- const sessionSidecarDir = join(sessionDir, 'sidecars');
152
- const legacyDir = join(home, '.polygraph', 'sidecars', polygraphSessionId);
153
-
154
- // New location when the session directory exists; legacy flat dir only
155
- // when it does not.
156
- const targetDir = existsSync(sessionDir) ? sessionSidecarDir : legacyDir;
157
- mkdirSync(targetDir, { recursive: true });
158
-
159
- const finalPath = join(targetDir, fileName);
160
- const tmpPath = `${finalPath}.tmp-${process.pid}`;
161
-
162
- const now = Date.now();
163
-
164
- // Refresh semantics: preserve firstSeenAt from a valid prior mapping.
165
- // Check the new location first, then the legacy flat dir — this keeps
166
- // firstSeenAt continuity when migrating a mapping from the legacy dir.
167
- let firstSeenAt = now;
168
- for (const candidate of [
169
- join(sessionSidecarDir, fileName),
170
- join(legacyDir, fileName),
171
- ]) {
172
- if (!existsSync(candidate)) continue;
173
- const existing = tryParseJson(readFileSync(candidate, 'utf8'));
174
- if (
175
- existing !== null &&
176
- existing.version === 1 &&
177
- existing.polygraphSessionId === polygraphSessionId &&
178
- existing.agentSessionId === agentSessionId &&
179
- Number.isFinite(existing.firstSeenAt)
180
- ) {
181
- firstSeenAt = existing.firstSeenAt;
182
- break;
183
- }
184
- }
185
-
186
- const mapping = {
187
- version: 1,
188
- polygraphSessionId,
189
- agentType,
190
- agentSessionId,
191
- cwd,
192
- ...(transcriptPath != null ? { transcriptPath } : {}),
193
- ...(pid != null ? { pid } : {}),
194
- source: 'hook',
195
- firstSeenAt,
196
- lastSeenAt: now,
197
- };
198
-
199
- writeFileSync(tmpPath, JSON.stringify(mapping, null, 2) + '\n');
200
- renameSync(tmpPath, finalPath);
201
- }
202
-
203
- export function main() {
204
- try {
205
- const polygraphSessionId = process.env.POLYGRAPH_SESSION_ID;
206
- if (!polygraphSessionId) return;
207
- if (process.env.POLYGRAPH_CHILD_AGENT) return;
208
-
209
- const agentType = process.argv[2];
210
- if (!agentType) return;
211
-
212
- let payload = {};
213
- const raw = readStdin();
214
- if (raw) {
215
- const parsed = tryParseJson(raw);
216
- if (parsed !== null) payload = parsed;
217
- }
218
-
219
- const agentSessionId =
220
- typeof payload.session_id === 'string' ? payload.session_id : '';
221
- if (!agentSessionId) return;
222
-
223
- const cwd =
224
- typeof payload.cwd === 'string' && payload.cwd
225
- ? payload.cwd
226
- : process.cwd();
227
-
228
- // transcript_path is present on Claude/Codex payloads; may be null — omit
229
- // the field when absent or null rather than writing null into the mapping.
230
- const transcriptPath =
231
- typeof payload.transcript_path === 'string' && payload.transcript_path
232
- ? payload.transcript_path
233
- : undefined;
234
-
235
- writeCaptureMapping({
236
- agentType,
237
- agentSessionId,
238
- polygraphSessionId,
239
- cwd,
240
- transcriptPath,
241
- // process.ppid is the harness pid when the hook is spawned as a child.
242
- pid: process.ppid,
243
- });
244
39
  } catch (error) {
245
- // Silent toward the agent a broken hook must never break the session —
246
- // but record it so failures are not invisible.
247
- logHookFailure(`${process.argv[2] || 'unknown'}:record-session-mapping`, error);
40
+ logHookFailure(`${agentType || 'unknown'}:link-agent-session`, error, {
41
+ hookEventName: payload?.hook_event_name,
42
+ agentSessionId: payload?.session_id,
43
+ });
44
+ return false;
248
45
  }
249
46
  }
250
47
 
251
- // Run only when executed directly as a hook, not when imported (e.g. by tests).
252
- // realpathSync both sides so the check holds when the plugin lives under a
253
- // symlinked path (e.g. macOS /tmp -> /private/tmp).
254
48
  function isMainModule() {
255
49
  if (!process.argv[1]) return false;
256
50
  try {
257
- return (
258
- realpathSync(process.argv[1]) ===
259
- realpathSync(fileURLToPath(import.meta.url))
260
- );
51
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
261
52
  } catch {
262
53
  return false;
263
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polygraph/claude-plugin",
3
- "version": "0.4.44",
3
+ "version": "0.4.46",
4
4
  "description": "AI agent skills and subagents for Polygraph sessions, repository context, and coordination",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -11,9 +11,9 @@ allowed-tools:
11
11
 
12
12
  **IMPORTANT:** Polygraph keeps local clones only for *other* repositories in the session. NEVER `cd` into those clones or access their files directly — work in other repositories ALWAYS happens through the Polygraph MCP `spawn_agent` tool, invoked via background `polygraph-delegate-subagent` Tasks.
13
13
 
14
- Polygraph connects repositories and the agent work happening across them. Its central artifact is the session, which groups the repositories, branches, PRs, and CI status for one piece of work and can be shared and resumed: use it to coordinate changes across multiple repositories, and also on its own to share the session URL with collaborators, hand off progress via the session description, resume prior work, and watch CI across the session's PRs.
14
+ Polygraph connects repos and the agent work happening across them. Its central artifact is the session, which groups the repos, branches, PRs, and CI status for one piece of work and can be shared and resumed: use it to coordinate changes across multiple repos or in a single repoto share the session URL with collaborators, hand off progress via the session description, resume prior work, and watch CI across the session's PRs.
15
15
 
16
- **Polygraph operates on the current repo in place.** Starting or joining a session never clones or modifies the repository you are in — you keep working in your real working directory, and `push_branch` pushes your local commits from that checkout. Only *other* repositories are worked on in separate Polygraph-managed clones via `spawn_agent`.
16
+ **Polygraph operates on the current repo in place.** Starting or joining a session never clones or modifies the repository you are in — you keep working in your real working directory, and `push_branch` pushes your local commits from that checkout. Only *other* repos are worked on in separate Polygraph-managed clones via `spawn_agent`.
17
17
 
18
18
  ## Sandboxing in Polygraph Sessions
19
19
 
@@ -68,29 +68,28 @@ Use `polygraph whoami` (or the `whoami` MCP tool) before session work to check i
68
68
  - If the user **is logged in** and an org is selected → proceed to the workflow.
69
69
  - If auth is **missing, expired, or no org is selected** → stop session work. Do not keep trying session creation, repository discovery, delegation, or CI checks.
70
70
  - Facilitate user reauth through the browser-based login flow, such as `polygraph auth login` (or the `login` MCP tool). In interactive desktop clients, browser reauth is usually user-driven; surface the need clearly and wait for the user to complete it.
71
- - After login, an organization must be selected. Use `polygraph account select` (or the MCP equivalent) when needed.
71
+ - After login, an organization must be selected. Use `polygraph account select` (or MCP equivalent) when needed.
72
72
  - Re-run `polygraph whoami` (or `whoami`) after reauth and org selection. Continue only after it confirms a valid login and selected organization.
73
73
 
74
74
  ### Select Organization
75
75
 
76
- After logging in (or if logged in but no org is selected), use `polygraph account select` (or the equivalent MCP tool) to choose the organization that future commands will run against.
76
+ After logging in (or if logged in but no org is selected), use `polygraph account select` (or MCP equivalent) to choose the organization that future commands will run against.
77
77
 
78
78
  ## Workflow Overview
79
79
 
80
80
  The delegate/monitor/stop steps apply only when working across repos. A single-repo session skips them and still benefits from shared progress, resume, and CI visibility.
81
81
 
82
- 0. **Initialize or join Polygraph session** - If you were spawned inside an existing session (the startup banner names a session ID), reuse it. Call `show_session` first; if it already has repos and the user did not ask to add more, you're done. If the user asks to add exact repo refs, call `add_repo` directly with those refs and skip candidate discovery. If the session has no repos and no exact refs were provided, launch the `polygraph-init-subagent` with that `sessionId` so it discovers candidates and uses `add_repo` (NOT `start_session`). Only when there is no session ID at all should the init subagent create a new session.
83
- 1. **Delegate work to each repo** - Use the `polygraph-delegate-subagent` to start child agents in other repositories. Delegate only to *other* repos — never to the repo you are in; work on it directly (your regular subagents are fine for local work — only Polygraph delegation is reserved for other repos). Parallel delegation across repos is encouraged. Choose the Simple (fire-and-forget) or Multi-turn (interactive) pattern described below based on whether the child may need clarification.
82
+ 0. **Initialize or join Polygraph session** - If you were spawned inside an existing session (the startup banner names a session ID), reuse it. Call `show_session` first; if it already has repos and the user did not ask to add more, you're done. If the user asks to add exact repo refs, call `add_repo` directly and skip candidate discovery. If the session has no repos and no exact refs were provided, launch the `polygraph-init-subagent` with that `sessionId` so it discovers candidates and uses `add_repo` (NOT `start_session`). Only when there is no session ID at all should the init subagent create a new session.
83
+ 1. **Delegate work to each repo** - Use the `polygraph-delegate-subagent` to start child agents. Delegate only to *other* repos — never to the repo you are in; work on it directly (your regular subagents are fine for local work — only Polygraph delegation is reserved for other repos). Parallel delegation across repos is encouraged. Choose the Simple (fire-and-forget) or Multi-turn (interactive) pattern described below based on whether the child may need clarification.
84
84
 
85
85
  4. **Monitor child agents** - Use `show_agent` to poll one repo's children (`repo` is required; pass `role` to narrow to one agent) and read each entry's `status` and `lastOutputLines` from the `children[]` array.
86
86
  5. **Stop child agents** (if needed) - Use `stop_agent` (with `role` when targeting a non-default agent) to cancel an in-progress child agent. The agent's session is preserved for later read-only context restoration; after a resume, wait for explicit user instructions before making changes.
87
87
  6. **Push branches** - Use `push_branch` after making commits. A required `description` must follow the Session Description Policy.
88
- 7. **Update session description** - Use `update_session` to update the session description; must follow the Session Description Policy. Independent of PR creation or mark-ready.
89
- 8. **Create draft PRs** - Use `create_pr` to create linked draft PRs. Always pass `description` following the Session Description Policy.
90
- 9. **Associate existing PRs** (optional) - Use `associate_pr` to link PRs created outside Polygraph.
91
- 10. **Query PR status** - Use `show_session` to check progress.
92
- 11. **Mark PRs ready** - Use `mark_pr_ready` when work is complete.
93
- 12. **Archive session** - Use `archive_session` to archive the session when the user requests it.
88
+ 7. **Create draft PRs** - Use `create_pr` to create linked draft PRs. Always pass `description` following the Session Description Policy.
89
+ 8. **Associate existing PRs** (optional) - Use `associate_pr` to link PRs created outside Polygraph.
90
+ 9. **Query PR status** - Use `show_session` to check progress.
91
+ 10. **Mark PRs ready** - Use `mark_pr_ready` when work is complete.
92
+ 11. **Archive session** - Use `archive_session` to archive the session when the user requests it.
94
93
 
95
94
  ## Step-by-Step Guide
96
95
 
@@ -98,7 +97,7 @@ The delegate/monitor/stop steps apply only when working across repos. A single-r
98
97
 
99
98
  There are three cases. Pick exactly one before calling any tool. The case labels are internal routing shorthand — never mention them in anything you show the user.
100
99
 
101
- **Hard rule: if a session ID is already in scope (e.g., the startup banner says "You're in Polygraph session …", or the user passed one), that session ID is authoritative for this entire conversation. NEVER call `start_session` — doing so creates a brand-new session and orphans the one the parent harness is pointed at. Reuse the existing session via `show_session` and, if needed, `add_repo`.**
100
+ **Hard rule: if a session ID is already in scope (e.g., the startup banner says "You're in Polygraph session …", or the user passed one or you are provided one by a reminder hook), that session ID is authoritative for this entire conversation. NEVER call `start_session` — doing so creates a brand-new session and orphans the one the parent harness is pointed at. Reuse the existing session via `show_session` and, if needed, `add_repo`.**
102
101
 
103
102
  **Case A — Existing session, already has repos.** Call `show_session` directly with the known session ID. Skip the init subagent entirely, show the session details (format below), and proceed.
104
103
 
@@ -111,12 +110,10 @@ In case B, call `add_repo` yourself when exact repo refs were provided; otherwis
111
110
  **Session ID handling:**
112
111
 
113
112
  - For a new session (case C), `start_session` auto-generates a unique session ID. You do NOT need to pass one.
114
- - For cases A and B, the session ID already exists; reuse it everywhere — never let `start_session` run in this conversation.
113
+ - For cases A and B, the session ID already exists; reuse it everywhere
115
114
  - The parent conversation is responsible for detecting an existing session ID from current context, the startup banner, or a user-provided session URL/ID, then passing it explicitly to `polygraph-init-subagent`. The init subagent cannot infer parent session context by itself.
116
115
  - For a fresh Codex Desktop conversation started with `/polygraph:session-start`, no `sessionId` is expected; launch `polygraph-init-subagent` without `sessionId` so it creates a new session.
117
116
 
118
- **Launch the init subagent** (cases B and C — skip in case A) as a `Task` with `subagent_type: "polygraph:polygraph-init-subagent"`. Pass `userContext` (what the user wants to do); for case B also pass the existing `sessionId`. Instruct it: with a `sessionId`, reuse that session and attach repos via `add_repo`, never `start_session`; if exact repo refs were given, pass them straight to `add_repo` without `list_repos`; if discovery is needed, discover and select candidates; with no `sessionId`, create the session via `start_session`. It returns a structured summary.
119
-
120
117
  The subagent will:
121
118
 
122
119
  1. Use exact repo refs directly when provided for an existing session; otherwise call `list_repos` to discover available repositories
@@ -125,7 +122,7 @@ The subagent will:
125
122
  4. Call `show_session` to retrieve session details
126
123
  5. Return a summary with session URL and repo info
127
124
 
128
- **When the init subagent has just created a brand-new session,** render the session welcome card instead of the session-details block below. Prefer the `session_intro` MCP tool call it with the session ID; it returns the card as markdown. If that tool is unavailable, run `polygraph session intro -s <sessionId>` via the CLI instead. The CLI command is intentionally hidden/internal and may not appear in public command listings, but it remains the correct skill fallback for rendering the welcome card. Either way, print the result to the user verbatim as markdown — do NOT wrap it in a code block or reformat it (the logo is pre-fenced; the rest is live markdown). It needs no other input, and you do not need to call `show_session` first. Then continue (ask the user what they want, or start the requested task).
125
+ **When the init subagent has just created a brand-new session,** render the session welcome card instead of the session-details block below. Prefer the `session_intro` MCP tool (or the hidden `polygraph session intro -s <sessionId>` via the CLI) call it with the session ID; it returns the card as markdown. Print the result to the user verbatim as markdown — do NOT wrap it in a code block or reformat it (the logo is pre-fenced; the rest is live markdown). It needs no other input, and you do not need to call `show_session` first. Then continue.
129
126
 
130
127
  **For an existing session — after `show_session` returns or the init subagent's summary arrives — show the session details:**
131
128
 
@@ -145,7 +142,7 @@ Use this workflow when the user gives a Polygraph session ID and asks to underst
145
142
  **Resume is not a work command.** If the user's intent is to resume, reconnect, or reconstruct a prior Polygraph session, fetch and summarize the restored context, then stop. Do not edit files, push branches, add repos, delegate new work, or continue previous changes until the user explicitly asks for changes. Treat "resume" as context restoration followed by waiting for user instructions.
146
143
 
147
144
  1. Fetch detailed session context:
148
- - Prefer `show_session` with `details: true` when the MCP tool exposes that option.
145
+ - Prefer `show_session` with `details: true`
149
146
  - Otherwise run `polygraph session show --details <session-id>`.
150
147
  2. Treat the detailed output as authoritative context. It should include:
151
148
  - `<summary>` — the session summary.
@@ -185,29 +182,8 @@ Inspect the PR commits/diff and investigate the requested behavior. Report findi
185
182
 
186
183
  ### Finding the Session Behind a Commit or Line
187
184
 
188
- Use this workflow when the user asks which Polygraph session produced, is behind, or changed a particular commit — or a particular line of code.
189
-
190
- **Given a commit sha.** When the user names a sha, or asks what session is behind a commit, resolve it with `search_sessions` using the `sha` parameter (CLI: `polygraph session search --sha <sha>`):
191
-
192
- - Pass **exactly one** of `query` or `sha` — they are mutually exclusive.
193
- - `sha` accepts a full or partial sha, 7-40 hex chars.
194
- - The lookup is exact and one-shot: it returns the session(s) linked to that commit, newest first, scoped to the current org, and only explicit sessions.
195
-
196
- ```
197
- search_sessions(sha: "a1b2c3d")
198
- # CLI equivalent:
199
- polygraph session search --sha a1b2c3d
200
- ```
201
-
202
- **Given a line number.** There is no line-number lookup — a line MUST first be resolved to a commit sha with `git blame`, then that sha is fed into the sha lookup:
203
-
204
- 1. `git blame -L <line>,<line> -- <file>` to get the commit that last touched the line.
205
- 2. Pass that sha to `search_sessions(sha: ...)` (or `polygraph session search --sha <sha>`).
206
-
207
- **Reading the results.**
208
-
209
- - Multiple sessions may match a sha. They come back newest first — pick the most relevant one and report the others if they matter.
210
- - **A "no match" result does NOT prove the commit had no work behind it.** Not every commit is linked to an explicit session: commits pushed directly (rather than via an ingested PR) and gaps in ingestion metadata mean the sha may simply not be recorded, and implicit sessions are never returned. Report "no linked session found for that sha" — never assert that no work exists behind the commit.
185
+ Use this workflow when the user asks which Polygraph session produced, is behind, or changed a particular commit — or a particular line of code.
186
+ **Read [`reference/session-by-commit.md`](reference/session-by-commit.md) before running any lookup.** That reference file holds clear, reliable steps for answering questions related to this.
211
187
 
212
188
  ## Agent roles
213
189
 
@@ -284,7 +260,7 @@ Use Multi-turn when the child may need clarification, the task is exploratory, o
284
260
 
285
261
  Your MCP client supports the native permission dialog. When a child agent requests permission, the dialog renders directly in your UI and the user picks — the decision routes back through `polygraph-mcp` automatically.
286
262
 
287
- **Critical: do NOT call `allow_agent` or `deny_agent` yourself.** If `show_agent` (or `cloud_polygraph_child_status`) briefly reports a child in `permission-required` state with `pendingPermission` populated, that is a transient state the dialog is in the middle of resolving. Your job is to keep polling — the next poll will see the child back in `in-progress` (or `failed` / `cancelled` if the user denied or dismissed).
263
+ **Critical: do NOT call `allow_agent` or `deny_agent` yourself.** If `show_agent` briefly reports a child in `permission-required` state with `pendingPermission` populated, that is a transient state the dialog is in the middle of resolving. Your job is to keep polling — the next poll will see the child back in `in-progress` (or `failed` / `cancelled` if the user denied or dismissed).
288
264
 
289
265
  If you call `allow_agent` while the dialog is already open, you create a race: the user's pick lands first and the explicit allow fails with `Task <id> is in state 'completed', not 'permission-required'`. The child receives the user's choice; your call is wasted work.
290
266
 
@@ -296,30 +272,16 @@ The `allow_agent` and `deny_agent` tools exist for parents whose MCP clients do
296
272
 
297
273
  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), and `associate_pr` (link PRs created outside Polygraph).
298
274
 
299
- **Whenever you push a branch, create or associate a PR, or mark PRs ready, read [`reference/publish-changes.md`](reference/publish-changes.md) first.** That reference file holds the full flow: parameters and examples for each tool, `push_branch` local-checkout semantics, the PR title format rules, and the session-URL printing steps. `push_branch`, `create_pr`, and `associate_pr` all require a `description` following the Session Description Policy below.
275
+ **Whenever you push a branch, create or associate a PR, or mark PRs ready, read [`reference/publish-changes.md`](reference/publish-changes.md) first.** That reference file holds the full flow.
300
276
 
301
277
  ### Session Description Policy
302
278
 
303
279
  `description` is user-facing Polygraph session context. It is required for `push_branch`, `create_pr`, and `associate_pr`, and is the primary input to `update_session` (`mark_pr_ready` does not take a description).
304
280
 
305
- **Whenever you write or update a session description, read [`reference/session-description.md`](reference/session-description.md) first.** That reference file holds the full policy: the canonical Markdown-heading template (`## Goal` / `## Current progress` / `## What worked` / `## Next steps`), the dual-audience guidance (humans in the web UI now, agents reconstructing history later), and the formatting building blocks the app renders (callouts, tables, mermaid, links, `link_reference`).
306
-
307
- ### Get Current Polygraph Session
281
+ **Whenever you write or update a session description, read [`reference/session-description.md`](reference/session-description.md) first.** That reference file holds the full policy.
308
282
 
309
- Check the details of a session using `show_session` or `polygraph session show --details <session-id>`. Returns the full session state basic metadata like id, url & description timeline, plus the connected repositories, `pullRequests[]`, per-PR `ciStatus`, and `session.linkedReferences`.
310
-
311
- **Parameters:**
312
-
313
- - `sessionId` (required): The Polygraph session ID
314
-
315
- **CI status rules:**
316
-
317
- - `ciStatus[prId].cipeUrl` (null if no CIPE) is a human-facing Nx Cloud web link — display it to the user, but never fetch, curl, or poll it directly; CIPE data is only accessible programmatically via the Nx MCP `ci_information` tool.
318
- - When no CIPE exists, external CI data (e.g., GitHub Actions) appears in `ciStatus[prId].externalCIRuns[]` as runs with nested `jobs[]`; each job's `jobId` is the input for `get_ci_logs`.
319
-
320
- ```
321
- show_session(sessionId: "<session-id>")
322
- ```
283
+ Use `update_session` directly when the user asks to summarize progress, update the session description, or capture the current state.
284
+ Be liberal about updating the session description when you make changes that affect the scope of the session, how logic flows between repos, or anything else important for posterity. Avoid updating it for small implementation details that are not relevant outside of this session. An up-to-date session description matters for maintainability.
323
285
 
324
286
  ### Linked References
325
287
 
@@ -333,55 +295,16 @@ Use `link_reference` to link an external reference to the current Polygraph sess
333
295
 
334
296
  When an external resource is mentioned during a Polygraph session and appears relevant to the current work, the parent agent should record it with `link_reference({ sessionId, reference })`. This applies to relevant external resources such as pull requests, GitHub issues, other Polygraph sessions, and Linear issues.
335
297
 
336
- Invoke the MCP tool with a single object containing `{ sessionId, reference }`. For example, to record a relevant pull request:
337
-
338
- ```
339
- link_reference({
340
- sessionId: "<current-session-id>",
341
- reference: {
342
- type: "github_pr",
343
- url: "https://github.com/nrwl/polygraph-skills/pull/123",
344
- label: "Implementation PR"
345
- }
346
- })
347
- ```
348
-
349
- To record a relevant Polygraph session, use the same invocation shape and include `reference.sessionId`:
350
-
351
- ```
352
- link_reference({
353
- sessionId: "<current-session-id>",
354
- reference: {
355
- type: "session",
356
- url: "https://polygraph.example/s/<inspected-session-id>",
357
- label: "Inspected Polygraph session",
358
- sessionId: "<inspected-session-id>"
359
- }
360
- })
361
- ```
362
-
363
- The canonical MCP parameters are `{ sessionId, reference }`. There is no unlink command.
298
+ The canonical MCP parameters are `{ sessionId, reference }`. There is no unlink command; `show_session` returns a session's existing links as `session.linkedReferences`.
364
299
 
365
300
  ### Add Repositories to a Session
366
301
 
367
302
  Use `add_repo` to add repositories to an existing Polygraph session after it has already started.
368
303
 
369
- **Direct-add rule:** When the user provides exact repo refs by ID, short name, full name, GitHub `owner/repo` slug, or URL-like slug, pass those refs directly to `add_repo` and do not call `list_repos` first. Candidate discovery remains account-repo-only and is only for cases where the user does not know the exact repo or asks to choose/filter candidates.
304
+ **Direct-add rule:** When the user provides exact repo refs by ID, short name, full name, GitHub `owner/repo` slug, or URL-like slug, pass those refs directly to `add_repo` and do not call `list_repos` first. Candidate discovery is only for cases where the user does not know the exact repo.
370
305
 
371
306
  **Not limited to your organization:** repos outside the org — including public open-source repos — can be added by GitHub `owner/repo` slug or URL. Only `list_repos` discovery is org-scoped, so a repo missing from `list_repos` can still be added directly.
372
307
 
373
- **Parameters:**
374
-
375
- - `sessionId` (required): The Polygraph session ID
376
- - `repoIds` (required): Repository IDs or exact repository refs to add. Accepts IDs, short names, full names, GitHub `owner/repo` slugs, and URL-like slugs.
377
-
378
- ```
379
- add_repo(
380
- sessionId: "<session-id>",
381
- repoIds: ["org/repo-name", "facebook/react"]
382
- )
383
- ```
384
-
385
308
  ### Archive Session
386
309
 
387
310
  **IMPORTANT: Only call this tool when the user explicitly asks to archive or close the session.** Do not archive sessions automatically as part of the workflow.
@@ -402,14 +325,6 @@ When you need to fetch and read a failed job's log, read [`reference/ci-job-logs
402
325
 
403
326
  Session repos are shallow (`--depth 1`) clones. When git fails on missing history (`bad object` from `git revert`, `git log`, `git blame`, etc.), call `git_fetch({ sessionId, repo })` and retry. Read [`reference/shallow-clone-history.md`](reference/shallow-clone-history.md) for the CLI form, the `depth`/`refs` options, and the redundant-call behavior.
404
327
 
405
- ### Update Session Description
406
-
407
- Use this when the user asks to summarize progress, update the session description, or capture the current state.
408
-
409
- Read [`reference/session-description.md`](reference/session-description.md) for the full update procedure (what to read before writing, how to append vs. replace) and the canonical Markdown-heading format. Then call `update_session` with the resulting summary as `description`.
410
-
411
- Be liberal about updating the session description when you make changes that affect the scope of the session, how logic flows between repos, or anything else important for posterity. Avoid updating it for small implementation details that are not relevant outside of this session. An up-to-date session description matters for maintainability.
412
-
413
328
  ### Print Polygraph Session Details
414
329
 
415
330
  When asked to print polygraph session details, use `show_session` or `polygraph session show --details <session-id>` and display in the following format.
@@ -430,7 +345,7 @@ If the session has a description timeline, also display:
430
345
  - PR_URL, PR_TITLE, PR_STATUS: from `pullRequests[]`
431
346
  - CI_STATUS: from `ciStatus[prId].status`
432
347
  - SELF_HEALING_STATUS: from `ciStatus[prId].selfHealingStatus` (omit or show `-` if null)
433
- - CIPE_URL: from `ciStatus[prId].cipeUrl`
348
+ - CIPE_URL: from `ciStatus[prId].cipeUrl` (null if no CIPE — omit the CI Link cell) — a human-facing Nx Cloud link: render it for the user, never fetch, curl, or poll it. CIPE data is only reachable via the Nx MCP `ci_information` tool.
434
349
  - POLYGRAPH_SESSION_URL: from `polygraphSessionUrl`
435
350
  - SESSION_DESCRIPTION: from the latest/current item in `description`
436
351
 
@@ -446,7 +361,3 @@ If the session has a description timeline, also display:
446
361
  1. **Coordinate merge order** if there are deployment dependencies
447
362
 
448
363
  1. **Use `stop_agent` to clean up** — Stop child agents that are stuck or no longer needed (pass `role` to target a non-default agent). The child's session is preserved (`sessionPreserved: true`) so the context can be restored later, but after resuming you must wait for explicit user instructions before making changes.
449
- 1. **Only archive sessions when asked** — Only call `archive_session` when the user explicitly requests it. Archiving hides the session from active lists; it can still be resumed later.
450
-
451
- 1. **Respect the sandbox** — When a command fails with a sandbox denial (`EPERM` binding a port, blocked host, denied write), stop instead of retrying and point the user to the options in "Sandboxing in Polygraph Sessions": commit harness sandbox settings to the repo, or toggle sandboxing via `polygraph config`.
452
-
@@ -6,6 +6,8 @@ The branch-to-PR flow: push branches, create draft PRs, mark them ready, and ass
6
6
 
7
7
  Once work is complete in a repository, push the branch using `push_branch`. This must be done before creating a PR.
8
8
 
9
+ **If `push_branch` fails, don't guess at the cause.** Report the tool's actual error to the user first. Only once the real cause is clear, offer to fall back to a manual `git push` — let the user decide, rather than falling back unprompted or asserting what a credential/token can or cannot do.
10
+
9
11
  `push_branch` pushes from the local checkout: for the repo you are in, that is your current working directory with your commits; for delegated repos, it is the Polygraph-managed clone the child agent worked in. There is no separate session copy of the current repo.
10
12
 
11
13
  **Parameters:**
@@ -0,0 +1,25 @@
1
+ # Finding the Session Behind a Commit or Line
2
+
3
+ Use this workflow when the user asks which Polygraph session produced, is behind, or changed a particular commit — or a particular line of code.
4
+
5
+ **Given a commit sha.** When the user names a sha, or asks what session is behind a commit, resolve it with `search_sessions` using the `sha` parameter (CLI: `polygraph session search --sha <sha>`):
6
+
7
+ - Pass **exactly one** of `query` or `sha` — they are mutually exclusive.
8
+ - `sha` accepts a full or partial sha, 7-40 hex chars.
9
+ - The lookup is exact and one-shot: it returns the session(s) linked to that commit, newest first, scoped to the current org, and only explicit sessions.
10
+
11
+ ```
12
+ search_sessions(sha: "a1b2c3d")
13
+ # CLI equivalent:
14
+ polygraph session search --sha a1b2c3d
15
+ ```
16
+
17
+ **Given a line number.** There is no line-number lookup — a line MUST first be resolved to a commit sha with `git blame`, then that sha is fed into the sha lookup:
18
+
19
+ 1. `git blame -L <line>,<line> -- <file>` to get the commit that last touched the line.
20
+ 2. Pass that sha to `search_sessions(sha: ...)` (or `polygraph session search --sha <sha>`).
21
+
22
+ **Reading the results.**
23
+
24
+ - Multiple sessions may match a sha. They come back newest first — pick the most relevant one and report the others if they matter.
25
+ - **A "no match" result does NOT prove the commit had no work behind it.** Not every commit is linked to an explicit session: commits pushed directly (rather than via an ingested PR) and gaps in ingestion metadata mean the sha may simply not be recorded, and implicit sessions are never returned. Report "no linked session found for that sha" — never assert that no work exists behind the commit.
@@ -1,294 +0,0 @@
1
- // SessionStart hook — checks whether the installed Polygraph plugin is
2
- // outdated and, when it is, emits a single stdout message so the agent
3
- // surfaces the problem to the user. Stale plugin versions have silently
4
- // caused incorrect Polygraph behavior in the past; this makes it visible
5
- // for agent launches that bypass the polygraph CLI (e.g. desktop apps).
6
- //
7
- // Unlike the sibling hooks, this one deliberately writes to stdout — but
8
- // ONLY when the plugin is outdated. When current, unknown, offline, or on
9
- // any error it prints nothing and exits 0.
10
- //
11
- // The harness ('claude' | 'codex') is passed as the first CLI argument so
12
- // the same script ships in both plugin artifacts.
13
-
14
- import {
15
- appendFileSync,
16
- mkdirSync,
17
- readFileSync,
18
- realpathSync,
19
- renameSync,
20
- statSync,
21
- writeFileSync,
22
- } from 'node:fs';
23
- import { homedir } from 'node:os';
24
- import { dirname, join } from 'node:path';
25
- import { fileURLToPath } from 'node:url';
26
-
27
- const HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024;
28
- const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
29
- const FETCH_TIMEOUT_MS = 3000;
30
- const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
31
-
32
- const PACKAGE_BY_HARNESS = {
33
- claude: '@polygraph/claude-plugin',
34
- codex: '@polygraph/codex-plugin',
35
- };
36
-
37
- const REMEDIATION_BY_HARNESS = {
38
- claude: 'run `claude plugins update polygraph@polygraph-plugins`',
39
- codex:
40
- 'run `npx --prefer-online @polygraph/codex-plugin@latest install` then `codex plugin add polygraph@polygraph-plugins`',
41
- };
42
-
43
- // Append a one-line JSON record of a hook failure to ~/.polygraph/logs/hooks.log.
44
- // This hook swallows its errors silently, so this on-disk log is the only
45
- // record that something went wrong. The logger is itself failure-proof.
46
- function logHookFailure(
47
- hook,
48
- error,
49
- meta = {},
50
- home = process.env.HOME?.trim() || homedir()
51
- ) {
52
- try {
53
- const logsDir = join(home, '.polygraph', 'logs');
54
- mkdirSync(logsDir, { recursive: true });
55
- const logFile = join(logsDir, 'hooks.log');
56
-
57
- try {
58
- if (statSync(logFile).size > HOOK_LOG_MAX_BYTES) {
59
- renameSync(logFile, `${logFile}.1`);
60
- }
61
- } catch {
62
- // no prior log, or rotation failed — ignore
63
- }
64
-
65
- const entry = {
66
- time: new Date().toISOString(),
67
- hook,
68
- pid: process.pid,
69
- ...meta,
70
- error: error instanceof Error ? error.message : String(error),
71
- ...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
72
- };
73
- appendFileSync(logFile, JSON.stringify(entry) + '\n');
74
- } catch {
75
- // Logging must never throw — a failing logger must not break the hook.
76
- }
77
- }
78
-
79
- function tryParseJson(str) {
80
- try {
81
- return JSON.parse(str);
82
- } catch {
83
- return null;
84
- }
85
- }
86
-
87
- function parseSemver(version) {
88
- if (typeof version !== 'string') return null;
89
- const match = version
90
- .trim()
91
- .match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
92
- if (!match) return null;
93
- return {
94
- major: Number(match[1]),
95
- minor: Number(match[2]),
96
- patch: Number(match[3]),
97
- prerelease: match[4] ? match[4].split('.') : [],
98
- };
99
- }
100
-
101
- // Returns -1, 0, or 1 when a is lower than, equal to, or higher than b.
102
- // Returns null when either version is unparseable.
103
- export function compareSemver(a, b) {
104
- const pa = parseSemver(a);
105
- const pb = parseSemver(b);
106
- if (!pa || !pb) return null;
107
-
108
- for (const key of ['major', 'minor', 'patch']) {
109
- if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1;
110
- }
111
-
112
- // Same core version: a prerelease sorts below a release.
113
- if (pa.prerelease.length && !pb.prerelease.length) return -1;
114
- if (!pa.prerelease.length && pb.prerelease.length) return 1;
115
-
116
- const len = Math.max(pa.prerelease.length, pb.prerelease.length);
117
- for (let i = 0; i < len; i++) {
118
- const ia = pa.prerelease[i];
119
- const ib = pb.prerelease[i];
120
- if (ia === undefined) return -1;
121
- if (ib === undefined) return 1;
122
- if (ia === ib) continue;
123
- const na = /^\d+$/.test(ia) ? Number(ia) : null;
124
- const nb = /^\d+$/.test(ib) ? Number(ib) : null;
125
- if (na !== null && nb !== null) return na < nb ? -1 : 1;
126
- if (na !== null) return -1; // numeric identifiers sort below alphanumeric
127
- if (nb !== null) return 1;
128
- return ia < ib ? -1 : 1;
129
- }
130
- return 0;
131
- }
132
-
133
- // Resolve the installed plugin version from the manifest shipped alongside
134
- // this script: <pluginRoot>/hooks/check-plugin-version.mjs sits next to
135
- // .claude-plugin/plugin.json (Claude), .codex-plugin/plugin.json (Codex),
136
- // or package.json.
137
- export function resolveInstalledVersion(pluginRoot) {
138
- const manifests = [
139
- join(pluginRoot, '.claude-plugin', 'plugin.json'),
140
- join(pluginRoot, '.codex-plugin', 'plugin.json'),
141
- join(pluginRoot, 'package.json'),
142
- ];
143
- for (const manifestPath of manifests) {
144
- let raw;
145
- try {
146
- raw = readFileSync(manifestPath, 'utf8');
147
- } catch {
148
- continue;
149
- }
150
- const parsed = tryParseJson(raw);
151
- if (parsed && parseSemver(parsed.version)) return parsed.version.trim();
152
- }
153
- return null;
154
- }
155
-
156
- function cachePath(harness, home) {
157
- return join(home, '.polygraph', 'logs', `plugin-version-check-${harness}.json`);
158
- }
159
-
160
- export function readCache(harness, home) {
161
- try {
162
- return tryParseJson(readFileSync(cachePath(harness, home), 'utf8'));
163
- } catch {
164
- return null;
165
- }
166
- }
167
-
168
- // A cache entry is only trusted when it is recent, was recorded for the
169
- // currently installed version (updating the plugin invalidates it), and holds
170
- // either a parseable latest version or null (a negatively-cached failed
171
- // fetch, so an offline machine does not re-stall on every session start).
172
- export function isCacheFresh(cache, installed, now) {
173
- return Boolean(
174
- cache &&
175
- Number.isFinite(cache.checkedAt) &&
176
- now - cache.checkedAt >= 0 &&
177
- now - cache.checkedAt < CACHE_MAX_AGE_MS &&
178
- cache.installed === installed &&
179
- (cache.latest === null || parseSemver(cache.latest))
180
- );
181
- }
182
-
183
- function writeCache(harness, home, entry) {
184
- const path = cachePath(harness, home);
185
- mkdirSync(dirname(path), { recursive: true });
186
- const tmpPath = `${path}.tmp-${process.pid}`;
187
- writeFileSync(tmpPath, JSON.stringify(entry) + '\n');
188
- renameSync(tmpPath, path);
189
- }
190
-
191
- async function fetchLatestVersion(packageName, fetchImpl) {
192
- const registry = (process.env.npm_config_registry?.trim() || DEFAULT_REGISTRY)
193
- .replace(/\/+$/, '');
194
- const url = `${registry}/-/package/${packageName.replace('/', '%2f')}/dist-tags`;
195
- const response = await fetchImpl(url, {
196
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
197
- });
198
- if (!response.ok) throw new Error(`registry responded ${response.status}`);
199
- const distTags = await response.json();
200
- return typeof distTags?.latest === 'string' ? distTags.latest : null;
201
- }
202
-
203
- export function buildOutdatedMessage(harness, installed, latest) {
204
- return (
205
- `The Polygraph plugin is outdated: ${installed} installed, ${latest} latest. ` +
206
- 'Stale plugin versions cause incorrect Polygraph behavior. ' +
207
- `Tell the user to update it now: ${REMEDIATION_BY_HARNESS[harness]} ` +
208
- '(or re-run `polygraph config`), then restart the agent session.'
209
- );
210
- }
211
-
212
- /**
213
- * Check whether the installed plugin is outdated.
214
- *
215
- * @param {object} opts
216
- * @param {string} opts.harness 'claude' | 'codex'
217
- * @param {string} opts.pluginRoot Directory containing the plugin manifest.
218
- * @param {string} [opts.home] Override HOME for testing.
219
- * @param {Function} [opts.fetchImpl] Override fetch for testing.
220
- * @param {number} [opts.now] Override the clock for testing.
221
- * @returns {Promise<string|null>} The message to emit, or null to stay silent.
222
- */
223
- export async function checkPluginVersion({
224
- harness,
225
- pluginRoot,
226
- home = process.env.HOME?.trim() || homedir(),
227
- fetchImpl = fetch,
228
- now = Date.now(),
229
- }) {
230
- const packageName = PACKAGE_BY_HARNESS[harness];
231
- if (!packageName) return null;
232
-
233
- const installed = resolveInstalledVersion(pluginRoot);
234
- if (!installed) return null;
235
-
236
- let latest;
237
- const cache = readCache(harness, home);
238
- if (isCacheFresh(cache, installed, now)) {
239
- if (cache.latest === null) return null;
240
- latest = cache.latest;
241
- } else {
242
- try {
243
- latest = await fetchLatestVersion(packageName, fetchImpl);
244
- } catch (error) {
245
- // Negative cache: remember the failed fetch so an offline machine
246
- // does not re-stall for the fetch timeout on every session start.
247
- writeCache(harness, home, { checkedAt: now, installed, latest: null });
248
- throw error;
249
- }
250
- if (!parseSemver(latest)) {
251
- writeCache(harness, home, { checkedAt: now, installed, latest: null });
252
- return null;
253
- }
254
- writeCache(harness, home, { checkedAt: now, installed, latest });
255
- }
256
-
257
- if (compareSemver(installed, latest) === -1) {
258
- return buildOutdatedMessage(harness, installed, latest);
259
- }
260
- return null;
261
- }
262
-
263
- export async function main() {
264
- const harness = process.argv[2];
265
- try {
266
- const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
267
- const message = await checkPluginVersion({ harness, pluginRoot });
268
- if (message) process.stdout.write(message + '\n');
269
- } catch (error) {
270
- // Offline or broken registry must never block or pollute the session,
271
- // but record it so failures are not invisible.
272
- logHookFailure(`${harness || 'unknown'}:check-plugin-version`, error);
273
- }
274
- process.exitCode = 0;
275
- }
276
-
277
- // Run only when executed directly as a hook, not when imported (e.g. by tests).
278
- // realpathSync both sides so the check holds when the plugin lives under a
279
- // symlinked path (e.g. macOS /tmp -> /private/tmp).
280
- function isMainModule() {
281
- if (!process.argv[1]) return false;
282
- try {
283
- return (
284
- realpathSync(process.argv[1]) ===
285
- realpathSync(fileURLToPath(import.meta.url))
286
- );
287
- } catch {
288
- return false;
289
- }
290
- }
291
-
292
- if (isMainModule()) {
293
- main();
294
- }