@gobing-ai/spur 0.3.47 → 0.3.49

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 (148) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.example.yaml +94 -15
  3. package/config/transition-shims.json +33 -0
  4. package/config/workflows/basic.yaml +2 -0
  5. package/config/workflows/docs-pipeline.yaml +2 -0
  6. package/config/workflows/feature-dev.yaml +8 -0
  7. package/config/workflows/idea-pipeline.yaml +10 -0
  8. package/config/workflows/planning-pipeline.yaml +4 -0
  9. package/config/workflows/pr-review.yaml +338 -0
  10. package/config/workflows/task-pipeline.yaml +8 -0
  11. package/config/workflows/wayfinder-resolution.yaml +4 -0
  12. package/config/workflows/wrapup-pipeline.yaml +18 -1
  13. package/package.json +8 -8
  14. package/plugins/sp/README.md +9 -6
  15. package/plugins/sp/agents/expert-spur.md +1 -0
  16. package/plugins/sp/commands/dev-arch.md +2 -1
  17. package/plugins/sp/commands/dev-brainstorm.md +2 -1
  18. package/plugins/sp/commands/dev-changelog.md +1 -0
  19. package/plugins/sp/commands/dev-daily.md +1 -0
  20. package/plugins/sp/commands/dev-debug.md +2 -1
  21. package/plugins/sp/commands/dev-dogfood.md +2 -1
  22. package/plugins/sp/commands/{dev-featurechange.md → dev-feature-change.md} +8 -10
  23. package/plugins/sp/commands/dev-find-conflict.md +2 -1
  24. package/plugins/sp/commands/dev-find-issue.md +36 -43
  25. package/plugins/sp/commands/dev-find-next.md +5 -4
  26. package/plugins/sp/commands/dev-fixall.md +1 -0
  27. package/plugins/sp/commands/dev-gitmsg.md +1 -0
  28. package/plugins/sp/commands/dev-gtd.md +12 -12
  29. package/plugins/sp/commands/dev-handover.md +1 -0
  30. package/plugins/sp/commands/dev-history-load.md +63 -0
  31. package/plugins/sp/commands/dev-idea.md +1 -0
  32. package/plugins/sp/commands/dev-next.md +2 -1
  33. package/plugins/sp/commands/dev-parallel.md +2 -1
  34. package/plugins/sp/commands/dev-plan.md +2 -1
  35. package/plugins/sp/commands/dev-pr-review.md +39 -0
  36. package/plugins/sp/commands/dev-refine.md +5 -3
  37. package/plugins/sp/commands/dev-refineall.md +2 -1
  38. package/plugins/sp/commands/dev-refresh.md +2 -1
  39. package/plugins/sp/commands/dev-reverse.md +2 -1
  40. package/plugins/sp/commands/dev-review.md +2 -1
  41. package/plugins/sp/commands/dev-run.md +3 -2
  42. package/plugins/sp/commands/dev-runall.md +3 -2
  43. package/plugins/sp/commands/dev-simplify.md +2 -1
  44. package/plugins/sp/commands/dev-unit.md +2 -1
  45. package/plugins/sp/commands/dev-verify.md +2 -1
  46. package/plugins/sp/commands/dev-verifyall.md +2 -1
  47. package/plugins/sp/commands/dev-wrap.md +7 -5
  48. package/plugins/sp/commands/dev-wrapall.md +7 -5
  49. package/plugins/sp/commands/rule-add.md +1 -0
  50. package/plugins/sp/commands/rule-refine.md +1 -0
  51. package/plugins/sp/commands/rule-scan.md +1 -0
  52. package/plugins/sp/commands/spur-init.md +1 -0
  53. package/plugins/sp/commands/workflow-add.md +1 -0
  54. package/plugins/sp/commands/workflow-refine.md +1 -0
  55. package/plugins/sp/hooks/careful-guard.ts +5 -80
  56. package/plugins/sp/hooks/destructive-policy.ts +146 -0
  57. package/plugins/sp/hooks/pi/guard-extension.ts +33 -46
  58. package/plugins/sp/hooks/task-file-policy.ts +31 -0
  59. package/plugins/sp/hooks/task-write-guard.ts +4 -0
  60. package/plugins/sp/plugin.json +1 -1
  61. package/plugins/sp/references/roles.md +106 -0
  62. package/plugins/sp/scripts/feature-sync-bounded.ts +28 -2
  63. package/plugins/sp/scripts/history-load.ts +400 -0
  64. package/plugins/sp/scripts/pr-reviewing.ts +867 -0
  65. package/plugins/sp/scripts/stage-registry-adapter.ts +66 -31
  66. package/plugins/sp/scripts/surface-drift-inventory.ts +908 -0
  67. package/plugins/sp/scripts/task-size-precheck.ts +30 -4
  68. package/plugins/sp/scripts/transition-shim-check.ts +238 -0
  69. package/plugins/sp/scripts/validate-commands.ts +33 -2
  70. package/plugins/sp/scripts/validate-flag-contracts.ts +5 -2
  71. package/plugins/sp/skills/code-implementation/SKILL.md +9 -1
  72. package/plugins/sp/skills/code-verification/SKILL.md +29 -28
  73. package/plugins/sp/skills/issue-finding/SKILL.md +123 -141
  74. package/plugins/sp/skills/issue-finding/examples/expected-findings.json +1 -1
  75. package/plugins/sp/skills/issue-finding/references/session-formats.md +87 -90
  76. package/plugins/sp/skills/next-feature/SKILL.md +6 -6
  77. package/plugins/sp/skills/next-feature/references/handoff-routing.md +5 -5
  78. package/plugins/sp/skills/next-feature/references/signal-derivation.md +7 -2
  79. package/plugins/sp/skills/next-router/SKILL.md +1 -1
  80. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +40 -2
  81. package/plugins/sp/skills/pr-reviewing/SKILL.md +285 -0
  82. package/plugins/sp/skills/spur-cli/SKILL.md +3 -0
  83. package/plugins/sp/skills/spur-cli/references/agent.md +12 -7
  84. package/plugins/sp/skills/spur-cli/references/features/hierarchy-mece.md +5 -5
  85. package/plugins/sp/skills/spur-cli/references/features.md +1 -1
  86. package/plugins/sp/skills/spur-cli/references/team.md +10 -3
  87. package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
  88. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +17 -0
  89. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +44 -23
  90. package/plugins/sp/skills/spur-dev/references/dev-operations.md +14 -11
  91. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +14 -12
  92. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +28 -7
  93. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +2 -0
  94. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +12 -2
  95. package/schemas/spur-config.schema.json +47 -3
  96. package/spur.js +12223 -7716
  97. package/web/_astro/BoardApp.8hiqShQn.js +1 -0
  98. package/web/_astro/{BoardApp.DKyrGxdo.js → BoardApp.BjQUNhuj.js} +74 -74
  99. package/web/_astro/{TaskDetail.6-27_LMa.js → TaskDetail.CVBuD6dF.js} +1 -1
  100. package/web/_astro/{arc.Df-9AQvS.js → arc.BMMjdODi.js} +1 -1
  101. package/web/_astro/{architectureDiagram-3BPJPVTR.VAI_-paS.js → architectureDiagram-3BPJPVTR.BU5ShzXf.js} +1 -1
  102. package/web/_astro/{blockDiagram-GPEHLZMM.DFpUY1ue.js → blockDiagram-GPEHLZMM.Bj1iEqPD.js} +1 -1
  103. package/web/_astro/{c4Diagram-AAUBKEIU.CF8doOpg.js → c4Diagram-AAUBKEIU.vX8wepCL.js} +1 -1
  104. package/web/_astro/channel.EwdSemIC.js +1 -0
  105. package/web/_astro/{chunk-2J33WTMH.BnjK3fjt.js → chunk-2J33WTMH.BKAipTym.js} +1 -1
  106. package/web/_astro/{chunk-4BX2VUAB.x6ZDnJKq.js → chunk-4BX2VUAB.B68XkPG7.js} +1 -1
  107. package/web/_astro/{chunk-55IACEB6.zY-0uu7w.js → chunk-55IACEB6.BmeDLcrc.js} +1 -1
  108. package/web/_astro/{chunk-727SXJPM.BZxKg_Vi.js → chunk-727SXJPM.PDuBA3Kw.js} +1 -1
  109. package/web/_astro/{chunk-AQP2D5EJ.Cpi9G9Td.js → chunk-AQP2D5EJ.C7A044za.js} +1 -1
  110. package/web/_astro/{chunk-FMBD7UC4.DWTB-Pif.js → chunk-FMBD7UC4.BtzKKFqR.js} +1 -1
  111. package/web/_astro/{chunk-ND2GUHAM.BPDQbiOG.js → chunk-ND2GUHAM.BJuDeeOy.js} +1 -1
  112. package/web/_astro/{chunk-QZHKN3VN.BRWIcuoM.js → chunk-QZHKN3VN.DSeMDgcQ.js} +1 -1
  113. package/web/_astro/{classDiagram-4FO5ZUOK.mGTCZsDO.js → classDiagram-4FO5ZUOK.D53Q4tCw.js} +1 -1
  114. package/web/_astro/{classDiagram-v2-Q7XG4LA2.mGTCZsDO.js → classDiagram-v2-Q7XG4LA2.D53Q4tCw.js} +1 -1
  115. package/web/_astro/{cose-bilkent-S5V4N54A.D1GEut-z.js → cose-bilkent-S5V4N54A.c712AFRH.js} +1 -1
  116. package/web/_astro/{dagre-BM42HDAG.BV0XG9Do.js → dagre-BM42HDAG.D-idisph.js} +1 -1
  117. package/web/_astro/{diagram-2AECGRRQ.DzpYxsjo.js → diagram-2AECGRRQ.DLgnsJCU.js} +1 -1
  118. package/web/_astro/{diagram-5GNKFQAL.Cm9YzJh4.js → diagram-5GNKFQAL.BiaxBVqx.js} +1 -1
  119. package/web/_astro/{diagram-KO2AKTUF.BjhottUj.js → diagram-KO2AKTUF.C8HX1vd8.js} +1 -1
  120. package/web/_astro/{diagram-LMA3HP47.BFsQW5kb.js → diagram-LMA3HP47.CfqDLLes.js} +1 -1
  121. package/web/_astro/{diagram-OG6HWLK6.8pdpzSWO.js → diagram-OG6HWLK6.15SDiEed.js} +1 -1
  122. package/web/_astro/{erDiagram-TEJ5UH35.Bd7KUJmJ.js → erDiagram-TEJ5UH35.DksYtOYM.js} +1 -1
  123. package/web/_astro/{flowDiagram-I6XJVG4X.7LWffkaE.js → flowDiagram-I6XJVG4X.DR_Au-HV.js} +1 -1
  124. package/web/_astro/{ganttDiagram-6RSMTGT7.BeDcO5tI.js → ganttDiagram-6RSMTGT7.CHhHrffI.js} +1 -1
  125. package/web/_astro/{gitGraphDiagram-PVQCEYII.Ca4n730A.js → gitGraphDiagram-PVQCEYII.B2Xehvam.js} +1 -1
  126. package/web/_astro/{index.Dbvuw6d4.css → index.DAxu50UF.css} +1 -1
  127. package/web/_astro/{infoDiagram-5YYISTIA.B0OakQYb.js → infoDiagram-5YYISTIA.C9c3CNNN.js} +1 -1
  128. package/web/_astro/{ishikawaDiagram-YF4QCWOH.DSmNQe-1.js → ishikawaDiagram-YF4QCWOH.BibUHkh8.js} +1 -1
  129. package/web/_astro/{journeyDiagram-JHISSGLW.Cy5ruEUu.js → journeyDiagram-JHISSGLW.BYoVHiyO.js} +1 -1
  130. package/web/_astro/{kanban-definition-UN3LZRKU.CUJXub0p.js → kanban-definition-UN3LZRKU.CM1K5wHE.js} +1 -1
  131. package/web/_astro/{linear.DC1jCCXn.js → linear.SPpjJUb-.js} +1 -1
  132. package/web/_astro/{mermaid.core.DxVP99Ab.js → mermaid.core.BAgx3nnb.js} +4 -4
  133. package/web/_astro/{mindmap-definition-RKZ34NQL.D0MaV6sJ.js → mindmap-definition-RKZ34NQL.D35oPG1R.js} +1 -1
  134. package/web/_astro/{pieDiagram-4H26LBE5.DCC6_q32.js → pieDiagram-4H26LBE5.DiWuRwk7.js} +1 -1
  135. package/web/_astro/{quadrantDiagram-W4KKPZXB.BeUOAM7C.js → quadrantDiagram-W4KKPZXB.B9PBzTWn.js} +1 -1
  136. package/web/_astro/{requirementDiagram-4Y6WPE33.Dbl4MASO.js → requirementDiagram-4Y6WPE33.CYuuamFN.js} +1 -1
  137. package/web/_astro/{sankeyDiagram-5OEKKPKP.HsLg0VS4.js → sankeyDiagram-5OEKKPKP.W24UhhtD.js} +1 -1
  138. package/web/_astro/{sequenceDiagram-3UESZ5HK.DT7DJTnZ.js → sequenceDiagram-3UESZ5HK.BpbNjA51.js} +1 -1
  139. package/web/_astro/{stateDiagram-AJRCARHV.d_ju1Vr1.js → stateDiagram-AJRCARHV.DqVsHudf.js} +1 -1
  140. package/web/_astro/{stateDiagram-v2-BHNVJYJU.DMCAjMJ4.js → stateDiagram-v2-BHNVJYJU.CzwHYX81.js} +1 -1
  141. package/web/_astro/{timeline-definition-PNZ67QCA.DNOHr62_.js → timeline-definition-PNZ67QCA.Bc3B6djw.js} +1 -1
  142. package/web/_astro/{vennDiagram-CIIHVFJN.B7dUy-1W.js → vennDiagram-CIIHVFJN.C-D5rh8O.js} +1 -1
  143. package/web/_astro/{wardley-L42UT6IY.DEqOXvBh.js → wardley-L42UT6IY.D7PdYCqn.js} +1 -1
  144. package/web/_astro/{wardleyDiagram-YWT4CUSO.BCRb2p6x.js → wardleyDiagram-YWT4CUSO.CwmJKXF3.js} +1 -1
  145. package/web/_astro/{xychartDiagram-2RQKCTM6.NxVQLdBh.js → xychartDiagram-2RQKCTM6.avDYnLsb.js} +1 -1
  146. package/web/index.html +2 -2
  147. package/web/_astro/BoardApp.Ce6zJYAH.js +0 -1
  148. package/web/_astro/channel.Uhm9O3UV.js +0 -1
@@ -0,0 +1,867 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * pr-reviewing.ts — deterministic spine for the sp:pr-reviewing skill (/sp:dev-pr-review).
4
+ *
5
+ * Owns every git/gh interaction of the GitHub Codex PR-review loop: preflight, push,
6
+ * find-or-create PR, submission hygiene scan, `@codex review` request with per-HEAD dedupe,
7
+ * bounded polling, findings collection, and a composite status read. All model-bearing work
8
+ * (finding triage, fixes, AGENTS.md rules authoring) stays in the skill; this script is the
9
+ * testable core that `.spur/workflows/pr-review.yaml` (the workflow SSOT) shells out to.
10
+ *
11
+ * Subcommands: preflight | push | ensure-pr | hygiene | request | wait | collect | status
12
+ * Flags: --json (single JSON object on stdout), --status-file <path> (one-word verdict),
13
+ * plus per-subcommand flags documented in --help.
14
+ *
15
+ * Exit codes: 0 success (including WARN / ALREADY_REVIEWED / UP_TO_DATE verdicts),
16
+ * 1 usage error, 2 hard failure, 3 wait timeout (pending — never a failure).
17
+ *
18
+ * Non-negotiable routing: the external review goes through the GitHub PR and an
19
+ * `@codex review` comment. This script never invokes a local Codex CLI as a substitute.
20
+ */
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+
26
+ // ─── Types ──────────────────────────────────────────────────────────────────
27
+
28
+ interface CmdResult {
29
+ code: number;
30
+ stdout: string;
31
+ stderr: string;
32
+ error?: string;
33
+ }
34
+
35
+ interface GhUser {
36
+ login?: string;
37
+ type?: string;
38
+ }
39
+
40
+ interface GhReview {
41
+ user?: GhUser;
42
+ commit_id?: string;
43
+ submitted_at?: string;
44
+ state?: string;
45
+ body?: string;
46
+ html_url?: string;
47
+ }
48
+
49
+ interface GhIssueComment {
50
+ user?: GhUser;
51
+ created_at?: string;
52
+ commit_id?: string;
53
+ body?: string;
54
+ html_url?: string;
55
+ }
56
+
57
+ interface GhReviewComment {
58
+ user?: GhUser;
59
+ created_at?: string;
60
+ commit_id?: string;
61
+ path?: string;
62
+ line?: number | null;
63
+ body?: string;
64
+ html_url?: string;
65
+ }
66
+
67
+ interface GhPr {
68
+ number: number;
69
+ url: string;
70
+ state: string;
71
+ isDraft: boolean;
72
+ headRefName: string;
73
+ baseRefName: string;
74
+ title: string;
75
+ headRefOid: string;
76
+ }
77
+
78
+ interface GhCheck {
79
+ bucket?: string;
80
+ }
81
+
82
+ interface PreflightContext {
83
+ repoRoot: string;
84
+ nameWithOwner: string;
85
+ branch: string;
86
+ head: string;
87
+ shortHead: string;
88
+ defaultBranch: string;
89
+ }
90
+
91
+ interface Finding {
92
+ kind: 'review' | 'inline' | 'comment';
93
+ severity: string;
94
+ path: string | null;
95
+ line: number | null;
96
+ body: string;
97
+ url: string;
98
+ at: string;
99
+ }
100
+
101
+ interface ParsedArgs {
102
+ subcommand: string;
103
+ flags: Map<string, string>;
104
+ booleans: Set<string>;
105
+ }
106
+
107
+ // ─── Process runner (git/gh resolved from PATH so tests can stub them) ──────
108
+
109
+ function run(cmd: readonly string[]): CmdResult {
110
+ // Pass env explicitly so both Node and Bun resolve git/gh against the test fixture PATH.
111
+ const proc = spawnSync(cmd[0] ?? '', [...cmd.slice(1)], { encoding: 'utf8', env: process.env });
112
+ return {
113
+ code: proc.status ?? 1,
114
+ stdout: proc.stdout ?? '',
115
+ stderr: proc.stderr ?? '',
116
+ error: proc.error?.message,
117
+ };
118
+ }
119
+
120
+ function runOk(cmd: readonly string[], what: string): string {
121
+ const res = run(cmd);
122
+ if (res.code !== 0) {
123
+ throw new Error(`${what} failed: ${res.stderr.trim() || res.stdout.trim() || res.error || `exit ${res.code}`}`);
124
+ }
125
+ return res.stdout.trim();
126
+ }
127
+
128
+ function parseJson<T>(raw: string, what: string): T {
129
+ try {
130
+ return JSON.parse(raw) as T;
131
+ } catch {
132
+ throw new Error(`${what}: unparseable JSON output`);
133
+ }
134
+ }
135
+
136
+ // ─── Argument parsing ───────────────────────────────────────────────────────
137
+
138
+ const VALUE_FLAGS = new Set(['--base', '--focus', '--since', '--head', '--timeout', '--interval', '--status-file']);
139
+ const BOOL_FLAGS = new Set(['--json', '--force']);
140
+
141
+ export function parseArgs(argv: readonly string[]): ParsedArgs {
142
+ const [subcommand = '', ...rest] = argv;
143
+ const flags = new Map<string, string>();
144
+ const booleans = new Set<string>();
145
+ for (let i = 0; i < rest.length; i++) {
146
+ const tok = rest[i];
147
+ if (BOOL_FLAGS.has(tok)) {
148
+ booleans.add(tok);
149
+ } else if (VALUE_FLAGS.has(tok)) {
150
+ const value = rest[i + 1];
151
+ if (value === undefined) throw new Error(`flag ${tok} requires a value`);
152
+ flags.set(tok, value);
153
+ i++;
154
+ } else {
155
+ throw new Error(`unknown argument: ${tok}`);
156
+ }
157
+ }
158
+ return { subcommand, flags, booleans };
159
+ }
160
+
161
+ // ─── Output helpers ─────────────────────────────────────────────────────────
162
+
163
+ function emit(args: ParsedArgs, payload: Record<string, unknown>, human: string): void {
164
+ if (args.booleans.has('--json')) {
165
+ console.log(JSON.stringify(payload));
166
+ } else {
167
+ console.log(human);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Carries an intended CLI exit. Thrown anywhere, rendered once by {@link main}; an empty
173
+ * message is a silent exit (the payload was already emitted). Keeps `main` in-process
174
+ * testable — only the `import.meta.main` entrypoint calls `process.exit`.
175
+ */
176
+ class ScriptExit extends Error {
177
+ constructor(
178
+ message: string,
179
+ readonly code: number,
180
+ readonly json: boolean,
181
+ ) {
182
+ super(message);
183
+ }
184
+ }
185
+
186
+ function fail(args: ParsedArgs | null, message: string, code: number): never {
187
+ throw new ScriptExit(message, code, args?.booleans.has('--json') ?? false);
188
+ }
189
+
190
+ function writeStatus(args: ParsedArgs, verdict: string): void {
191
+ const file = args.flags.get('--status-file');
192
+ if (file) writeFileSync(file, `${verdict}\n`);
193
+ }
194
+
195
+ /** Record FAIL for an uncaught hard failure when a status artifact was requested. */
196
+ function writeFailureStatus(args: ParsedArgs | null): void {
197
+ const file = args?.flags.get('--status-file');
198
+ if (!file) return;
199
+ try {
200
+ writeFileSync(file, 'FAIL\n');
201
+ } catch {
202
+ // Keep the original command error; a missing/unwritable status path is secondary.
203
+ }
204
+ }
205
+
206
+ // ─── Codex identity + review correlation ────────────────────────────────────
207
+
208
+ /** Codex bot logins vary; require the Codex name plus GitHub's bot identity signal. */
209
+ export function isCodexAuthor(login: string | undefined, type?: string): boolean {
210
+ return /codex/i.test(login ?? '') && (type === 'Bot' || /\[bot\]$/i.test(login ?? ''));
211
+ }
212
+
213
+ /** True when a Codex review already covers this exact pushed HEAD. */
214
+ export function isHeadReviewed(reviews: readonly GhReview[], head: string): boolean {
215
+ return reviews.some(
216
+ (review) =>
217
+ isCodexAuthor(review.user?.login, review.user?.type) &&
218
+ review.commit_id === head &&
219
+ isCompletedReview(review),
220
+ );
221
+ }
222
+
223
+ function isCompletedReview(review: GhReview): boolean {
224
+ const state = (review.state ?? '').toUpperCase();
225
+ return review.submitted_at !== undefined && state !== 'DISMISSED' && state !== 'PENDING';
226
+ }
227
+
228
+ const CLEAN_REVIEW_RE =
229
+ /\b(?:no|zero)\s+(?:actionable\s+)?(?:findings?|issues?|problems?)\b|\b(?:looks|seems)\s+good\b|\blgtm\b|\bno\s+concerns\b/i;
230
+
231
+ /** True only for an explicit Codex clean result attached to this exact HEAD. */
232
+ export function isExplicitCleanReview(review: GhReview, head: string): boolean {
233
+ if (
234
+ !isCodexAuthor(review.user?.login, review.user?.type) ||
235
+ review.commit_id !== head ||
236
+ !isCompletedReview(review)
237
+ ) {
238
+ return false;
239
+ }
240
+ const body = (review.body ?? '').trim();
241
+ if (/\bP[0-3]\b/i.test(body)) return false;
242
+ const state = (review.state ?? '').toUpperCase();
243
+ if (!['APPROVED', 'CLEAN', 'COMMENTED'].includes(state)) return false;
244
+ return state === 'CLEAN' || (state === 'APPROVED' && body === '') || CLEAN_REVIEW_RE.test(body);
245
+ }
246
+
247
+ function isFresh(at: string | undefined, since: string): boolean {
248
+ if (at === undefined) return false;
249
+ if (since === '') return true;
250
+ const atMs = Date.parse(at);
251
+ const sinceMs = Date.parse(since);
252
+ return Number.isFinite(atMs) && Number.isFinite(sinceMs) && atMs >= Math.floor(sinceMs / 1000) * 1000;
253
+ }
254
+
255
+ /** True when a fresh, explicit, current-HEAD Codex clean review exists. */
256
+ export function hasCurrentCleanReview(reviews: readonly GhReview[], since: string, head: string): boolean {
257
+ return reviews.some((review) => isFresh(review.submitted_at, since) && isExplicitCleanReview(review, head));
258
+ }
259
+
260
+ /** Extract a severity badge from a Codex comment body (`**P1**`, `P2`, …) else `unrated`. */
261
+ export function extractSeverity(body: string): string {
262
+ const match = body.match(/\bP([0-3])\b/);
263
+ return match ? `P${match[1]}` : 'unrated';
264
+ }
265
+
266
+ // ─── Request body ───────────────────────────────────────────────────────────
267
+
268
+ const DEFAULT_FOCUS =
269
+ 'for correctness and regressions, security boundaries, data-loss risks, concurrency or race conditions, ' +
270
+ 'API/backward compatibility, migration safety, error handling, and missing high-value tests. ' +
271
+ 'Prioritize actionable issues over style or nits.';
272
+
273
+ export function hasCodeReviewRules(repoRoot: string): boolean {
274
+ const agentsPath = join(repoRoot, 'AGENTS.md');
275
+ if (!existsSync(agentsPath)) return false;
276
+ return /^## Code Review Rules\s*$/m.test(readFileSync(agentsPath, 'utf8'));
277
+ }
278
+
279
+ export function buildRequestBody(rulesPresent: boolean, focus: string): string {
280
+ const base = rulesPresent ? '@codex review' : `@codex review ${DEFAULT_FOCUS}`;
281
+ const trimmed = focus.trim();
282
+ return trimmed === '' ? base : `${base} Focus especially on ${trimmed}.`;
283
+ }
284
+
285
+ // ─── Hygiene scan ───────────────────────────────────────────────────────────
286
+
287
+ interface HygieneResult {
288
+ verdict: 'PASS' | 'WARN' | 'BLOCK';
289
+ blockers: string[];
290
+ warnings: string[];
291
+ }
292
+
293
+ const BLOCK_LINE_PATTERNS: ReadonlyArray<[RegExp, string]> = [
294
+ [/^(<{7}|={7}|>{7})(\s|$)/, 'merge-conflict marker'],
295
+ [/^-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/, 'private key material'],
296
+ [/\bAKIA[0-9A-Z]{16}\b/, 'AWS access key id'],
297
+ [/\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/, 'GitHub token'],
298
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/, 'GitHub fine-grained PAT'],
299
+ [/\bsk-[A-Za-z0-9_-]{20,}\b/, 'API secret key'],
300
+ [/\bxox[bapors]-[A-Za-z0-9-]{10,}\b/, 'Slack token'],
301
+ ];
302
+
303
+ const WARN_LINE_PATTERNS: ReadonlyArray<[RegExp, string]> = [
304
+ [/\bdebugger\b/, 'debugger statement'],
305
+ [/\bconsole\.(log|debug|trace)\(/, 'debug logging'],
306
+ ];
307
+
308
+ /** Scan added lines (+) and added file names of a `base...HEAD` diff for submission mistakes. */
309
+ export function scanHygiene(diffText: string, changedFiles: readonly string[]): HygieneResult {
310
+ const blockers: string[] = [];
311
+ const warnings: string[] = [];
312
+ for (const file of changedFiles) {
313
+ if (/(^|\/)\.env(\.[^/]+)?$/.test(file) && !/\.(example|sample|template)$/.test(file)) {
314
+ blockers.push(`${file}: committed .env file`);
315
+ }
316
+ }
317
+ for (const line of diffText.split('\n')) {
318
+ if (!line.startsWith('+') || line.startsWith('+++')) continue;
319
+ const added = line.slice(1);
320
+ let blocked = false;
321
+ for (const [pattern, label] of BLOCK_LINE_PATTERNS) {
322
+ if (pattern.test(added)) {
323
+ blockers.push(`${label}: redacted`);
324
+ blocked = true;
325
+ break;
326
+ }
327
+ }
328
+ if (blocked) continue;
329
+ for (const [pattern, label] of WARN_LINE_PATTERNS) {
330
+ if (pattern.test(added)) {
331
+ warnings.push(`${label}: ${added.trim().slice(0, 80)}`);
332
+ break;
333
+ }
334
+ }
335
+ }
336
+ const verdict = blockers.length > 0 ? 'BLOCK' : warnings.length > 0 ? 'WARN' : 'PASS';
337
+ return { verdict, blockers, warnings };
338
+ }
339
+
340
+ // ─── Findings normalization ─────────────────────────────────────────────────
341
+
342
+ export function normalizeFindings(
343
+ reviews: readonly GhReview[],
344
+ inline: readonly GhReviewComment[],
345
+ comments: readonly GhIssueComment[],
346
+ since: string,
347
+ head: string,
348
+ ): Finding[] {
349
+ const findings: Finding[] = [];
350
+ const fresh = (at: string | undefined): boolean => isFresh(at, since);
351
+ const onHead = (commitId: string | undefined): boolean => commitId === head;
352
+ for (const r of reviews) {
353
+ if (
354
+ !isCodexAuthor(r.user?.login, r.user?.type) ||
355
+ !isCompletedReview(r) ||
356
+ !fresh(r.submitted_at) ||
357
+ !onHead(r.commit_id)
358
+ )
359
+ continue;
360
+ if (isExplicitCleanReview(r, head)) continue;
361
+ if ((r.body ?? '').trim() === '') continue;
362
+ findings.push({
363
+ kind: 'review',
364
+ severity: extractSeverity(r.body ?? ''),
365
+ path: null,
366
+ line: null,
367
+ body: r.body ?? '',
368
+ url: r.html_url ?? '',
369
+ at: r.submitted_at ?? '',
370
+ });
371
+ }
372
+ for (const c of inline) {
373
+ if (!isCodexAuthor(c.user?.login, c.user?.type) || !fresh(c.created_at) || !onHead(c.commit_id)) continue;
374
+ findings.push({
375
+ kind: 'inline',
376
+ severity: extractSeverity(c.body ?? ''),
377
+ path: c.path ?? null,
378
+ line: c.line ?? null,
379
+ body: c.body ?? '',
380
+ url: c.html_url ?? '',
381
+ at: c.created_at ?? '',
382
+ });
383
+ }
384
+ for (const c of comments) {
385
+ if (!isCodexAuthor(c.user?.login, c.user?.type) || !fresh(c.created_at) || !onHead(c.commit_id)) continue;
386
+ findings.push({
387
+ kind: 'comment',
388
+ severity: extractSeverity(c.body ?? ''),
389
+ path: null,
390
+ line: null,
391
+ body: c.body ?? '',
392
+ url: c.html_url ?? '',
393
+ at: c.created_at ?? '',
394
+ });
395
+ }
396
+ return findings.sort((a, b) => a.at.localeCompare(b.at));
397
+ }
398
+
399
+ export function renderFindings(findings: readonly Finding[]): string {
400
+ if (findings.length === 0) return 'Codex review completed without actionable findings.';
401
+ const lines: string[] = [`Findings (${findings.length})`, ''];
402
+ findings.forEach((f, i) => {
403
+ const where = f.path ? `${f.path}${f.line ? `:${f.line}` : ''}` : f.kind;
404
+ lines.push(`${i + 1}. [${f.severity}] ${where}`);
405
+ lines.push(` ${f.body.split('\n')[0]}`);
406
+ if (f.url) lines.push(` ${f.url}`);
407
+ });
408
+ return lines.join('\n');
409
+ }
410
+
411
+ // ─── git/gh primitives ──────────────────────────────────────────────────────
412
+
413
+ function preflightContext(): PreflightContext {
414
+ const repoRoot = runOk(['git', 'rev-parse', '--show-toplevel'], 'git rev-parse --show-toplevel');
415
+ const branch = runOk(['git', 'branch', '--show-current'], 'git branch --show-current');
416
+ if (branch === '') throw new Error('HEAD is detached — check out a branch before requesting a PR review');
417
+ const head = runOk(['git', 'rev-parse', 'HEAD'], 'git rev-parse HEAD');
418
+ const auth = run(['gh', 'auth', 'status']);
419
+ if (auth.code !== 0) {
420
+ throw new Error('gh CLI missing or unauthenticated — install gh and run `gh auth login` (no browser fallback)');
421
+ }
422
+ const repo = parseJson<{ nameWithOwner: string; defaultBranchRef: { name: string } }>(
423
+ runOk(['gh', 'repo', 'view', '--json', 'nameWithOwner,defaultBranchRef'], 'gh repo view'),
424
+ 'gh repo view',
425
+ );
426
+ return {
427
+ repoRoot,
428
+ nameWithOwner: repo.nameWithOwner,
429
+ branch,
430
+ head,
431
+ shortHead: head.slice(0, 7),
432
+ defaultBranch: repo.defaultBranchRef.name,
433
+ };
434
+ }
435
+
436
+ function viewPr(): GhPr | null {
437
+ const res = run([
438
+ 'gh',
439
+ 'pr',
440
+ 'view',
441
+ '--json',
442
+ 'number,url,state,isDraft,headRefName,baseRefName,title,headRefOid',
443
+ ]);
444
+ if (res.code !== 0) {
445
+ const detail = `${res.stderr}\n${res.stdout}`;
446
+ if (/no pull requests? found|no pull request associated|could not find pull request/i.test(detail)) return null;
447
+ throw new Error(
448
+ `gh pr view failed: ${res.stderr.trim() || res.stdout.trim() || res.error || `exit ${res.code}`}`,
449
+ );
450
+ }
451
+ return parseJson<GhPr>(res.stdout, 'gh pr view');
452
+ }
453
+
454
+ function fetchReviews(ctx: PreflightContext, pr: number): GhReview[] {
455
+ return fetchPaginated<GhReview>(`repos/${ctx.nameWithOwner}/pulls/${pr}/reviews`, 'gh api reviews');
456
+ }
457
+
458
+ function fetchInlineComments(ctx: PreflightContext, pr: number): GhReviewComment[] {
459
+ return fetchPaginated<GhReviewComment>(`repos/${ctx.nameWithOwner}/pulls/${pr}/comments`, 'gh api review comments');
460
+ }
461
+
462
+ function fetchIssueComments(ctx: PreflightContext, pr: number): GhIssueComment[] {
463
+ return fetchPaginated<GhIssueComment>(`repos/${ctx.nameWithOwner}/issues/${pr}/comments`, 'gh api issue comments');
464
+ }
465
+
466
+ function fetchPaginated<T>(endpoint: string, what: string): T[] {
467
+ const pages = parseJson<T[][]>(
468
+ runOk(['gh', 'api', '--method', 'GET', endpoint, '--paginate', '--slurp'], what),
469
+ what,
470
+ );
471
+ return pages.flat();
472
+ }
473
+
474
+ const requestMarker = (head: string): string => `<!-- spur-pr-review head:${head} -->`;
475
+
476
+ function hasPendingRequest(comments: readonly GhIssueComment[], head: string, login: string): boolean {
477
+ const marker = requestMarker(head);
478
+ return comments.some((comment) => comment.user?.login === login && (comment.body ?? '').includes(marker));
479
+ }
480
+
481
+ function requireExpectedHead(args: ParsedArgs, pr: GhPr): void {
482
+ const expected = args.flags.get('--head');
483
+ if (expected && expected !== pr.headRefOid) {
484
+ writeStatus(args, 'FAIL');
485
+ fail(
486
+ args,
487
+ `PR HEAD moved from ${expected.slice(0, 7)} to ${pr.headRefOid.slice(0, 7)} — request a new review`,
488
+ 2,
489
+ );
490
+ }
491
+ }
492
+
493
+ // ─── Subcommands ────────────────────────────────────────────────────────────
494
+
495
+ function cmdPreflight(args: ParsedArgs): void {
496
+ const ctx = preflightContext();
497
+ const dirty = run(['git', 'status', '--porcelain']);
498
+ if (dirty.code !== 0)
499
+ fail(args, `git status failed: ${dirty.stderr.trim() || dirty.error || `exit ${dirty.code}`}`, 2);
500
+ const dirtyFiles = dirty.stdout.split('\n').filter((l) => l.trim() !== '');
501
+ if (dirtyFiles.length > 0) {
502
+ writeStatus(args, 'FAIL');
503
+ fail(
504
+ args,
505
+ `working tree is dirty (${dirtyFiles.length} entries) — a PR only reviews pushed commits; ` +
506
+ 'commit or stash first, or let the skill triage the changes interactively',
507
+ 2,
508
+ );
509
+ }
510
+ writeStatus(args, 'PASS');
511
+ emit(
512
+ args,
513
+ { ok: true, ...ctx },
514
+ [
515
+ `Repository: ${ctx.nameWithOwner}`,
516
+ `Branch: ${ctx.branch}`,
517
+ `HEAD: ${ctx.shortHead}`,
518
+ `Default: ${ctx.defaultBranch}`,
519
+ 'Local: clean',
520
+ ].join('\n'),
521
+ );
522
+ }
523
+
524
+ function cmdPush(args: ParsedArgs): void {
525
+ const ctx = preflightContext();
526
+ const upstream = run(['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
527
+ if (upstream.code !== 0) {
528
+ runOk(['git', 'push', '-u', 'origin', 'HEAD'], 'git push -u origin HEAD');
529
+ writeStatus(args, 'PUSHED');
530
+ emit(args, { ok: true, pushed: true, head: ctx.head }, `Pushed ${ctx.branch} and set upstream to origin/HEAD.`);
531
+ return;
532
+ }
533
+ const remoteHead = runOk(['git', 'rev-parse', '@{u}'], 'git rev-parse @{u}');
534
+ if (remoteHead === ctx.head) {
535
+ writeStatus(args, 'UP_TO_DATE');
536
+ emit(args, { ok: true, pushed: false, head: ctx.head }, `Remote already at ${ctx.shortHead}.`);
537
+ return;
538
+ }
539
+ runOk(['git', 'push'], 'git push (fast-forward only — never force)');
540
+ writeStatus(args, 'PUSHED');
541
+ emit(args, { ok: true, pushed: true, head: ctx.head }, `Pushed ${ctx.branch} -> ${ctx.shortHead}.`);
542
+ }
543
+
544
+ function cmdEnsurePr(args: ParsedArgs): void {
545
+ const ctx = preflightContext();
546
+ const existing = viewPr();
547
+ if (existing) {
548
+ writeStatus(args, 'FOUND');
549
+ emit(
550
+ args,
551
+ { ok: true, created: false, pr: existing },
552
+ `PR #${existing.number} ${existing.url}\nBase: ${existing.baseRefName} State: ${existing.state}`,
553
+ );
554
+ return;
555
+ }
556
+ const base = (args.flags.get('--base') ?? '').trim() || ctx.defaultBranch;
557
+ if (ctx.branch === base) {
558
+ writeStatus(args, 'FAIL');
559
+ fail(args, `current branch is the base branch (${base}) — nothing to review`, 2);
560
+ }
561
+ const commits = run(['git', 'log', '--oneline', `${base}..HEAD`]);
562
+ if (commits.code !== 0)
563
+ fail(args, `git log failed: ${commits.stderr.trim() || commits.error || `exit ${commits.code}`}`, 2);
564
+ if (commits.stdout.trim() === '') {
565
+ writeStatus(args, 'FAIL');
566
+ fail(args, `no commits on ${ctx.branch} beyond ${base} — nothing to review`, 2);
567
+ }
568
+ runOk(['gh', 'pr', 'create', '--fill', '--base', base], 'gh pr create');
569
+ const pr = viewPr();
570
+ if (!pr) {
571
+ writeStatus(args, 'FAIL');
572
+ fail(args, 'gh pr create reported success but the PR is not visible', 2);
573
+ }
574
+ writeStatus(args, 'CREATED');
575
+ emit(args, { ok: true, created: true, pr }, `Created PR #${pr.number} ${pr.url}\nBase: ${pr.baseRefName}`);
576
+ }
577
+
578
+ function cmdHygiene(args: ParsedArgs): void {
579
+ const ctx = preflightContext();
580
+ const pr = viewPr();
581
+ const base = (args.flags.get('--base') ?? '').trim() || pr?.baseRefName || ctx.defaultBranch;
582
+ const filesRaw = run(['git', 'diff', '--name-only', '--diff-filter=AMCR', `${base}...HEAD`]);
583
+ if (filesRaw.code !== 0) {
584
+ writeStatus(args, 'FAIL');
585
+ fail(args, `git diff --name-only failed (exit ${filesRaw.code})`, 2);
586
+ }
587
+ const changedFiles = filesRaw.stdout.split('\n').filter((l) => l.trim() !== '');
588
+ const diffRaw = run(['git', 'diff', `${base}...HEAD`]);
589
+ if (diffRaw.code !== 0) {
590
+ writeStatus(args, 'FAIL');
591
+ fail(args, `git diff failed (exit ${diffRaw.code})`, 2);
592
+ }
593
+ const diff = diffRaw.stdout;
594
+ const result = scanHygiene(diff, changedFiles);
595
+ writeStatus(args, result.verdict);
596
+ const human = [
597
+ `Hygiene (${base}...HEAD): ${result.verdict}`,
598
+ ...result.blockers.map((b) => ` BLOCK ${b}`),
599
+ ...result.warnings.map((w) => ` WARN ${w}`),
600
+ ].join('\n');
601
+ emit(args, { ok: result.verdict !== 'BLOCK', ...result }, human);
602
+ if (result.verdict === 'BLOCK') throw new ScriptExit('', 2, args.booleans.has('--json'));
603
+ }
604
+
605
+ function cmdRequest(args: ParsedArgs): void {
606
+ const ctx = preflightContext();
607
+ const pr = viewPr();
608
+ if (!pr) {
609
+ writeStatus(args, 'FAIL');
610
+ fail(args, 'no PR for the current branch — run ensure-pr first', 2);
611
+ }
612
+ const force = args.booleans.has('--force');
613
+ const reviews = fetchReviews(ctx, pr.number);
614
+ if (!force && isHeadReviewed(reviews, pr.headRefOid)) {
615
+ writeStatus(args, 'ALREADY_REVIEWED');
616
+ emit(
617
+ args,
618
+ { ok: true, requested: false, alreadyReviewed: true, pr: pr.number, url: pr.url, head: pr.headRefOid },
619
+ `PR #${pr.number}: current HEAD ${pr.headRefOid.slice(0, 7)} already has a Codex review — not requesting a duplicate.`,
620
+ );
621
+ return;
622
+ }
623
+ if (!force) {
624
+ const login = runOk(['gh', 'api', 'user', '--jq', '.login'], 'gh api user');
625
+ if (hasPendingRequest(fetchIssueComments(ctx, pr.number), pr.headRefOid, login)) {
626
+ writeStatus(args, 'ALREADY_REQUESTED');
627
+ emit(
628
+ args,
629
+ { ok: true, requested: false, pending: true, pr: pr.number, url: pr.url, head: pr.headRefOid },
630
+ `PR #${pr.number}: current HEAD ${pr.headRefOid.slice(0, 7)} already has a pending Codex request.`,
631
+ );
632
+ return;
633
+ }
634
+ }
635
+ const body = `${buildRequestBody(hasCodeReviewRules(ctx.repoRoot), args.flags.get('--focus') ?? '')}\n\n${requestMarker(pr.headRefOid)}`;
636
+ const requestedAt = new Date().toISOString();
637
+ runOk(['gh', 'pr', 'comment', String(pr.number), '--body', body], 'gh pr comment (@codex review)');
638
+ writeStatus(args, 'REQUESTED');
639
+ emit(
640
+ args,
641
+ { ok: true, requested: true, pr: pr.number, url: pr.url, head: pr.headRefOid, requestedAt, body },
642
+ `Requested GitHub Codex review on PR #${pr.number} (${pr.url}) at HEAD ${pr.headRefOid.slice(0, 7)}.`,
643
+ );
644
+ }
645
+
646
+ function cmdWait(args: ParsedArgs): void {
647
+ const since = args.flags.get('--since') ?? new Date().toISOString();
648
+ const timeoutSec = Number(args.flags.get('--timeout') ?? '600');
649
+ const intervalSec = Number(args.flags.get('--interval') ?? '30');
650
+ if (!Number.isFinite(timeoutSec) || timeoutSec < 0) fail(args, '--timeout must be a non-negative number', 1);
651
+ if (!Number.isFinite(intervalSec) || intervalSec <= 0) fail(args, '--interval must be a positive number', 1);
652
+ const ctx = preflightContext();
653
+ const pr = viewPr();
654
+ if (!pr) {
655
+ writeStatus(args, 'FAIL');
656
+ fail(args, 'no PR for the current branch', 2);
657
+ }
658
+ requireExpectedHead(args, pr);
659
+ const deadline = Date.now() + timeoutSec * 1000;
660
+ for (;;) {
661
+ const reviews = fetchReviews(ctx, pr.number);
662
+ const findings = normalizeFindings(
663
+ reviews,
664
+ fetchInlineComments(ctx, pr.number),
665
+ fetchIssueComments(ctx, pr.number),
666
+ since,
667
+ pr.headRefOid,
668
+ );
669
+ if (findings.length > 0) {
670
+ writeStatus(args, 'FOUND');
671
+ emit(args, { ok: true, verdict: 'FOUND', findings }, renderFindings(findings));
672
+ return;
673
+ }
674
+ if (hasCurrentCleanReview(reviews, since, pr.headRefOid)) {
675
+ writeStatus(args, 'CLEAN');
676
+ emit(
677
+ args,
678
+ { ok: true, verdict: 'CLEAN', pr: pr.number, url: pr.url, head: pr.headRefOid, findings: [] },
679
+ `Codex review completed cleanly for HEAD ${pr.headRefOid.slice(0, 7)}.`,
680
+ );
681
+ return;
682
+ }
683
+ if (Date.now() >= deadline) {
684
+ writeStatus(args, 'TIMEOUT');
685
+ emit(
686
+ args,
687
+ { ok: true, verdict: 'TIMEOUT', pr: pr.number, url: pr.url },
688
+ `No Codex review within ${timeoutSec}s — still pending. Collect later with /sp:dev-pr-review collect.\nPR: ${pr.url}`,
689
+ );
690
+ throw new ScriptExit('', 3, args.booleans.has('--json'));
691
+ }
692
+ const sleepMs = intervalSec * 1000;
693
+ if (sleepMs > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, sleepMs);
694
+ }
695
+ }
696
+
697
+ function cmdCollect(args: ParsedArgs): void {
698
+ const ctx = preflightContext();
699
+ const pr = viewPr();
700
+ if (!pr) {
701
+ fail(args, 'no PR for the current branch', 2);
702
+ }
703
+ requireExpectedHead(args, pr);
704
+ const since = args.flags.get('--since') ?? '';
705
+ const reviews = fetchReviews(ctx, pr.number);
706
+ const findings = normalizeFindings(
707
+ reviews,
708
+ fetchInlineComments(ctx, pr.number),
709
+ fetchIssueComments(ctx, pr.number),
710
+ since,
711
+ pr.headRefOid,
712
+ );
713
+ const verdict =
714
+ findings.length > 0 ? 'FINDINGS' : hasCurrentCleanReview(reviews, since, pr.headRefOid) ? 'CLEAN' : 'PENDING';
715
+ writeStatus(args, verdict);
716
+ const header = `PR #${pr.number} ${pr.url}\nHEAD ${pr.headRefOid.slice(0, 7)} — Codex: ${verdict === 'FINDINGS' ? 'findings' : verdict.toLowerCase()}`;
717
+ const summary =
718
+ verdict === 'PENDING' ? 'No current-HEAD Codex review result yet — still pending.' : renderFindings(findings);
719
+ emit(
720
+ args,
721
+ { ok: true, verdict, pr: pr.number, url: pr.url, head: pr.headRefOid, findings },
722
+ `${header}\n\n${summary}`,
723
+ );
724
+ }
725
+
726
+ function cmdStatus(args: ParsedArgs): void {
727
+ const ctx = preflightContext();
728
+ const pr = viewPr();
729
+ if (pr) requireExpectedHead(args, pr);
730
+ const dirtyResult = run(['git', 'status', '--porcelain']);
731
+ if (dirtyResult.code !== 0) {
732
+ fail(
733
+ args,
734
+ `git status failed: ${dirtyResult.stderr.trim() || dirtyResult.error || `exit ${dirtyResult.code}`}`,
735
+ 2,
736
+ );
737
+ }
738
+ const dirty = dirtyResult.stdout.trim();
739
+ let ci = 'unavailable';
740
+ let codex = 'not requested';
741
+ const since = args.flags.get('--since') ?? '';
742
+ if (pr) {
743
+ const checks = run(['gh', 'pr', 'checks', String(pr.number), '--json', 'bucket']);
744
+ if ([0, 1, 8].includes(checks.code) && checks.stdout.trim() !== '') {
745
+ const buckets = parseJson<GhCheck[]>(checks.stdout, 'gh pr checks').map((check) => check.bucket ?? '');
746
+ if (buckets.some((bucket) => bucket === 'fail' || bucket === 'cancel')) ci = 'failing';
747
+ else if (buckets.some((bucket) => bucket === 'pending')) ci = 'pending';
748
+ else if (buckets.length > 0 && buckets.every((bucket) => bucket === 'pass')) ci = 'passing';
749
+ }
750
+ const reviews = fetchReviews(ctx, pr.number);
751
+ const codexReviews = reviews.filter((r) => isCodexAuthor(r.user?.login, r.user?.type));
752
+ if (codexReviews.length > 0) {
753
+ const inline = fetchInlineComments(ctx, pr.number);
754
+ const findings = normalizeFindings(reviews, inline, [], since, pr.headRefOid);
755
+ if (findings.length > 0) codex = 'findings';
756
+ else if (hasCurrentCleanReview(reviews, since, pr.headRefOid)) codex = 'clean';
757
+ else if (codexReviews.some((review) => review.commit_id === pr.headRefOid)) codex = 'pending';
758
+ else codex = 'stale — HEAD moved';
759
+ }
760
+ }
761
+ const payload = {
762
+ ok: true,
763
+ repo: ctx.nameWithOwner,
764
+ branch: ctx.branch,
765
+ head: ctx.shortHead,
766
+ base: pr?.baseRefName ?? ctx.defaultBranch,
767
+ pr: pr ? { number: pr.number, url: pr.url, state: pr.state } : null,
768
+ local: dirty === '' ? 'clean' : 'modified',
769
+ ci,
770
+ codex,
771
+ };
772
+ emit(
773
+ args,
774
+ payload,
775
+ [
776
+ `Repository: ${payload.repo}`,
777
+ `PR: ${pr ? `#${pr.number} ${pr.url}` : 'none'}`,
778
+ `Branch: ${payload.branch}`,
779
+ `HEAD: ${payload.head}`,
780
+ `Base: ${payload.base}`,
781
+ `Local: ${payload.local}`,
782
+ `CI: ${payload.ci}`,
783
+ `Codex: ${payload.codex}`,
784
+ ].join('\n'),
785
+ );
786
+ }
787
+
788
+ // ─── Entrypoint ─────────────────────────────────────────────────────────────
789
+
790
+ const HELP = `pr-reviewing.ts — deterministic spine for /sp:dev-pr-review (sp:pr-reviewing)
791
+
792
+ Installed usage: bun "$(superskill script path sp pr-reviewing.ts)" <subcommand> [flags]
793
+ Source-tree usage: bun plugins/sp/scripts/pr-reviewing.ts <subcommand> [flags]
794
+
795
+ Subcommands:
796
+ preflight git/gh/repo checks; FAILs on detached HEAD or a dirty tree
797
+ push push the branch (never force); sets upstream when missing
798
+ ensure-pr [--base b] reuse the branch's PR or create one with gh pr create --fill
799
+ hygiene [--base b] scan base...HEAD for secrets/.env/conflict markers (BLOCK) and debug residue (WARN)
800
+ request [--force] [--focus text]
801
+ post @codex review (dedupes reviewed/in-flight HEAD unless --force)
802
+ wait [--since iso] [--head sha] [--timeout 600] [--interval 30]
803
+ poll for Codex output; exit 3 on timeout (pending, not failed)
804
+ collect [--since iso] [--head sha]
805
+ fetch the current-HEAD Codex result (FINDINGS, CLEAN, or PENDING)
806
+ status [--since iso] [--head sha] composite repo/PR/CI/Codex status (read-only)
807
+
808
+ Global flags: --json (single JSON object) --status-file <path> (one-word verdict)
809
+ Exit codes: 0 ok · 1 usage · 2 hard failure · 3 wait timeout`;
810
+
811
+ export function main(argv: readonly string[]): number {
812
+ let args: ParsedArgs | null = null;
813
+ try {
814
+ args = parseArgs(argv);
815
+ switch (args.subcommand) {
816
+ case 'preflight':
817
+ cmdPreflight(args);
818
+ return 0;
819
+ case 'push':
820
+ cmdPush(args);
821
+ return 0;
822
+ case 'ensure-pr':
823
+ cmdEnsurePr(args);
824
+ return 0;
825
+ case 'hygiene':
826
+ cmdHygiene(args);
827
+ return 0;
828
+ case 'request':
829
+ cmdRequest(args);
830
+ return 0;
831
+ case 'wait':
832
+ cmdWait(args);
833
+ return 0;
834
+ case 'collect':
835
+ cmdCollect(args);
836
+ return 0;
837
+ case 'status':
838
+ cmdStatus(args);
839
+ return 0;
840
+ case '':
841
+ case '--help':
842
+ case 'help':
843
+ console.log(HELP);
844
+ return 0;
845
+ default:
846
+ fail(args, `unknown subcommand: ${args.subcommand}`, 1);
847
+ }
848
+ } catch (error) {
849
+ if (error instanceof ScriptExit) {
850
+ if (error.code === 2 && error.message !== '') writeFailureStatus(args);
851
+ if (error.message !== '') {
852
+ if (error.json) console.log(JSON.stringify({ ok: false, error: error.message }));
853
+ console.error(`error: ${error.message}`);
854
+ }
855
+ return error.code;
856
+ }
857
+ const message = error instanceof Error ? error.message : String(error);
858
+ writeFailureStatus(args);
859
+ if (args?.booleans.has('--json')) console.log(JSON.stringify({ ok: false, error: message }));
860
+ console.error(`error: ${message}`);
861
+ return 2;
862
+ }
863
+ }
864
+
865
+ if (import.meta.main) {
866
+ process.exit(main(process.argv.slice(2)));
867
+ }