@webpieces/pr-gate 0.4.592 → 0.4.594

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.
@@ -19,19 +19,46 @@ const pr_merger_1 = require("../workflow/pr-merger");
19
19
  const finish_banner_1 = require("../workflow/finish-banner");
20
20
  const merge_body_filer_1 = require("../workflow/merge-body-filer");
21
21
  const gated_pr_publisher_1 = require("../workflow/gated-pr-publisher");
22
+ const provenance_enforcer_1 = require("../workflow/provenance-enforcer");
23
+ const pr_comment_upserter_1 = require("../workflow/pr-comment-upserter");
22
24
  const dashboard_1 = require("../../dashboard/dashboard");
25
+ const checklist_comment_renderer_1 = require("../../dashboard/checklist-comment-renderer");
23
26
  const checklist_comment_row_1 = require("../../dashboard/checklist-comment-row");
24
27
  const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
25
28
  /**
26
- * The provenance outcome: whether each reviewer was VERIFIED to have run (the integrity check, which
27
- * blocks) plus what each one actually read (the quality signal, which is published). Data-only.
29
+ * The three inputs the PR COMMENTS are rendered from, bundled so `publishAll` takes one parameter for
30
+ * them instead of three it only forwards. Data-only, per CLAUDE.md.
28
31
  */
29
- class ProvenanceReport {
30
- verified;
31
- evidence;
32
- constructor(verified, evidence) {
33
- this.verified = verified;
34
- this.evidence = evidence;
32
+ class PrCommentSources {
33
+ scan;
34
+ review;
35
+ provenance;
36
+ constructor(scan, review, provenance) {
37
+ this.scan = scan;
38
+ this.review = review;
39
+ this.provenance = provenance;
40
+ }
41
+ }
42
+ /**
43
+ * Everything the PR upsert needs. Data-only, per CLAUDE.md — it replaced a 5-positional-parameter method
44
+ * once `tokenSuffix` had to travel too, so the body can be re-rendered with the PR's own URL after
45
+ * `gh pr create` returns it (see the INVARIANT comment in upsertPr).
46
+ */
47
+ class PrUpsertRequest {
48
+ repoRoot = '';
49
+ baseBranch = '';
50
+ title = '';
51
+ /** The description as first published — rendered with whatever URL was known at the time. */
52
+ body = '';
53
+ /**
54
+ * The hidden gate-token marker exactly as appended to `body`, so a re-render can reproduce the same
55
+ * bytes. Kept separate from `body` because Dashboard renders the human/git-log half and must not know
56
+ * about HMACs.
57
+ */
58
+ tokenSuffix = '';
59
+ input;
60
+ constructor(input) {
61
+ this.input = input;
35
62
  }
36
63
  }
37
64
  // A resolved PR's number + web URL. Both '' when the PR can't be resolved (e.g. create failed).
@@ -76,24 +103,30 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
76
103
  prMerger;
77
104
  publisher;
78
105
  dashboard;
106
+ checklistComment;
79
107
  checklistScanner;
80
108
  verdictGate;
81
109
  reviewJsonService;
82
110
  gateTokenService;
83
- provenance;
84
- provenanceRecord;
85
- reviewerInstructions;
111
+ provenanceEnforcer;
86
112
  receipts;
87
113
  banner;
88
114
  mergeBodyFiler;
89
- constructor(repoRootFinder, aiBranchName, branchNaming, gitExec, buildAffected, mergeState, prMerger, publisher, dashboard, checklistScanner, verdictGate, reviewJsonService, gateTokenService, provenance, provenanceRecord, reviewerInstructions,
115
+ commentUpserter;
116
+ constructor(repoRootFinder, aiBranchName, branchNaming, gitExec, buildAffected, mergeState, prMerger, publisher, dashboard,
117
+ // The 2nd PR comment. Its own class because it is its own surface — see ChecklistCommentRenderer.
118
+ checklistComment, checklistScanner, verdictGate, reviewJsonService, gateTokenService,
119
+ // Owns the reviewer-provenance integrity check and its audit record — see ProvenanceEnforcer.
120
+ provenanceEnforcer,
90
121
  // NOTE: ChecklistInstructionsService is deliberately NOT injected here any more. Its "You MUST run
91
122
  // these N reviewer subagent(s)" block is now rendered by ReviewerVerdictGate and ONLY for checklists
92
123
  // that genuinely never ran, so no other code path in this command can print it at a refusal.
93
124
  receipts, banner,
94
125
  // The MACHINE-GLOBAL home for the one artifact whose scope is bigger than this tree: the gated
95
126
  // squash-commit body, which `wp-land-pr` must find from any tree (see MergeBodyFiler).
96
- mergeBodyFiler) {
127
+ mergeBodyFiler,
128
+ // ONE marker-keyed upsert, shared by both PR comments (the full dashboard and the checklist).
129
+ commentUpserter) {
97
130
  this.repoRootFinder = repoRootFinder;
98
131
  this.aiBranchName = aiBranchName;
99
132
  this.branchNaming = branchNaming;
@@ -103,16 +136,16 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
103
136
  this.prMerger = prMerger;
104
137
  this.publisher = publisher;
105
138
  this.dashboard = dashboard;
139
+ this.checklistComment = checklistComment;
106
140
  this.checklistScanner = checklistScanner;
107
141
  this.verdictGate = verdictGate;
108
142
  this.reviewJsonService = reviewJsonService;
109
143
  this.gateTokenService = gateTokenService;
110
- this.provenance = provenance;
111
- this.provenanceRecord = provenanceRecord;
112
- this.reviewerInstructions = reviewerInstructions;
144
+ this.provenanceEnforcer = provenanceEnforcer;
113
145
  this.receipts = receipts;
114
146
  this.banner = banner;
115
147
  this.mergeBodyFiler = mergeBodyFiler;
148
+ this.commentUpserter = commentUpserter;
116
149
  }
117
150
  async run() {
118
151
  const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
@@ -149,7 +182,7 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
149
182
  // artifacts) that such a subagent actually ran on this branch — the coding agent may not
150
183
  // self-certify. Absent CLAUDE_CODE_SESSION_ID this skips with a warning (CI / plain terminal).
151
184
  const currentBranch = (0, child_process_1.execSync)('git branch --show-current', { encoding: 'utf8' }).trim();
152
- const provenance = this.enforceProvenance(verdicted, currentBranch, repoRoot, (0, rules_config_1.loadAndValidate)(repoRoot).prGate);
185
+ const provenance = this.provenanceEnforcer.enforce(verdicted, currentBranch, repoRoot, (0, rules_config_1.loadAndValidate)(repoRoot).prGate);
153
186
  // 2b. The build gate validates the WORKING TREE but we push HEAD — so they MUST be identical.
154
187
  this.gitExec.assertCleanTree(repoRoot);
155
188
  // 3. Build gate, then post the gated body, then push (that ORDER — see GatedPrPublisher).
@@ -158,18 +191,7 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
158
191
  process.stdout.write('\n' + SEP + '📋 Dashboard + PR\n' + SEP + '\n');
159
192
  const title = this.prTitleFrom(review);
160
193
  const input = this.computeDashboardInput(repoRoot, true, review, title, verdicted);
161
- // Append the hidden HMAC gate token bound to the LOCAL HEAD sha — computed BEFORE the push, because
162
- // GatedPrPublisher writes the body first so CI's `synchronize` read can never see a stale token. A
163
- // valid token in the PR body is proof this gated flow ran + passed on this exact commit, which CI
164
- // (`wp-check-pr`) recomputes. We reach here only after the build gate + every BLOCK checklist
165
- // passed, so minting is legitimate. Nothing about HMAC(salt, HEAD) needs the remote to have it.
166
- const gateSalt = (0, rules_config_1.loadAndValidate)(repoRoot).prGate.gateSalt;
167
- const headSha = this.gitOut(['rev-parse', 'HEAD']);
168
- const body = this.dashboard.renderDashboard(input) + this.gateTokenBody(gateSalt, headSha);
169
- const result = this.upsertPr(repoRoot, base, body, title, input);
170
- // Publish each reviewer's full output as ONE combined PR comment (idempotent, opt-out-aware). Never
171
- // fatal — the PR is already up by now, so a comment failure only warns.
172
- this.postChecklistComment(repoRoot, result.prNumber, scan, review, provenance);
194
+ const result = this.publishAll(repoRoot, base, input, new PrCommentSources(scan, review, provenance));
173
195
  this.archiveConsumedReview(repoRoot, featureName, result);
174
196
  // The closing recap + the clickable-link directive, BOTH derived from the real merge outcome.
175
197
  // Nothing here may hard-code success: a stranded PR under a green checkmark is how PRs got
@@ -283,7 +305,7 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
283
305
  process.stdout.write(` archived this run's review.json → ${archived} (audit only) ✓\n`);
284
306
  // Beside it, so an archived review keeps the transcript links belonging to the round that
285
307
  // produced it — a review whose provenance was overwritten by the NEXT round audits nothing.
286
- this.provenanceRecord.archive((0, rules_config_1.prDirFor)(repoRoot, featureName));
308
+ this.provenanceEnforcer.archiveRecord((0, rules_config_1.prDirFor)(repoRoot, featureName));
287
309
  }
288
310
  catch (err) {
289
311
  const error = (0, rules_config_1.toError)(err);
@@ -313,7 +335,10 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
313
335
  const gateResults = this.dashboard.computeGateResults(config.gates, changedFiles);
314
336
  const disables = this.dashboard.countAddedDisables(patch);
315
337
  const rows = this.checklistRows(required, review);
316
- return new dashboard_1.DashboardInput(title, gateResults, disables, buildPassed, forkPoint, featureHead, mainHead, review, rows);
338
+ // buildCommand travels into the dashboard so the PR-body footer can NAME the command that vouched
339
+ // for this commit. The footer used to assert "build ran via nx affected" on every repo, which was
340
+ // simply false wherever buildCommand is not nx.
341
+ return new dashboard_1.DashboardInput(title, gateResults, disables, buildPassed, forkPoint, featureHead, mainHead, review, rows, config.buildCommand);
317
342
  }
318
343
  /**
319
344
  * The applicable checklists MINUS the optional ones nobody ran.
@@ -373,100 +398,6 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
373
398
  const marker = this.gateTokenService.gateTokenMarker(gateSalt, headSha);
374
399
  return marker === '' ? '' : `\n\n${marker}\n`;
375
400
  }
376
- // Enforce that EACH matched checklist was reviewed by its OWN named subagent, as a DISTINCT run —
377
- // the coding agent may not self-certify, and one reviewer may not stand in for several. A verified set
378
- // passes silently; no session id warns but passes; any missing reviewer throws so the PR does not open.
379
- // eslint-disable-next-line @typescript-eslint/max-params
380
- enforceProvenance(required, branch, repoRoot, config) {
381
- const errors = [];
382
- const report = new ProvenanceReport(true, []); // no reviewers to verify ⇒ vacuously verified
383
- const subagents = required.map((r) => r.subagent.trim()).filter((s) => s !== '');
384
- // verifyDistinct short-circuits to OK on an empty set, so this runs unconditionally: a repo with no
385
- // checklists still gets a provenance record naming the session and the main agent's own transcript.
386
- const result = this.provenance.verifyDistinct(subagents, branch);
387
- report.verified = result.status === rules_config_1.PROVENANCE_OK;
388
- if (result.status === rules_config_1.PROVENANCE_MISSING) {
389
- errors.push(result.detail);
390
- }
391
- else if (result.status === rules_config_1.PROVENANCE_SKIPPED) {
392
- process.stderr.write(`⚠️ ${result.detail}\n`);
393
- }
394
- report.evidence = this.gatherEvidence(repoRoot, required, result, branch);
395
- errors.push(...this.evidenceErrors(report.evidence, config));
396
- // BEFORE the throw below, deliberately. A refused round is the one most worth auditing, and a record
397
- // that only ever appeared on success could not answer what the reviewers did the time it was refused.
398
- this.writeProvenanceRecord(repoRoot, required, result, report.evidence);
399
- if (errors.length > 0) {
400
- throw new rules_config_1.InformAiError(`${errors.length} checklist(s) require an independent reviewer subagent that did not run — fix, then re-run pnpm wp-finish-upsert-pr:\n\n` +
401
- errors.map((e) => ` • ${e}`).join('\n') +
402
- `\n\nSpawn the named reviewer subagent to review the checklist on THIS branch, then re-run.`);
403
- }
404
- return report;
405
- }
406
- /**
407
- * What each credited reviewer actually read. Purely observational here — {@link evidenceErrors} decides
408
- * whether any of it blocks, and by default none of it does.
409
- */
410
- // eslint-disable-next-line @typescript-eslint/max-params
411
- gatherEvidence(repoRoot, required, result, branch) {
412
- const docPaths = {};
413
- for (const req of required) {
414
- if (req.subagent.trim() !== '')
415
- docPaths[req.subagent] = req.doc.trim() === '' ? '' : path.resolve(repoRoot, req.doc);
416
- }
417
- const diffDir = path.join((0, rules_config_1.prDirFor)(repoRoot, this.aiBranchName.getFeatureName()), 'diff');
418
- return this.provenance.evidenceFor(new rules_config_1.EvidenceRequest(branch, result.agentIds, diffDir, docPaths));
419
- }
420
- /**
421
- * Write this round's audit record: `.webpieces/pr-review/<branch>/provenance.json`, linking each verdict
422
- * to the transcript of the subagent that produced it.
423
- *
424
- * A SEPARATE file rather than a field inside review.json / review-<id>.json, for two reasons. The
425
- * reviewer cannot supply this itself — a subagent's environment exposes the PARENT session id and no
426
- * agent id, so a self-reported transcript link would be invented — and keeping the AI-authored files
427
- * byte-untouched means nothing in the record can be mistaken for something a reviewer claimed about
428
- * itself. Every path here is derived by the tooling from the harness's own artifacts.
429
- *
430
- * Never fatal: an unwritable record is a lost audit trail, not a reason to refuse a PR.
431
- */
432
- // eslint-disable-next-line @typescript-eslint/max-params
433
- writeProvenanceRecord(repoRoot, required, result, evidence) {
434
- const featureName = this.aiBranchName.getFeatureName();
435
- const prDir = (0, rules_config_1.prDirFor)(repoRoot, featureName);
436
- const request = new rules_config_1.ProvenanceWriteRequest(prDir, featureName, this.gitOut(['rev-parse', 'HEAD']), result.status);
437
- request.offered = new rules_config_1.OfferedContext(path.join(prDir, 'diff'), this.reviewerInstructions.instructionsDirFor(repoRoot, featureName));
438
- request.reviewers = evidence.map((e) => new rules_config_1.ReviewerTranscript(e, this.reviewerPathsFor(repoRoot, featureName, required, e.agentType)));
439
- const written = this.provenanceRecord.write(request);
440
- if (written !== '')
441
- process.stdout.write(` transcript provenance → ${written}\n`);
442
- }
443
- // Where ONE reviewer's verdict, instructions and checklist doc live. Keyed by agentType, which IS the
444
- // checklist id: ChecklistDefinition sets `id = subagent`, so the two never diverge.
445
- // eslint-disable-next-line @typescript-eslint/max-params
446
- reviewerPathsFor(repoRoot, featureName, required, agentType) {
447
- const req = required.find((r) => r.subagent.trim() === agentType);
448
- const doc = req !== undefined && req.doc.trim() !== '' ? path.resolve(repoRoot, req.doc) : '';
449
- return new rules_config_1.ReviewerPaths(this.reviewJsonService.checklistResultPath((0, rules_config_1.reviewJsonPath)(repoRoot, featureName), req?.id ?? agentType), this.reviewerInstructions.pathFor(repoRoot, featureName, agentType), doc);
450
- }
451
- /**
452
- * WARN (default) or REFUSE (opt-in) on a reviewer that wrote a verdict without opening the diff.
453
- *
454
- * Default-warn because the signal is derived from undocumented Claude Code transcript internals: if the
455
- * format shifts, a blocking check wedges every PR in every consumer repo with no self-service recovery.
456
- * `requireDiffEvidence` lets a repo that has watched the warning promote it deliberately.
457
- */
458
- evidenceErrors(evidence, config) {
459
- const blind = evidence.filter((e) => !e.readDiff);
460
- if (blind.length === 0)
461
- return [];
462
- const names = blind.map((e) => e.agentType).join(', ');
463
- if (!config.requireDiffEvidence) {
464
- process.stderr.write(`\n⚠️ ${blind.length} reviewer(s) wrote a verdict with no record of opening the extracted diff: ${names}\n` +
465
- ' Published on the PR as a note. Not blocking — set pr-gate.requireDiffEvidence:true to make it one.\n');
466
- return [];
467
- }
468
- return [`these reviewers wrote a verdict without opening the diff (pr-gate.requireDiffEvidence is on): ${names}`];
469
- }
470
401
  /**
471
402
  * Publish the roster + every reviewer's full `output` as ONE combined PR comment, idempotently (find the
472
403
  * marker comment → PATCH it, else POST). Never fatal: by here the PR is already created/updated, so a
@@ -475,51 +406,36 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
475
406
  * Posted on EVERY run of a repo that defines checklists — including one where nothing matched, because
476
407
  * "all five were evaluated and none applied" is the good news the old comment could not deliver. The
477
408
  * guard is `scan.defined.length`, deliberately NOT the number of rows that ran: a repo with no
478
- * checklists configured must still see no comment at all (see ChecklistNotice / renderDashboard).
409
+ * checklists configured must still see no comment at all (see ChecklistCommentRenderer).
479
410
  */
480
411
  // eslint-disable-next-line @typescript-eslint/max-params
481
- // eslint-disable-next-line @typescript-eslint/max-params
482
412
  postChecklistComment(repoRoot, prNumber, scan, review, provenance) {
483
413
  if (prNumber === '' || scan.defined.length === 0)
484
414
  return;
485
415
  if (!(0, rules_config_1.loadAndValidate)(repoRoot).prGate.checklistComments)
486
416
  return;
487
- const body = this.dashboard.renderChecklistComment(this.commentRows(scan, review, provenance), provenance.verified, scan.roster.baseResolved);
488
- const prDir = (0, rules_config_1.prDirFor)(repoRoot, this.aiBranchName.getFeatureName());
489
- fs.mkdirSync(prDir, { recursive: true });
490
- const payload = path.join(prDir, 'checklist-comment.json');
491
- fs.writeFileSync(payload, JSON.stringify({ body }));
492
- const commentId = this.findChecklistCommentId(prNumber);
493
- const args = commentId !== ''
494
- ? ['api', '--method', 'PATCH', `repos/{owner}/{repo}/issues/comments/${commentId}`, '--input', payload]
495
- : ['api', '--method', 'POST', `repos/{owner}/{repo}/issues/${prNumber}/comments`, '--input', payload];
496
- const res = (0, child_process_1.spawnSync)('gh', args, { encoding: 'utf8' });
497
- if (res.status !== 0) {
498
- process.stderr.write('⚠️ Could not post the checklist review comment (non-fatal — the PR is already up).\n');
499
- }
500
- else {
501
- process.stdout.write(` ${commentId !== '' ? 'updated' : 'posted'} the checklist review comment ✓\n`);
502
- }
503
- }
504
- // The id of THIS tool's existing checklist comment on the PR (by the hidden marker), or '' if none.
505
- findChecklistCommentId(prNumber) {
506
- const res = (0, child_process_1.spawnSync)('gh', [
507
- 'api', '--paginate', `repos/{owner}/{repo}/issues/${prNumber}/comments`,
508
- '--jq', `.[] | select(.body | contains("${dashboard_1.CHECKLIST_COMMENT_MARKER}")) | .id`,
509
- ], { encoding: 'utf8' });
510
- if (res.status !== 0)
511
- return '';
512
- return (res.stdout ?? '').trim().split('\n')[0] ?? '';
417
+ const request = new pr_comment_upserter_1.PrCommentRequest();
418
+ request.prNumber = prNumber;
419
+ request.marker = checklist_comment_renderer_1.CHECKLIST_COMMENT_MARKER;
420
+ request.body = this.checklistComment.render(this.commentRows(scan, review, provenance), provenance.verified, scan.roster.baseResolved);
421
+ request.payloadDir = (0, rules_config_1.prDirFor)(repoRoot, this.aiBranchName.getFeatureName());
422
+ request.payloadName = 'checklist-comment.json';
423
+ request.label = 'checklist review comment';
424
+ this.commentUpserter.upsert(request);
513
425
  }
514
426
  // The PR, the remote branch, and the local branch all share the one stable feature name. Look up /
515
427
  // create / merge against `baseBranch` (baseBranchName tolerates a leftover `…wpN` mid-transition).
516
428
  // GatedPrPublisher owns the edit/push/create half and its ORDERING — the gated body goes up before
517
429
  // the push, so CI's `synchronize` read cannot see the previous run's token.
518
- upsertPr(repoRoot, baseBranch, body, title, input) {
430
+ upsertPr(request) {
431
+ const repoRoot = request.repoRoot;
432
+ const baseBranch = request.baseBranch;
433
+ const title = request.title;
434
+ const input = request.input;
519
435
  const prDir = (0, rules_config_1.prDirFor)(repoRoot, this.aiBranchName.getFeatureName());
520
436
  fs.mkdirSync(prDir, { recursive: true });
521
437
  const bodyFile = path.join(prDir, 'pr-body.md');
522
- fs.writeFileSync(bodyFile, body + '\n');
438
+ fs.writeFileSync(bodyFile, request.body + '\n');
523
439
  const published = this.publisher.publish(baseBranch, title, bodyFile);
524
440
  if (published.createFailed) {
525
441
  process.stderr.write('⚠️ gh pr create failed — create the PR manually with the body in:\n ' + bodyFile + '\n');
@@ -532,6 +448,24 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
532
448
  // subject GitHub would inherit from the single squash commit on the branch.
533
449
  const ref = this.prRef(baseBranch);
534
450
  const subject = ref.number !== '' ? `${title} (#${ref.number})` : title;
451
+ /*
452
+ * THE INVARIANT: the merge body IS the PR description, byte for byte.
453
+ *
454
+ * That is what makes every landing route agree — the GitHub Merge button and a bare `gh pr merge`
455
+ * copy the description via `squash_merge_commit_message: PR_BODY`, while `wp-land-pr` and finish's
456
+ * own auto-merge pass these bytes with `--body-file`. If the two strings could differ, which route
457
+ * landed the commit would change what history says, which is precisely the bug this whole change
458
+ * exists to remove. `pr-body-is-merge-body.spec.ts` pins it.
459
+ *
460
+ * The one wrinkle is the self-link. A brand-new PR has no URL until `gh pr create` returns, so the
461
+ * body published a moment ago was rendered with `prUrl: ''`. Re-render now that the URL is known
462
+ * and push the corrected description back, so the two strings match and the commit carries its own
463
+ * link. Only the CREATE path pays that extra edit: on every later run the URL was already known
464
+ * before publishing, `finalBody` matches what went up, and nothing is re-sent.
465
+ */
466
+ const finalBody = this.dashboard.renderPrBody(input, ref.url) + request.tokenSuffix;
467
+ if (finalBody !== request.body)
468
+ this.backfillPrBody(ref.number, bodyFile, finalBody);
535
469
  // MACHINE-GLOBAL, keyed by the PR's identity, so `wp-land-pr` finds it from ANY tree — see
536
470
  // MergeBodyFiler for the incident that moved it out of this worktree's pr-review/ dir.
537
471
  const bodyRequest = new merge_body_filer_1.MergeBodyRequest();
@@ -540,7 +474,7 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
540
474
  bodyRequest.feature = this.aiBranchName.getFeatureName();
541
475
  bodyRequest.prNumber = ref.number;
542
476
  bodyRequest.prUrl = ref.url;
543
- bodyRequest.body = this.dashboard.renderCommitBody(input, ref.url) + '\n';
477
+ bodyRequest.body = finalBody + '\n';
544
478
  const mergeBodyFile = this.mergeBodyFiler.file(bodyRequest);
545
479
  // PrMerger owns the direct-merge / auto-merge-fallback decision AND checks every gh status, so a
546
480
  // merge that did not happen is reported as such instead of being swallowed (see pr-merger.ts).
@@ -551,6 +485,93 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
551
485
  const outcome = this.prMerger.merge(baseBranch, subject, mergeBodyFile, new pr_merger_1.MergeIntent(mergeMode, false));
552
486
  return new UpsertResult(ref.number !== '' ? ref.number : num, ref.url, outcome);
553
487
  }
488
+ /**
489
+ * Everything the gated flow PUBLISHES, in the one order that is safe — the three surfaces plus the
490
+ * merge, as a single unit.
491
+ *
492
+ * 1. the PR DESCRIPTION = the compact git-log body (+ the hidden gate token), written before the
493
+ * push so CI's `synchronize` read can never see the previous run's token,
494
+ * 2. the 1st comment = the full dashboard,
495
+ * 3. the 2nd comment = each reviewer's output.
496
+ *
497
+ * The token is bound to the LOCAL HEAD sha and minted here because we only reach this line after the
498
+ * build gate and every BLOCK checklist passed, so minting is legitimate. Nothing about
499
+ * HMAC(salt, HEAD) needs the remote to have the commit yet.
500
+ *
501
+ * Extracted from `run` when the two comments made it 82 lines against a hard 80-line rule — and it
502
+ * earns its own name: these four steps have a REQUIRED order, and the comment explaining that order
503
+ * belongs with them rather than inside a method that also loads config and runs a build gate.
504
+ */
505
+ publishAll(repoRoot, base, input, sources) {
506
+ const gateSalt = (0, rules_config_1.loadAndValidate)(repoRoot).prGate.gateSalt;
507
+ const headSha = this.gitOut(['rev-parse', 'HEAD']);
508
+ const upsert = new PrUpsertRequest(input);
509
+ upsert.repoRoot = repoRoot;
510
+ upsert.baseBranch = base;
511
+ upsert.title = input.title;
512
+ upsert.tokenSuffix = this.gateTokenBody(gateSalt, headSha);
513
+ // `existingPrUrl` is '' only for a brand-new PR — there is no URL to self-link to until
514
+ // `gh pr create` returns one, and upsertPr back-fills it the moment it does.
515
+ upsert.body = this.dashboard.renderPrBody(input, this.existingPrUrl(base)) + upsert.tokenSuffix;
516
+ const result = this.upsertPr(upsert);
517
+ // In the order the PR body's pointer promises: full dashboard 1st, reviewer output 2nd. Both are
518
+ // idempotent by hidden marker and both non-fatal — the PR is up by now, so a `gh` hiccup on a
519
+ // comment must not turn a finished run into a failed one.
520
+ this.postDetailComment(repoRoot, result.prNumber, input);
521
+ this.postChecklistComment(repoRoot, result.prNumber, sources.scan, sources.review, sources.provenance);
522
+ return result;
523
+ }
524
+ /**
525
+ * Re-publish the description now that the PR's own URL is known (create path only).
526
+ *
527
+ * Non-fatal on purpose. The PR exists, its body already carries a valid gate token for the pushed
528
+ * head, and the code is up — the ONLY thing missing is the self-link on the first line. Aborting a
529
+ * finished run over a cosmetic back-link would be a worse outcome than the missing link, and the very
530
+ * next `wp-finish-upsert-pr` writes it (by then the URL is known before publishing, so it goes up with
531
+ * the body). The merge body still gets the linked version regardless: it is filed from `finalBody`.
532
+ */
533
+ backfillPrBody(prNumber, bodyFile, finalBody) {
534
+ if (prNumber === '')
535
+ return;
536
+ fs.writeFileSync(bodyFile, finalBody + '\n');
537
+ const res = (0, child_process_1.spawnSync)('gh', ['pr', 'edit', prNumber, '--body-file', bodyFile], { encoding: 'utf8' });
538
+ if (res.status !== 0) {
539
+ process.stderr.write('⚠️ Could not add the PR\'s own link to its description (non-fatal — the PR and the gate\n' +
540
+ ' token are already up). The next wp-finish-upsert-pr writes it.\n');
541
+ return;
542
+ }
543
+ process.stdout.write(' back-filled the PR description with its own link ✓\n');
544
+ }
545
+ /**
546
+ * The PR's web URL if one is already open for this branch, else '' — read BEFORE publishing so the
547
+ * description can carry its own link on the first render. Only a brand-new PR gets '' (and then
548
+ * {@link backfillPrBody} fixes it up after `gh pr create` returns a URL).
549
+ */
550
+ existingPrUrl(baseBranch) {
551
+ return this.prRef(baseBranch).url;
552
+ }
553
+ /**
554
+ * The 1st PR comment: the FULL dashboard — every row including the green ones, the whole summary, the
555
+ * 3-point hash points. This is what the PR description used to be, and moving it here is the entire
556
+ * point of the change: the description is now the git-log body, and a squash commit must not carry a
557
+ * risk table.
558
+ *
559
+ * Posted unconditionally (any repo, checklists or not), because the description's last bullet PROMISES
560
+ * a 1st comment holding the detail. A pointer to a comment that does not exist is worse than no
561
+ * pointer. Non-fatal — the PR is already up, and the description alone is a complete, readable record.
562
+ */
563
+ postDetailComment(repoRoot, prNumber, input) {
564
+ if (prNumber === '')
565
+ return;
566
+ const request = new pr_comment_upserter_1.PrCommentRequest();
567
+ request.prNumber = prNumber;
568
+ request.marker = dashboard_1.DETAIL_COMMENT_MARKER;
569
+ request.body = dashboard_1.DETAIL_COMMENT_MARKER + '\n' + this.dashboard.renderDetailComment(input);
570
+ request.payloadDir = (0, rules_config_1.prDirFor)(repoRoot, this.aiBranchName.getFeatureName());
571
+ request.payloadName = 'detail-comment.json';
572
+ request.label = 'full dashboard comment';
573
+ this.commentUpserter.upsert(request);
574
+ }
554
575
  // The PR's number + web URL (for the merge subject `(#N)` and the commit-body back-link). Both ''
555
576
  // if it can't be resolved. Rendered via jq into one tab-separated line so no JSON parsing is needed.
556
577
  prRef(baseBranch) {
@@ -574,15 +595,15 @@ exports.FinishUpsertPrCommand = FinishUpsertPrCommand = tslib_1.__decorate([
574
595
  pr_merger_1.PrMerger,
575
596
  gated_pr_publisher_1.GatedPrPublisher,
576
597
  dashboard_1.Dashboard,
598
+ checklist_comment_renderer_1.ChecklistCommentRenderer,
577
599
  checklist_scanner_1.ChecklistScanner,
578
600
  reviewer_verdict_gate_1.ReviewerVerdictGate,
579
601
  rules_config_1.ReviewJsonService,
580
602
  rules_config_1.GateTokenService,
581
- rules_config_1.SubagentProvenanceService,
582
- rules_config_1.ReviewProvenanceService,
583
- rules_config_1.ReviewerInstructionsService,
603
+ provenance_enforcer_1.ProvenanceEnforcer,
584
604
  review_stage_receipt_1.ReviewStageReceiptService,
585
605
  finish_banner_1.FinishBanner,
586
- merge_body_filer_1.MergeBodyFiler])
606
+ merge_body_filer_1.MergeBodyFiler,
607
+ pr_comment_upserter_1.PrCommentUpserter])
587
608
  ], FinishUpsertPrCommand);
588
609
  //# sourceMappingURL=finish-upsert-pr-command.js.map