pstack-claude-mirror 0.9.133 → 0.9.134

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.
Files changed (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGES.md +8 -0
  3. package/README.md +23 -7
  4. package/UPSTREAM-README.md +6 -0
  5. package/bin/pstack-claude.mjs +278 -130
  6. package/docs/reference.md +4 -4
  7. package/package.json +27 -9
  8. package/plugins/pstack/.antigravity-plugin/plugin.json +1 -1
  9. package/plugins/pstack/.claude-plugin/plugin.json +1 -1
  10. package/plugins/pstack/.codex-plugin/plugin.json +1 -1
  11. package/plugins/pstack/.github/plugin/plugin.json +1 -1
  12. package/plugins/pstack/.grok-plugin/plugin.json +1 -1
  13. package/plugins/pstack/.muse-plugin/plugin.json +1 -1
  14. package/plugins/pstack/LICENSE +22 -0
  15. package/plugins/pstack/hooks/copilot-hooks.json +4 -2
  16. package/plugins/pstack/hooks/session-start.ps1 +76 -12
  17. package/plugins/pstack/hooks/session-start.sh +6 -0
  18. package/plugins/pstack/models.json +1 -1
  19. package/plugins/pstack/package.json +1 -1
  20. package/plugins/pstack/pi/slash.ts +19 -5
  21. package/plugins/pstack/skills/poteto-mode/SKILL.md +1 -0
  22. package/plugins/pstack/skills/poteto-mode/playbooks/bug-fix.md +17 -0
  23. package/plugins/pstack/skills/poteto-mode/references/agy-tools.md +5 -4
  24. package/plugins/pstack/skills/poteto-mode/references/codex-tools.md +3 -2
  25. package/plugins/pstack/skills/poteto-mode/references/copilot-tools.md +2 -1
  26. package/plugins/pstack/skills/poteto-mode/references/pi-tools.md +1 -1
  27. package/plugins/pstack/skills/poteto-mode/scripts/.prettierignore +1 -1
  28. package/plugins/pstack/skills/poteto-mode/scripts/codex-session-effort.mjs +35 -7
  29. package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.port.test.ts +427 -0
  30. package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts +3 -399
  31. package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.port.test.ts +708 -0
  32. package/plugins/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts +7 -666
  33. package/plugins/pstack/skills/setup-pstack/SKILL.md +6 -4
  34. package/plugins/pstack/skills/setup-pstack/references/runtimes.md +1 -1
  35. package/tools/forks.json +5 -5
@@ -3,7 +3,7 @@
3
3
  // defaults, another thread, or a completed turn. Output contains no transcript.
4
4
  import { createReadStream, existsSync, readdirSync, realpathSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
- import { join } from "node:path";
6
+ import { isAbsolute, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
9
  const levels = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
@@ -40,20 +40,48 @@ export async function activeEffort(stream, threadId) {
40
40
  return effort;
41
41
  }
42
42
 
43
- export async function sessionEffort(threadId = process.env.CODEX_THREAD_ID, codexHome = process.env.CODEX_HOME) {
44
- if (!threadId || !/^[a-zA-Z0-9_-]+$/.test(threadId)) throw new Error("CODEX_THREAD_ID is unavailable or invalid");
45
- const root = join(codexHome || join(homedir(), ".codex"), "sessions");
43
+ async function currentRollout(threadId, home) {
44
+ const databases = readdirSync(home).filter((name) => /^state_\d+\.sqlite$/.test(name));
45
+ if (databases.length > 1) throw new Error("current thread state database is ambiguous");
46
+ const sqlite = databases.length === 1 ? await import("node:sqlite").catch(() => null) : null;
47
+ if (sqlite) {
48
+ const db = new sqlite.DatabaseSync(join(home, databases[0]), { readOnly: true });
49
+ try {
50
+ const row = db.prepare("SELECT rollout_path FROM threads WHERE id = ?").get(threadId);
51
+ if (row) {
52
+ const path = row.rollout_path;
53
+ if (typeof path !== "string" || !isAbsolute(path) || !path.endsWith(".jsonl")) {
54
+ throw new Error("current thread runtime log is unavailable");
55
+ }
56
+ return path;
57
+ }
58
+ } finally {
59
+ db.close();
60
+ }
61
+ }
62
+
63
+ const root = join(home, "sessions");
46
64
  const matches = [];
65
+ const suffix = new RegExp(`-${threadId}(?:_[a-zA-Z0-9-]+)?\\.jsonl(?:\\.zst)?$`);
47
66
  const walk = (directory, depth = 0) => {
48
67
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
49
68
  const path = join(directory, entry.name);
50
- if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(`-${threadId}.jsonl`)) matches.push(path);
69
+ if (entry.isFile() && entry.name.startsWith("rollout-") && suffix.test(entry.name)) matches.push(path);
51
70
  else if (entry.isDirectory() && depth < 3) walk(path, depth + 1);
52
71
  }
53
72
  };
54
73
  walk(root);
55
- if (matches.length !== 1) throw new Error("current thread runtime log is missing or ambiguous");
56
- return activeEffort(createReadStream(matches[0], { encoding: "utf8" }), threadId);
74
+ if (matches.length !== 1 || !matches[0].endsWith(`-${threadId}.jsonl`)) {
75
+ throw new Error("current thread runtime log is missing or ambiguous");
76
+ }
77
+ return matches[0];
78
+ }
79
+
80
+ export async function sessionEffort(threadId = process.env.CODEX_THREAD_ID, codexHome = process.env.CODEX_HOME) {
81
+ if (!threadId || !/^[a-zA-Z0-9_-]+$/.test(threadId)) throw new Error("CODEX_THREAD_ID is unavailable or invalid");
82
+ const home = codexHome || join(homedir(), ".codex");
83
+ const path = await currentRollout(threadId, home);
84
+ return activeEffort(createReadStream(path, { encoding: "utf8" }), threadId);
57
85
  }
58
86
 
59
87
  if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
@@ -0,0 +1,427 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { WatchDeadline } from "./deadline.ts";
3
+ import {
4
+ ChecksUnavailable,
5
+ WatcherQueryError,
6
+ discoverStack,
7
+ orderStack,
8
+ parseFastCheck,
9
+ parsePullRequest,
10
+ parseReviewThreadPage,
11
+ PR_FACTS_QUERY,
12
+ resolveChecks,
13
+ resolveContext,
14
+ resolveReviewThreads,
15
+ runJson,
16
+ } from "./github.ts";
17
+ import { fakeReader } from "./fakes.test-helper.ts";
18
+ import type { PrContext } from "./types.ts";
19
+ import { parsePrNumber } from "./types.ts";
20
+
21
+ const context = {
22
+ owner: "owner",
23
+ repo: "repo",
24
+ number: parsePrNumber(42),
25
+ };
26
+
27
+ const numbers = (stack: readonly PrContext[]): number[] =>
28
+ stack.map((pr) => Number(pr.number));
29
+
30
+ describe("checks fallback chain", () => {
31
+ it("reads no checks when gh reports none and the head commit has no rollup", async () => {
32
+ const reader = fakeReader({
33
+ fastPath: { kind: "none-reported" },
34
+ rollupPages: [{ kind: "no-rollup" }],
35
+ });
36
+ expect(await resolveChecks(reader, context)).toEqual({
37
+ kind: "no-checks",
38
+ checks: [],
39
+ });
40
+ expect(reader.calls).toEqual(["checksFastPath", "checkRollupPage:null"]);
41
+ });
42
+
43
+ it("fails closed when only one read says there are no checks", async () => {
44
+ const oneSided = [
45
+ {
46
+ fastPath: { kind: "unusable", exitCode: 1, stderr: "HTTP 401" },
47
+ rollupPages: [{ kind: "no-rollup" }],
48
+ },
49
+ {
50
+ fastPath: { kind: "checks", checks: [] },
51
+ rollupPages: [{ kind: "no-rollup" }],
52
+ },
53
+ {
54
+ fastPath: { kind: "none-reported" },
55
+ rollupPages: [{ kind: "contexts", checks: [], endCursor: null }],
56
+ },
57
+ ] as const;
58
+ for (const options of oneSided)
59
+ await expect(
60
+ resolveChecks(fakeReader(options), context)
61
+ ).rejects.toBeInstanceOf(ChecksUnavailable);
62
+ });
63
+
64
+ it("rejects a rollup cursor that does not advance instead of paging on", async () => {
65
+ const stuck = { kind: "contexts", checks: [], endCursor: "same" } as const;
66
+ const reader = fakeReader({
67
+ fastPath: { kind: "checks", checks: [] },
68
+ rollupPages: [
69
+ stuck,
70
+ stuck,
71
+ { kind: "contexts", checks: [], endCursor: null },
72
+ ],
73
+ });
74
+ await expect(resolveChecks(reader, context)).rejects.toMatchObject({
75
+ failure: {
76
+ kind: "missing-field",
77
+ retryable: true,
78
+ detail: expect.stringContaining("must advance"),
79
+ },
80
+ });
81
+ expect(reader.calls).toEqual([
82
+ "checksFastPath",
83
+ "checkRollupPage:null",
84
+ "checkRollupPage:same",
85
+ ]);
86
+ });
87
+
88
+ it("rejects a rollup cursor that returns to an earlier page after moving on", async () => {
89
+ const page = (endCursor: string | null) =>
90
+ ({ kind: "contexts", checks: [], endCursor }) as const;
91
+ const reader = fakeReader({
92
+ fastPath: { kind: "checks", checks: [] },
93
+ rollupPages: [page("a"), page("b"), page("a"), page(null)],
94
+ });
95
+ await expect(resolveChecks(reader, context)).rejects.toMatchObject({
96
+ failure: {
97
+ kind: "missing-field",
98
+ detail: expect.stringContaining("must advance"),
99
+ },
100
+ });
101
+ expect(reader.calls).toEqual([
102
+ "checksFastPath",
103
+ "checkRollupPage:null",
104
+ "checkRollupPage:a",
105
+ "checkRollupPage:b",
106
+ ]);
107
+ });
108
+
109
+ it("propagates a failed rollup query instead of reading it as no checks", async () => {
110
+ const reader = fakeReader({ fastPath: { kind: "none-reported" } });
111
+ const failure = new WatcherQueryError({
112
+ kind: "command-exit",
113
+ retryable: true,
114
+ code: 1,
115
+ detail: "HTTP 502",
116
+ });
117
+ reader.checkRollupPage = async () => {
118
+ throw failure;
119
+ };
120
+ await expect(resolveChecks(reader, context)).rejects.toBe(failure);
121
+ });
122
+ });
123
+
124
+ it("reports a binary that cannot be spawned as a query failure that does not retry", async () => {
125
+ await expect(
126
+ runJson(
127
+ ["/nonexistent/watch-pr-missing-gh", "pr", "view"],
128
+ new WatchDeadline(0, () => 0)
129
+ )
130
+ ).rejects.toMatchObject({
131
+ failure: {
132
+ kind: "spawn-failed",
133
+ retryable: false,
134
+ detail: expect.stringContaining("ENOENT"),
135
+ },
136
+ });
137
+ });
138
+
139
+ describe("fast-path check mapping", () => {
140
+ // gh's aggregate.go files every state it does not name into the pending
141
+ // bucket, including completed conclusions.
142
+ it("fails completed conclusions that gh buckets as pending", () => {
143
+ for (const state of ["STARTUP_FAILURE", "STALE"]) {
144
+ expect(
145
+ parseFastCheck({ name: "ci", state, bucket: "pending" })
146
+ ).toMatchObject({ kind: "failed", reportedState: state });
147
+ }
148
+ });
149
+
150
+ it("keeps in-flight states pending", () => {
151
+ for (const state of [
152
+ "EXPECTED",
153
+ "REQUESTED",
154
+ "WAITING",
155
+ "QUEUED",
156
+ "PENDING",
157
+ "IN_PROGRESS",
158
+ ]) {
159
+ expect(
160
+ parseFastCheck({ name: "ci", state, bucket: "pending" })
161
+ ).toMatchObject({ kind: "pending", reportedState: state });
162
+ }
163
+ });
164
+ });
165
+
166
+ describe("closed enum parsing", () => {
167
+ const rawPullRequest = {
168
+ mergeable: "MERGEABLE",
169
+ mergeStateStatus: "CLEAN",
170
+ reviewDecision: "APPROVED",
171
+ headRefOid: "head",
172
+ baseRef: { target: { oid: "base" } },
173
+ headRefName: "feature",
174
+ baseRefName: "main",
175
+ state: "OPEN",
176
+ mergedAt: null,
177
+ isDraft: false,
178
+ };
179
+
180
+ it("uses the current base target when the scalar base OID is stale", () => {
181
+ expect(
182
+ parsePullRequest(
183
+ {
184
+ ...rawPullRequest,
185
+ baseRefOid: "stale-base",
186
+ baseRef: { target: { oid: "current-base" } },
187
+ },
188
+ context
189
+ ).baseRefOid
190
+ ).toBe("current-base");
191
+ });
192
+
193
+ it("queries the current base target with the other PR facts", () => {
194
+ expect(PR_FACTS_QUERY).toContain("baseRef { target { oid } }");
195
+ expect(PR_FACTS_QUERY).not.toContain("baseRefOid");
196
+ });
197
+
198
+ it("fails closed when the current base ref is null or missing", () => {
199
+ const missingBaseRef = Object.fromEntries(
200
+ Object.entries(rawPullRequest).filter(([key]) => key !== "baseRef")
201
+ );
202
+ for (const value of [{ ...rawPullRequest, baseRef: null }, missingBaseRef])
203
+ expect(() => parsePullRequest(value, context)).toThrow(WatcherQueryError);
204
+ });
205
+ });
206
+
207
+ describe("review thread pagination", () => {
208
+ it("rejects a page that promises more pages without a cursor", () => {
209
+ for (const endCursor of [null, ""]) {
210
+ const response = {
211
+ data: {
212
+ repository: {
213
+ pullRequest: {
214
+ reviewThreads: {
215
+ pageInfo: { hasNextPage: true, endCursor },
216
+ nodes: [],
217
+ },
218
+ },
219
+ },
220
+ },
221
+ };
222
+ expect(() => parseReviewThreadPage(response)).toThrow(WatcherQueryError);
223
+ }
224
+ });
225
+
226
+ it("rejects a thread cursor that does not advance instead of paging on", async () => {
227
+ const stuck = { threads: [], endCursor: "same" };
228
+ const reader = fakeReader({
229
+ threadPages: [stuck, stuck, { threads: [], endCursor: null }],
230
+ });
231
+ await expect(resolveReviewThreads(reader, context)).rejects.toMatchObject({
232
+ failure: {
233
+ kind: "missing-field",
234
+ retryable: true,
235
+ detail: expect.stringContaining(
236
+ "reviewThreads.pageInfo.endCursor must advance"
237
+ ),
238
+ },
239
+ });
240
+ expect(reader.calls).toEqual([
241
+ "reviewThreadPage:null",
242
+ "reviewThreadPage:same",
243
+ ]);
244
+ });
245
+ });
246
+
247
+ describe("context and stack discovery", () => {
248
+ it("refuses to pair the checkout's PR number with a different explicit repository", async () => {
249
+ const reader = fakeReader({
250
+ current: { owner: "acme", repo: "web", number: parsePrNumber(57) },
251
+ });
252
+ const resolved = resolveContext({
253
+ reader,
254
+ owner: "acme",
255
+ repo: "api",
256
+ pr: null,
257
+ });
258
+ await expect(resolved).rejects.toBeInstanceOf(WatcherQueryError);
259
+ await expect(resolved).rejects.toMatchObject({
260
+ failure: { retryable: false },
261
+ });
262
+ });
263
+
264
+ it("accepts an explicit repository that matches the checkout's PR", async () => {
265
+ const reader = fakeReader({
266
+ current: { owner: "acme", repo: "web", number: parsePrNumber(57) },
267
+ });
268
+ expect(
269
+ await resolveContext({ reader, owner: "ACME", repo: "Web", pr: null })
270
+ ).toEqual({ owner: "ACME", repo: "Web", number: parsePrNumber(57) });
271
+ });
272
+
273
+ it("keeps a PR whose head is the default branch out of every stack", () => {
274
+ const repo = { owner: "owner", repo: "repo" };
275
+ const backport = parsePrNumber(2);
276
+ const open = [
277
+ {
278
+ number: backport,
279
+ headRepository: repo,
280
+ headRefName: "main",
281
+ baseRefName: "release",
282
+ },
283
+ {
284
+ number: context.number,
285
+ headRepository: repo,
286
+ headRefName: "feature",
287
+ baseRefName: "main",
288
+ },
289
+ {
290
+ number: parsePrNumber(43),
291
+ headRepository: repo,
292
+ headRefName: "upstack",
293
+ baseRefName: "feature",
294
+ },
295
+ ];
296
+ expect(numbers(orderStack(context, "main", open))).toEqual([42, 43]);
297
+ expect(
298
+ numbers(orderStack({ ...context, number: backport }, "main", open))
299
+ ).toEqual([2]);
300
+ });
301
+
302
+ it("keeps a PR that brings the default branch into a feature branch out of that feature's stack", () => {
303
+ const repo = { owner: "owner", repo: "repo" };
304
+ const sync = parsePrNumber(7);
305
+ const open = [
306
+ {
307
+ number: context.number,
308
+ headRepository: repo,
309
+ headRefName: "feature",
310
+ baseRefName: "main",
311
+ },
312
+ {
313
+ number: sync,
314
+ headRepository: repo,
315
+ headRefName: "main",
316
+ baseRefName: "feature",
317
+ },
318
+ ];
319
+ expect(numbers(orderStack(context, "main", open))).toEqual([42]);
320
+ expect(
321
+ numbers(orderStack({ ...context, number: sync }, "main", open))
322
+ ).toEqual([7]);
323
+ });
324
+
325
+ it("still stacks a fork PR whose head branch has the default branch's name", () => {
326
+ const fork = parsePrNumber(50);
327
+ const open = [
328
+ {
329
+ number: context.number,
330
+ headRepository: { owner: "owner", repo: "repo" },
331
+ headRefName: "feature",
332
+ baseRefName: "main",
333
+ },
334
+ {
335
+ number: fork,
336
+ headRepository: { owner: "contributor", repo: "repo" },
337
+ headRefName: "main",
338
+ baseRefName: "feature",
339
+ },
340
+ ];
341
+ expect(numbers(orderStack(context, "main", open))).toEqual([42, 50]);
342
+ expect(
343
+ numbers(orderStack({ ...context, number: fork }, "main", open))
344
+ ).toEqual([42, 50]);
345
+ });
346
+
347
+ it("stops at the default branch when it is an integration branch with a release PR", () => {
348
+ const repo = { owner: "owner", repo: "repo" };
349
+ const open = [
350
+ {
351
+ number: parsePrNumber(500),
352
+ headRepository: repo,
353
+ headRefName: "develop",
354
+ baseRefName: "main",
355
+ },
356
+ {
357
+ number: parsePrNumber(612),
358
+ headRepository: repo,
359
+ headRefName: "feature/login",
360
+ baseRefName: "develop",
361
+ },
362
+ {
363
+ number: parsePrNumber(613),
364
+ headRepository: repo,
365
+ headRefName: "fix/typo",
366
+ baseRefName: "develop",
367
+ },
368
+ ];
369
+ expect(
370
+ numbers(
371
+ orderStack({ ...repo, number: parsePrNumber(612) }, "develop", open)
372
+ )
373
+ ).toEqual([612]);
374
+ expect(
375
+ numbers(
376
+ orderStack({ ...repo, number: parsePrNumber(500) }, "develop", open)
377
+ )
378
+ ).toEqual([500]);
379
+ });
380
+
381
+ it("reads the default branch once while discovering a stack", async () => {
382
+ const reader = fakeReader({ defaultBranch: "trunk" });
383
+ expect(await discoverStack(reader, context)).toEqual([context]);
384
+ expect(reader.calls).toEqual(["openPullRequests", "defaultBranch"]);
385
+ });
386
+
387
+ it("orders a discovered stack around the default branch the repository reports", async () => {
388
+ const repo = { owner: "owner", repo: "repo" };
389
+ const reader = fakeReader({
390
+ defaultBranch: "develop",
391
+ openPullRequests: [
392
+ {
393
+ number: parsePrNumber(500),
394
+ headRepository: repo,
395
+ headRefName: "develop",
396
+ baseRefName: "main",
397
+ },
398
+ {
399
+ number: context.number,
400
+ headRepository: repo,
401
+ headRefName: "feature",
402
+ baseRefName: "develop",
403
+ },
404
+ ],
405
+ });
406
+ expect(numbers(await discoverStack(reader, context))).toEqual([42]);
407
+ });
408
+
409
+ it("refuses a full open-PR page, which may have cut the stack", async () => {
410
+ const openPrs = (count: number) =>
411
+ Array.from({ length: count }, (_, index) => ({
412
+ number: parsePrNumber(index + 1),
413
+ headRepository: { owner: "owner", repo: "repo" },
414
+ headRefName: `branch-${index + 1}`,
415
+ baseRefName: "main",
416
+ }));
417
+ await expect(
418
+ discoverStack(fakeReader({ openPullRequests: openPrs(300) }), context)
419
+ ).rejects.toMatchObject({ failure: { kind: "invalid-stack" } });
420
+ expect(
421
+ await discoverStack(
422
+ fakeReader({ openPullRequests: openPrs(299) }),
423
+ context
424
+ )
425
+ ).toEqual([context]);
426
+ });
427
+ });