@polygraph/claude-plugin 0.4.37 → 0.4.38

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.37",
3
+ "version": "0.4.38",
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,294 @@
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
+ }
package/hooks/hooks.json CHANGED
@@ -24,6 +24,15 @@
24
24
  "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/record-session-mapping.mjs claude"
25
25
  }
26
26
  ]
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
+ ]
27
36
  }
28
37
  ]
29
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polygraph/claude-plugin",
3
- "version": "0.4.37",
3
+ "version": "0.4.38",
4
4
  "description": "AI agent skills and subagents for Polygraph sessions, repository context, and coordination",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -14,14 +14,22 @@ You produce debriefs of PAST Polygraph sessions so a parent agent working on a N
14
14
 
15
15
  ## Procedure
16
16
 
17
- For each session, in rank order:
17
+ Speed matters: the parent agent keeps working while it waits for you, and it folds your debrief in whenever it lands.
18
18
 
19
- 1. `polygraph session show --details <sessionId>` metadata, description timeline, repositories, PRs.
20
- 2. `polygraph session logs -s <sessionId> --json` — the full parent transcript (`--json` returns the full transcript by default).
21
- 3. If the session delegated work to other repositories (child-agent steps exist), pull those transcripts too: `polygraph session logs -s <sessionId> --all --json`, or `--repo <org/repo> --json` for one repo.
22
- 4. Write the debrief section (format below) before moving to the next session.
19
+ **Single session (the usual case).** The parent normally launches one background debrief agent per related session, so your input will usually contain exactly one session. Run the "Debriefing one session" steps directlydo not spawn a subagent for a single session.
23
20
 
24
- Large transcripts: if the full `--json` output is too large to hold, page with `--tail 200 --page <n>` and prioritize, in order: user prompts, assistant text and final messages, tool errors and failure events, task notifications. Routine tool-use noise (file reads, searches) is safe to skim.
21
+ **Multiple sessions: fan out.** When given more than one session, debrief them CONCURRENTLY, never one after another: spawn one subagent per session, all in a single message so they run in parallel. Give each subagent: its session entry (sessionId, title, url, rank), the current-task statement, the "Debriefing one session" steps, and the per-session output template — copied into its prompt, since it cannot see this skill. Each subagent returns its completed debrief section as its final message. Assemble the returned sections in rank order and return them; do not rewrite them. Only if your environment cannot spawn subagents, run the "Debriefing one session" steps yourself, sequentially in rank order.
22
+
23
+ ### Debriefing one session
24
+
25
+ Invoke the CLI as `${POLYGRAPH_CLI:-polygraph}` in every command: when the session was launched from a specific CLI build, `POLYGRAPH_CLI` points at it and children must use the same one. When you copy these steps into a subagent prompt, keep the `${POLYGRAPH_CLI:-polygraph}` form verbatim.
26
+
27
+ 1. `${POLYGRAPH_CLI:-polygraph} session show --details <sessionId>` — metadata, description timeline, repositories, PRs. The description timeline often already summarizes goals and outcomes; mine it before reading transcripts.
28
+ 2. Duplicate-work check: if the metadata shows this session pursuing the SAME task as the current one (not merely related work) and it is unfinished or recently active, do NOT read the transcripts. Return the debrief section immediately, with `**DUPLICATE WORK IN FLIGHT**` as the first line after the heading, followed by the session's status and last activity, one line of evidence for the match, and what resuming it would restore. The parent halts and asks the user to choose between resuming that session and continuing the current one, so speed matters more than depth here.
29
+ 3. `${POLYGRAPH_CLI:-polygraph} session logs -s <sessionId> --all --tail none > "$TMPDIR/<sessionId>-logs.txt" 2>&1` — the parent transcript plus every child transcript, rendered as plain text, in ONE call. Then read the file directly (with offsets for large files). Do NOT fetch `--json` and do NOT query the transcript with node/python one-liners — reading the rendered text is faster and you extract while reading.
30
+ 4. Write the debrief section (format below).
31
+
32
+ Large transcripts: read the file in a few large chunks, prioritizing user prompts, assistant text and final messages, tool errors and failure events, and task notifications. Routine tool-use noise (file reads, searches) is safe to skim. Do not make repeated small queries against the transcript; each round trip costs more than reading a bigger chunk.
25
33
 
26
34
  ## Output
27
35
 
@@ -29,6 +37,7 @@ Return ONE consolidated debrief as your final message — it is consumed by the
29
37
 
30
38
  ### Rank N — <session title> (<sessionId>)
31
39
 
40
+ **DUPLICATE WORK IN FLIGHT** — only when the duplicate-work check fired: status, last activity, one-line evidence. Omit this line otherwise.
32
41
  **URL:** <session url>
33
42
  **Goal:** what the session set out to do.
34
43
  **What happened:** condensed narrative of the work performed.
@@ -44,3 +53,5 @@ Return ONE consolidated debrief as your final message — it is consumed by the
44
53
  - If a session's logs are hidden or unavailable, say which (`hidden: true` in the CLI output means hidden by the author; empty steps mean no logs uploaded) and debrief from `session show --details` metadata, description timeline, and PRs alone.
45
54
  - No speculation: when the transcript does not show why a decision was made, write "rationale not recorded".
46
55
  - Read-only: the inspected sessions must be byte-for-byte unaffected by your work.
56
+ - Session data only: debrief from what the CLI and MCP tools return (metadata, description timeline, transcripts, PRs). Do NOT read repository code, run git or gh, or fetch PR diffs to verify claims — report what the session shows and leave verification to the parent.
57
+ - Fail fast: if a CLI command errors, retry it once at most, then report the error verbatim in your debrief section and move on. Do not build workarounds (no copying auth/config to a fake HOME, no POLYGRAPH_ROOT redirection, no privilege or sandbox escapes) — a debrief that says "logs unavailable: <error>" is more useful than one that arrives late.