@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.
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/agent-session-link.mjs +146 -0
- package/hooks/hooks.json +12 -9
- package/hooks/record-session-mapping.mjs +34 -243
- package/package.json +1 -1
- package/skills/polygraph/SKILL.md +25 -114
- package/skills/polygraph/reference/publish-changes.md +2 -0
- package/skills/polygraph/reference/session-by-commit.md +25 -0
- package/hooks/check-plugin-version.mjs +0 -294
|
@@ -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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
10
|
+
function readPayload() {
|
|
94
11
|
try {
|
|
95
|
-
|
|
12
|
+
const raw = readFileSync(0, 'utf8');
|
|
13
|
+
return raw ? JSON.parse(raw) : undefined;
|
|
96
14
|
} catch {
|
|
97
|
-
return
|
|
15
|
+
return undefined;
|
|
98
16
|
}
|
|
99
17
|
}
|
|
100
18
|
|
|
101
|
-
function
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
114
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
@@ -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
|
|
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*
|
|
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
|
|
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
|
|
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
|
|
83
|
-
1. **Delegate work to each repo** - Use the `polygraph-delegate-subagent` to start child agents
|
|
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. **
|
|
89
|
-
8. **
|
|
90
|
-
9. **
|
|
91
|
-
10. **
|
|
92
|
-
11. **
|
|
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
|
|
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
|
|
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`
|
|
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`
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|