@integrity-labs/agt-cli 0.28.834 → 0.28.836

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.
@@ -0,0 +1,589 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * VENDORED (ENG-10001) — THIS MONOREPO COPY IS CANONICAL.
4
+ *
5
+ * Reaches managed agent hosts via `provisionReviewPoster()`
6
+ * (apps/cli/src/lib/review-poster-asset.ts), which copies this file and its
7
+ * sibling `obiwan-auth.mjs` into the agent's dir at session provision time.
8
+ *
9
+ * It is delivered that way, and NOT from `packages/claudecode-plugin-augmented/`,
10
+ * because the managed fleet never installs that plugin — the ENG-6233 mistake,
11
+ * recorded in-tree at `apps/cli/src/lib/manager-worker.ts` ("which the managed
12
+ * fleet never installs"), whose ENG-6268 fix named manager provisioning as "the
13
+ * actual fleet delivery path".
14
+ *
15
+ * Origin: the Smithers Claude Code plugin (~/.claude/plugins/smithers/scripts/).
16
+ * That copy is now DOWNSTREAM of this one. Do NOT add an equality sync test:
17
+ * this copy is expected to diverge, and already has — `refuseIdentity()` below
18
+ * makes a write without the VERIFIED review-bot identity impossible here, while
19
+ * the plugin copy stays permissive for the human who runs it. A `--min-severity`
20
+ * floor is the remaining planned divergence (ENG-10001).
21
+ */
22
+ /**
23
+ * post-review-findings — deliver `/s:code-review` and `/s:security-review`
24
+ * findings to a PR as inline threads, and track them across rounds.
25
+ *
26
+ * THIS MODULE CAN NEVER SUBMIT A BLOCKING VERDICT, and that is its central
27
+ * design constraint rather than a setting. Measured on Integrity-Labs/augmented
28
+ * on 2026-09-03: five PRs are permanently unmergeable and twenty-five more sit
29
+ * in CHANGES_REQUESTED, because a reviewer that submits CHANGES_REQUESTED can
30
+ * only clear it by submitting APPROVED — and a clean re-scan submits COMMENTED,
31
+ * which clears nothing. `dismiss_stale_reviews_on_push` does not rescue this:
32
+ * GitHub defines it as dismissing review APPROVALS, so it never touches a change
33
+ * request. The repo's real remedy today is a bot calling the dismissals API by
34
+ * hand, one review at a time, with a written justification each.
35
+ *
36
+ * Nothing is given up by refusing the verdict. `required_approving_review_count`
37
+ * is 0, so the verdict gates no merge; `required_review_thread_resolution` is
38
+ * true, so the THREADS do the gating — per finding, and they clear cleanly.
39
+ *
40
+ * Because threads gate the merge, a reviewer that opens them must be able to
41
+ * close them, or it has built a blocker only a human can clear. Hence
42
+ * `--resolve`, which REQUIRES a reason and posts that reason before resolving.
43
+ * A reviewer that silently resolves its own findings is worse than one that
44
+ * never posted.
45
+ *
46
+ * Usage:
47
+ * post-review-findings.mjs --pr 5409 # dry run (default)
48
+ * post-review-findings.mjs --pr 5409 --post
49
+ * post-review-findings.mjs --pr 5409 --status
50
+ * post-review-findings.mjs --pr 5409 --resolve <id> --reason "..."
51
+ */
52
+
53
+ import { execFileSync } from 'node:child_process';
54
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
55
+ import { resolve } from 'node:path';
56
+ import { createHash } from 'node:crypto';
57
+ import { join } from 'node:path';
58
+ import { resolveIdentity, redact } from './obiwan-auth.mjs';
59
+ import { realpathSync } from 'node:fs';
60
+ import { pathToFileURL } from 'node:url';
61
+
62
+ /**
63
+ * The ONLY review event this module will ever submit.
64
+ *
65
+ * Asserted at the point of construction, not merely used as a default — a
66
+ * default is a value a caller can pass past. `eng-9927-posting-layer.test.mjs`
67
+ * drives the real `submitReview()` against a stub and reads the event off the
68
+ * payload it actually built. A source grep for 'REQUEST_CHANGES' would be
69
+ * satisfied by this very docblock, which is why the proof is behavioural.
70
+ */
71
+ export const REVIEW_EVENT = 'COMMENT';
72
+
73
+ export const REVIEW_DIR = '.smithers/review';
74
+
75
+ /**
76
+ * A finding's identity across rounds. Deliberately EXCLUDES the line number: a
77
+ * finding whose anchor shifted because unrelated code was inserted above it is
78
+ * the same finding, and keying on line would repost it as new on every push.
79
+ * File + category + summary is what makes two reports the same claim.
80
+ */
81
+ export function findingId(f) {
82
+ return createHash('sha256')
83
+ .update(`${f.file} ${f.category ?? ''} ${f.summary ?? ''}`)
84
+ .digest('hex')
85
+ .slice(0, 12);
86
+ }
87
+
88
+ /** The marker that makes a posted comment recognisably ours on a later run. */
89
+ export function marker(id) {
90
+ return `<!-- smithers-finding:${id} -->`;
91
+ }
92
+
93
+ export function buildBody(f, id) {
94
+ const sev = String(f.severity ?? 'minor').toUpperCase();
95
+ const cat = f.category ? ` · ${f.category}` : '';
96
+ return [
97
+ `**${sev}${cat}** — ${f.summary}`,
98
+ '',
99
+ f.why ?? f.attack ?? '',
100
+ '',
101
+ '<sub>Posted by `/s:code-review`. This reviewer never submits a blocking verdict.',
102
+ 'Reply if this is deliberate and it becomes a rule in `docs/reference/review-rules.md`.</sub>',
103
+ marker(id),
104
+ ].join('\n');
105
+ }
106
+
107
+ /** Thin `gh` wrapper. `input` is piped to stdin, for endpoints taking a body. */
108
+ let AUTH_TOKEN = null;
109
+
110
+ /** Authenticate subsequent gh() calls as this token. Null keeps gh's own auth. */
111
+ export function setAuthToken(t) {
112
+ AUTH_TOKEN = t || null;
113
+ }
114
+
115
+ export function gh(args, { json = true, input } = {}) {
116
+ let out;
117
+ try {
118
+ out = execFileSync('gh', args, {
119
+ encoding: 'utf8',
120
+ maxBuffer: 64 * 1024 * 1024,
121
+ // The token goes in the ENVIRONMENT. A command line is world-readable via
122
+ // `ps` and is copied verbatim into CI logs and SSM run history.
123
+ ...(AUTH_TOKEN ? { env: { ...process.env, GH_TOKEN: AUTH_TOKEN } } : {}),
124
+ ...(input === undefined ? {} : { input }),
125
+ });
126
+ } catch (e) {
127
+ // gh echoes the failing request, so an auth error can carry the token.
128
+ e.message = redact(e.message, AUTH_TOKEN);
129
+ if (e.stderr) e.stderr = redact(e.stderr, AUTH_TOKEN);
130
+ throw e;
131
+ }
132
+ return json && out.trim() !== '' ? JSON.parse(out) : out;
133
+ }
134
+
135
+ /**
136
+ * Lines GitHub will accept an inline comment on: the RIGHT side of each hunk in
137
+ * the PR diff. A comment on any other line is rejected outright, so this is
138
+ * what separates a placeable finding from one that needs the summary body.
139
+ */
140
+ export function commentableLines(patchesByFile) {
141
+ const ok = new Map();
142
+ for (const [file, patch] of patchesByFile) {
143
+ const lines = new Set();
144
+ let n = 0;
145
+ for (const l of String(patch ?? '').split('\n')) {
146
+ const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
147
+ if (h) {
148
+ n = Number(h[1]);
149
+ continue;
150
+ }
151
+ if (l.startsWith('-')) continue;
152
+ if (l.startsWith('+') || l.startsWith(' ')) {
153
+ lines.add(n);
154
+ n += 1;
155
+ }
156
+ }
157
+ ok.set(file, lines);
158
+ }
159
+ return ok;
160
+ }
161
+
162
+ /**
163
+ * Read every findings file written for this PR head.
164
+ *
165
+ * A document whose review did not COMPLETE is refused rather than contributing
166
+ * an empty `findings` array. Merged into a real file, an incomplete review's
167
+ * empty list is indistinguishable from a clean one — the precise conflation
168
+ * both review commands carry a `status` field to prevent.
169
+ */
170
+ export function loadFindings(dir, pr, head) {
171
+ const want = `${pr}-${String(head).slice(0, 12)}.`;
172
+
173
+ // Three different situations, told apart. REVIEW_DIR is relative to the cwd,
174
+ // so running this from anywhere but the repo produced a bare ENOENT stack
175
+ // out of readdirSync — a Node internal frame in place of the one sentence
176
+ // that says what to do. And an absent directory is not the same as a
177
+ // directory holding no review for THIS head: the first means no review has
178
+ // ever run here, the second means the PR moved since the last one.
179
+ if (!existsSync(dir)) {
180
+ throw new Error(
181
+ `no findings directory at ${resolve(dir)} — run /s:code-review from the repo first, ` +
182
+ 'or pass --dir pointing at an existing .smithers/review',
183
+ );
184
+ }
185
+ const out = [];
186
+ const present = readdirSync(dir).sort();
187
+ const forThisPr = present.filter((n) => n.startsWith(`${pr}-`));
188
+ if (!present.some((n) => n.startsWith(want))) {
189
+ throw new Error(
190
+ forThisPr.length > 0
191
+ ? `no review for PR #${pr} at head ${String(head).slice(0, 12)} — found ${forThisPr.join(', ')}. ` +
192
+ 'The PR has moved since it was reviewed; re-run /s:code-review.'
193
+ : `no review found for PR #${pr} in ${resolve(dir)} — run /s:code-review ${pr} first`,
194
+ );
195
+ }
196
+ for (const name of present) {
197
+ if (!name.startsWith(want) || !name.endsWith('.json')) continue;
198
+ const doc = JSON.parse(readFileSync(join(dir, name), 'utf8'));
199
+ if (doc.status !== 'reviewed') {
200
+ throw new Error(
201
+ `${name}: status is "${doc.status}" — refusing to post from an incomplete review`,
202
+ );
203
+ }
204
+ for (const f of doc.findings ?? []) out.push({ ...f, _source: name });
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * Split findings into those that can be anchored inline and those that cannot.
211
+ *
212
+ * An unanchorable finding is NOT dropped — it is returned for the review body.
213
+ * A reviewer that silently discards what it could not place reports fewer
214
+ * findings than it made, with nothing on the wire to say so, which is the same
215
+ * silent-failure shape these reviews exist to find in other people's code.
216
+ */
217
+ export function partitionByAnchor(findings, commentable) {
218
+ const inline = [];
219
+ const outside = [];
220
+ for (const f of findings) {
221
+ const lines = commentable.get(f.file);
222
+ if (lines && f.line && lines.has(f.line)) inline.push(f);
223
+ else {
224
+ outside.push({
225
+ ...f,
226
+ _why: lines ? `line ${f.line} is not in the diff` : 'file is not in the diff',
227
+ });
228
+ }
229
+ }
230
+ return { inline, outside };
231
+ }
232
+
233
+ /**
234
+ * Findings already posted on this PR, by id — so a round adds only what is new.
235
+ * Keyed on the marker, which survives an anchor shift; see {@link findingId}.
236
+ */
237
+ export function postedIds(comments) {
238
+ const seen = new Set();
239
+ for (const c of comments) {
240
+ const m = /<!-- smithers-finding:([0-9a-f]{12}) -->/.exec(String(c.body ?? ''));
241
+ if (m) seen.add(m[1]);
242
+ }
243
+ return seen;
244
+ }
245
+
246
+ /**
247
+ * Build and submit the review.
248
+ *
249
+ * The event is read from the frozen constant and re-asserted here, so neither a
250
+ * caller nor a later edit can widen this into a verdict without the assertion
251
+ * failing first.
252
+ */
253
+ export function submitReview({ repo, pr, comments, body, api = gh }) {
254
+ if (REVIEW_EVENT !== 'COMMENT') {
255
+ throw new Error(
256
+ `refusing to submit review event "${REVIEW_EVENT}" — see this module's header`,
257
+ );
258
+ }
259
+ const payload = { event: REVIEW_EVENT, body, comments };
260
+ api(['api', `repos/${repo}/pulls/${pr}/reviews`, '--method', 'POST', '--input', '-'], {
261
+ json: false,
262
+ input: JSON.stringify(payload),
263
+ });
264
+ return payload;
265
+ }
266
+
267
+ /**
268
+ * Every thread this reviewer opened on a PR, with its resolution state.
269
+ *
270
+ * You cannot act on feedback you cannot enumerate. Without this the finding ids
271
+ * lived only in the output of the run that posted them, so resolving one meant
272
+ * re-deriving the id or reading it out of an HTML comment in the PR.
273
+ */
274
+ export async function listFindingThreads({ repo, pr, api = gh }) {
275
+ const [owner, name] = repo.split('/');
276
+ const q = `query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){reviewThreads(first:100){pageInfo{hasNextPage} nodes{id isResolved isOutdated path line comments(first:1){nodes{body author{login} url}}}}}}}`;
277
+ const res = api(['api', 'graphql', '-f', `query=${q}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `pr=${pr}`]);
278
+ const conn = res.data.repository.pullRequest.reviewThreads;
279
+ if (conn.pageInfo.hasNextPage) {
280
+ throw new Error('more than 100 review threads — refusing to report a partial list');
281
+ }
282
+ const out = [];
283
+ for (const t of conn.nodes) {
284
+ const body = String(t.comments?.nodes?.[0]?.body ?? '');
285
+ const m = /<!-- smithers-finding:([0-9a-f]{12}) -->/.exec(body);
286
+ if (!m) continue;
287
+ out.push({
288
+ id: m[1],
289
+ resolved: t.isResolved,
290
+ outdated: t.isOutdated,
291
+ path: t.path,
292
+ line: t.line,
293
+ summary: body.split('\n')[0].replace(/\*\*/g, '').slice(0, 88),
294
+ url: t.comments?.nodes?.[0]?.url,
295
+ });
296
+ }
297
+ return out;
298
+ }
299
+
300
+ /**
301
+ * Resolve one finding's thread, with the reason posted into the thread first.
302
+ *
303
+ * The reason is REQUIRED. Resolving is how a merge blocker this tool created is
304
+ * cleared, so an unexplained resolve is indistinguishable from the tool hiding
305
+ * a finding it could not answer.
306
+ */
307
+ export function resolveFinding({ repo, pr, id, reason, api = gh }) {
308
+ if (!reason || !String(reason).trim()) {
309
+ throw new Error('--resolve requires --reason: an unexplained resolve hides the finding it closes');
310
+ }
311
+ const [owner, name] = repo.split('/');
312
+ const q = `query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){reviewThreads(first:100){pageInfo{hasNextPage} nodes{id isResolved comments(first:1){nodes{body databaseId}}}}}}}`;
313
+ const res = api(['api', 'graphql', '-f', `query=${q}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `pr=${pr}`]);
314
+ const conn = res.data.repository.pullRequest.reviewThreads;
315
+ if (conn.pageInfo.hasNextPage) {
316
+ throw new Error('more than 100 review threads — refusing to resolve from a truncated read');
317
+ }
318
+ const thread = conn.nodes.find((t) =>
319
+ String(t.comments?.nodes?.[0]?.body ?? '').includes(marker(id)),
320
+ );
321
+ if (!thread) throw new Error(`no thread found carrying finding ${id}`);
322
+ if (thread.isResolved) return { id, alreadyResolved: true };
323
+
324
+ const commentId = thread.comments.nodes[0].databaseId;
325
+ api(
326
+ ['api', `repos/${repo}/pulls/${pr}/comments/${commentId}/replies`, '--method', 'POST', '--input', '-'],
327
+ { json: false, input: JSON.stringify({ body: `Resolved: ${reason}` }) },
328
+ );
329
+
330
+ // GITHUB APPS CANNOT RESOLVE REVIEW THREADS. Measured, not assumed: with
331
+ // `pull_requests: write` the App reads threads and posts replies fine, and
332
+ // `resolveReviewThread` returns "Resource not accessible by integration".
333
+ // There is no permission that grants it — the mutation requires a user.
334
+ //
335
+ // So the design goal "whatever opens a blocker can close it" is NOT
336
+ // achievable by the App, and pretending otherwise would leave threads open
337
+ // with a reply claiming they were resolved. The reply is posted as the bot,
338
+ // because it is the reviewer's statement; the resolve falls back to the
339
+ // caller's own gh credentials, because deciding a finding is addressed is a
340
+ // human judgement and should be attributed to the human who made it.
341
+ //
342
+ // The consequence is worth stating plainly rather than hiding in a retry: an
343
+ // UNATTENDED run cannot resolve anything. It can post findings and it can
344
+ // post the reason; a person has to close the thread.
345
+ const mutation = `mutation{resolveReviewThread(input:{threadId:"${thread.id}"}){thread{isResolved}}}`;
346
+ try {
347
+ api(['api', 'graphql', '-f', `query=${mutation}`]);
348
+ return { id, resolved: true, reason, resolvedBy: 'app' };
349
+ } catch (e) {
350
+ if (!/not accessible by integration/i.test(String(e.stderr ?? e.message))) throw e;
351
+ }
352
+ const prev = AUTH_TOKEN;
353
+ setAuthToken(null); // the caller's own gh auth
354
+ try {
355
+ api(['api', 'graphql', '-f', `query=${mutation}`]);
356
+ return { id, resolved: true, reason, resolvedBy: 'you (the App cannot resolve threads)' };
357
+ } catch (e) {
358
+ return {
359
+ id,
360
+ resolved: false,
361
+ reason,
362
+ note:
363
+ 'the reason was posted, but the thread is still open: GitHub Apps cannot resolve ' +
364
+ 'review threads and your own gh credentials could not either. Resolve it in the UI.',
365
+ };
366
+ } finally {
367
+ setAuthToken(prev);
368
+ }
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // CLI
373
+ // ---------------------------------------------------------------------------
374
+
375
+ export function parseArgs(argv) {
376
+ const a = { post: false, status: false };
377
+ for (let i = 0; i < argv.length; i += 1) {
378
+ const k = argv[i];
379
+ if (k === '--post') a.post = true;
380
+ else if (k === '--status') a.status = true;
381
+ else if (k === '--pr') a.pr = Number(argv[++i]);
382
+ else if (k === '--repo') a.repo = argv[++i];
383
+ else if (k === '--dir') a.dir = argv[++i];
384
+ else if (k === '--resolve') a.resolve = argv[++i];
385
+ else if (k === '--reason') a.reason = argv[++i];
386
+ }
387
+ return a;
388
+ }
389
+
390
+ /**
391
+ * Which modes may run against a PR in this state — one place, so the answer
392
+ * cannot differ between the branches that ask it.
393
+ *
394
+ * The carve-out for `--status` was added because a read-only listing is useful
395
+ * on a closed PR. Written as `!a.status` it was too wide: `--resolve` returns
396
+ * BEFORE the status branch, so `--status --resolve <id>` satisfied the
397
+ * carve-out and then took a reply through the door on a merged PR — the very
398
+ * defect the carve-out was written alongside, one argument along (CodeRabbit,
399
+ * #156). Read-only therefore means `--status` and NOTHING ELSE.
400
+ *
401
+ * The conflicting pair is refused outright rather than silently resolved in
402
+ * `--resolve`'s favour, because a flag that is accepted and then ignored is
403
+ * indistinguishable from one that was honoured.
404
+ *
405
+ * Returns a refusal message, or null to proceed.
406
+ */
407
+ export function refuseMode({ state, status, resolve, pr }) {
408
+ if (status && resolve) {
409
+ return '--status and --resolve are separate modes: --resolve runs first and --status would be ignored. Pick one.';
410
+ }
411
+ const readOnly = Boolean(status) && !resolve;
412
+ if (state !== 'OPEN' && !readOnly) {
413
+ return (
414
+ `PR #${pr} is ${state} — refusing to write to it. Findings and replies on a ` +
415
+ 'closed PR reach no one; if they still matter, they belong on a follow-up.'
416
+ );
417
+ }
418
+ return null;
419
+ }
420
+
421
+ /**
422
+ * Whether this identity may WRITE. Deliberately a second function rather than
423
+ * another clause in refuseMode(): they gate different axes, and their read-only
424
+ * sets genuinely differ.
425
+ *
426
+ * refuseMode asks "may this MODE touch a PR in this STATE", and there a dry run
427
+ * counts as a write, because computing findings against a closed PR is pointless
428
+ * work. Here a dry run is a READ — it posts nothing, so who we are cannot matter
429
+ * — and refusing it would deny an operator the one command that shows what the
430
+ * misconfiguration would have done. So `willWrite` is `--post || --resolve`, and
431
+ * nothing else. Folding the two together would force one of those two readings
432
+ * onto the other.
433
+ *
434
+ * FAIL CLOSED, WITH NO OVERRIDE FLAG. There is deliberately no
435
+ * `--allow-fallback-identity`. This copy's only caller is an unattended agent,
436
+ * so any escape hatch is one the AGENT can take, and an agent writing under an
437
+ * identity nobody chose is the exact thing being guarded. A documented bypass of
438
+ * a fail-closed check is not a fail-closed check.
439
+ *
440
+ * AND THE FALLBACK IS NOT WHAT IT LOOKS LIKE ON A HOST. The obvious reading —
441
+ * "it would post as the operator" — is a workstation reading. ENG-7506
442
+ * materialises the Augmented App's installation token into the agent session as
443
+ * GITHUB_ACCESS_TOKEN and GITHUB_TOKEN, and `gh` reads GITHUB_TOKEN. So a
444
+ * fallback write from a managed agent posts as the PLATFORM's bot, on a review
445
+ * the platform did not make. Unattributable rather than misattributed, which is
446
+ * no better: nobody can mute it, and nobody can tell whose claim it is.
447
+ *
448
+ * Returns a refusal message, or null to proceed.
449
+ */
450
+ export function refuseIdentity({ kind, willWrite }) {
451
+ if (!willWrite || kind === 'app') return null;
452
+ return (
453
+ 'refusing to write as anything but the review bot: OBIWAN_APP_ID / OBIWAN_PRIVATE_KEY ' +
454
+ 'are not configured, so --post and --resolve would write under whatever credentials ' +
455
+ '`gh` happens to hold. Configure the App (see obiwan-auth.mjs), or drop --post to see ' +
456
+ 'what would have been posted.'
457
+ );
458
+ }
459
+
460
+ async function main() {
461
+ const a = parseArgs(process.argv.slice(2));
462
+ if (!a.pr) throw new Error('--pr is required');
463
+ const repo = a.repo ?? gh(['repo', 'view', '--json', 'nameWithOwner'], {}).nameWithOwner;
464
+ const dir = a.dir ?? REVIEW_DIR;
465
+
466
+ // Resolve identity before anything is written, and print it. A reviewer that
467
+ // posts under the operator's name when the App is misconfigured has produced
468
+ // the exact outcome the App exists to prevent, so the fallback is announced
469
+ // rather than merely permitted.
470
+ const identity = await resolveIdentity({ repo });
471
+ setAuthToken(identity.token);
472
+ console.log(
473
+ identity.kind === 'app'
474
+ ? `identity: ${identity.why} (token expires ${identity.expiresAt})`
475
+ : `identity: FALLBACK — whatever \`gh\` already holds, which is NOT the review bot ` +
476
+ `(${identity.why}); reads are fine, --post and --resolve will refuse`,
477
+ );
478
+
479
+ // Before the PR is even read, so a misconfigured machine learns it from the
480
+ // first line of output rather than after a full scan it cannot deliver.
481
+ const identityRefusal = refuseIdentity({
482
+ kind: identity.kind,
483
+ willWrite: Boolean(a.post) || Boolean(a.resolve),
484
+ });
485
+ if (identityRefusal) throw new Error(identityRefusal);
486
+
487
+ // Read PR state BEFORE the --resolve branch, not after it (CodeRabbit, #156).
488
+ // --resolve returned early, and resolveFinding posts a reply before it
489
+ // resolves — so a merged PR could still take a bot comment through this door
490
+ // while the posting door refused. The same defect, one branch along, which is
491
+ // what a guard placed at one call site rather than at the boundary tends to
492
+ // leave behind. What may run against which state now lives in refuseMode(),
493
+ // so the answer is one function rather than a condition per branch.
494
+ const view = gh(['pr', 'view', String(a.pr), '--repo', repo, '--json', 'headRefOid,state']);
495
+ const refusal = refuseMode({ state: view.state, status: a.status, resolve: a.resolve, pr: a.pr });
496
+ if (refusal) throw new Error(refusal);
497
+
498
+ if (a.resolve) {
499
+ console.log(JSON.stringify(resolveFinding({ repo, pr: a.pr, id: a.resolve, reason: a.reason }), null, 2));
500
+ return;
501
+ }
502
+
503
+ if (a.status) {
504
+ const threads = await listFindingThreads({ repo, pr: a.pr });
505
+ console.log(`\n${threads.length} thread(s) opened by this reviewer:`);
506
+ for (const t of threads) {
507
+ const mark = t.resolved ? 'RESOLVED' : t.outdated ? 'OUTDATED' : 'OPEN ';
508
+ console.log(` ${mark} ${t.id} ${t.path}:${t.line ?? '-'} ${t.summary}`);
509
+ }
510
+ const open = threads.filter((t) => !t.resolved).length;
511
+ if (open > 0) {
512
+ console.log(
513
+ `\n${open} open. Resolve one only once it is ADDRESSED or judged not to apply:\n` +
514
+ ` post-review-findings.mjs --pr ${a.pr} --resolve <id> --reason "what changed, or why it does not apply"\n` +
515
+ 'The reason is posted into the thread before it closes — a resolve with no\n' +
516
+ 'explanation is indistinguishable from the reviewer hiding a finding.',
517
+ );
518
+ }
519
+ return;
520
+ }
521
+ // A merged or closed PR takes comments happily and shows them to nobody.
522
+ const head = view.headRefOid;
523
+ const findings = loadFindings(dir, a.pr, head);
524
+ // --slurp (gh >= 2.44) is REQUIRED with --paginate here: without it gh emits
525
+ // one JSON array PER PAGE, and gh()'s JSON.parse of the concatenation throws
526
+ // `Unexpected token [`. That makes --post fail on exactly the large PRs it is
527
+ // most needed on, and never on the small ones it gets tested against.
528
+ const files = gh(['api', `repos/${repo}/pulls/${a.pr}/files`, '--paginate', '--slurp']).flat();
529
+ const commentable = commentableLines(files.map((f) => [f.filename, f.patch]));
530
+ const { inline, outside } = partitionByAnchor(findings, commentable);
531
+
532
+ const existing = gh(['api', `repos/${repo}/pulls/${a.pr}/comments`, '--paginate', '--slurp']).flat();
533
+ // ENG-10001 (CodeRabbit, major): `/pulls/{n}/comments` returns INLINE review
534
+ // comments only. An outside-diff finding's marker is written into the review
535
+ // BODY (see the `<details>` block below), which that endpoint never returns —
536
+ // so deduping on inline comments alone reposts every outside-diff finding on
537
+ // each --post, and undercounts "already posted". Reviews carry `.body` too,
538
+ // so postedIds reads both without change.
539
+ const reviews = gh(['api', `repos/${repo}/pulls/${a.pr}/reviews`, '--paginate', '--slurp']).flat();
540
+ const already = postedIds([...existing, ...reviews]);
541
+ const newInline = inline.filter((f) => !already.has(findingId(f)));
542
+ const newOutside = outside.filter((f) => !already.has(findingId(f)));
543
+
544
+ console.log(`PR #${a.pr} @ ${head.slice(0, 9)} — ${findings.length} finding(s) loaded`);
545
+ console.log(` inline-anchorable: ${inline.length} outside-diff: ${outside.length}`);
546
+ console.log(` already posted: ${inline.length + outside.length - newInline.length - newOutside.length}`);
547
+ console.log(` would post now: ${newInline.length} inline + ${newOutside.length} in the body\n`);
548
+ for (const f of newInline) console.log(` [inline] ${f.file}:${f.line} ${findingId(f)} ${f.summary.slice(0, 78)}`);
549
+ for (const f of newOutside) console.log(` [body] ${f.file} ${findingId(f)} (${f._why}) ${f.summary.slice(0, 60)}`);
550
+
551
+ if (!a.post) {
552
+ console.log(`\nDRY RUN — nothing posted. Re-run with --post to deliver.`);
553
+ return;
554
+ }
555
+ if (newInline.length + newOutside.length === 0) {
556
+ console.log('Nothing new to post.');
557
+ return;
558
+ }
559
+
560
+ const comments = newInline.map((f) => ({
561
+ path: f.file,
562
+ line: f.line,
563
+ side: 'RIGHT',
564
+ body: buildBody(f, findingId(f)),
565
+ }));
566
+ const bodyParts = [
567
+ `**${newInline.length + newOutside.length} finding(s)** from \`/s:code-review\` and \`/s:security-review\`.`,
568
+ '',
569
+ 'This reviewer never submits a blocking verdict — see `scripts/post-review-findings.mjs`.',
570
+ ];
571
+ if (newOutside.length > 0) {
572
+ bodyParts.push('', `<details><summary>${newOutside.length} finding(s) outside the diff range</summary>`, '');
573
+ for (const f of newOutside) bodyParts.push(`- \`${f.file}\` — ${f.summary}\n\n ${f.why ?? f.attack ?? ''}\n\n ${marker(findingId(f))}`);
574
+ bodyParts.push('', '</details>');
575
+ }
576
+ submitReview({ repo, pr: a.pr, comments, body: bodyParts.join('\n') });
577
+ console.log(`Posted ${comments.length} inline + ${newOutside.length} in the body, as COMMENT.`);
578
+ }
579
+
580
+ /**
581
+ * Entrypoint check via the REAL path.
582
+ *
583
+ * `~/.claude/plugins/smithers` is a symlink, and Node gives `import.meta.url`
584
+ * the resolved target while `process.argv[1]` keeps the symlink the caller
585
+ * typed. Comparing them directly meant the CLI never ran when invoked the only
586
+ * way anyone invokes it — exiting 0, printing nothing, with no error to say the
587
+ * work had not happened. Proved by `--cli-symlink` in this module's test.
588
+ */
589
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) main();
package/dist/bin/agt.js CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  success,
41
41
  table,
42
42
  warn
43
- } from "../chunk-LLIAJK4F.js";
43
+ } from "../chunk-E6B43IJZ.js";
44
44
  import {
45
45
  getProjectDir,
46
46
  isSessionResumeDisabled,
@@ -5467,7 +5467,7 @@ import { execFileSync, execSync } from "child_process";
5467
5467
  import { existsSync as existsSync11, realpathSync as realpathSync2 } from "fs";
5468
5468
  import chalk18 from "chalk";
5469
5469
  import ora16 from "ora";
5470
- var cliVersion = true ? "0.28.834" : "dev";
5470
+ var cliVersion = true ? "0.28.836" : "dev";
5471
5471
  async function fetchLatestVersion() {
5472
5472
  const host2 = getHost();
5473
5473
  if (!host2) return null;
@@ -6658,7 +6658,7 @@ function handleError(err) {
6658
6658
  }
6659
6659
 
6660
6660
  // src/bin/agt.ts
6661
- var cliVersion2 = true ? "0.28.834" : "dev";
6661
+ var cliVersion2 = true ? "0.28.836" : "dev";
6662
6662
  var program = new Command();
6663
6663
  program.name("agt").description("Augmented CLI \u2014 agent provisioning and management").version(cliVersion2).option("--json", "Emit machine-readable JSON output (suppress spinners and colors)").option("--skip-update-check", "Skip the automatic update check on startup");
6664
6664
  program.hook("preAction", async (thisCommand, actionCommand) => {
@@ -6537,7 +6537,7 @@ function exchangeFailureKind(err) {
6537
6537
  }
6538
6538
 
6539
6539
  // src/lib/api-client.ts
6540
- var agtCliVersion = true ? "0.28.834" : "dev";
6540
+ var agtCliVersion = true ? "0.28.836" : "dev";
6541
6541
  var lastConfigHash = null;
6542
6542
  function setConfigHash(hash) {
6543
6543
  lastConfigHash = hash && hash.length > 0 ? hash : null;
@@ -10895,4 +10895,4 @@ export {
10895
10895
  managerInstallSystemUnitCommand,
10896
10896
  managerUninstallSystemUnitCommand
10897
10897
  };
10898
- //# sourceMappingURL=chunk-LLIAJK4F.js.map
10898
+ //# sourceMappingURL=chunk-E6B43IJZ.js.map