@integrity-labs/agt-cli 0.28.834 → 0.28.835

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