@webpieces/pr-gate 0.4.497 → 0.4.499
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.
- package/package.json +3 -3
- package/src/dashboard/dashboard.d.ts +15 -0
- package/src/dashboard/dashboard.js +25 -2
- package/src/dashboard/dashboard.js.map +1 -1
- package/src/scripts/commands/cleanup-command.d.ts +42 -3
- package/src/scripts/commands/cleanup-command.js +69 -11
- package/src/scripts/commands/cleanup-command.js.map +1 -1
- package/src/scripts/commands/finish-upsert-pr-command.d.ts +55 -5
- package/src/scripts/commands/finish-upsert-pr-command.js +162 -30
- package/src/scripts/commands/finish-upsert-pr-command.js.map +1 -1
- package/src/scripts/commands/land-pr-command.d.ts +17 -2
- package/src/scripts/commands/land-pr-command.js +32 -3
- package/src/scripts/commands/land-pr-command.js.map +1 -1
- package/src/scripts/commands/review-upsert-pr-command.d.ts +88 -0
- package/src/scripts/commands/review-upsert-pr-command.js +244 -0
- package/src/scripts/commands/review-upsert-pr-command.js.map +1 -0
- package/src/scripts/commands/start-upsert-pr-command.d.ts +10 -15
- package/src/scripts/commands/start-upsert-pr-command.js +41 -47
- package/src/scripts/commands/start-upsert-pr-command.js.map +1 -1
- package/src/scripts/commands/worktree-cleanup.d.ts +45 -0
- package/src/scripts/commands/worktree-cleanup.js +130 -0
- package/src/scripts/commands/worktree-cleanup.js.map +1 -0
- package/src/scripts/pr-gate-app.d.ts +9 -5
- package/src/scripts/pr-gate-app.js +12 -8
- package/src/scripts/pr-gate-app.js.map +1 -1
- package/src/scripts/workflow/checklist-scanner.d.ts +27 -11
- package/src/scripts/workflow/checklist-scanner.js +37 -15
- package/src/scripts/workflow/checklist-scanner.js.map +1 -1
- package/src/scripts/workflow/diff-basis.d.ts +53 -0
- package/src/scripts/workflow/diff-basis.js +113 -0
- package/src/scripts/workflow/diff-basis.js.map +1 -0
- package/src/scripts/workflow/diff-materializer.d.ts +85 -0
- package/src/scripts/workflow/diff-materializer.js +253 -0
- package/src/scripts/workflow/diff-materializer.js.map +1 -0
- package/src/scripts/workflow/git-findForkPoint.d.ts +1 -1
- package/src/scripts/workflow/git-findForkPoint.js +1 -1
- package/src/scripts/workflow/git-findForkPoint.js.map +1 -1
- package/src/scripts/workflow/pr-context-writer.d.ts +16 -10
- package/src/scripts/workflow/pr-context-writer.js +20 -14
- package/src/scripts/workflow/pr-context-writer.js.map +1 -1
- package/src/scripts/workflow/review-stage-receipt.d.ts +36 -0
- package/src/scripts/workflow/review-stage-receipt.js +82 -0
- package/src/scripts/workflow/review-stage-receipt.js.map +1 -0
- package/src/scripts/workflow/reviewer-briefing-builder.d.ts +36 -0
- package/src/scripts/workflow/reviewer-briefing-builder.js +104 -0
- package/src/scripts/workflow/reviewer-briefing-builder.js.map +1 -0
- package/src/scripts/{wp-checklist.js → wp-review-upsert-pr.js} +3 -3
- package/src/scripts/wp-review-upsert-pr.js.map +1 -0
- package/src/scripts/commands/checklist-command.d.ts +0 -27
- package/src/scripts/commands/checklist-command.js +0 -97
- package/src/scripts/commands/checklist-command.js.map +0 -1
- package/src/scripts/wp-checklist.js.map +0 -1
- /package/src/scripts/{wp-checklist.d.ts → wp-review-upsert-pr.d.ts} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cleanup-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/cleanup-command.ts"],"names":[],"mappings":"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","sourcesContent":["import * as readline from 'readline';\nimport {\n BranchArchiver,\n BranchReaper,\n DeletableBranch,\n ReapResult,\n ReapedBranch,\n RepoRootFinder,\n loadAndValidate,\n CLASSIFICATION_SUPERSEDED,\n CLASSIFICATION_CONTENT_IN_MAIN,\n CLASSIFICATION_NEVER_PROPOSED,\n PROMPTABLE_CLASSIFICATIONS,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// One line of human-facing explanation per promptable classification, ordered most-safe first. These\n// replace the single string `no merged PR found — a human must decide`, which covered all three of\n// these situations identically and so told a human nothing they could act on.\nconst CLASSIFICATION_HEADINGS: Readonly<Record<string, string>> = {\n [CLASSIFICATION_SUPERSEDED]:\n 'SUPERSEDED — the PR was closed WITHOUT merging and later PRs have merged since. Near-certainly\\n'\n + ' the abandoned first attempt at work that landed under a different number. Safest group to delete.',\n [CLASSIFICATION_CONTENT_IN_MAIN]:\n 'CONTENT ALREADY IN MAIN — every commit has a patch-equivalent in origin/main (git cherry).\\n'\n + ' The work is not unique to this branch; only the commit objects are.',\n [CLASSIFICATION_NEVER_PROPOSED]:\n 'NEVER PROPOSED — no PR was ever opened, and these commits may be the ONLY copy in existence.\\n'\n + ' Read the unique-commit counts before answering. This is the group to say no to if unsure.',\n};\n\n/**\n * wp-cleanup: delete the local branches whose PR is already merged (or that hold no commits), then ASK\n * about the ones that are merely probably-dead.\n *\n * WHY a named command instead of the `git branch -D a b c` the guards used to print: an AI agent\n * reads a raw `-D` as destructive, so it asks permission and stops — which is exactly why branches\n * piled up despite the tooling knowing precisely which ones were dead. `pnpm wp-cleanup` is one\n * boring, allowlistable verb whose safety is a property of the command itself rather than of the\n * agent's judgement about a git flag.\n *\n * WHY IT NOW PROMPTS: sparing silently was the other half of the same problem. Every spared branch\n * reported the identical `no merged PR found — a human must decide`, so the human could not decide,\n * so nothing got deleted, so the pile grew until branch-creation-guard refused to make the next branch\n * and an agent went looking for a config knob to loosen. Shown a real classification with unique-commit\n * counts, the human in that session answered in five words: \"these should all be delete branches\".\n * The prompt is cheap because archiving happens FIRST — a yes costs a tag, not the history.\n *\n * All the danger still lives in the verdicts, not here — see BranchReaper for why every AUTOMATICALLY\n * deleted branch is provably dead and recoverable, and note that nothing in the prompted group is ever\n * deleted without an explicit typed answer.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CleanupCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly branchReaper: BranchReaper,\n private readonly archiver: BranchArchiver,\n ) {}\n\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const retention = loadAndValidate(repoRoot).prGate.landPr.branchRetention;\n // No cache argument: wp-cleanup recomputes the verdicts itself. The file on disk is allowed to\n // go stale, and stale evidence is fine for BLOCKING but never for DELETING.\n const result = this.branchReaper.reap(repoRoot, 'wp-cleanup', null, retention);\n process.stdout.write(this.report(result));\n\n const promptable = this.promptable(result.spared);\n if (promptable.length === 0) return;\n process.stdout.write(this.classifiedBlock(promptable));\n const approved = await this.askWhichToDelete(promptable);\n if (approved.length === 0) {\n process.stdout.write('\\nNothing deleted — the branches above were kept.\\n');\n return;\n }\n const second = this.branchReaper.reapApproved(repoRoot, 'wp-cleanup', approved, retention);\n process.stdout.write(this.report(second));\n }\n\n private report(result: ReapResult): string {\n if (result.reaped.length === 0 && result.failed.length === 0) {\n return '\\n✅ Nothing to clean up — no local branch is provably dead.\\n';\n }\n\n let out = '\\n' + SEP + `🧹 Cleaned up ${String(result.reaped.length)} dead local branch(es)\\n` + SEP + '\\n';\n for (const entry of result.reaped) out += this.reapedLine(entry);\n\n if (result.failed.length > 0) {\n out += `\\n⚠️ ${String(result.failed.length)} branch(es) could not be deleted:\\n`;\n for (const entry of result.failed) out += ` ✗ ${entry.branch} — ${entry.error}\\n`;\n }\n\n // Printed even on success: a deletion the human cannot undo is a deletion they have to trust\n // blindly, and the whole argument for auto-cleanup is that they never have to.\n out += '\\nEvery deletion is logged with its pre-delete SHA in .webpieces/hooks/branch-mutations.log —\\n'\n + 'recover any of them with the `recover=` command on its line.\\n';\n return out;\n }\n\n private reapedLine(entry: ReapedBranch): string {\n const sha = entry.sha !== '' ? ` (was ${entry.sha.slice(0, 8)})` : '';\n // The archive tag is printed inline because it is the ONE thing that makes this delete casually\n // reversible — a name a human can type, rather than a sha they have to go dig out of a log.\n const archived = entry.archiveTag !== ''\n ? `\\n archived → ${entry.archiveTag} (restore: ${this.archiver.restoreCommand(entry.branch, entry.archiveTag)})`\n : '';\n return ` ✓ ${entry.branch}${sha} — ${entry.reason}${archived}\\n`;\n }\n\n // The spared branches a human can meaningfully rule on, grouped and ordered most-safe first.\n // Branches spared for a MECHANICAL reason (checked out in a worktree) are deliberately excluded:\n // there is no judgement to make, git would simply refuse.\n private promptable(spared: DeletableBranch[]): DeletableBranch[] {\n const out: DeletableBranch[] = [];\n for (const classification of PROMPTABLE_CLASSIFICATIONS) {\n for (const entry of spared) {\n if (entry.classification === classification) out.push(entry);\n }\n }\n return out;\n }\n\n // The classification table: what each group means, and per branch its unique-commit count — the one\n // number that says whether a yes costs nothing or costs the only copy of somebody's work.\n private classifiedBlock(promptable: DeletableBranch[]): string {\n let out = '\\n' + SEP + `🤔 ${String(promptable.length)} branch(es) are probably dead — your call\\n` + SEP;\n let current = '';\n for (let i = 0; i < promptable.length; i += 1) {\n const entry = promptable[i];\n if (entry.classification !== current) {\n current = entry.classification;\n out += `\\n${CLASSIFICATION_HEADINGS[current] ?? current}\\n\\n`;\n }\n const commits = entry.commits >= 0 ? `${String(entry.commits)} unique commit(s)` : 'unique commits unknown';\n out += ` [${String(i + 1)}] ${entry.branch}\\n ${commits} — ${entry.reason}\\n`;\n }\n return out;\n }\n\n /**\n * Ask which of the classified branches to delete. Answers: `all`, `none` (default), or a\n * comma/space-separated list of the numbers shown.\n *\n * NON-INTERACTIVE (no TTY — CI, a hook, a piped agent shell) answers NONE and says so. A prompt\n * nobody can see must never be read as consent, and this is the one place in the tooling where a\n * deletion is not backed by a proof.\n */\n private async askWhichToDelete(promptable: DeletableBranch[]): Promise<DeletableBranch[]> {\n if (process.stdin.isTTY !== true) {\n process.stdout.write(\n '\\nNot a terminal — nothing was deleted and nothing was assumed.\\n'\n + 'Run `pnpm wp-cleanup` in an interactive shell to answer, or delete individually.\\n',\n );\n return [];\n }\n const answer = (await this.question(\n `\\nDelete which? [all / none / e.g. \"1,3\"] (default none): `)).trim().toLowerCase();\n if (answer === '' || answer === 'none' || answer === 'n') return [];\n if (answer === 'all' || answer === 'a') return promptable;\n return this.pickByNumber(promptable, answer);\n }\n\n // Parse `1,3` / `1 3` into branches, ignoring anything out of range. An unparseable answer selects\n // nothing, which is the fail-safe direction for a question about deleting.\n private pickByNumber(promptable: DeletableBranch[], answer: string): DeletableBranch[] {\n const out: DeletableBranch[] = [];\n for (const token of answer.split(/[\\s,]+/)) {\n const index = Number(token);\n if (!Number.isInteger(index) || index < 1 || index > promptable.length) continue;\n out.push(promptable[index - 1]);\n }\n return out;\n }\n\n // Seam: overridden in the spec so the prompt parsing is testable with no terminal.\n protected question(prompt: string): Promise<string> {\n const rl = readline.createInterface({ input: process.stdin, output: process.stdout });\n return new Promise<string>((resolve: (value: string) => void): void => {\n rl.question(prompt, (answer: string): void => {\n rl.close();\n resolve(answer);\n });\n });\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"cleanup-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/cleanup-command.ts"],"names":[],"mappings":"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","sourcesContent":["import * as readline from 'readline';\nimport {\n BranchArchiver,\n BranchReaper,\n DeletableBranch,\n DeletableWorktree,\n ReapResult,\n ReapedBranch,\n RepoRootFinder,\n loadAndValidate,\n CLASSIFICATION_SUPERSEDED,\n CLASSIFICATION_CONTENT_IN_MAIN,\n CLASSIFICATION_NEVER_PROPOSED,\n PROMPTABLE_CLASSIFICATIONS,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { WorktreeCleanupSection } from './worktree-cleanup';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// One line of human-facing explanation per promptable classification, ordered most-safe first. These\n// replace the single string `no merged PR found — a human must decide`, which covered all three of\n// these situations identically and so told a human nothing they could act on.\nconst CLASSIFICATION_HEADINGS: Readonly<Record<string, string>> = {\n [CLASSIFICATION_SUPERSEDED]:\n 'SUPERSEDED — the PR was closed WITHOUT merging and later PRs have merged since. Near-certainly\\n'\n + ' the abandoned first attempt at work that landed under a different number. Safest group to delete.',\n [CLASSIFICATION_CONTENT_IN_MAIN]:\n 'CONTENT ALREADY IN MAIN — every commit has a patch-equivalent in origin/main (git cherry).\\n'\n + ' The work is not unique to this branch; only the commit objects are.',\n [CLASSIFICATION_NEVER_PROPOSED]:\n 'NEVER PROPOSED — no PR was ever opened, and these commits may be the ONLY copy in existence.\\n'\n + ' Read the unique-commit counts before answering. This is the group to say no to if unsure.',\n};\n\n/**\n * wp-cleanup: remove the dead WORKTREES, delete the local branches whose PR is already merged (or that\n * hold no commits), then ASK about the ones that are merely probably-dead.\n *\n * WHY WORKTREES ARE PART OF THIS: they were the half that never got reaped. The verdicts existed —\n * merged-branches.ts has been writing a full `DeletableWorktree[]` into the cache all along — and their\n * only consumer used them to BLOCK the next `git worktree add`, never to remove anything. Meanwhile a\n * live worktree pins its branch, so the branch was spared too. Two things the tooling could PROVE were\n * dead, accumulating forever, until the guard refused to create the next branch and the only remedy it\n * could offer was loosening its own cap. See WorktreeCleanupSection and WorktreeReaper.\n *\n * WHY a named command instead of the `git branch -D a b c` the guards used to print: an AI agent\n * reads a raw `-D` as destructive, so it asks permission and stops — which is exactly why branches\n * piled up despite the tooling knowing precisely which ones were dead. `pnpm wp-cleanup` is one\n * boring, allowlistable verb whose safety is a property of the command itself rather than of the\n * agent's judgement about a git flag.\n *\n * WHY IT NOW PROMPTS: sparing silently was the other half of the same problem. Every spared branch\n * reported the identical `no merged PR found — a human must decide`, so the human could not decide,\n * so nothing got deleted, so the pile grew until branch-creation-guard refused to make the next branch\n * and an agent went looking for a config knob to loosen. Shown a real classification with unique-commit\n * counts, the human in that session answered in five words: \"these should all be delete branches\".\n * The prompt is cheap because archiving happens FIRST — a yes costs a tag, not the history.\n *\n * All the danger still lives in the verdicts, not here — see BranchReaper for why every AUTOMATICALLY\n * deleted branch is provably dead and recoverable, and note that nothing in the prompted group is ever\n * deleted without an explicit typed answer.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CleanupCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly branchReaper: BranchReaper,\n private readonly archiver: BranchArchiver,\n private readonly worktreeSection: WorktreeCleanupSection,\n ) {}\n\n /**\n * WORKTREES FIRST, then branches. The order is the fix, not a detail: a worktree HOLDS its branch,\n * so that branch is spared `in-use` (\"remove that worktree before deleting the branch\") and nothing\n * used to remove the worktree — so both piled up until branch-creation-guard refused to make the\n * next one. Reaping the worktree takes its branch with it, and the branch pass then recomputes its\n * verdicts from scratch against the post-removal truth.\n */\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n const retention = loadAndValidate(repoRoot).prGate.landPr.branchRetention;\n await this.cleanUpWorktrees(repoRoot, retention);\n await this.cleanUpBranches(repoRoot, retention);\n }\n\n private async cleanUpBranches(repoRoot: string, retention: string): Promise<void> {\n // No cache argument: wp-cleanup recomputes the verdicts itself. The file on disk is allowed to\n // go stale, and stale evidence is fine for BLOCKING but never for DELETING.\n const result = this.branchReaper.reap(repoRoot, 'wp-cleanup', null, retention);\n process.stdout.write(this.report(result));\n\n const promptable = this.promptable(result.spared);\n if (promptable.length === 0) return;\n process.stdout.write(this.classifiedBlock(promptable));\n const approved = await this.askWhichToDelete(promptable, 'branch');\n if (approved.length === 0) {\n process.stdout.write('\\nNothing deleted — the branches above were kept.\\n');\n return;\n }\n const second = this.branchReaper.reapApproved(repoRoot, 'wp-cleanup', approved, retention);\n process.stdout.write(this.report(second));\n }\n\n /**\n * Reap the provably-dead worktrees, then ASK about the probably-dead ones — the same two-tier\n * posture the branch half has, because it is the same verdict on the same branch.\n *\n * WorktreeReaper enforces the safety rails regardless of what is passed or answered: never the\n * primary clone, never the tree this command is running in, and never `--force` (git's refusal to\n * remove a worktree holding untracked or modified files is a feature, and forcing it is how a\n * cleanup command becomes a data-loss command).\n */\n private async cleanUpWorktrees(repoRoot: string, retention: string): Promise<void> {\n const verdicts = this.worktreeSection.verdicts(repoRoot);\n const dead = this.worktreeSection.provablyDead(verdicts);\n if (dead.length > 0) {\n process.stdout.write(\n this.worktreeSection.report(\n this.worktreeSection.reap(repoRoot, 'wp-cleanup', dead, retention)));\n }\n process.stdout.write(this.worktreeSection.sparedBlock(verdicts, dead));\n\n const promptable = this.worktreeSection.promptable(verdicts);\n if (promptable.length === 0) return;\n process.stdout.write(this.worktreeSection.promptBlock(promptable));\n const approved = await this.askWhichToDelete(promptable, 'worktree');\n if (approved.length === 0) {\n process.stdout.write('\\nNothing removed — the worktrees above were kept.\\n');\n return;\n }\n process.stdout.write(\n this.worktreeSection.report(\n this.worktreeSection.reap(repoRoot, 'wp-cleanup', approved, retention)));\n }\n\n private report(result: ReapResult): string {\n if (result.reaped.length === 0 && result.failed.length === 0) {\n return '\\n✅ Nothing to clean up — no local branch is provably dead.\\n';\n }\n\n let out = '\\n' + SEP + `🧹 Cleaned up ${String(result.reaped.length)} dead local branch(es)\\n` + SEP + '\\n';\n for (const entry of result.reaped) out += this.reapedLine(entry);\n\n if (result.failed.length > 0) {\n out += `\\n⚠️ ${String(result.failed.length)} branch(es) could not be deleted:\\n`;\n for (const entry of result.failed) out += ` ✗ ${entry.branch} — ${entry.error}\\n`;\n }\n\n // Printed even on success: a deletion the human cannot undo is a deletion they have to trust\n // blindly, and the whole argument for auto-cleanup is that they never have to.\n out += '\\nEvery deletion is logged with its pre-delete SHA in .webpieces/hooks/branch-mutations.log —\\n'\n + 'recover any of them with the `recover=` command on its line.\\n';\n return out;\n }\n\n private reapedLine(entry: ReapedBranch): string {\n const sha = entry.sha !== '' ? ` (was ${entry.sha.slice(0, 8)})` : '';\n // The archive tag is printed inline because it is the ONE thing that makes this delete casually\n // reversible — a name a human can type, rather than a sha they have to go dig out of a log.\n const archived = entry.archiveTag !== ''\n ? `\\n archived → ${entry.archiveTag} (restore: ${this.archiver.restoreCommand(entry.branch, entry.archiveTag)})`\n : '';\n return ` ✓ ${entry.branch}${sha} — ${entry.reason}${archived}\\n`;\n }\n\n /**\n * The spared branches a human can meaningfully rule on, grouped and ordered most-safe first.\n *\n * Branches spared as IN_USE — checked out in a worktree — are still excluded here, but the reason\n * is no longer \"git would simply refuse\". That premise died the moment worktrees became reapable.\n * The real reason is the ORDER in run(): the worktree pass has already run, so an IN_USE branch is\n * one of exactly two things. Either its worktree was dead and the reap took the branch with it (so\n * it is not in this list at all), or its worktree is one we are deliberately keeping — locked, held\n * open by uncommitted work, or the one we are standing in — and offering to delete the branch out\n * from under a live checkout is not a question worth asking. The pair is offered TOGETHER by the\n * worktree prompt, which shows both the path and the branch it holds, or it is not offered at all.\n */\n private promptable(spared: DeletableBranch[]): DeletableBranch[] {\n const out: DeletableBranch[] = [];\n for (const classification of PROMPTABLE_CLASSIFICATIONS) {\n for (const entry of spared) {\n if (entry.classification === classification) out.push(entry);\n }\n }\n return out;\n }\n\n // The classification table: what each group means, and per branch its unique-commit count — the one\n // number that says whether a yes costs nothing or costs the only copy of somebody's work.\n private classifiedBlock(promptable: DeletableBranch[]): string {\n let out = '\\n' + SEP + `🤔 ${String(promptable.length)} branch(es) are probably dead — your call\\n` + SEP;\n let current = '';\n for (let i = 0; i < promptable.length; i += 1) {\n const entry = promptable[i];\n if (entry.classification !== current) {\n current = entry.classification;\n out += `\\n${CLASSIFICATION_HEADINGS[current] ?? current}\\n\\n`;\n }\n const commits = entry.commits >= 0 ? `${String(entry.commits)} unique commit(s)` : 'unique commits unknown';\n out += ` [${String(i + 1)}] ${entry.branch}\\n ${commits} — ${entry.reason}\\n`;\n }\n return out;\n }\n\n /**\n * Ask which of the classified branches to delete. Answers: `all`, `none` (default), or a\n * comma/space-separated list of the numbers shown.\n *\n * NON-INTERACTIVE (no TTY — CI, a hook, a piped agent shell) answers NONE and says so. A prompt\n * nobody can see must never be read as consent, and this is the one place in the tooling where a\n * deletion is not backed by a proof.\n */\n private async askWhichToDelete<T extends DeletableBranch | DeletableWorktree>(\n promptable: T[], kind: string,\n ): Promise<T[]> {\n if (process.stdin.isTTY !== true) {\n process.stdout.write(\n `\\nNot a terminal — no ${kind} was deleted and nothing was assumed.\\n`\n + 'Run `pnpm wp-cleanup` in an interactive shell to answer, or delete individually.\\n',\n );\n return [];\n }\n const answer = (await this.question(\n `\\nDelete which ${kind}(s)? [all / none / e.g. \"1,3\"] (default none): `)).trim().toLowerCase();\n if (answer === '' || answer === 'none' || answer === 'n') return [];\n if (answer === 'all' || answer === 'a') return promptable;\n return this.pickByNumber(promptable, answer);\n }\n\n // Parse `1,3` / `1 3` into branches, ignoring anything out of range. An unparseable answer selects\n // nothing, which is the fail-safe direction for a question about deleting.\n private pickByNumber<T extends DeletableBranch | DeletableWorktree>(promptable: T[], answer: string): T[] {\n const out: T[] = [];\n for (const token of answer.split(/[\\s,]+/)) {\n const index = Number(token);\n if (!Number.isInteger(index) || index < 1 || index > promptable.length) continue;\n out.push(promptable[index - 1]);\n }\n return out;\n }\n\n // Seam: overridden in the spec so the prompt parsing is testable with no terminal.\n protected question(prompt: string): Promise<string> {\n const rl = readline.createInterface({ input: process.stdin, output: process.stdout });\n return new Promise<string>((resolve: (value: string) => void): void => {\n rl.question(prompt, (answer: string): void => {\n rl.close();\n resolve(answer);\n });\n });\n }\n}\n"]}
|
|
@@ -5,7 +5,7 @@ import { ChecklistScanner } from '../workflow/checklist-scanner';
|
|
|
5
5
|
import { GitExec } from '../workflow/git-exec';
|
|
6
6
|
import { BuildAffected } from '../workflow/build-affected';
|
|
7
7
|
import { MergeState } from '../workflow/merge-state';
|
|
8
|
-
import {
|
|
8
|
+
import { ReviewStageReceiptService } from '../workflow/review-stage-receipt';
|
|
9
9
|
import { PrMerger } from '../workflow/pr-merger';
|
|
10
10
|
import { GatedPrPublisher } from '../workflow/gated-pr-publisher';
|
|
11
11
|
import { Dashboard } from '../../dashboard/dashboard';
|
|
@@ -16,7 +16,6 @@ export declare class FinishUpsertPrCommand {
|
|
|
16
16
|
private readonly gitExec;
|
|
17
17
|
private readonly buildAffected;
|
|
18
18
|
private readonly mergeState;
|
|
19
|
-
private readonly mergeEnd;
|
|
20
19
|
private readonly prMerger;
|
|
21
20
|
private readonly publisher;
|
|
22
21
|
private readonly dashboard;
|
|
@@ -25,13 +24,51 @@ export declare class FinishUpsertPrCommand {
|
|
|
25
24
|
private readonly gateTokenService;
|
|
26
25
|
private readonly provenance;
|
|
27
26
|
private readonly instructions;
|
|
28
|
-
|
|
27
|
+
private readonly receipts;
|
|
28
|
+
constructor(repoRootFinder: RepoRootFinder, aiBranchName: AiBranchName, branchNaming: BranchNaming, gitExec: GitExec, buildAffected: BuildAffected, mergeState: MergeState, prMerger: PrMerger, publisher: GatedPrPublisher, dashboard: Dashboard, checklistScanner: ChecklistScanner, reviewJsonService: ReviewJsonService, gateTokenService: GateTokenService, provenance: SubagentProvenanceService, instructions: ChecklistInstructionsService, receipts: ReviewStageReceiptService);
|
|
29
29
|
run(): Promise<void>;
|
|
30
30
|
private emitClickableLinkDirective;
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The build gate — SKIPPED when stage ②'s receipt already covers this exact HEAD.
|
|
33
|
+
*
|
|
34
|
+
* The gate is authoritative per-COMMIT, not per-command: re-running it on a tree that has not moved
|
|
35
|
+
* since stage ② verified it buys nothing and costs a full `nx affected`. That skip is what makes the
|
|
36
|
+
* three-stage flow cost ONE build rather than two, and it is why moving the gate earlier was affordable.
|
|
37
|
+
*/
|
|
38
|
+
private runOrSkipBuildGate;
|
|
39
|
+
/**
|
|
40
|
+
* The two stage-② preconditions, together: no unvalidated merge, and a receipt proving stage ② ran.
|
|
41
|
+
* Returns true when that receipt covers the CURRENT HEAD, i.e. the build gate can be skipped.
|
|
42
|
+
*/
|
|
43
|
+
private assertStageTwoRan;
|
|
44
|
+
/**
|
|
45
|
+
* REFUSE while a 3-point merge is still unvalidated — do not finalize it here.
|
|
46
|
+
*
|
|
47
|
+
* This command used to do the finalizing itself. Finalizing means validating a conflict resolution, and
|
|
48
|
+
* two commands owning that means two implementations that can drift; `PrContextWriter`'s docstring
|
|
49
|
+
* records this codebase already paying for exactly that once. It also came too late to matter: by the
|
|
50
|
+
* time finish ran, the reviewers had already reviewed the pre-merge tree.
|
|
51
|
+
*
|
|
52
|
+
* So stage ② owns it, and finish only checks. Nothing is lost — the recovery is one command away, and
|
|
53
|
+
* running it also builds the merged tree and re-briefs the reviewers against it, which finalizing here
|
|
54
|
+
* never did.
|
|
55
|
+
*/
|
|
56
|
+
private assertNoUnvalidatedMerge;
|
|
57
|
+
/**
|
|
58
|
+
* REQUIRE stage ② (`wp-review-upsert-pr`) to have run, and report whether it ran on THIS commit.
|
|
59
|
+
*
|
|
60
|
+
* Returns true when the receipt matches the current HEAD — the caller then skips its own build, because
|
|
61
|
+
* the receipt IS the gate for that sha and rebuilding an unchanged tree is a second full `nx affected`
|
|
62
|
+
* run for a foregone answer.
|
|
63
|
+
*
|
|
64
|
+
* Without this check a repo with NO checklists has nothing forcing stage ②: `assertEveryReviewerRan`
|
|
65
|
+
* is vacuous, and review.json — the only other interlock — is a file the AI writes itself. It could
|
|
66
|
+
* write it and come straight here, skipping the merge validation and the build entirely.
|
|
67
|
+
*/
|
|
68
|
+
private assertReviewStageRan;
|
|
32
69
|
/**
|
|
33
70
|
* Refuse the PR while ANY applicable checklist still owes a verdict, naming exactly those reviewers and
|
|
34
|
-
* exactly what to tell them — via the same renderer `wp-
|
|
71
|
+
* exactly what to tell them — via the same renderer `wp-review-upsert-pr` uses, so the AI sees the identical
|
|
35
72
|
* block it would have seen there. Lists ONLY the outstanding ones: re-instructing an already-reviewed
|
|
36
73
|
* checklist invites a redundant second run and reads as though the earlier verdict did not count.
|
|
37
74
|
* No-op for a repo with no applicable checklists.
|
|
@@ -50,6 +87,19 @@ export declare class FinishUpsertPrCommand {
|
|
|
50
87
|
private commentRows;
|
|
51
88
|
private gateTokenBody;
|
|
52
89
|
private enforceProvenance;
|
|
90
|
+
/**
|
|
91
|
+
* What each credited reviewer actually read. Purely observational here — {@link evidenceErrors} decides
|
|
92
|
+
* whether any of it blocks, and by default none of it does.
|
|
93
|
+
*/
|
|
94
|
+
private gatherEvidence;
|
|
95
|
+
/**
|
|
96
|
+
* WARN (default) or REFUSE (opt-in) on a reviewer that wrote a verdict without opening the diff.
|
|
97
|
+
*
|
|
98
|
+
* Default-warn because the signal is derived from undocumented Claude Code transcript internals: if the
|
|
99
|
+
* format shifts, a blocking check wedges every PR in every consumer repo with no self-service recovery.
|
|
100
|
+
* `requireDiffEvidence` lets a repo that has watched the warning promote it deliberately.
|
|
101
|
+
*/
|
|
102
|
+
private evidenceErrors;
|
|
53
103
|
/**
|
|
54
104
|
* Publish the roster + every reviewer's full `output` as ONE combined PR comment, idempotently (find the
|
|
55
105
|
* marker comment → PATCH it, else POST). Never fatal: by here the PR is already created/updated, so a
|
|
@@ -13,12 +13,23 @@ const checklist_scanner_1 = require("../workflow/checklist-scanner");
|
|
|
13
13
|
const git_exec_1 = require("../workflow/git-exec");
|
|
14
14
|
const build_affected_1 = require("../workflow/build-affected");
|
|
15
15
|
const merge_state_1 = require("../workflow/merge-state");
|
|
16
|
-
const
|
|
17
|
-
const merge_start_1 = require("../workflow/merge-start");
|
|
16
|
+
const review_stage_receipt_1 = require("../workflow/review-stage-receipt");
|
|
18
17
|
const pr_merger_1 = require("../workflow/pr-merger");
|
|
19
18
|
const gated_pr_publisher_1 = require("../workflow/gated-pr-publisher");
|
|
20
19
|
const dashboard_1 = require("../../dashboard/dashboard");
|
|
21
20
|
const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
|
|
21
|
+
/**
|
|
22
|
+
* The provenance outcome: whether each reviewer was VERIFIED to have run (the integrity check, which
|
|
23
|
+
* blocks) plus what each one actually read (the quality signal, which is published). Data-only.
|
|
24
|
+
*/
|
|
25
|
+
class ProvenanceReport {
|
|
26
|
+
verified;
|
|
27
|
+
evidence;
|
|
28
|
+
constructor(verified, evidence) {
|
|
29
|
+
this.verified = verified;
|
|
30
|
+
this.evidence = evidence;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
22
33
|
// A resolved PR's number + web URL. Both '' when the PR can't be resolved (e.g. create failed).
|
|
23
34
|
class PrRef {
|
|
24
35
|
number;
|
|
@@ -41,10 +52,16 @@ class UpsertResult {
|
|
|
41
52
|
this.merge = merge;
|
|
42
53
|
}
|
|
43
54
|
}
|
|
44
|
-
// FINISH of the AI-first PR flow
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
55
|
+
// STAGE ③ — FINISH of the AI-first PR flow, and the ONLY command that posts PRs. Runs after
|
|
56
|
+
// `wp-review-upsert-pr` verified the branch and the AI spawned the reviewers + wrote review.json.
|
|
57
|
+
//
|
|
58
|
+
// In order: (1) REFUSE on an unvalidated 3-point merge and REQUIRE stage ②'s receipt; (2) REQUIRE
|
|
59
|
+
// review.json + every reviewer's verdict + provenance; (3) run the build gate UNLESS the receipt already
|
|
60
|
+
// covers this exact HEAD; (4) render the dashboard; (5) create/update the PR via `gh`.
|
|
61
|
+
//
|
|
62
|
+
// It no longer FINALIZES a merge — stage ② does. Two commands owning conflict-resolution validation is two
|
|
63
|
+
// implementations that drift, and finalizing here came too late to help anyway: the reviewers had already
|
|
64
|
+
// reviewed the pre-merge tree by then.
|
|
48
65
|
let FinishUpsertPrCommand = class FinishUpsertPrCommand {
|
|
49
66
|
repoRootFinder;
|
|
50
67
|
aiBranchName;
|
|
@@ -52,7 +69,6 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
|
|
|
52
69
|
gitExec;
|
|
53
70
|
buildAffected;
|
|
54
71
|
mergeState;
|
|
55
|
-
mergeEnd;
|
|
56
72
|
prMerger;
|
|
57
73
|
publisher;
|
|
58
74
|
dashboard;
|
|
@@ -61,14 +77,14 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
|
|
|
61
77
|
gateTokenService;
|
|
62
78
|
provenance;
|
|
63
79
|
instructions;
|
|
64
|
-
|
|
80
|
+
receipts;
|
|
81
|
+
constructor(repoRootFinder, aiBranchName, branchNaming, gitExec, buildAffected, mergeState, prMerger, publisher, dashboard, checklistScanner, reviewJsonService, gateTokenService, provenance, instructions, receipts) {
|
|
65
82
|
this.repoRootFinder = repoRootFinder;
|
|
66
83
|
this.aiBranchName = aiBranchName;
|
|
67
84
|
this.branchNaming = branchNaming;
|
|
68
85
|
this.gitExec = gitExec;
|
|
69
86
|
this.buildAffected = buildAffected;
|
|
70
87
|
this.mergeState = mergeState;
|
|
71
|
-
this.mergeEnd = mergeEnd;
|
|
72
88
|
this.prMerger = prMerger;
|
|
73
89
|
this.publisher = publisher;
|
|
74
90
|
this.dashboard = dashboard;
|
|
@@ -77,17 +93,19 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
|
|
|
77
93
|
this.gateTokenService = gateTokenService;
|
|
78
94
|
this.provenance = provenance;
|
|
79
95
|
this.instructions = instructions;
|
|
96
|
+
this.receipts = receipts;
|
|
80
97
|
}
|
|
81
98
|
async run() {
|
|
82
99
|
const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
|
|
83
100
|
// Refresh the AI-facing workflow doc so it's present + current for any failure message to cite.
|
|
84
101
|
(0, rules_config_1.writeTemplate)(repoRoot, 'webpieces.git-workflow.md');
|
|
85
|
-
// 1.
|
|
86
|
-
|
|
102
|
+
// 1. REQUIRE stage ② — see assertStageTwoRan. Returns true when its receipt covers THIS commit,
|
|
103
|
+
// which is what lets the build gate below be skipped rather than re-run for a foregone answer.
|
|
104
|
+
const buildAlreadyGreen = this.assertStageTwoRan(repoRoot);
|
|
87
105
|
// 2. REQUIRE the AI-authored review.json (throws InformAiError with the schema if missing/invalid).
|
|
88
106
|
// Compute the consumer checklists this diff triggered FIRST so an unacknowledged BLOCK throws
|
|
89
107
|
// here — BEFORE any `gh pr create` — matching the guarantee buildCommand already provides.
|
|
90
|
-
// The SAME scan wp-
|
|
108
|
+
// The SAME scan wp-review-upsert-pr runs, with filterAlreadyReviewed:true so `outstanding` is exactly what
|
|
91
109
|
// still owes a verdict (Z of N of X). Sharing the computation is the point: the command that REPORTS
|
|
92
110
|
// and the command that GATES must not be able to disagree about what is owed. review-<id>.json files
|
|
93
111
|
// persist locally, so a re-run re-validates the EXISTING verdicts against the (possibly changed)
|
|
@@ -105,11 +123,11 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
|
|
|
105
123
|
// artifacts) that such a subagent actually ran on this branch — the coding agent may not
|
|
106
124
|
// self-certify. Absent CLAUDE_CODE_SESSION_ID this skips with a warning (CI / plain terminal).
|
|
107
125
|
const currentBranch = (0, child_process_1.execSync)('git branch --show-current', { encoding: 'utf8' }).trim();
|
|
108
|
-
const
|
|
126
|
+
const provenance = this.enforceProvenance(required, currentBranch, repoRoot, (0, rules_config_1.loadAndValidate)(repoRoot).prGate);
|
|
109
127
|
// 2b. The build gate validates the WORKING TREE but we push HEAD — so they MUST be identical.
|
|
110
128
|
this.gitExec.assertCleanTree(repoRoot);
|
|
111
|
-
// 3.
|
|
112
|
-
this.
|
|
129
|
+
// 3. Build gate, then post the gated body, then push (that ORDER — see GatedPrPublisher).
|
|
130
|
+
this.runOrSkipBuildGate(repoRoot, buildAlreadyGreen);
|
|
113
131
|
const base = this.branchNaming.baseBranchName((0, child_process_1.execSync)('git branch --show-current', { encoding: 'utf8' }).trim());
|
|
114
132
|
process.stdout.write('\n' + SEP + '📋 Dashboard + PR\n' + SEP + '\n');
|
|
115
133
|
const title = this.prTitleFrom(review);
|
|
@@ -125,7 +143,7 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
|
|
|
125
143
|
const result = this.upsertPr(repoRoot, base, body, title, input);
|
|
126
144
|
// Publish each reviewer's full output as ONE combined PR comment (idempotent, opt-out-aware). Never
|
|
127
145
|
// fatal — the PR is already up by now, so a comment failure only warns.
|
|
128
|
-
this.postChecklistComment(repoRoot, result.prNumber, scan, review,
|
|
146
|
+
this.postChecklistComment(repoRoot, result.prNumber, scan, review, provenance);
|
|
129
147
|
const prNum = result.prNumber;
|
|
130
148
|
process.stdout.write('\n' + SEP + '✅ PR finished — here is exactly what I did\n' + SEP + '\n' +
|
|
131
149
|
` 1. validated the build gate (authoritative)\n` +
|
|
@@ -150,17 +168,85 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
|
|
|
150
168
|
// Validate + commit + finalize a 3-point merge the AI resolved, if one is in progress. Finalizing here
|
|
151
169
|
// does NOT push (pushRemote=false): this command pushes exactly ONCE, from GatedPrPublisher, and only
|
|
152
170
|
// after review.json + every BLOCK checklist + the build gate pass and the gated PR body is written.
|
|
153
|
-
|
|
171
|
+
/**
|
|
172
|
+
* The build gate — SKIPPED when stage ②'s receipt already covers this exact HEAD.
|
|
173
|
+
*
|
|
174
|
+
* The gate is authoritative per-COMMIT, not per-command: re-running it on a tree that has not moved
|
|
175
|
+
* since stage ② verified it buys nothing and costs a full `nx affected`. That skip is what makes the
|
|
176
|
+
* three-stage flow cost ONE build rather than two, and it is why moving the gate earlier was affordable.
|
|
177
|
+
*/
|
|
178
|
+
runOrSkipBuildGate(repoRoot, alreadyGreen) {
|
|
179
|
+
if (alreadyGreen) {
|
|
180
|
+
process.stdout.write('\n🛠️ Build gate: already green for this commit (stage ② receipt) — skipping the rebuild.\n');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.buildAffected.runBuildGate(repoRoot, new build_affected_1.BuildGateOptions('🛠️ Build gate (authoritative)', 'pnpm wp-finish-upsert-pr', 'Build failed — no PR created/updated.'));
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The two stage-② preconditions, together: no unvalidated merge, and a receipt proving stage ② ran.
|
|
187
|
+
* Returns true when that receipt covers the CURRENT HEAD, i.e. the build gate can be skipped.
|
|
188
|
+
*/
|
|
189
|
+
assertStageTwoRan(repoRoot) {
|
|
190
|
+
this.assertNoUnvalidatedMerge(repoRoot);
|
|
191
|
+
return this.assertReviewStageRan(repoRoot, this.aiBranchName.getFeatureName(), this.gitOut(['rev-parse', 'HEAD']));
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* REFUSE while a 3-point merge is still unvalidated — do not finalize it here.
|
|
195
|
+
*
|
|
196
|
+
* This command used to do the finalizing itself. Finalizing means validating a conflict resolution, and
|
|
197
|
+
* two commands owning that means two implementations that can drift; `PrContextWriter`'s docstring
|
|
198
|
+
* records this codebase already paying for exactly that once. It also came too late to matter: by the
|
|
199
|
+
* time finish ran, the reviewers had already reviewed the pre-merge tree.
|
|
200
|
+
*
|
|
201
|
+
* So stage ② owns it, and finish only checks. Nothing is lost — the recovery is one command away, and
|
|
202
|
+
* running it also builds the merged tree and re-briefs the reviewers against it, which finalizing here
|
|
203
|
+
* never did.
|
|
204
|
+
*/
|
|
205
|
+
assertNoUnvalidatedMerge(repoRoot) {
|
|
154
206
|
const home = this.mergeState.mergeDirFor(repoRoot, this.aiBranchName.getFeatureName());
|
|
155
207
|
const activeDir = this.mergeState.findActiveMergeRunDir(home);
|
|
156
208
|
const marker = activeDir ? this.mergeState.readMergeMarker(activeDir) : null;
|
|
157
209
|
if (!activeDir || !marker || marker.validated)
|
|
158
210
|
return;
|
|
159
|
-
|
|
211
|
+
throw new rules_config_1.InformAiError('⛔ NO PR — a 3-point merge on this branch is still unvalidated, so nothing here has been\n' +
|
|
212
|
+
'verified: the conflict resolution is unchecked, the merged tree was never built, and any\n' +
|
|
213
|
+
'reviewer that ran judged the PRE-merge code.\n\n' +
|
|
214
|
+
`Conflicted file(s): ${marker.conflictedFiles.join(', ')}\n\n` +
|
|
215
|
+
'Resolve them (see .webpieces/instruct-ai/webpieces.mergeprocess.md), then run:\n' +
|
|
216
|
+
' pnpm wp-review-upsert-pr\n' +
|
|
217
|
+
'It validates the resolution, commits it, builds it, and re-briefs the reviewers.');
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* REQUIRE stage ② (`wp-review-upsert-pr`) to have run, and report whether it ran on THIS commit.
|
|
221
|
+
*
|
|
222
|
+
* Returns true when the receipt matches the current HEAD — the caller then skips its own build, because
|
|
223
|
+
* the receipt IS the gate for that sha and rebuilding an unchanged tree is a second full `nx affected`
|
|
224
|
+
* run for a foregone answer.
|
|
225
|
+
*
|
|
226
|
+
* Without this check a repo with NO checklists has nothing forcing stage ②: `assertEveryReviewerRan`
|
|
227
|
+
* is vacuous, and review.json — the only other interlock — is a file the AI writes itself. It could
|
|
228
|
+
* write it and come straight here, skipping the merge validation and the build entirely.
|
|
229
|
+
*/
|
|
230
|
+
assertReviewStageRan(repoRoot, featureName, headSha) {
|
|
231
|
+
const receipt = this.receipts.read(repoRoot, featureName);
|
|
232
|
+
if (receipt === null) {
|
|
233
|
+
throw new rules_config_1.InformAiError('⛔ NO PR — stage ② never ran on this branch. That means the 3-point merge is unvalidated,\n' +
|
|
234
|
+
'the build gate has not run, no diff was extracted, and no reviewer was briefed.\n\n' +
|
|
235
|
+
'Run: pnpm wp-review-upsert-pr\n' +
|
|
236
|
+
'(then spawn the reviewers it names, write review.json, and re-run this command)');
|
|
237
|
+
}
|
|
238
|
+
if (receipt.headSha === headSha)
|
|
239
|
+
return true;
|
|
240
|
+
// Not fatal. Re-reviewing on every follow-up commit would be intolerable, and most drift is a typo
|
|
241
|
+
// fix. But it is never silent: the build re-runs here, and the PR says the reviewers saw an older tree.
|
|
242
|
+
process.stderr.write(`\n⚠️ HEAD moved since stage ② ran (reviewed ${receipt.headSha.slice(0, 8)}, now ${headSha.slice(0, 8)}).\n` +
|
|
243
|
+
' The build gate will re-run, and the PR will record that reviewers judged an earlier tree.\n' +
|
|
244
|
+
' If the change was substantive, re-run pnpm wp-review-upsert-pr and re-spawn the reviewers.\n\n');
|
|
245
|
+
return false;
|
|
160
246
|
}
|
|
161
247
|
/**
|
|
162
248
|
* Refuse the PR while ANY applicable checklist still owes a verdict, naming exactly those reviewers and
|
|
163
|
-
* exactly what to tell them — via the same renderer `wp-
|
|
249
|
+
* exactly what to tell them — via the same renderer `wp-review-upsert-pr` uses, so the AI sees the identical
|
|
164
250
|
* block it would have seen there. Lists ONLY the outstanding ones: re-instructing an already-reviewed
|
|
165
251
|
* checklist invites a redundant second run and reads as though the earlier verdict did not count.
|
|
166
252
|
* No-op for a repo with no applicable checklists.
|
|
@@ -179,7 +265,9 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
|
|
|
179
265
|
`branch have no passing verdict yet: ${this.instructions.names(scan.outstanding)}\n\n` +
|
|
180
266
|
format +
|
|
181
267
|
`${this.instructions.render(scan.outstanding, scan.reviewPath, scan.context)}\n\n` +
|
|
182
|
-
`Then re-run: pnpm wp-finish-upsert-pr
|
|
268
|
+
`Then re-run: pnpm wp-finish-upsert-pr\n` +
|
|
269
|
+
`(Each reviewer's generated instructions file is already written — re-running wp-review-upsert-pr\n` +
|
|
270
|
+
` is only needed if the code changed since it ran.)`);
|
|
183
271
|
}
|
|
184
272
|
gitOut(args) {
|
|
185
273
|
const result = (0, child_process_1.spawnSync)('git', args, { encoding: 'utf8' });
|
|
@@ -220,7 +308,11 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
|
|
|
220
308
|
* absent from `applicable` by construction, and their "why not" evidence (the configured globs and the
|
|
221
309
|
* changed-file total) exists nowhere else without recomputing the diff a second way.
|
|
222
310
|
*/
|
|
223
|
-
commentRows(scan, review) {
|
|
311
|
+
commentRows(scan, review, provenance) {
|
|
312
|
+
// agentType -> did it open the diff. Absent ⇒ not assessed, which prints nothing (see ChecklistCommentRow.diffRead).
|
|
313
|
+
const readByAgent = new Map();
|
|
314
|
+
for (const e of provenance.evidence)
|
|
315
|
+
readByAgent.set(e.agentType, e.readDiff);
|
|
224
316
|
return scan.roster.entries.map((entry) => {
|
|
225
317
|
const ran = entry.matchedFiles.length > 0;
|
|
226
318
|
const req = new rules_config_1.RequiredChecklist(entry.def.id, entry.def.subagent, entry.def.doc, entry.matchedFiles, entry.matchedPatterns);
|
|
@@ -229,7 +321,10 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
|
|
|
229
321
|
const verdict = ran
|
|
230
322
|
? this.reviewJsonService.resolveVerdict(req, review.results)
|
|
231
323
|
: new rules_config_1.ChecklistVerdict(entry.def.id, '', '');
|
|
232
|
-
|
|
324
|
+
const row = new dashboard_1.ChecklistCommentRow(entry.def.subagent, verdict.status, verdict.detail, ran, entry.def.patterns, entry.matchedPatterns, entry.matchedFiles, scan.roster.changedFileCount);
|
|
325
|
+
const read = readByAgent.get(entry.def.subagent);
|
|
326
|
+
row.diffRead = read === undefined ? '' : (read ? 'yes' : 'no');
|
|
327
|
+
return row;
|
|
233
328
|
});
|
|
234
329
|
}
|
|
235
330
|
// Hidden HMAC gate-token marker (with a leading blank line) to append to the PR body, or '' when the
|
|
@@ -241,26 +336,62 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
|
|
|
241
336
|
// Enforce that EACH matched checklist was reviewed by its OWN named subagent, as a DISTINCT run —
|
|
242
337
|
// the coding agent may not self-certify, and one reviewer may not stand in for several. A verified set
|
|
243
338
|
// passes silently; no session id warns but passes; any missing reviewer throws so the PR does not open.
|
|
244
|
-
|
|
339
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
340
|
+
enforceProvenance(required, branch, repoRoot, config) {
|
|
245
341
|
const errors = [];
|
|
246
|
-
|
|
342
|
+
const report = new ProvenanceReport(true, []); // no reviewers to verify ⇒ vacuously verified
|
|
247
343
|
const subagents = required.map((r) => r.subagent.trim()).filter((s) => s !== '');
|
|
248
344
|
if (subagents.length > 0) {
|
|
249
345
|
const result = this.provenance.verifyDistinct(subagents, branch);
|
|
250
|
-
verified = result.status === rules_config_1.PROVENANCE_OK;
|
|
346
|
+
report.verified = result.status === rules_config_1.PROVENANCE_OK;
|
|
251
347
|
if (result.status === rules_config_1.PROVENANCE_MISSING) {
|
|
252
348
|
errors.push(result.detail);
|
|
253
349
|
}
|
|
254
350
|
else if (result.status === rules_config_1.PROVENANCE_SKIPPED) {
|
|
255
351
|
process.stderr.write(`⚠️ ${result.detail}\n`);
|
|
256
352
|
}
|
|
353
|
+
report.evidence = this.gatherEvidence(repoRoot, required, result, branch);
|
|
354
|
+
errors.push(...this.evidenceErrors(report.evidence, config));
|
|
257
355
|
}
|
|
258
356
|
if (errors.length > 0) {
|
|
259
357
|
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` +
|
|
260
358
|
errors.map((e) => ` • ${e}`).join('\n') +
|
|
261
359
|
`\n\nSpawn the named reviewer subagent to review the checklist on THIS branch, then re-run.`);
|
|
262
360
|
}
|
|
263
|
-
return
|
|
361
|
+
return report;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* What each credited reviewer actually read. Purely observational here — {@link evidenceErrors} decides
|
|
365
|
+
* whether any of it blocks, and by default none of it does.
|
|
366
|
+
*/
|
|
367
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
368
|
+
gatherEvidence(repoRoot, required, result, branch) {
|
|
369
|
+
const docPaths = {};
|
|
370
|
+
for (const req of required) {
|
|
371
|
+
if (req.subagent.trim() !== '')
|
|
372
|
+
docPaths[req.subagent] = req.doc.trim() === '' ? '' : path.resolve(repoRoot, req.doc);
|
|
373
|
+
}
|
|
374
|
+
const diffDir = path.join((0, rules_config_1.prDirFor)(repoRoot, this.aiBranchName.getFeatureName()), 'diff');
|
|
375
|
+
return this.provenance.evidenceFor(new rules_config_1.EvidenceRequest(branch, result.agentIds, diffDir, docPaths));
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* WARN (default) or REFUSE (opt-in) on a reviewer that wrote a verdict without opening the diff.
|
|
379
|
+
*
|
|
380
|
+
* Default-warn because the signal is derived from undocumented Claude Code transcript internals: if the
|
|
381
|
+
* format shifts, a blocking check wedges every PR in every consumer repo with no self-service recovery.
|
|
382
|
+
* `requireDiffEvidence` lets a repo that has watched the warning promote it deliberately.
|
|
383
|
+
*/
|
|
384
|
+
evidenceErrors(evidence, config) {
|
|
385
|
+
const blind = evidence.filter((e) => !e.readDiff);
|
|
386
|
+
if (blind.length === 0)
|
|
387
|
+
return [];
|
|
388
|
+
const names = blind.map((e) => e.agentType).join(', ');
|
|
389
|
+
if (!config.requireDiffEvidence) {
|
|
390
|
+
process.stderr.write(`\n⚠️ ${blind.length} reviewer(s) wrote a verdict with no record of opening the extracted diff: ${names}\n` +
|
|
391
|
+
' Published on the PR as a note. Not blocking — set pr-gate.requireDiffEvidence:true to make it one.\n');
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
return [`these reviewers wrote a verdict without opening the diff (pr-gate.requireDiffEvidence is on): ${names}`];
|
|
264
395
|
}
|
|
265
396
|
/**
|
|
266
397
|
* Publish the roster + every reviewer's full `output` as ONE combined PR comment, idempotently (find the
|
|
@@ -273,12 +404,13 @@ let FinishUpsertPrCommand = class FinishUpsertPrCommand {
|
|
|
273
404
|
* checklists configured must still see no comment at all (see ChecklistNotice / renderDashboard).
|
|
274
405
|
*/
|
|
275
406
|
// eslint-disable-next-line @typescript-eslint/max-params
|
|
276
|
-
|
|
407
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
408
|
+
postChecklistComment(repoRoot, prNumber, scan, review, provenance) {
|
|
277
409
|
if (prNumber === '' || scan.defined.length === 0)
|
|
278
410
|
return;
|
|
279
411
|
if (!(0, rules_config_1.loadAndValidate)(repoRoot).prGate.checklistComments)
|
|
280
412
|
return;
|
|
281
|
-
const body = this.dashboard.renderChecklistComment(this.commentRows(scan, review),
|
|
413
|
+
const body = this.dashboard.renderChecklistComment(this.commentRows(scan, review, provenance), provenance.verified, scan.roster.baseResolved);
|
|
282
414
|
const prDir = (0, rules_config_1.prDirFor)(repoRoot, this.aiBranchName.getFeatureName());
|
|
283
415
|
fs.mkdirSync(prDir, { recursive: true });
|
|
284
416
|
const payload = path.join(prDir, 'checklist-comment.json');
|
|
@@ -356,7 +488,6 @@ exports.FinishUpsertPrCommand = FinishUpsertPrCommand = tslib_1.__decorate([
|
|
|
356
488
|
git_exec_1.GitExec,
|
|
357
489
|
build_affected_1.BuildAffected,
|
|
358
490
|
merge_state_1.MergeState,
|
|
359
|
-
merge_end_1.MergeEnd,
|
|
360
491
|
pr_merger_1.PrMerger,
|
|
361
492
|
gated_pr_publisher_1.GatedPrPublisher,
|
|
362
493
|
dashboard_1.Dashboard,
|
|
@@ -364,6 +495,7 @@ exports.FinishUpsertPrCommand = FinishUpsertPrCommand = tslib_1.__decorate([
|
|
|
364
495
|
rules_config_1.ReviewJsonService,
|
|
365
496
|
rules_config_1.GateTokenService,
|
|
366
497
|
rules_config_1.SubagentProvenanceService,
|
|
367
|
-
rules_config_1.ChecklistInstructionsService
|
|
498
|
+
rules_config_1.ChecklistInstructionsService,
|
|
499
|
+
review_stage_receipt_1.ReviewStageReceiptService])
|
|
368
500
|
], FinishUpsertPrCommand);
|
|
369
501
|
//# sourceMappingURL=finish-upsert-pr-command.js.map
|