@webpieces/pr-gate 0.4.757 → 0.4.758
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/scripts/commands/await-checks-command.d.ts +2 -2
- package/src/scripts/commands/await-checks-command.js +2 -2
- package/src/scripts/commands/await-checks-command.js.map +1 -1
- package/src/scripts/workflow/await-loop.d.ts +8 -7
- package/src/scripts/workflow/await-loop.js +8 -7
- package/src/scripts/workflow/await-loop.js.map +1 -1
- package/src/scripts/workflow/finish-banner.d.ts +5 -4
- package/src/scripts/workflow/finish-banner.js +9 -8
- package/src/scripts/workflow/finish-banner.js.map +1 -1
- package/src/scripts/workflow/review-report.d.ts +3 -3
- package/src/scripts/workflow/review-report.js +7 -10
- package/src/scripts/workflow/review-report.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/pr-gate",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.758",
|
|
4
4
|
"description": "Gated PR system: 3-point squash-merge, merge validation gate, and red/yellow/green PR dashboard. Standalone scripts, no Nx dependency required.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@inversifyjs/binding-decorators": "1.1.5",
|
|
19
|
-
"@webpieces/ai-hook-rules": "0.4.
|
|
20
|
-
"@webpieces/rules-config": "0.4.
|
|
19
|
+
"@webpieces/ai-hook-rules": "0.4.758",
|
|
20
|
+
"@webpieces/rules-config": "0.4.758",
|
|
21
21
|
"inversify": "7.10.4",
|
|
22
22
|
"reflect-metadata": "0.2.2"
|
|
23
23
|
},
|
|
@@ -18,8 +18,8 @@ export declare class AwaitChecksOptions {
|
|
|
18
18
|
* the banner names this command as an option and says out loud that stopping is still correct.
|
|
19
19
|
*
|
|
20
20
|
* What it replaces is the `echo .` keep-alive an agent falls back to when it has decided to see the PR
|
|
21
|
-
* land. See {@link AwaitLoop} for when a blocking command is the right wait at all —
|
|
22
|
-
*
|
|
21
|
+
* land. See {@link AwaitLoop} for when a blocking command is the right wait at all — this is offered as
|
|
22
|
+
* the efficient alternative to polling, and nothing here rules on turn-level behaviour (issue #902).
|
|
23
23
|
*
|
|
24
24
|
* ─── `gh pr checks <n> --watch` EXISTS, AND WHEN TO REACH FOR EACH ─────────────────────────────────
|
|
25
25
|
* It is a real blocking wait, not a spin, and `wait-spin-guard` never denies it — 224 subagent and 84
|
|
@@ -31,8 +31,8 @@ exports.AwaitChecksOptions = AwaitChecksOptions;
|
|
|
31
31
|
* the banner names this command as an option and says out loud that stopping is still correct.
|
|
32
32
|
*
|
|
33
33
|
* What it replaces is the `echo .` keep-alive an agent falls back to when it has decided to see the PR
|
|
34
|
-
* land. See {@link AwaitLoop} for when a blocking command is the right wait at all —
|
|
35
|
-
*
|
|
34
|
+
* land. See {@link AwaitLoop} for when a blocking command is the right wait at all — this is offered as
|
|
35
|
+
* the efficient alternative to polling, and nothing here rules on turn-level behaviour (issue #902).
|
|
36
36
|
*
|
|
37
37
|
* ─── `gh pr checks <n> --watch` EXISTS, AND WHEN TO REACH FOR EACH ─────────────────────────────────
|
|
38
38
|
* It is a real blocking wait, not a spin, and `wait-spin-guard` never denies it — 224 subagent and 84
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"await-checks-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/await-checks-command.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { CliExitError } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { AwaitLoop, WaitOutcome, WaitProbe } from '../workflow/await-loop';\nimport { StageOutputLog } from '../workflow/stage-output-log';\n\n/** What `wp-await-checks` was asked to wait on. Data-only (a class, per CLAUDE.md). */\nexport class AwaitChecksOptions {\n /** The PR number, exactly as `--pr` gave it. REQUIRED — there is no \"guess which PR\" branch. */\n prNumber: string;\n\n // REQUIRED, no default. A defaulted \"current branch's PR\" would be a second spelling of the one\n // question this command asks, reachable by typing less, and the failure mode is watching the wrong\n // PR go green.\n constructor(prNumber: string) {\n this.prNumber = prNumber;\n }\n}\n\n/**\n * `wp-await-checks --pr <n>` — BLOCK until GitHub's checks for that PR stop being in flight, then print\n * where they landed.\n *\n * ─── OFFERED, NEVER INSTRUCTED ─────────────────────────────────────────────────────────────────────\n * This exists for the see-it-land case, and for nothing else. `wp-finish-upsert-pr` still ends with\n * \"Nothing else is owed by the tooling — a person merges it. You can stop here.\", and that stays the\n * DEFAULT: several of this repo's workflows deliberately stop at a green PR, and landing is the\n * developer's call, not the tooling's. Nothing here or in the finish banner tells an agent to wait —\n * the banner names this command as an option and says out loud that stopping is still correct.\n *\n * What it replaces is the `echo .` keep-alive an agent falls back to when it has decided to see the PR\n * land. See {@link AwaitLoop} for when a blocking command is the right wait at all — ending the turn is\n * cheaper whenever something pending would re-invoke you (issue #878).\n *\n * ─── `gh pr checks <n> --watch` EXISTS, AND WHEN TO REACH FOR EACH ─────────────────────────────────\n * It is a real blocking wait, not a spin, and `wait-spin-guard` never denies it — 224 subagent and 84\n * main-agent uses in the measured window. So this command does not pretend it is absent, and it is not\n * a replacement for it. The difference is one property and it decides the choice:\n *\n * `gh pr checks <n> --watch` has NO BOUNDED EXIT. On a CI run longer than the harness's 600s\n * silence ceiling it is KILLED having printed nothing, and the wait is\n * simply lost. Right for a short, known-fast run you are watching.\n * `pnpm wp-await-checks` heartbeats throughout and RETURNS CLEANLY at 540s saying \"run me\n * again\", so a 100-minute wait costs ~11 calls instead of a killed\n * command and a restart. Right for any wait that might outlast the\n * ceiling, which is any wait you cannot bound in advance.\n *\n * ─── It reports, it does not judge ─────────────────────────────────────────────────────────────────\n * A red check is an ANSWER, so the wait ends and the state is printed. Deciding what a failure means is\n * the caller's, and turning a red check into a non-zero exit here would make \"the checks finished\" and\n * \"the checks passed\" the same signal — which is exactly how a red PR gets reported as landed.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class AwaitChecksCommand {\n constructor(\n private readonly awaitLoop: AwaitLoop,\n private readonly stageConsole: StageOutputLog,\n ) {}\n\n async run(opts: AwaitChecksOptions): Promise<void> {\n const probe = new ChecksWaitProbe(opts.prNumber);\n const outcome = await this.awaitLoop.run(probe);\n this.stageConsole.say(outcome.done ? probe.settledReport(outcome) : probe.stillWaitingReport(outcome));\n }\n}\n\n/** One check run's rollup state, as GitHub reports it. Data-only. */\nexport class ChecksState {\n /** How many checks GitHub currently lists. 0 means it has not created any yet — QUEUED, not absent. */\n total: number;\n pending: number;\n failed: number;\n /** True when `gh` could not be asked at all — the wait keeps going rather than claiming an answer. */\n unreadable: boolean;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(total: number, pending: number, failed: number, unreadable: boolean) {\n this.total = total;\n this.pending = pending;\n this.failed = failed;\n this.unreadable = unreadable;\n }\n\n /**\n * Settled means GitHub has created checks AND none of them is still running.\n *\n * `total === 0` is deliberately NOT settled. An empty rollup seconds after a push means the workflow\n * has not been created yet — QUEUED — and treating it as \"no checks, all clear\" is how a PR gets\n * called green before CI has started. The wait's own ceiling is what ends that case, and it ends it\n * by saying \"still waiting\", which is the truth.\n */\n get settled(): boolean {\n return !this.unreadable && this.total > 0 && this.pending === 0;\n }\n\n describe(): string {\n if (this.unreadable) return 'gh could not be read';\n if (this.total === 0) return 'no checks reported yet (queued)';\n return `${String(this.total - this.pending)} of ${String(this.total)} done, `\n + `${String(this.failed)} failed`;\n }\n}\n\n/**\n * The CI wait: one `gh pr view` per poll, classified into {@link ChecksState}.\n *\n * `gh pr view --json statusCheckRollup` rather than `gh pr checks`, because the rollup field is the\n * stable JSON surface and returns an EMPTY array — not an error exit — for a PR whose checks have not\n * been created yet. `gh pr checks` exits non-zero in several distinct \"nothing to report\" situations,\n * which would make \"queued\" and \"gh is broken\" the same observation.\n */\nexport class ChecksWaitProbe implements WaitProbe {\n readonly label = 'CI checks';\n // A network round trip per poll, so this is deliberately slow. CI on this repo runs ~1 minute; a\n // 15-second poll finds it within a heartbeat of finishing and costs ~36 API calls per full wait.\n readonly pollMs = 15_000;\n\n private state: ChecksState = new ChecksState(0, 0, 0, true);\n\n constructor(private readonly prNumber: string) {}\n\n done(): boolean {\n this.state = this.read();\n return this.state.settled;\n }\n\n describe(): string {\n return this.state.describe();\n }\n\n settledReport(outcome: WaitOutcome): string {\n const verdict = this.state.failed === 0\n ? `🟢 All ${String(this.state.total)} check(s) passed`\n : `🔴 ${String(this.state.failed)} of ${String(this.state.total)} check(s) FAILED`;\n return `\\n${verdict} after ${String(outcome.waitedSeconds)}s.\\n`\n + ` Read the detail with: gh pr checks ${this.prNumber}\\n`;\n }\n\n stillWaitingReport(outcome: WaitOutcome): string {\n return `\\n⏳ Checks are still in flight after ${String(outcome.waitedSeconds)}s (${this.state.describe()})`\n + ` — run me again: pnpm wp-await-checks --pr ${this.prNumber}\\n`\n + ' (This is not a failure. The wait returns before the harness kills a quiet command.)\\n';\n }\n\n /**\n * One read of the rollup. Fails to `unreadable` rather than to \"settled\": a `gh` that cannot answer\n * must never be able to end the wait, because the only thing worse than waiting too long is\n * announcing an outcome nobody observed.\n */\n private read(): ChecksState {\n const result = spawnSync(\n 'gh',\n ['pr', 'view', this.prNumber, '--json', 'statusCheckRollup',\n '--jq', '[.statusCheckRollup[] | (.status // .state)] | @tsv'],\n { encoding: 'utf8' });\n if (result.status !== 0) return new ChecksState(0, 0, 0, true);\n return this.classify((result.stdout ?? '').trim());\n }\n\n // `status` is COMPLETED / IN_PROGRESS / QUEUED for a check run; a plain commit status has no\n // `status` and its `state` is SUCCESS / PENDING / FAILURE / ERROR. The jq above collapses the two\n // into one token per check, and everything that is not finished counts as pending.\n private classify(tsv: string): ChecksState {\n const tokens = tsv === '' ? [] : tsv.split(/\\s+/);\n const pending = tokens.filter((t: string): boolean => PENDING_TOKENS.has(t)).length;\n const failed = tokens.filter((t: string): boolean => FAILED_TOKENS.has(t)).length;\n return new ChecksState(tokens.length, pending, failed, false);\n }\n}\n\nconst PENDING_TOKENS: ReadonlySet<string> = new Set(['QUEUED', 'IN_PROGRESS', 'PENDING', 'WAITING', 'REQUESTED']);\nconst FAILED_TOKENS: ReadonlySet<string> = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);\n\n/**\n * `--pr` is required, and this is where that is enforced — before the loop starts, so a caller that\n * forgot it is told immediately rather than after a nine-minute wait on nothing.\n */\nexport class AwaitChecksArgs {\n /** The PR number from argv, or a CliExitError naming the flag it needs. */\n parse(value: string): AwaitChecksOptions {\n if (!/^\\d+$/.test(value.trim())) {\n throw new CliExitError(2,\n '❌ wp-await-checks needs the PR number to wait on: pnpm wp-await-checks --pr <n>\\n'\n + ' `gh pr view --json number` prints it for the current branch.');\n }\n return new AwaitChecksOptions(value.trim());\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"await-checks-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/await-checks-command.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { CliExitError } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { AwaitLoop, WaitOutcome, WaitProbe } from '../workflow/await-loop';\nimport { StageOutputLog } from '../workflow/stage-output-log';\n\n/** What `wp-await-checks` was asked to wait on. Data-only (a class, per CLAUDE.md). */\nexport class AwaitChecksOptions {\n /** The PR number, exactly as `--pr` gave it. REQUIRED — there is no \"guess which PR\" branch. */\n prNumber: string;\n\n // REQUIRED, no default. A defaulted \"current branch's PR\" would be a second spelling of the one\n // question this command asks, reachable by typing less, and the failure mode is watching the wrong\n // PR go green.\n constructor(prNumber: string) {\n this.prNumber = prNumber;\n }\n}\n\n/**\n * `wp-await-checks --pr <n>` — BLOCK until GitHub's checks for that PR stop being in flight, then print\n * where they landed.\n *\n * ─── OFFERED, NEVER INSTRUCTED ─────────────────────────────────────────────────────────────────────\n * This exists for the see-it-land case, and for nothing else. `wp-finish-upsert-pr` still ends with\n * \"Nothing else is owed by the tooling — a person merges it. You can stop here.\", and that stays the\n * DEFAULT: several of this repo's workflows deliberately stop at a green PR, and landing is the\n * developer's call, not the tooling's. Nothing here or in the finish banner tells an agent to wait —\n * the banner names this command as an option and says out loud that stopping is still correct.\n *\n * What it replaces is the `echo .` keep-alive an agent falls back to when it has decided to see the PR\n * land. See {@link AwaitLoop} for when a blocking command is the right wait at all — this is offered as\n * the efficient alternative to polling, and nothing here rules on turn-level behaviour (issue #902).\n *\n * ─── `gh pr checks <n> --watch` EXISTS, AND WHEN TO REACH FOR EACH ─────────────────────────────────\n * It is a real blocking wait, not a spin, and `wait-spin-guard` never denies it — 224 subagent and 84\n * main-agent uses in the measured window. So this command does not pretend it is absent, and it is not\n * a replacement for it. The difference is one property and it decides the choice:\n *\n * `gh pr checks <n> --watch` has NO BOUNDED EXIT. On a CI run longer than the harness's 600s\n * silence ceiling it is KILLED having printed nothing, and the wait is\n * simply lost. Right for a short, known-fast run you are watching.\n * `pnpm wp-await-checks` heartbeats throughout and RETURNS CLEANLY at 540s saying \"run me\n * again\", so a 100-minute wait costs ~11 calls instead of a killed\n * command and a restart. Right for any wait that might outlast the\n * ceiling, which is any wait you cannot bound in advance.\n *\n * ─── It reports, it does not judge ─────────────────────────────────────────────────────────────────\n * A red check is an ANSWER, so the wait ends and the state is printed. Deciding what a failure means is\n * the caller's, and turning a red check into a non-zero exit here would make \"the checks finished\" and\n * \"the checks passed\" the same signal — which is exactly how a red PR gets reported as landed.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class AwaitChecksCommand {\n constructor(\n private readonly awaitLoop: AwaitLoop,\n private readonly stageConsole: StageOutputLog,\n ) {}\n\n async run(opts: AwaitChecksOptions): Promise<void> {\n const probe = new ChecksWaitProbe(opts.prNumber);\n const outcome = await this.awaitLoop.run(probe);\n this.stageConsole.say(outcome.done ? probe.settledReport(outcome) : probe.stillWaitingReport(outcome));\n }\n}\n\n/** One check run's rollup state, as GitHub reports it. Data-only. */\nexport class ChecksState {\n /** How many checks GitHub currently lists. 0 means it has not created any yet — QUEUED, not absent. */\n total: number;\n pending: number;\n failed: number;\n /** True when `gh` could not be asked at all — the wait keeps going rather than claiming an answer. */\n unreadable: boolean;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(total: number, pending: number, failed: number, unreadable: boolean) {\n this.total = total;\n this.pending = pending;\n this.failed = failed;\n this.unreadable = unreadable;\n }\n\n /**\n * Settled means GitHub has created checks AND none of them is still running.\n *\n * `total === 0` is deliberately NOT settled. An empty rollup seconds after a push means the workflow\n * has not been created yet — QUEUED — and treating it as \"no checks, all clear\" is how a PR gets\n * called green before CI has started. The wait's own ceiling is what ends that case, and it ends it\n * by saying \"still waiting\", which is the truth.\n */\n get settled(): boolean {\n return !this.unreadable && this.total > 0 && this.pending === 0;\n }\n\n describe(): string {\n if (this.unreadable) return 'gh could not be read';\n if (this.total === 0) return 'no checks reported yet (queued)';\n return `${String(this.total - this.pending)} of ${String(this.total)} done, `\n + `${String(this.failed)} failed`;\n }\n}\n\n/**\n * The CI wait: one `gh pr view` per poll, classified into {@link ChecksState}.\n *\n * `gh pr view --json statusCheckRollup` rather than `gh pr checks`, because the rollup field is the\n * stable JSON surface and returns an EMPTY array — not an error exit — for a PR whose checks have not\n * been created yet. `gh pr checks` exits non-zero in several distinct \"nothing to report\" situations,\n * which would make \"queued\" and \"gh is broken\" the same observation.\n */\nexport class ChecksWaitProbe implements WaitProbe {\n readonly label = 'CI checks';\n // A network round trip per poll, so this is deliberately slow. CI on this repo runs ~1 minute; a\n // 15-second poll finds it within a heartbeat of finishing and costs ~36 API calls per full wait.\n readonly pollMs = 15_000;\n\n private state: ChecksState = new ChecksState(0, 0, 0, true);\n\n constructor(private readonly prNumber: string) {}\n\n done(): boolean {\n this.state = this.read();\n return this.state.settled;\n }\n\n describe(): string {\n return this.state.describe();\n }\n\n settledReport(outcome: WaitOutcome): string {\n const verdict = this.state.failed === 0\n ? `🟢 All ${String(this.state.total)} check(s) passed`\n : `🔴 ${String(this.state.failed)} of ${String(this.state.total)} check(s) FAILED`;\n return `\\n${verdict} after ${String(outcome.waitedSeconds)}s.\\n`\n + ` Read the detail with: gh pr checks ${this.prNumber}\\n`;\n }\n\n stillWaitingReport(outcome: WaitOutcome): string {\n return `\\n⏳ Checks are still in flight after ${String(outcome.waitedSeconds)}s (${this.state.describe()})`\n + ` — run me again: pnpm wp-await-checks --pr ${this.prNumber}\\n`\n + ' (This is not a failure. The wait returns before the harness kills a quiet command.)\\n';\n }\n\n /**\n * One read of the rollup. Fails to `unreadable` rather than to \"settled\": a `gh` that cannot answer\n * must never be able to end the wait, because the only thing worse than waiting too long is\n * announcing an outcome nobody observed.\n */\n private read(): ChecksState {\n const result = spawnSync(\n 'gh',\n ['pr', 'view', this.prNumber, '--json', 'statusCheckRollup',\n '--jq', '[.statusCheckRollup[] | (.status // .state)] | @tsv'],\n { encoding: 'utf8' });\n if (result.status !== 0) return new ChecksState(0, 0, 0, true);\n return this.classify((result.stdout ?? '').trim());\n }\n\n // `status` is COMPLETED / IN_PROGRESS / QUEUED for a check run; a plain commit status has no\n // `status` and its `state` is SUCCESS / PENDING / FAILURE / ERROR. The jq above collapses the two\n // into one token per check, and everything that is not finished counts as pending.\n private classify(tsv: string): ChecksState {\n const tokens = tsv === '' ? [] : tsv.split(/\\s+/);\n const pending = tokens.filter((t: string): boolean => PENDING_TOKENS.has(t)).length;\n const failed = tokens.filter((t: string): boolean => FAILED_TOKENS.has(t)).length;\n return new ChecksState(tokens.length, pending, failed, false);\n }\n}\n\nconst PENDING_TOKENS: ReadonlySet<string> = new Set(['QUEUED', 'IN_PROGRESS', 'PENDING', 'WAITING', 'REQUESTED']);\nconst FAILED_TOKENS: ReadonlySet<string> = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);\n\n/**\n * `--pr` is required, and this is where that is enforced — before the loop starts, so a caller that\n * forgot it is told immediately rather than after a nine-minute wait on nothing.\n */\nexport class AwaitChecksArgs {\n /** The PR number from argv, or a CliExitError naming the flag it needs. */\n parse(value: string): AwaitChecksOptions {\n if (!/^\\d+$/.test(value.trim())) {\n throw new CliExitError(2,\n '❌ wp-await-checks needs the PR number to wait on: pnpm wp-await-checks --pr <n>\\n'\n + ' `gh pr view --json number` prints it for the current branch.');\n }\n return new AwaitChecksOptions(value.trim());\n }\n}\n"]}
|
|
@@ -2,14 +2,15 @@ import { StageOutputLog } from './stage-output-log';
|
|
|
2
2
|
/**
|
|
3
3
|
* THE BLOCKING WAIT, and why webpieces owns one (issue #874, scoped by #878).
|
|
4
4
|
*
|
|
5
|
-
* ─── IT IS
|
|
6
|
-
* #874 justified this class by asserting a worktree-isolated subagent cannot end its turn
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* and
|
|
10
|
-
*
|
|
5
|
+
* ─── IT IS ONE EFFICIENT WAIT, OFFERED — NEVER A TURN-LEVEL INSTRUCTION (issue #902) ───────────────
|
|
6
|
+
* #874 justified this class by asserting a worktree-isolated subagent cannot end its turn, #878
|
|
7
|
+
* corrected that, and #900 measured that the background re-invocation the correction relied on does not
|
|
8
|
+
* reliably fire. #902 settles the shape of the advice: webpieces names the efficient wait and blocks the
|
|
9
|
+
* wasteful poll, and says NOTHING about when an agent should end its turn. An agent already waits on the
|
|
10
|
+
* subagents it spawns, routinely, without being told; a fixed rule in a string cannot see the situation
|
|
11
|
+
* it is ruling on.
|
|
11
12
|
*
|
|
12
|
-
* What this class is for is the case where
|
|
13
|
+
* What this class is for is the case where an agent has decided to block. There, a Bash call is
|
|
13
14
|
* the only pause it can express: `Monitor` does not block (its own result says "Keep working — do not
|
|
14
15
|
* poll or sleep"), and a `Monitor` carrying a real polling loop is refused by the HARNESS, because a
|
|
15
16
|
* `while`/`until` with a redirect cannot be statically proven to stay inside the worktree (178 of 553
|
|
@@ -7,14 +7,15 @@ const stage_output_log_1 = require("./stage-output-log");
|
|
|
7
7
|
/**
|
|
8
8
|
* THE BLOCKING WAIT, and why webpieces owns one (issue #874, scoped by #878).
|
|
9
9
|
*
|
|
10
|
-
* ─── IT IS
|
|
11
|
-
* #874 justified this class by asserting a worktree-isolated subagent cannot end its turn
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* and
|
|
15
|
-
*
|
|
10
|
+
* ─── IT IS ONE EFFICIENT WAIT, OFFERED — NEVER A TURN-LEVEL INSTRUCTION (issue #902) ───────────────
|
|
11
|
+
* #874 justified this class by asserting a worktree-isolated subagent cannot end its turn, #878
|
|
12
|
+
* corrected that, and #900 measured that the background re-invocation the correction relied on does not
|
|
13
|
+
* reliably fire. #902 settles the shape of the advice: webpieces names the efficient wait and blocks the
|
|
14
|
+
* wasteful poll, and says NOTHING about when an agent should end its turn. An agent already waits on the
|
|
15
|
+
* subagents it spawns, routinely, without being told; a fixed rule in a string cannot see the situation
|
|
16
|
+
* it is ruling on.
|
|
16
17
|
*
|
|
17
|
-
* What this class is for is the case where
|
|
18
|
+
* What this class is for is the case where an agent has decided to block. There, a Bash call is
|
|
18
19
|
* the only pause it can express: `Monitor` does not block (its own result says "Keep working — do not
|
|
19
20
|
* poll or sleep"), and a `Monitor` carrying a real polling loop is refused by the HARNESS, because a
|
|
20
21
|
* `while`/`until` with a redirect cannot be statically proven to stay inside the worktree (178 of 553
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"await-loop.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/await-loop.ts"],"names":[],"mappings":"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","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\n\nimport { StageOutputLog } from './stage-output-log';\n\n/**\n * THE BLOCKING WAIT, and why webpieces owns one (issue #874, scoped by #878).\n *\n * ─── IT IS THE SECOND-CHEAPEST WAIT, NOT THE CHEAPEST (issue #878) ─────────────────────────────────\n * #874 justified this class by asserting a worktree-isolated subagent cannot end its turn. That is\n * FALSE and was corrected: measured over every subagent transcript on this machine, 588 of 2,605\n * `end_turn` turns were followed by more turns, 449 of them resumed by a background-task notification,\n * and 288 of those were waiting on spawned subagents. So ENDING THE TURN is the cheapest wait a\n * subagent has — it costs nothing — and `wait-spin-guard`'s cure now names it first.\n *\n * What this class is for is the case where nothing pending would wake the agent. There, a Bash call is\n * the only pause it can express: `Monitor` does not block (its own result says \"Keep working — do not\n * poll or sleep\"), and a `Monitor` carrying a real polling loop is refused by the HARNESS, because a\n * `while`/`until` with a redirect cannot be statically proven to stay inside the worktree (178 of 553\n * measured subagent Monitor calls). That refusal is Claude Code's and is not ours to relax.\n *\n * Without either, the agent falls back to `echo .` every three seconds — measured at 920M tokens, 18.3%\n * of every token the fleet spent in the 24h to 2026-09-07, because every turn resends the whole\n * conversation at ~557,000 tokens. This class is the thing that makes that unnecessary, and\n * `wait-spin-guard` is the thing that makes it unused.\n *\n * ─── THREE CONSTRAINTS, all of them the harness's, none of them negotiable ─────────────────────────\n *\n * 1. **What the AGENT TYPES must be a plain command.** No loop, no redirect, no pipe, no command\n * substitution — those are exactly what the isolation check refuses. All the looping is in here,\n * inside one process, behind a bare `pnpm wp-await-reviews`.\n * 2. **It must print something roughly every {@link HEARTBEAT_MS}.** The harness backgrounds and then\n * KILLS a foreground command that has produced no output for 600 seconds, and a killed wait throws\n * away the wait. Same defence `wp-build` already runs, same `still` word for the same reason: a\n * tick that has not moved must be distinguishable from a stalled reporter.\n * 3. **It must RETURN before that ceiling on its own**, saying to run it again. A wait that dies at\n * the watchdog looks like a crash; a wait that returns at {@link CEILING_MS} with \"still waiting\"\n * turns a 100-minute wait into ~11 tool calls instead of the ~2,000 an `echo .` loop costs.\n *\n * `done` is a normal, ZERO exit either way. \"Still waiting\" is not a failure — it is an answer the agent\n * acts on — and making it non-zero would put a red exit code on the healthy path.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class AwaitLoop {\n constructor(private readonly stageConsole: StageOutputLog) {}\n\n /**\n * Block until `probe` says it is done, or until {@link CEILING_MS} elapses, printing a heartbeat\n * throughout. Returns WHICH of those happened; the caller renders the outcome.\n */\n async run(probe: WaitProbe): Promise<WaitOutcome> {\n const started = Date.now();\n const heartbeat = new WaitHeartbeat(probe.label);\n // `done()` FIRST, before anything is printed. It is what establishes the probe's state, so\n // describing the wait before asking it would report a state nobody has measured — and an\n // already-finished wait must cost nothing at all.\n for (let first = true; ; first = false) {\n if (probe.done()) return new WaitOutcome(true, Date.now() - started);\n const elapsed = Date.now() - started;\n if (elapsed >= CEILING_MS) return new WaitOutcome(false, elapsed);\n // One heartbeat per HEARTBEAT_MS of ELAPSED time, whatever the poll interval — a probe that\n // costs a network round trip is polled far more slowly than one reading a file, and the\n // watchdog counts seconds of silence, not polls.\n if (first || heartbeat.isDue(elapsed)) {\n this.stageConsole.say(`${heartbeat.tick(probe.describe(), elapsed)}\\n`);\n }\n await this.sleep(probe.pollMs);\n }\n }\n\n private sleep(ms: number): Promise<void> {\n return new Promise<void>((resolve: () => void): void => {\n setTimeout(resolve, ms);\n });\n }\n}\n\n/**\n * WHAT is being waited for. An interface rather than a class because it is business logic — `done()`\n * reads verdict files for one waiter and asks GitHub for the other — and the loop must not know which.\n */\nexport interface WaitProbe {\n /** Names the wait in every heartbeat line, e.g. `reviewers` / `checks`. */\n readonly label: string;\n /** How long to sleep between two `done()` calls. A file read polls fast; a `gh` call does not. */\n readonly pollMs: number;\n /** True once the wait is over. Called before the first sleep, so an already-finished wait is free. */\n done(): boolean;\n /** The current state, in one short phrase, for the heartbeat: `2 of 4 verdicts in`. */\n describe(): string;\n}\n\n/** How the wait ended: satisfied, or out of time. Data-only (a class, per CLAUDE.md). */\nexport class WaitOutcome {\n done: boolean;\n waitedMs: number;\n\n constructor(done: boolean, waitedMs: number) {\n this.done = done;\n this.waitedMs = waitedMs;\n }\n\n /** Whole seconds waited — what every message prints, so nothing formats it twice. */\n get waitedSeconds(): number {\n return Math.round(this.waitedMs / 1000);\n }\n}\n\n/**\n * The heartbeat's state: what the probe said on the PREVIOUS tick, so a tick that has not moved can say\n * so. `still` is the load-bearing word, exactly as it is in `BuildLogHeartbeat`: a wait where nothing\n * has happened for two minutes and a wait whose REPORTER has died look identical without it.\n */\nexport class WaitHeartbeat {\n private previous: string | null = null;\n private nextDueMs = HEARTBEAT_MS;\n\n constructor(private readonly label: string) {}\n\n /** True when `elapsedMs` has passed the next heartbeat slot; consumes the slot when it has. */\n isDue(elapsedMs: number): boolean {\n if (elapsedMs < this.nextDueMs) return false;\n this.nextDueMs = elapsedMs + HEARTBEAT_MS;\n return true;\n }\n\n /** One heartbeat line — `waiting on <label>: <state> (<n>s)`, plus ` still` when it has not moved. */\n tick(state: string, elapsedMs: number): string {\n const still = this.previous !== null && state === this.previous ? ' still' : '';\n this.previous = state;\n return `waiting on ${this.label}: ${state} (${String(Math.round(elapsedMs / 1000))}s)${still}`;\n }\n}\n\n/**\n * How often the wait proves it is alive. Hardcoded, like `wp-build`'s: the number that matters is the\n * harness's 600-second silence watchdog, which no repo configures, so a knob here would only ever be a\n * way to set it wrong.\n */\nexport const HEARTBEAT_MS = 20_000;\n\n/**\n * When the wait gives up and asks to be re-run — comfortably under the harness's 600-second ceiling, so\n * the command always returns its own answer rather than being killed holding one.\n */\nexport const CEILING_MS = 540_000;\n"]}
|
|
1
|
+
{"version":3,"file":"await-loop.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/await-loop.ts"],"names":[],"mappings":"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","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\n\nimport { StageOutputLog } from './stage-output-log';\n\n/**\n * THE BLOCKING WAIT, and why webpieces owns one (issue #874, scoped by #878).\n *\n * ─── IT IS ONE EFFICIENT WAIT, OFFERED — NEVER A TURN-LEVEL INSTRUCTION (issue #902) ───────────────\n * #874 justified this class by asserting a worktree-isolated subagent cannot end its turn, #878\n * corrected that, and #900 measured that the background re-invocation the correction relied on does not\n * reliably fire. #902 settles the shape of the advice: webpieces names the efficient wait and blocks the\n * wasteful poll, and says NOTHING about when an agent should end its turn. An agent already waits on the\n * subagents it spawns, routinely, without being told; a fixed rule in a string cannot see the situation\n * it is ruling on.\n *\n * What this class is for is the case where an agent has decided to block. There, a Bash call is\n * the only pause it can express: `Monitor` does not block (its own result says \"Keep working — do not\n * poll or sleep\"), and a `Monitor` carrying a real polling loop is refused by the HARNESS, because a\n * `while`/`until` with a redirect cannot be statically proven to stay inside the worktree (178 of 553\n * measured subagent Monitor calls). That refusal is Claude Code's and is not ours to relax.\n *\n * Without either, the agent falls back to `echo .` every three seconds — measured at 920M tokens, 18.3%\n * of every token the fleet spent in the 24h to 2026-09-07, because every turn resends the whole\n * conversation at ~557,000 tokens. This class is the thing that makes that unnecessary, and\n * `wait-spin-guard` is the thing that makes it unused.\n *\n * ─── THREE CONSTRAINTS, all of them the harness's, none of them negotiable ─────────────────────────\n *\n * 1. **What the AGENT TYPES must be a plain command.** No loop, no redirect, no pipe, no command\n * substitution — those are exactly what the isolation check refuses. All the looping is in here,\n * inside one process, behind a bare `pnpm wp-await-reviews`.\n * 2. **It must print something roughly every {@link HEARTBEAT_MS}.** The harness backgrounds and then\n * KILLS a foreground command that has produced no output for 600 seconds, and a killed wait throws\n * away the wait. Same defence `wp-build` already runs, same `still` word for the same reason: a\n * tick that has not moved must be distinguishable from a stalled reporter.\n * 3. **It must RETURN before that ceiling on its own**, saying to run it again. A wait that dies at\n * the watchdog looks like a crash; a wait that returns at {@link CEILING_MS} with \"still waiting\"\n * turns a 100-minute wait into ~11 tool calls instead of the ~2,000 an `echo .` loop costs.\n *\n * `done` is a normal, ZERO exit either way. \"Still waiting\" is not a failure — it is an answer the agent\n * acts on — and making it non-zero would put a red exit code on the healthy path.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class AwaitLoop {\n constructor(private readonly stageConsole: StageOutputLog) {}\n\n /**\n * Block until `probe` says it is done, or until {@link CEILING_MS} elapses, printing a heartbeat\n * throughout. Returns WHICH of those happened; the caller renders the outcome.\n */\n async run(probe: WaitProbe): Promise<WaitOutcome> {\n const started = Date.now();\n const heartbeat = new WaitHeartbeat(probe.label);\n // `done()` FIRST, before anything is printed. It is what establishes the probe's state, so\n // describing the wait before asking it would report a state nobody has measured — and an\n // already-finished wait must cost nothing at all.\n for (let first = true; ; first = false) {\n if (probe.done()) return new WaitOutcome(true, Date.now() - started);\n const elapsed = Date.now() - started;\n if (elapsed >= CEILING_MS) return new WaitOutcome(false, elapsed);\n // One heartbeat per HEARTBEAT_MS of ELAPSED time, whatever the poll interval — a probe that\n // costs a network round trip is polled far more slowly than one reading a file, and the\n // watchdog counts seconds of silence, not polls.\n if (first || heartbeat.isDue(elapsed)) {\n this.stageConsole.say(`${heartbeat.tick(probe.describe(), elapsed)}\\n`);\n }\n await this.sleep(probe.pollMs);\n }\n }\n\n private sleep(ms: number): Promise<void> {\n return new Promise<void>((resolve: () => void): void => {\n setTimeout(resolve, ms);\n });\n }\n}\n\n/**\n * WHAT is being waited for. An interface rather than a class because it is business logic — `done()`\n * reads verdict files for one waiter and asks GitHub for the other — and the loop must not know which.\n */\nexport interface WaitProbe {\n /** Names the wait in every heartbeat line, e.g. `reviewers` / `checks`. */\n readonly label: string;\n /** How long to sleep between two `done()` calls. A file read polls fast; a `gh` call does not. */\n readonly pollMs: number;\n /** True once the wait is over. Called before the first sleep, so an already-finished wait is free. */\n done(): boolean;\n /** The current state, in one short phrase, for the heartbeat: `2 of 4 verdicts in`. */\n describe(): string;\n}\n\n/** How the wait ended: satisfied, or out of time. Data-only (a class, per CLAUDE.md). */\nexport class WaitOutcome {\n done: boolean;\n waitedMs: number;\n\n constructor(done: boolean, waitedMs: number) {\n this.done = done;\n this.waitedMs = waitedMs;\n }\n\n /** Whole seconds waited — what every message prints, so nothing formats it twice. */\n get waitedSeconds(): number {\n return Math.round(this.waitedMs / 1000);\n }\n}\n\n/**\n * The heartbeat's state: what the probe said on the PREVIOUS tick, so a tick that has not moved can say\n * so. `still` is the load-bearing word, exactly as it is in `BuildLogHeartbeat`: a wait where nothing\n * has happened for two minutes and a wait whose REPORTER has died look identical without it.\n */\nexport class WaitHeartbeat {\n private previous: string | null = null;\n private nextDueMs = HEARTBEAT_MS;\n\n constructor(private readonly label: string) {}\n\n /** True when `elapsedMs` has passed the next heartbeat slot; consumes the slot when it has. */\n isDue(elapsedMs: number): boolean {\n if (elapsedMs < this.nextDueMs) return false;\n this.nextDueMs = elapsedMs + HEARTBEAT_MS;\n return true;\n }\n\n /** One heartbeat line — `waiting on <label>: <state> (<n>s)`, plus ` still` when it has not moved. */\n tick(state: string, elapsedMs: number): string {\n const still = this.previous !== null && state === this.previous ? ' still' : '';\n this.previous = state;\n return `waiting on ${this.label}: ${state} (${String(Math.round(elapsedMs / 1000))}s)${still}`;\n }\n}\n\n/**\n * How often the wait proves it is alive. Hardcoded, like `wp-build`'s: the number that matters is the\n * harness's 600-second silence watchdog, which no repo configures, so a knob here would only ever be a\n * way to set it wrong.\n */\nexport const HEARTBEAT_MS = 20_000;\n\n/**\n * When the wait gives up and asks to be re-run — comfortably under the harness's 600-second ceiling, so\n * the command always returns its own answer rather than being killed holding one.\n */\nexport const CEILING_MS = 540_000;\n"]}
|
|
@@ -57,10 +57,11 @@ export declare class FinishBanner {
|
|
|
57
57
|
* to 2026-09-07 (issue #874). Naming a blocking command is cheaper than the wait somebody was going
|
|
58
58
|
* to do anyway; it is not a reason to wait.
|
|
59
59
|
*
|
|
60
|
-
* It names
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
60
|
+
* It names the EFFICIENT WAIT and the wasteful one, and says nothing about when to end a turn
|
|
61
|
+
* (issue #902). An earlier cut led with "END YOUR TURN"; that is a turn-level judgement the caller is
|
|
62
|
+
* better placed to make than this string is, and it contradicted `SUBAGENT_CURE` shipped in the same
|
|
63
|
+
* release. `--watch` is named because it exists, it blocks, and it is never refused; what it lacks is
|
|
64
|
+
* a bounded exit, so the harness kills it at 600s having printed nothing.
|
|
64
65
|
*/
|
|
65
66
|
private watchOffer;
|
|
66
67
|
/**
|
|
@@ -201,17 +201,18 @@ let FinishBanner = class FinishBanner {
|
|
|
201
201
|
* to 2026-09-07 (issue #874). Naming a blocking command is cheaper than the wait somebody was going
|
|
202
202
|
* to do anyway; it is not a reason to wait.
|
|
203
203
|
*
|
|
204
|
-
* It names
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
204
|
+
* It names the EFFICIENT WAIT and the wasteful one, and says nothing about when to end a turn
|
|
205
|
+
* (issue #902). An earlier cut led with "END YOUR TURN"; that is a turn-level judgement the caller is
|
|
206
|
+
* better placed to make than this string is, and it contradicted `SUBAGENT_CURE` shipped in the same
|
|
207
|
+
* release. `--watch` is named because it exists, it blocks, and it is never refused; what it lacks is
|
|
208
|
+
* a bounded exit, so the harness kills it at 600s having printed nothing.
|
|
208
209
|
*/
|
|
209
210
|
watchOffer() {
|
|
210
211
|
return ' (Optional — only if you have decided to watch it land, which nothing here asks you to do.\n' +
|
|
211
|
-
'
|
|
212
|
-
'
|
|
213
|
-
'
|
|
214
|
-
'
|
|
212
|
+
' Be efficient with tokens: `pnpm wp-await-checks --pr <n>` BLOCKS in one call and returns\n' +
|
|
213
|
+
' at 540s asking to be run again; `gh pr checks <n> --watch` also blocks but is killed at\n' +
|
|
214
|
+
' the 600s silence ceiling. Do not send status checks every few seconds, and\n' +
|
|
215
|
+
' never `echo` to keep a turn alive — a turn costs your whole context.)\n';
|
|
215
216
|
}
|
|
216
217
|
/**
|
|
217
218
|
* The closing AI directive: the resolved PR's number/title/URL and an instruction to end the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"finish-banner.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/finish-banner.ts"],"names":[],"mappings":"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","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\nimport {\n MergeOutcome, MERGE_RESULT_MERGED, MERGE_RESULT_AUTO_QUEUED, MERGE_RESULT_LEFT_TO_HUMAN,\n MERGE_RESULT_BEHIND_CONFLICTING, MERGE_RESULT_BEHIND_UNKNOWN,\n} from './pr-merger';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// Everything the closing block of wp-finish-upsert-pr needs to describe what actually happened. Both\n// `prNumber` and `prUrl` are '' when the PR could not be resolved (e.g. `gh pr create` failed).\nexport class FinishBannerInput {\n prNumber: string;\n prUrl: string;\n title: string;\n base: string;\n merge: MergeOutcome;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(prNumber: string, prUrl: string, title: string, base: string, merge: MergeOutcome) {\n this.prNumber = prNumber;\n this.prUrl = prUrl;\n this.title = title;\n this.base = base;\n this.merge = merge;\n }\n}\n\n/**\n * Renders the closing block of `wp-finish-upsert-pr` — and it is the FRAME, not the merge message, that\n * this class exists to get right.\n *\n * PrMerger has long been honest in its `message`. The banner around it was not: it printed a hard-coded\n * `✅ PR finished` on every path, so a run whose merge failed still looked like a completed one at a\n * glance. The worst case is `mergeStateStatus: BEHIND` — unlike BLOCKED (waiting on checks), which\n * auto-merge resolves on its own, a BEHIND branch NEVER lands. Under a green checkmark, agents walked\n * away from stranded PRs. Three of them independently reported the output as untrustworthy.\n *\n * So the header is derived from `MergeOutcome.result`, and a not-done outcome is loud, distinct, and\n * carries the exact commands that fix it — including in the clickable-link directive, whose whole job\n * is to be the last thing the AI says.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class FinishBanner {\n // The full closing recap: header keyed to the real outcome, the four things this command did, and —\n // when the PR is not done — what must happen next.\n render(input: FinishBannerInput): string {\n const prNum = input.prNumber;\n return '\\n' + SEP + this.header(input.merge) + SEP + '\\n' +\n ` 1. validated the build gate (authoritative)\\n` +\n ` 2. ${prNum ? `wrote the gated body to PR #${prNum}` : 'composed the gated PR body'} titled: \"${input.title}\"\\n` +\n ` 3. force-pushed your work to origin/${input.base} (after the body, so CI reads the right token)\\n` +\n ` 4. ${input.merge.message}\\n` +\n ` You are on ${input.base} — same name as the remote branch and the PR head.\\n` +\n this.whatIsOwed(input) + '\\n';\n }\n\n // TRUE only for the outcomes where nothing further is owed: merged, queued behind green-able checks,\n // or deliberately left for a human. Everything else — BEHIND, config mismatch, gh failure, no PR —\n // is unfinished work, and the banner must not decorate it with a checkmark.\n isDone(merge: MergeOutcome): boolean {\n return merge.result === MERGE_RESULT_MERGED\n || merge.result === MERGE_RESULT_AUTO_QUEUED\n || merge.result === MERGE_RESULT_LEFT_TO_HUMAN;\n }\n\n private header(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_MERGED) return '✅ PR finished AND MERGED — here is exactly what I did\\n';\n if (merge.result === MERGE_RESULT_AUTO_QUEUED) {\n return '✅ PR finished — auto-merge is ON; it lands itself when the checks pass\\n';\n }\n if (merge.result === MERGE_RESULT_LEFT_TO_HUMAN) {\n return '✅ PR finished — posted for a human to merge (that is this repo\\'s policy)\\n';\n }\n // NOT \"PR NOT FINISHED\". Everything this command owns SUCCEEDED — the branch is pushed, the body\n // is written, the build gate is green. What happened is that another author landed on main\n // between our fetch and our push. Reporting that as the author's failure sent agents off\n // re-auditing their own diff and hunting build flakes for a race they did not cause.\n if (merge.isBehind()) return this.behindHeader(merge);\n return '⚠️ PR NOT FINISHED — the PR is up, but the merge did NOT happen\\n';\n }\n\n private behindHeader(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_BEHIND_CONFLICTING) {\n return '⏸️ PR IS UP AND GREEN — someone landed on main first, and it CONFLICTS with your work\\n';\n }\n if (merge.result === MERGE_RESULT_BEHIND_UNKNOWN) {\n return '⏸️ PR IS UP AND GREEN — GitHub has not finished computing mergeability yet\\n';\n }\n return '⏸️ PR IS UP AND GREEN — someone landed on main first (no conflicts with your work)\\n';\n }\n\n // The follow-up block. '' for a done outcome — a finished run should not invent chores.\n private whatIsOwed(input: FinishBannerInput): string {\n if (this.isDone(input.merge)) return this.doneNote(input.merge);\n if (input.merge.isBehind()) return this.behindRemedy(input);\n return '\\n' + SEP +\n ' ⚠️ DO NOT report this PR as done. The merge failed for the reason in step 4 above.\\n' +\n ' Fix that, then re-run: pnpm wp-finish-upsert-pr\\n' +\n ` Confirm for yourself: gh pr view ${input.prNumber === '' ? '<n>' : input.prNumber} --json mergeable,mergeStateStatus,state\\n`;\n }\n\n /**\n * The BEHIND follow-up. Two rules govern every word here.\n *\n * FIRST: the remedy is the FULL ①②③, never a shortcut. `gh pr update-branch` looks like the obvious\n * one-command fix and is a trap — it rewrites the REMOTE branch while every fork-point consumer\n * (`ForkPoint.resolveForkPoint`, `nx affected --base=$(git merge-base ...)`, the review diff) computes\n * against the LOCAL HEAD. That splits reality in two: stage ③ force-pushes local over the remote and\n * silently reverts it, the recorded hash points describe a tree that is no longer the PR head, and the\n * rebased tree never passes a build gate. Only ① moves the fork point AND records it; only ② rebuilds\n * and re-receipts against the new one.\n *\n * SECOND: it ASKS, it does not order. An imperative command list is what turns an agent into a loop —\n * it complies, main moves again, it complies again. Asking forces a stop at a human, which is the only\n * thing that reliably terminates a race we cannot win by retrying.\n */\n private behindRemedy(input: FinishBannerInput): string {\n const num = input.prNumber === '' ? '<n>' : input.prNumber;\n return '\\n' + SEP + this.behindSituation(input.merge) + '\\n' +\n ' ⚠️ STOP HERE AND ASK THE HUMAN. Do NOT run these yourself — if main keeps moving,\\n' +\n ' running them on your own is an infinite loop with a full build inside it.\\n\\n' +\n ' Ask, in your own words:\\n' +\n ` \"${this.behindAsk(input.merge)}\\n` +\n ` ${this.behindAskClose(input.merge)}\"\\n\\n` +\n ' Only once they say yes:\\n\\n' +\n ' pnpm wp-start-upsert-pr # 3-point merge from main — re-forks onto the new main\\n' +\n ' pnpm wp-review-upsert-pr # re-validates the merge + REBUILDS on the new fork point\\n' +\n ' pnpm wp-finish-upsert-pr # gated body + merge, now up to date\\n\\n' +\n ' Do NOT skip ②. It is the only stage that validates the new merge and rebuilds against\\n' +\n ' the new fork point; skipping it publishes a PR whose tree was never gated.\\n' +\n ' Do NOT reach for `gh pr update-branch`. It rewrites the REMOTE branch only, which stage\\n' +\n ' ③ then force-pushes over — and it lands a tree no build gate ever saw.\\n' +\n ` Verify independently, do not trust this banner:\\n` +\n ` gh pr view ${num} --json mergeable,mergeStateStatus,state\\n`;\n }\n\n // What is actually true right now, per kind. The CLEAN case gets the caveat that it may not even\n // matter: \"out of date\" only blocks a merge on repos that require branches be up to date.\n private behindSituation(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_BEHIND_CONFLICTING) {\n return ' Your PR is pushed, its body is written, and the build gate passed. Then someone\\n' +\n ' else landed on main, and their change CONFLICTS with yours — same lines. Real\\n' +\n ' resolution is owed, and if people keep landing ahead of you it genuinely repeats.\\n' +\n ' That is inherent to concurrent editing, not a bug and not something you did wrong.\\n';\n }\n if (merge.result === MERGE_RESULT_BEHIND_UNKNOWN) {\n return ' Your PR is pushed, its body is written, and the build gate passed. GitHub has not\\n' +\n ' finished computing mergeability yet (it is asynchronous, and we asked seconds after\\n' +\n ' the push), so we do NOT know whether this conflicts. Re-check before doing anything:\\n' +\n ' a few seconds later the answer is usually CLEAN and no work is owed at all.\\n';\n }\n return ' Your PR is pushed, its body is written, and the build gate passed. Someone else simply\\n' +\n ' landed on main first. There are NO conflicts — nobody touched your lines.\\n' +\n ' This may not even matter: \"out of date\" blocks a merge only on repos that REQUIRE\\n' +\n ' branches be up to date. If yours does not, this PR can merge as-is.\\n';\n }\n\n // The one sentence to put to the human, in their terms.\n private behindAsk(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_BEHIND_CONFLICTING) {\n return 'Someone beat me to landing on main and there are conflicts. We MUST run a\\n' +\n ' 3-point merge so I can resolve them properly.';\n }\n if (merge.result === MERGE_RESULT_BEHIND_UNKNOWN) {\n return 'Someone beat me to landing on main. GitHub has not said yet whether it\\n' +\n ' conflicts, so I have not touched anything.';\n }\n return 'Someone beat me to landing on main. There are no conflicts, so this is just\\n' +\n ' a re-sync — but it costs a full rebuild.';\n }\n\n // The actual question. UNKNOWN gets a DIFFERENT one: with mergeability still uncomputed, proposing a\n // full re-run is proposing work we cannot yet show is needed — a re-check is free and often ends it.\n private behindAskClose(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_BEHIND_UNKNOWN) {\n return 'Shall I re-check in a moment, or start the wp-*-upsert-pr process over?';\n }\n return 'May I start the wp-*-upsert-pr process over again?';\n }\n\n // A short, positive \"you are free to stop\" line for the two non-merged-but-fine outcomes, so an AI\n // reading a queued PR does not go hunting for work that is not owed.\n private doneNote(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_AUTO_QUEUED) {\n return ' Nothing else is owed: GitHub lands it when the checks go green. You can stop here.\\n' +\n this.watchOffer();\n }\n if (merge.result === MERGE_RESULT_LEFT_TO_HUMAN) {\n return ' Nothing else is owed by the tooling — a person merges it. You can stop here.\\n' +\n this.watchOffer() +\n '\\n' +\n ' ℹ️ Clicking Merge in the GitHub UI is CORRECT here and produces the right history. The\\n' +\n ' PR description IS the commit body this flow rendered — compact, non-green flags\\n' +\n ' only, with the PR link on top — and this flow keeps the two repo settings that copy\\n' +\n ' it into main pinned itself (squash_merge_commit_title=PR_TITLE,\\n' +\n ' squash_merge_commit_message=PR_BODY). There is nothing for you to configure.\\n' +\n ' (`pnpm wp-land-pr` lands the identical bytes from the CLI, and also archives the\\n' +\n ' pre-squash tip and reaps the worktree. Either route is fine.)\\n';\n }\n return '';\n }\n\n /**\n * The one OPTIONAL line about watching CI — offered, never instructed.\n *\n * \"You can stop here\" above stays the DEFAULT and is deliberately printed first: landing is the\n * developer's call, and several of this repo's workflows stop at a green PR on purpose. This exists\n * for the caller who has ALREADY decided to see it land, because the alternative they otherwise\n * reach for is an `echo .` keep-alive — measured at 18.3% of every token the fleet spent in the 24h\n * to 2026-09-07 (issue #874). Naming a blocking command is cheaper than the wait somebody was going\n * to do anyway; it is not a reason to wait.\n *\n * It names ENDING THE TURN first (issue #878): if a backgrounded command or a spawned subagent is\n * still pending, the harness re-invokes the caller for free — 449 measured times. `--watch` is named\n * too, because it exists, it blocks, and it is never refused; what it lacks is a bounded exit, so\n * the harness kills it at 600s having printed nothing.\n */\n private watchOffer(): string {\n return ' (Optional — only if you have decided to watch it land, which nothing here asks you to do.\\n' +\n ' Cheapest: END YOUR TURN — anything still pending re-invokes you for free. Otherwise\\n' +\n ' `pnpm wp-await-checks --pr <n>` BLOCKS in one call and returns at 540s asking to be run\\n' +\n ' again; `gh pr checks <n> --watch` also blocks but is killed at the 600s silence ceiling.\\n' +\n ' Do not poll, and never `echo` to keep a turn alive — a turn costs your whole context.)\\n';\n }\n\n /**\n * The closing AI directive: the resolved PR's number/title/URL and an instruction to end the\n * user-facing summary with EXACTLY `[#N title](url)` as the final line, nothing after it.\n *\n * The link text CARRIES the outcome. Ending on a bare cheerful link is exactly how a stranded PR got\n * reported as finished, and the directive says the link must be last — so the truth has to live\n * inside the link itself, not merely above it.\n *\n * '' when the PR could not be resolved: better to say nothing than to point at a link that is not real.\n */\n linkDirective(input: FinishBannerInput): string {\n if (input.prNumber === '' || input.prUrl === '') return '';\n const label = this.isDone(input.merge) ? input.title : `${input.title} — ${this.notDoneTag(input.merge)}`;\n const preamble = this.isDone(input.merge)\n ? ''\n : `AI — this PR is NOT done (${this.notDoneTag(input.merge)}). Your summary MUST say so plainly.\\n` +\n (input.merge.isBehind()\n ? `MUST END BY ASKING the human for permission to re-run the flow, and MUST NOT run any of\\n` +\n `the commands above until they answer. Say plainly that nothing they did caused this —\\n` +\n `another author landed on main first — and that the PR itself is pushed and gate-green.\\n\\n`\n : `MUST include the remedy commands printed above. Do not describe this run as complete.\\n\\n`);\n return SEP + `🔗 PR #${input.prNumber}: ${input.title}\\n ${input.prUrl}\\n\\n` + SEP + preamble +\n `AI — the LAST line of your summary to the user MUST be EXACTLY this clickable link, with\\n` +\n `NOTHING printed after it (no sign-off, no next steps, no blank commentary):\\n\\n` +\n ` [#${input.prNumber} ${label}](${input.prUrl})\\n\\n`;\n }\n\n // The tag rides INSIDE the clickable link, which the directive above forces to be the last line the\n // AI prints — so it is the one piece of wording guaranteed to reach the user. It says who is waiting\n // on whom: for every BEHIND flavour the answer is \"a human\", not \"more automation\".\n private notDoneTag(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_BEHIND_CONFLICTING) return 'NOT MERGED — main moved and it conflicts, needs your OK to re-sync';\n if (merge.result === MERGE_RESULT_BEHIND_UNKNOWN) return 'NOT MERGED — main moved, GitHub still computing mergeability';\n if (merge.isBehind()) return 'NOT MERGED — main moved (no conflicts), needs your OK to re-sync';\n return 'NOT MERGED';\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"finish-banner.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/finish-banner.ts"],"names":[],"mappings":"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","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\nimport {\n MergeOutcome, MERGE_RESULT_MERGED, MERGE_RESULT_AUTO_QUEUED, MERGE_RESULT_LEFT_TO_HUMAN,\n MERGE_RESULT_BEHIND_CONFLICTING, MERGE_RESULT_BEHIND_UNKNOWN,\n} from './pr-merger';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// Everything the closing block of wp-finish-upsert-pr needs to describe what actually happened. Both\n// `prNumber` and `prUrl` are '' when the PR could not be resolved (e.g. `gh pr create` failed).\nexport class FinishBannerInput {\n prNumber: string;\n prUrl: string;\n title: string;\n base: string;\n merge: MergeOutcome;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(prNumber: string, prUrl: string, title: string, base: string, merge: MergeOutcome) {\n this.prNumber = prNumber;\n this.prUrl = prUrl;\n this.title = title;\n this.base = base;\n this.merge = merge;\n }\n}\n\n/**\n * Renders the closing block of `wp-finish-upsert-pr` — and it is the FRAME, not the merge message, that\n * this class exists to get right.\n *\n * PrMerger has long been honest in its `message`. The banner around it was not: it printed a hard-coded\n * `✅ PR finished` on every path, so a run whose merge failed still looked like a completed one at a\n * glance. The worst case is `mergeStateStatus: BEHIND` — unlike BLOCKED (waiting on checks), which\n * auto-merge resolves on its own, a BEHIND branch NEVER lands. Under a green checkmark, agents walked\n * away from stranded PRs. Three of them independently reported the output as untrustworthy.\n *\n * So the header is derived from `MergeOutcome.result`, and a not-done outcome is loud, distinct, and\n * carries the exact commands that fix it — including in the clickable-link directive, whose whole job\n * is to be the last thing the AI says.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class FinishBanner {\n // The full closing recap: header keyed to the real outcome, the four things this command did, and —\n // when the PR is not done — what must happen next.\n render(input: FinishBannerInput): string {\n const prNum = input.prNumber;\n return '\\n' + SEP + this.header(input.merge) + SEP + '\\n' +\n ` 1. validated the build gate (authoritative)\\n` +\n ` 2. ${prNum ? `wrote the gated body to PR #${prNum}` : 'composed the gated PR body'} titled: \"${input.title}\"\\n` +\n ` 3. force-pushed your work to origin/${input.base} (after the body, so CI reads the right token)\\n` +\n ` 4. ${input.merge.message}\\n` +\n ` You are on ${input.base} — same name as the remote branch and the PR head.\\n` +\n this.whatIsOwed(input) + '\\n';\n }\n\n // TRUE only for the outcomes where nothing further is owed: merged, queued behind green-able checks,\n // or deliberately left for a human. Everything else — BEHIND, config mismatch, gh failure, no PR —\n // is unfinished work, and the banner must not decorate it with a checkmark.\n isDone(merge: MergeOutcome): boolean {\n return merge.result === MERGE_RESULT_MERGED\n || merge.result === MERGE_RESULT_AUTO_QUEUED\n || merge.result === MERGE_RESULT_LEFT_TO_HUMAN;\n }\n\n private header(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_MERGED) return '✅ PR finished AND MERGED — here is exactly what I did\\n';\n if (merge.result === MERGE_RESULT_AUTO_QUEUED) {\n return '✅ PR finished — auto-merge is ON; it lands itself when the checks pass\\n';\n }\n if (merge.result === MERGE_RESULT_LEFT_TO_HUMAN) {\n return '✅ PR finished — posted for a human to merge (that is this repo\\'s policy)\\n';\n }\n // NOT \"PR NOT FINISHED\". Everything this command owns SUCCEEDED — the branch is pushed, the body\n // is written, the build gate is green. What happened is that another author landed on main\n // between our fetch and our push. Reporting that as the author's failure sent agents off\n // re-auditing their own diff and hunting build flakes for a race they did not cause.\n if (merge.isBehind()) return this.behindHeader(merge);\n return '⚠️ PR NOT FINISHED — the PR is up, but the merge did NOT happen\\n';\n }\n\n private behindHeader(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_BEHIND_CONFLICTING) {\n return '⏸️ PR IS UP AND GREEN — someone landed on main first, and it CONFLICTS with your work\\n';\n }\n if (merge.result === MERGE_RESULT_BEHIND_UNKNOWN) {\n return '⏸️ PR IS UP AND GREEN — GitHub has not finished computing mergeability yet\\n';\n }\n return '⏸️ PR IS UP AND GREEN — someone landed on main first (no conflicts with your work)\\n';\n }\n\n // The follow-up block. '' for a done outcome — a finished run should not invent chores.\n private whatIsOwed(input: FinishBannerInput): string {\n if (this.isDone(input.merge)) return this.doneNote(input.merge);\n if (input.merge.isBehind()) return this.behindRemedy(input);\n return '\\n' + SEP +\n ' ⚠️ DO NOT report this PR as done. The merge failed for the reason in step 4 above.\\n' +\n ' Fix that, then re-run: pnpm wp-finish-upsert-pr\\n' +\n ` Confirm for yourself: gh pr view ${input.prNumber === '' ? '<n>' : input.prNumber} --json mergeable,mergeStateStatus,state\\n`;\n }\n\n /**\n * The BEHIND follow-up. Two rules govern every word here.\n *\n * FIRST: the remedy is the FULL ①②③, never a shortcut. `gh pr update-branch` looks like the obvious\n * one-command fix and is a trap — it rewrites the REMOTE branch while every fork-point consumer\n * (`ForkPoint.resolveForkPoint`, `nx affected --base=$(git merge-base ...)`, the review diff) computes\n * against the LOCAL HEAD. That splits reality in two: stage ③ force-pushes local over the remote and\n * silently reverts it, the recorded hash points describe a tree that is no longer the PR head, and the\n * rebased tree never passes a build gate. Only ① moves the fork point AND records it; only ② rebuilds\n * and re-receipts against the new one.\n *\n * SECOND: it ASKS, it does not order. An imperative command list is what turns an agent into a loop —\n * it complies, main moves again, it complies again. Asking forces a stop at a human, which is the only\n * thing that reliably terminates a race we cannot win by retrying.\n */\n private behindRemedy(input: FinishBannerInput): string {\n const num = input.prNumber === '' ? '<n>' : input.prNumber;\n return '\\n' + SEP + this.behindSituation(input.merge) + '\\n' +\n ' ⚠️ STOP HERE AND ASK THE HUMAN. Do NOT run these yourself — if main keeps moving,\\n' +\n ' running them on your own is an infinite loop with a full build inside it.\\n\\n' +\n ' Ask, in your own words:\\n' +\n ` \"${this.behindAsk(input.merge)}\\n` +\n ` ${this.behindAskClose(input.merge)}\"\\n\\n` +\n ' Only once they say yes:\\n\\n' +\n ' pnpm wp-start-upsert-pr # 3-point merge from main — re-forks onto the new main\\n' +\n ' pnpm wp-review-upsert-pr # re-validates the merge + REBUILDS on the new fork point\\n' +\n ' pnpm wp-finish-upsert-pr # gated body + merge, now up to date\\n\\n' +\n ' Do NOT skip ②. It is the only stage that validates the new merge and rebuilds against\\n' +\n ' the new fork point; skipping it publishes a PR whose tree was never gated.\\n' +\n ' Do NOT reach for `gh pr update-branch`. It rewrites the REMOTE branch only, which stage\\n' +\n ' ③ then force-pushes over — and it lands a tree no build gate ever saw.\\n' +\n ` Verify independently, do not trust this banner:\\n` +\n ` gh pr view ${num} --json mergeable,mergeStateStatus,state\\n`;\n }\n\n // What is actually true right now, per kind. The CLEAN case gets the caveat that it may not even\n // matter: \"out of date\" only blocks a merge on repos that require branches be up to date.\n private behindSituation(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_BEHIND_CONFLICTING) {\n return ' Your PR is pushed, its body is written, and the build gate passed. Then someone\\n' +\n ' else landed on main, and their change CONFLICTS with yours — same lines. Real\\n' +\n ' resolution is owed, and if people keep landing ahead of you it genuinely repeats.\\n' +\n ' That is inherent to concurrent editing, not a bug and not something you did wrong.\\n';\n }\n if (merge.result === MERGE_RESULT_BEHIND_UNKNOWN) {\n return ' Your PR is pushed, its body is written, and the build gate passed. GitHub has not\\n' +\n ' finished computing mergeability yet (it is asynchronous, and we asked seconds after\\n' +\n ' the push), so we do NOT know whether this conflicts. Re-check before doing anything:\\n' +\n ' a few seconds later the answer is usually CLEAN and no work is owed at all.\\n';\n }\n return ' Your PR is pushed, its body is written, and the build gate passed. Someone else simply\\n' +\n ' landed on main first. There are NO conflicts — nobody touched your lines.\\n' +\n ' This may not even matter: \"out of date\" blocks a merge only on repos that REQUIRE\\n' +\n ' branches be up to date. If yours does not, this PR can merge as-is.\\n';\n }\n\n // The one sentence to put to the human, in their terms.\n private behindAsk(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_BEHIND_CONFLICTING) {\n return 'Someone beat me to landing on main and there are conflicts. We MUST run a\\n' +\n ' 3-point merge so I can resolve them properly.';\n }\n if (merge.result === MERGE_RESULT_BEHIND_UNKNOWN) {\n return 'Someone beat me to landing on main. GitHub has not said yet whether it\\n' +\n ' conflicts, so I have not touched anything.';\n }\n return 'Someone beat me to landing on main. There are no conflicts, so this is just\\n' +\n ' a re-sync — but it costs a full rebuild.';\n }\n\n // The actual question. UNKNOWN gets a DIFFERENT one: with mergeability still uncomputed, proposing a\n // full re-run is proposing work we cannot yet show is needed — a re-check is free and often ends it.\n private behindAskClose(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_BEHIND_UNKNOWN) {\n return 'Shall I re-check in a moment, or start the wp-*-upsert-pr process over?';\n }\n return 'May I start the wp-*-upsert-pr process over again?';\n }\n\n // A short, positive \"you are free to stop\" line for the two non-merged-but-fine outcomes, so an AI\n // reading a queued PR does not go hunting for work that is not owed.\n private doneNote(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_AUTO_QUEUED) {\n return ' Nothing else is owed: GitHub lands it when the checks go green. You can stop here.\\n' +\n this.watchOffer();\n }\n if (merge.result === MERGE_RESULT_LEFT_TO_HUMAN) {\n return ' Nothing else is owed by the tooling — a person merges it. You can stop here.\\n' +\n this.watchOffer() +\n '\\n' +\n ' ℹ️ Clicking Merge in the GitHub UI is CORRECT here and produces the right history. The\\n' +\n ' PR description IS the commit body this flow rendered — compact, non-green flags\\n' +\n ' only, with the PR link on top — and this flow keeps the two repo settings that copy\\n' +\n ' it into main pinned itself (squash_merge_commit_title=PR_TITLE,\\n' +\n ' squash_merge_commit_message=PR_BODY). There is nothing for you to configure.\\n' +\n ' (`pnpm wp-land-pr` lands the identical bytes from the CLI, and also archives the\\n' +\n ' pre-squash tip and reaps the worktree. Either route is fine.)\\n';\n }\n return '';\n }\n\n /**\n * The one OPTIONAL line about watching CI — offered, never instructed.\n *\n * \"You can stop here\" above stays the DEFAULT and is deliberately printed first: landing is the\n * developer's call, and several of this repo's workflows stop at a green PR on purpose. This exists\n * for the caller who has ALREADY decided to see it land, because the alternative they otherwise\n * reach for is an `echo .` keep-alive — measured at 18.3% of every token the fleet spent in the 24h\n * to 2026-09-07 (issue #874). Naming a blocking command is cheaper than the wait somebody was going\n * to do anyway; it is not a reason to wait.\n *\n * It names the EFFICIENT WAIT and the wasteful one, and says nothing about when to end a turn\n * (issue #902). An earlier cut led with \"END YOUR TURN\"; that is a turn-level judgement the caller is\n * better placed to make than this string is, and it contradicted `SUBAGENT_CURE` shipped in the same\n * release. `--watch` is named because it exists, it blocks, and it is never refused; what it lacks is\n * a bounded exit, so the harness kills it at 600s having printed nothing.\n */\n private watchOffer(): string {\n return ' (Optional — only if you have decided to watch it land, which nothing here asks you to do.\\n' +\n ' Be efficient with tokens: `pnpm wp-await-checks --pr <n>` BLOCKS in one call and returns\\n' +\n ' at 540s asking to be run again; `gh pr checks <n> --watch` also blocks but is killed at\\n' +\n ' the 600s silence ceiling. Do not send status checks every few seconds, and\\n' +\n ' never `echo` to keep a turn alive — a turn costs your whole context.)\\n';\n }\n\n /**\n * The closing AI directive: the resolved PR's number/title/URL and an instruction to end the\n * user-facing summary with EXACTLY `[#N title](url)` as the final line, nothing after it.\n *\n * The link text CARRIES the outcome. Ending on a bare cheerful link is exactly how a stranded PR got\n * reported as finished, and the directive says the link must be last — so the truth has to live\n * inside the link itself, not merely above it.\n *\n * '' when the PR could not be resolved: better to say nothing than to point at a link that is not real.\n */\n linkDirective(input: FinishBannerInput): string {\n if (input.prNumber === '' || input.prUrl === '') return '';\n const label = this.isDone(input.merge) ? input.title : `${input.title} — ${this.notDoneTag(input.merge)}`;\n const preamble = this.isDone(input.merge)\n ? ''\n : `AI — this PR is NOT done (${this.notDoneTag(input.merge)}). Your summary MUST say so plainly.\\n` +\n (input.merge.isBehind()\n ? `MUST END BY ASKING the human for permission to re-run the flow, and MUST NOT run any of\\n` +\n `the commands above until they answer. Say plainly that nothing they did caused this —\\n` +\n `another author landed on main first — and that the PR itself is pushed and gate-green.\\n\\n`\n : `MUST include the remedy commands printed above. Do not describe this run as complete.\\n\\n`);\n return SEP + `🔗 PR #${input.prNumber}: ${input.title}\\n ${input.prUrl}\\n\\n` + SEP + preamble +\n `AI — the LAST line of your summary to the user MUST be EXACTLY this clickable link, with\\n` +\n `NOTHING printed after it (no sign-off, no next steps, no blank commentary):\\n\\n` +\n ` [#${input.prNumber} ${label}](${input.prUrl})\\n\\n`;\n }\n\n // The tag rides INSIDE the clickable link, which the directive above forces to be the last line the\n // AI prints — so it is the one piece of wording guaranteed to reach the user. It says who is waiting\n // on whom: for every BEHIND flavour the answer is \"a human\", not \"more automation\".\n private notDoneTag(merge: MergeOutcome): string {\n if (merge.result === MERGE_RESULT_BEHIND_CONFLICTING) return 'NOT MERGED — main moved and it conflicts, needs your OK to re-sync';\n if (merge.result === MERGE_RESULT_BEHIND_UNKNOWN) return 'NOT MERGED — main moved, GitHub still computing mergeability';\n if (merge.isBehind()) return 'NOT MERGED — main moved (no conflicts), needs your OK to re-sync';\n return 'NOT MERGED';\n }\n}\n"]}
|
|
@@ -187,9 +187,9 @@ export declare class ReviewReport {
|
|
|
187
187
|
* It is here because the alternative is measured and expensive: `echo .` every three seconds at
|
|
188
188
|
* ~557,000 tokens a turn, 18.3% of every token the fleet spent in the 24h to 2026-09-07 (#874).
|
|
189
189
|
*
|
|
190
|
-
* It
|
|
191
|
-
*
|
|
192
|
-
*
|
|
190
|
+
* It names the efficient options and the wasteful one, and then stops (#902). It does NOT prescribe
|
|
191
|
+
* ending the turn: waiting on the subagents you just spawned is something an agent already does
|
|
192
|
+
* routinely, and whether to do that here is a judgement this string cannot make for it.
|
|
193
193
|
*
|
|
194
194
|
* It names no other stage. `finishStep` below is the ONE place this whole block names
|
|
195
195
|
* `wp-finish-upsert-pr`, and a second mention here would be a second "what to do next" instruction
|
|
@@ -324,9 +324,9 @@ let ReviewReport = class ReviewReport {
|
|
|
324
324
|
* It is here because the alternative is measured and expensive: `echo .` every three seconds at
|
|
325
325
|
* ~557,000 tokens a turn, 18.3% of every token the fleet spent in the 24h to 2026-09-07 (#874).
|
|
326
326
|
*
|
|
327
|
-
* It
|
|
328
|
-
*
|
|
329
|
-
*
|
|
327
|
+
* It names the efficient options and the wasteful one, and then stops (#902). It does NOT prescribe
|
|
328
|
+
* ending the turn: waiting on the subagents you just spawned is something an agent already does
|
|
329
|
+
* routinely, and whether to do that here is a judgement this string cannot make for it.
|
|
330
330
|
*
|
|
331
331
|
* It names no other stage. `finishStep` below is the ONE place this whole block names
|
|
332
332
|
* `wp-finish-upsert-pr`, and a second mention here would be a second "what to do next" instruction
|
|
@@ -334,13 +334,10 @@ let ReviewReport = class ReviewReport {
|
|
|
334
334
|
*/
|
|
335
335
|
awaitLines() {
|
|
336
336
|
return [
|
|
337
|
-
' Then WAIT.
|
|
338
|
-
'
|
|
339
|
-
'',
|
|
340
|
-
'
|
|
341
|
-
' times), and waiting that way costs nothing at all.',
|
|
342
|
-
'',
|
|
343
|
-
' ONLY if nothing pending would wake you, block in one call:',
|
|
337
|
+
' Then WAIT. Be efficient with tokens: wait on the subagents you just spawned, or block',
|
|
338
|
+
' in one call with the command below. Do NOT send status checks every few seconds, and',
|
|
339
|
+
' do NOT run `echo` to keep your turn alive — a turn costs your whole context, ~557k',
|
|
340
|
+
' tokens.',
|
|
344
341
|
'',
|
|
345
342
|
' pnpm wp-await-reviews',
|
|
346
343
|
'',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"review-report.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/review-report.ts"],"names":[],"mappings":"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","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\nimport {\n HOME_CONFIG_DIR, HOME_CONFIG_FILE, HOME_KEY_TURN_OFF_ALL_REVIEWERS, reviewJsonSchemaHint,\n RequiredChecklist, ReviewerBriefing, ReviewerInstructionsService,\n} from '@webpieces/rules-config';\nimport { ChecklistNotice } from './checklist-notice';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * One reviewer that ALREADY ANSWERED on this branch and refused, with the refusal rendered by\n * `ReviewJsonService.refusalError` — the ONE wording, shared with `wp-finish-upsert-pr`. Data-only.\n *\n * Stage ② carries these because it had the same defect finish did: a refused checklist has no passing\n * verdict, so it is \"owed\", so it got an ordinary spawn block identical to a reviewer that never ran. An\n * agent obeys that block, the reviewer re-reads unchanged code, refuses again — the loop, one stage earlier.\n *\n * The message is rendered by the COMMAND rather than in here for one reason: stage ② must not archive\n * anything. `refusalError` called with no archive path says \"fix it, then re-run\", which is only true while\n * the verdict file is still live — and at stage ② it is. Retiring a verdict is finish's act, on the refusal\n * it is actually acting on. (The ship-anyway route it prints — the command that writes `override-<id>.json`\n * — is correct at either stage: that is a different file, with its own writer, and no archive touches it.)\n */\nexport class RefusedReviewer {\n checklistId: string;\n message: string;\n\n constructor(checklistId: string, message: string) {\n this.checklistId = checklistId;\n this.message = message;\n }\n}\n\n/**\n * Everything the closing block of `wp-review-upsert-pr` needs. Data-only, and a class rather than an\n * object literal per CLAUDE.md. The three identifying paths are constructor args; the rest are optional\n * facts about the scan that default to \"nothing\", so a repo with no checklists constructs it in one line.\n */\nexport class ReviewReportInput {\n repoRoot: string;\n featureName: string;\n reviewPath: string; // the branch's review.json — the file the AI must write next\n definedCount: number; // how many checklists pr-gate.checklists defines\n applicableCount: number; // how many of them apply to THIS diff (0 ⇒ the notice, not spawn blocks)\n reviewed: RequiredChecklist[]; // already have a passing verdict on this branch\n formatErrors: string[]; // verdict files that exist but cannot be read as verdicts\n briefings: ReviewerBriefing[]; // one per applicable checklist, already written to disk\n refused: RefusedReviewer[]; // of the owed ones, those that already ran and said no (see RefusedReviewer)\n /**\n * `--no-optional` was passed: the human has already said to submit without the optional reviews, so the\n * block that offers them is replaced by a one-line statement that they were skipped. It never suppresses\n * a REQUIRED reviewer, and it is not a gate — nothing about what blocks the PR changes.\n */\n skipOptional: boolean;\n /**\n * `experimental.turnOffAllReviewers` is TRUE in `~/.webpieces/config.json`, so NO reviewer ran and\n * none will — the REQUIRED ones included. Nothing else in this class may be read as evidence of that:\n * `applicableCount` is 0 under suppression exactly as it is on a docs-only PR that matched nothing,\n * and those two must never print the same thing.\n *\n * When true this stage prints a LOUD banner naming the flag, the file and every suppressed checklist,\n * and prints NO spawn block. Reviewers cannot be spawned — there are no briefings and no instructions\n * files — so a block telling an agent to spawn them would be an instruction it cannot obey.\n */\n reviewersSuppressed: boolean;\n /**\n * The checklists that WOULD have applied, had they not been suppressed. Empty unless\n * `reviewersSuppressed`. Named, not counted, because the one thing a human must be able to see is\n * WHICH required reviewer was killed.\n */\n suppressed: RequiredChecklist[];\n\n constructor(repoRoot: string, featureName: string, reviewPath: string) {\n this.repoRoot = repoRoot;\n this.featureName = featureName;\n this.reviewPath = reviewPath;\n this.definedCount = 0;\n this.applicableCount = 0;\n this.reviewed = [];\n this.formatErrors = [];\n this.briefings = [];\n this.refused = [];\n this.skipOptional = false;\n this.reviewersSuppressed = false;\n this.suppressed = [];\n }\n}\n\n/**\n * Renders the closing block of `wp-review-upsert-pr` — the checklist verdict, then EXACTLY ONE \"what to do\n * next\".\n *\n * Extracted from the command so it can be asserted on as a rendered string, because the ordering IS the\n * contract. The bug it was extracted to fix: the zero-checklist notice ended with \"Carry on and run: pnpm\n * wp-finish-upsert-pr\", and the block printed directly beneath it said to write review.json first and\n * finish afterwards. Two next-steps, in the wrong order, and an agent that follows instructions literally —\n * which is the entire reason this command prints them — took the first one and opened a PR with no review.\n * That is precisely the failure the three-stage flow exists to prevent (reported on PR #519).\n *\n * The invariants, enforced by review-report.spec.ts:\n * 1. `wp-finish-upsert-pr` is named as a thing to run EXACTLY ONCE in the whole block.\n * 2. The review.json instruction comes BEFORE it — and BEFORE the spawn blocks (see nextSteps).\n * 3. With zero checklists the all-clear precedes any configuration guidance.\n * 4. REQUIRED reviewers are spawned unasked; OPTIONAL ones are only ever OFFERED, in one batched\n * question. The two never share a step, because one instruction says \"do it\" and the other says\n * \"ask first\", and an agent reading a merged list will act on the stronger of the two.\n *\n * Pure string building, no I/O. `@injectable(bindingScopeValues.Singleton)` so it is injected by type.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class ReviewReport {\n constructor(\n private readonly checklistNotice: ChecklistNotice,\n private readonly reviewerInstructions: ReviewerInstructionsService,\n ) {}\n\n render(input: ReviewReportInput): string {\n return '\\n' + SEP + this.header(input) + SEP\n + this.scanVerdict(input)\n + this.nextSteps(input);\n }\n\n /**\n * Name what this block is actually about. A repo with reviewers owed is being told to SPAWN; a repo with\n * none is not, and promising subagents it does not have is the same kind of noise as explaining checklist\n * configuration to a repo that configured none. Keyed on what is actually ACTIONABLE rather than on the\n * applicable count: every applicable checklist already having a verdict means nothing to spawn, and so\n * does a branch whose only outstanding reviews are optional ones the human already waved off.\n *\n * \"spawn\" and \"ask about\" are separate headings because they are separate obligations. A branch owing\n * only optional reviews has nothing the AI may do unilaterally, and a heading that says SPAWN is the\n * single line most likely to make it do exactly that.\n */\n private header(input: ReviewReportInput): string {\n // FIRST, and unconditional: with the kill switch on there is nothing to spawn and nothing to\n // offer, so every heading below would be true-but-misleading. The one thing a reader must take\n // from the first line of this block is that no reviewer looked at this branch.\n if (input.reviewersSuppressed) return '② ⚫ ALL REVIEWERS SUPPRESSED — review, then finish\\n';\n if (this.requiredOwed(input).length > 0) return '② Review, spawn subagent reviewers, then finish\\n';\n if (this.offerableOwed(input).length > 0) return '② Review, offer the optional reviewers, then finish\\n';\n return '② Review, then finish\\n';\n }\n\n /**\n * What the SCAN found: either \"nothing applies here\" or the already-reviewed / unreadable-verdict lines.\n * Verdicts, not instructions — every instruction lives in nextSteps() below, so no line up here can be\n * mistaken for the next action.\n */\n private scanVerdict(input: ReviewReportInput): string {\n // BEFORE the zero-applicable notice, which would otherwise say \"nothing matched this diff\" — the\n // single most misleading sentence available here, because plenty matched and every one of them was\n // switched off.\n if (input.reviewersSuppressed) return '\\n' + this.suppressionBanner(input);\n if (input.applicableCount === 0) return '\\n' + this.checklistNotice.build(input.definedCount);\n const lines: string[] = [];\n // The prohibition rides on the REUSE line itself, not only in the all-clear below, because the\n // all-clear is not printed when anything is still owed — and \"some reviewers are reused, others\n // must be spawned\" is exactly the shape in which an agent re-spawns the reused ones too.\n for (const r of input.reviewed) {\n lines.push(` ✓ ${r.subagent} — already reviewed on this branch; verdict STANDS, do NOT re-spawn (review-${r.id}.json)`);\n }\n // A verdict file that EXISTS but is unreadable as a verdict is called out here. Without it this\n // reports the checklist as simply owed, and the AI re-runs a reviewer that already ran instead of\n // correcting the file sitting right there.\n for (const e of input.formatErrors) lines.push(` ⛔ ${e}`);\n lines.push(...this.skippedLines(input));\n if (this.actionableOwed(input).length === 0) lines.push('', this.allClear(input));\n if (lines.length === 0) return '';\n return '\\n' + lines.join('\\n') + '\\n';\n }\n\n /**\n * THE LOUD BANNER. The one output in this whole flow that says an unreviewed PR is about to be posted.\n *\n * Everything in it is there because a reader has to be able to act on it or audit it: the FLAG name and\n * the FILE it was read from (the only actionable thing — nothing in any repo turns this off), the COUNT\n * split by required/optional, and every suppressed checklist BY NAME with REQUIRED marked. A count\n * alone would leave \"which required reviewer did I just skip?\" unanswerable at the one moment it is\n * being answered.\n *\n * It deliberately does NOT name `wp-finish-upsert-pr` — the closing step already does, exactly once,\n * and that \"exactly once\" is an invariant this class's docstring records having been bought with a bug.\n */\n private suppressionBanner(input: ReviewReportInput): string {\n const required = input.suppressed.filter((r: RequiredChecklist): boolean => r.required);\n const lines: string[] = [\n '⚫ ALL REVIEWER SUBAGENTS ARE SWITCHED OFF ON THIS MACHINE. Nothing reviewed this branch.',\n '',\n ` ${input.suppressed.length} checklist(s) matched this diff and were SUPPRESSED — `\n + `${required.length} of them REQUIRED:`,\n ];\n for (const r of input.suppressed) {\n lines.push(` • ${r.subagent}${r.required ? ' (REQUIRED — suppressed anyway)' : ' (optional)'}`);\n }\n lines.push(\n '',\n ` Switched off by: experimental.${HOME_KEY_TURN_OFF_ALL_REVIEWERS}: true`,\n ` Read from: ~/${HOME_CONFIG_DIR}/${HOME_CONFIG_FILE} (machine-local; no repo config can set this)`,\n '',\n ' There is NOTHING to spawn — no reviewer was briefed and no instructions file was written,',\n ' so any attempt to spawn one has nothing to read. Do NOT hand-write a verdict file in their',\n ' place: a fabricated verdict is worse than the absent one this flag deliberately produces.',\n '',\n ' The dashboard is the whole review product for this PR, and the suppression is carried into',\n ' the PR body — which is the squash-merge commit body — so main\\'s history records it.',\n ' To get the reviewers back, set that key to false (or delete it) and re-run this command.',\n );\n return lines.join('\\n') + '\\n';\n }\n\n /**\n * `--no-optional`, stated as a VERDICT rather than left silent. Named individually, not just counted: the\n * whole reason the human is allowed to skip these is that they know this diff, and the only way they can\n * catch \"wait, not THAT one\" is to see which ones went unreviewed.\n */\n private skippedLines(input: ReviewReportInput): string[] {\n const skipped = this.optionalOwed(input);\n if (!input.skipOptional || skipped.length === 0) return [];\n return [\n '',\n ` ⏭️ ${skipped.length} OPTIONAL checklist(s) matched this diff and were SKIPPED (--no-optional):`,\n ...skipped.map((b: ReviewerBriefing): string => ` ${b.subagent} — ${this.why(b)}`),\n ' Not blocking. Drop the flag and re-run this command to offer them after all.',\n ];\n }\n\n /**\n * The all-clear, then the RULE that makes it actionable.\n *\n * \"nothing to spawn\" on its own is a description of the current state, and an agent that has just been\n * told a state — rather than a rule — treats re-spawning as a judgement call it is entitled to make. It\n * then makes it, reasoning (correctly, on the facts) that the carried-forward verdicts judged an earlier\n * tree. Reviews here are once per branch BY CONSTRUCTION: a passing review-<id>.json satisfies its\n * checklist for the branch's whole life, and `wp-finish-upsert-pr` never archives one the way it archives\n * review.json. So the reuse is deliberate — it is what keeps post-PR iteration from re-paying for every\n * matched reviewer — and the output has to say so, because the alternative reading is the expensive one.\n *\n * The overwrite warning is not decoration. A re-spawned reviewer writes to the SAME verdict path, so a\n * gratuitous re-run does not merely cost a subagent — it destroys the verdict that was already banked.\n *\n * It must NOT claim everything was reviewed when optional reviews were skipped — that is the one sentence\n * that would turn a deliberate skip into a false record of a review that happened.\n */\n private allClear(input: ReviewReportInput): string {\n const headline = input.skipOptional && this.optionalOwed(input).length > 0\n ? '✅ Nothing left to spawn — every REQUIRED checklist is reviewed (optional ones skipped above).'\n : '✅ Every checklist that applies is already reviewed — nothing to spawn.';\n return headline + '\\n' + this.oncePerBranchRule();\n }\n\n /**\n * The once-per-branch rule, stated as a prohibition rather than left to be inferred from a ✅.\n *\n * Printed whenever verdicts are being reused — which is every iteration of a PR after the first, i.e. the\n * majority of stage-② runs on any branch that gets review feedback.\n */\n private oncePerBranchRule(): string {\n return (\n ' Reviews here are ONCE PER BRANCH: a passing verdict carries forward to every later iteration\\n' +\n ' of this PR, deliberately, so post-PR edits cost no reviewer tokens. Do NOT re-spawn a reviewer\\n' +\n ' listed above to \"re-check\" the newer code — it burns a full subagent run AND overwrites the\\n' +\n ' verdict file it already wrote. The only reviewers you may spawn are ones a STEP below names.'\n );\n }\n\n /**\n * The ONE instruction block, and the last thing the stage prints. Numbered rather than prose-linked\n * (\"Then… Finally…\") so that skipping step 1 is visibly skipping a step, and worded so no earlier line\n * can be mistaken for the real next action.\n *\n * review.json is STEP 1 and the spawn blocks are STEP 2 — that ORDER is the contract, not a preference.\n * This block used to print the spawn blocks first and then say to write review.json \"WHILE any reviewer\n * subagents above are still running\", which does not merely permit spawning first, it instructs it.\n * Harmless for a reviewer that only reads the diff; wrong for one that judges the PR's stated INTENT —\n * its title, summary or risk level — because review.json is the only place that intent lives. Such a\n * reviewer either finds no file (a false RED and a wasted reviewer run) or, on a second run of this\n * stage on the same branch, finds the PREVIOUS run's file and validates a title that no longer exists —\n * a false GREEN, with nothing in the output saying which of the two happened. A consuming repo had to\n * write itself a rule telling its agents to DISOBEY this block to work around it.\n *\n * What the old ordering bought was overlap on a single local file write, not a subagent round-trip.\n *\n * The schema hint is rendered by ReviewJsonService — the single renderer — so the shape printed here\n * can never drift from the shape `wp-finish-upsert-pr` validates.\n */\n private nextSteps(input: ReviewReportInput): string {\n const required = this.requiredOwed(input);\n const offerable = this.offerableOwed(input);\n // Numbered by what is actually PRINTED, so the numbers a reader sees are 1..n with no gaps: write\n // review.json, then a spawn step only if anything must run, then an offer step only if anything may.\n let step = 1;\n const write = this.writeReviewStep(input.reviewPath, step++);\n // The wait block goes under the LAST reviewer-listing step, and only there. Printed under both\n // it would be two \"what to do next\" instructions in one output, which is the defect this\n // method's docstring describes — an agent reading top to bottom obeys the first one it meets.\n const spawn = required.length === 0 ? '' : this.spawnStep(input, required, step++, offerable.length === 0);\n const offer = offerable.length === 0 ? '' : this.offerStep(input, offerable, step++, true);\n return '\\n' + SEP\n + `▶ NEXT — ${step} steps, in this order. Step 1 is NOT optional:\\n` + SEP + '\\n'\n + write + spawn + offer + this.finishStep(step, required.length + offerable.length > 0);\n }\n\n private writeReviewStep(reviewPath: string, step: number): string {\n return (\n `STEP ${step} — review your own changes, then write the review file. Write it FIRST — BEFORE you spawn\\n` +\n ' anything below. finish REFUSES without it, and a reviewer subagent may READ it: a\\n' +\n ' checklist that judges the PR title, summary or risk level reads exactly this file, so\\n' +\n ' writing it afterwards races that reviewer into seeing nothing — or, on a re-run of this\\n' +\n ' stage, into judging the PREVIOUS run\\'s review of code that has since changed.\\n\\n' +\n reviewJsonSchemaHint(reviewPath) + '\\n\\n'\n );\n }\n\n /**\n * The REQUIRED reviewers — one copy-paste block each, and nothing at all when none is owed. The prompt is\n * deliberately a POINTER and nothing else: the generated instructions file is the contract, so anything\n * restated here is a second copy that can go stale — which is exactly how a removed `success` field\n * outlived its own removal in print.\n *\n * These are spawned WITHOUT asking. They are the checklists the repo declared `required: true`, which is\n * the repo saying the decision was already made; putting them to the human again would re-open a question\n * the config exists to settle.\n */\n private spawnStep(input: ReviewReportInput, owed: readonly ReviewerBriefing[], step: number, withAwait: boolean): string {\n const lines: string[] = [\n `STEP ${step} — only once that file is written, spawn these ${owed.length} REQUIRED reviewer subagent(s) — a`,\n ' SEPARATE one each. They block the PR, so do NOT ask whether to run them. You may NOT',\n ' review your own work, and you may NOT write a reviewer\\'s verdict file on its behalf.',\n '',\n ];\n lines.push(...this.refusedWarning(input, owed));\n for (const b of owed) lines.push(...this.oneSpawnBlock(input, b));\n if (withAwait) lines.push(...this.awaitLines());\n lines.push('');\n return lines.join('\\n');\n }\n\n /**\n * How to WAIT once the spawn list has been spawned — printed after the blocks, because it is the\n * next thing to do and nothing before it can be mistaken for it.\n *\n * It is here because the alternative is measured and expensive: `echo .` every three seconds at\n * ~557,000 tokens a turn, 18.3% of every token the fleet spent in the 24h to 2026-09-07 (#874).\n *\n * It is the SECOND option, and says so out loud (#878). A subagent that has spawned reviewers CAN\n * end its turn and be re-invoked when they finish — 449 measured resumptions, 288 of them on exactly\n * this wait — and that costs nothing at all, where this command costs one turn per 540 seconds.\n *\n * It names no other stage. `finishStep` below is the ONE place this whole block names\n * `wp-finish-upsert-pr`, and a second mention here would be a second \"what to do next\" instruction\n * for an agent reading top to bottom — the exact defect the class docstring above describes.\n */\n private awaitLines(): string[] {\n return [\n ' Then WAIT. Do NOT poll, and do NOT run `echo` to keep your turn alive — a turn costs',\n ' your whole context, ~557k tokens. Cheapest first:',\n '',\n ' END YOUR TURN. Spawned subagents re-invoke you when they finish (measured 449',\n ' times), and waiting that way costs nothing at all.',\n '',\n ' ONLY if nothing pending would wake you, block in one call:',\n '',\n ' pnpm wp-await-reviews',\n '',\n ' It heartbeats while it waits, returns as soon as the last verdict lands, and prints',\n ' what each reviewer said. If the wait is long it exits asking to be run again.',\n ];\n }\n\n /**\n * STEP n — the OPTIONAL reviewers: listed, never spawned unasked.\n *\n * This is the whole point of `required: false`. A one-line bug fix in a repo whose checklists key on a\n * glob as broad as every TypeScript file otherwise pays for a dozen subagent reviews, and the only party\n * who can judge whether this particular diff is worth them is the human looking at it.\n *\n * ONE batched multi-select question, explicitly. Asked one at a time, a human answering \"no\" nine times\n * is being worn down rather than consulted, and by the third question the cheap thing is to say yes to\n * everything — which is the state this feature exists to leave. The \"None\" option has to be spelled out\n * too: an agent that offers a list without an explicit way to decline it has not really offered a choice.\n *\n * The blocking consequence is stated because it is the one non-obvious part of the contract: `required`\n * governs whether a reviewer must RUN, not whether its answer counts. Choosing to run one and then\n * shrugging off a red verdict would make the whole exercise theater.\n */\n private offerStep(input: ReviewReportInput, offerable: readonly ReviewerBriefing[], step: number, withAwait: boolean): string {\n const lines: string[] = [\n `STEP ${step} — these ${offerable.length} OPTIONAL review checklist(s) matched this diff. They do NOT block the`,\n ' PR, and you may NOT decide for the human whether to run them.',\n '',\n ' ASK THE HUMAN, in ONE multi-select question listing all of them plus an explicit',\n ' \"None — required only\" choice. Do not ask one question per reviewer. Then spawn ONLY',\n ' what they picked, the same way as any other reviewer.',\n '',\n ' If they pick none, that is a complete answer: go straight to the final step. If they',\n ' told you up front to submit without reviews, re-run this stage as',\n ' `pnpm wp-review-upsert-pr --no-optional` and this step disappears.',\n '',\n ' NOTE: whichever ones you DO run, their verdicts count in full — a red verdict from an',\n ' optional reviewer blocks the PR exactly like a required one.',\n '',\n ];\n lines.push(...this.refusedWarning(input, offerable));\n for (const b of offerable) lines.push(...this.oneSpawnBlock(input, b));\n if (withAwait) lines.push(...this.awaitLines());\n lines.push('');\n return lines.join('\\n');\n }\n\n // Said up front, not only beside the block: an agent that has decided to spawn everything listed here\n // needs to know BEFORE it starts that one of these entries is not a spawn-shaped task. Scoped to the\n // group being printed — a refusal among the REQUIRED reviewers is not a caveat on the optional list.\n private refusedWarning(input: ReviewReportInput, group: readonly ReviewerBriefing[]): string[] {\n const ids = new Set(group.map((b: ReviewerBriefing): string => b.checklistId));\n const n = input.refused.filter((r: RefusedReviewer): boolean => ids.has(r.checklistId)).length;\n if (n === 0) return [];\n return [\n ` ${n} of them already ANSWERED and refused (marked ⛔ below). Do not spawn`,\n ' those against unchanged code — fix what they found first; the fix is the prerequisite.',\n '',\n ];\n }\n\n private finishStep(stepNumber: number, anyReviewers: boolean): string {\n // \"every reviewer you ran\" rather than \"every reviewer above\": with an optional list the human may\n // legitimately have run none of them, and a precondition naming reviewers that were declined reads as\n // an unmeetable one.\n const precondition = anyReviewers\n ? 'once every reviewer you ran has written its verdict file'\n : 'once that file exists';\n return (\n `STEP ${stepNumber} — only ${precondition}, run: pnpm wp-finish-upsert-pr\\n` +\n ' (The build gate is already green for this commit — finish reuses it unless HEAD moves.)\\n\\n'\n );\n }\n\n // The briefings with no passing verdict yet — the ONE definition of \"owed\", shared by the header, the\n // scan verdict and the step numbering, so they cannot disagree about whether there is anything to spawn.\n private owedReviewers(input: ReviewReportInput): ReviewerBriefing[] {\n const reviewedIds = new Set(input.reviewed.map((r: RequiredChecklist): string => r.id));\n return input.briefings.filter((b: ReviewerBriefing): boolean => !reviewedIds.has(b.checklistId));\n }\n\n // Owed AND blocking — spawned without asking.\n private requiredOwed(input: ReviewReportInput): ReviewerBriefing[] {\n return this.owedReviewers(input).filter((b: ReviewerBriefing): boolean => b.required);\n }\n\n // Owed and optional. Still listed under `--no-optional` (as a skip verdict), just never as a step.\n private optionalOwed(input: ReviewReportInput): ReviewerBriefing[] {\n return this.owedReviewers(input).filter((b: ReviewerBriefing): boolean => !b.required);\n }\n\n // The optional ones the human is actually to be ASKED about — none, once they have already answered.\n private offerableOwed(input: ReviewReportInput): ReviewerBriefing[] {\n return input.skipOptional ? [] : this.optionalOwed(input);\n }\n\n // Everything the AI still has to act on. Distinct from `owedReviewers`: a skipped optional checklist is\n // owed a verdict it will never get, and treating it as pending work is what would print a spawn\n // instruction for a review the human just declined.\n private actionableOwed(input: ReviewReportInput): ReviewerBriefing[] {\n return [...this.requiredOwed(input), ...this.offerableOwed(input)];\n }\n\n /**\n * One reviewer's block. A reviewer that already REFUSED gets the SAME spawn coordinates but a different\n * lead-in, because the action before spawning is different: its own words are printed, and the spawn is\n * explicitly conditioned on having fixed the finding first.\n *\n * It keeps its spawn block rather than being dropped from the list, because the reviewer genuinely does\n * still owe a fresh verdict — dropping it would leave nothing anywhere saying how to get one. What must\n * not happen is a bare \"spawn this\" that reads identically to a reviewer that never ran, which is the\n * loop this exists to break.\n */\n private oneSpawnBlock(input: ReviewReportInput, b: ReviewerBriefing): string[] {\n const instructionsFile = this.reviewerInstructions.pathFor(input.repoRoot, input.featureName, b.subagent);\n return [\n ...this.leadIn(input, b),\n ` subagent_type: ${b.subagent}`,\n ' prompt: Read your instructions file FIRST and follow it exactly:',\n ` ${instructionsFile}`,\n '',\n ];\n }\n\n // The lines above the spawn coordinates: normally just why this reviewer is in scope; for one that\n // already refused, its verdict verbatim plus the order the two actions must happen in.\n private leadIn(input: ReviewReportInput, b: ReviewerBriefing): string[] {\n const refusal = input.refused.find((r: RefusedReviewer): boolean => r.checklistId === b.checklistId);\n if (!refusal) return [` ▶ ${b.subagent} — ${this.why(b)}`, ...this.docLine(b)];\n return [\n ` ⛔ ${b.subagent} — ALREADY REVIEWED THIS BRANCH AND REFUSED. It will refuse again on unchanged code.`,\n ` ${refusal.message}`,\n ' FIX THE FINDING FIRST (or record a human-authored override). ONLY THEN spawn it again, to',\n ' write a fresh verdict:',\n ];\n }\n\n /**\n * The checklist's guidance doc, for OPTIONAL reviewers only.\n *\n * \"4 file(s) matched\" plus a broad glob does not tell a human what the review would actually look AT,\n * and they are being asked to decide exactly that. Omitted for required reviewers: there is no decision\n * to inform there — the reviewer runs either way, and the doc is already in its instructions file.\n */\n private docLine(b: ReviewerBriefing): string[] {\n if (b.required || b.docPath === '') return [];\n return [` reviews against: ${b.docPath}`];\n }\n\n // Why this one is in scope. A patternless checklist is NOT \"matched\" — it always runs, over the whole\n // diff, and saying so is what tells a repo its checklist is firing on docs-only PRs by design.\n private why(b: ReviewerBriefing): string {\n if (b.matchedPatterns.length === 0) {\n return `ALWAYS RUNS (no \"patterns\" configured), whole diff in scope — ${b.myFiles.length} file(s)`;\n }\n return `${b.myFiles.length} file(s) matched ${b.matchedPatterns.map((p: string): string => `\"${p}\"`).join(', ')}`;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"review-report.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/review-report.ts"],"names":[],"mappings":"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","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\nimport {\n HOME_CONFIG_DIR, HOME_CONFIG_FILE, HOME_KEY_TURN_OFF_ALL_REVIEWERS, reviewJsonSchemaHint,\n RequiredChecklist, ReviewerBriefing, ReviewerInstructionsService,\n} from '@webpieces/rules-config';\nimport { ChecklistNotice } from './checklist-notice';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * One reviewer that ALREADY ANSWERED on this branch and refused, with the refusal rendered by\n * `ReviewJsonService.refusalError` — the ONE wording, shared with `wp-finish-upsert-pr`. Data-only.\n *\n * Stage ② carries these because it had the same defect finish did: a refused checklist has no passing\n * verdict, so it is \"owed\", so it got an ordinary spawn block identical to a reviewer that never ran. An\n * agent obeys that block, the reviewer re-reads unchanged code, refuses again — the loop, one stage earlier.\n *\n * The message is rendered by the COMMAND rather than in here for one reason: stage ② must not archive\n * anything. `refusalError` called with no archive path says \"fix it, then re-run\", which is only true while\n * the verdict file is still live — and at stage ② it is. Retiring a verdict is finish's act, on the refusal\n * it is actually acting on. (The ship-anyway route it prints — the command that writes `override-<id>.json`\n * — is correct at either stage: that is a different file, with its own writer, and no archive touches it.)\n */\nexport class RefusedReviewer {\n checklistId: string;\n message: string;\n\n constructor(checklistId: string, message: string) {\n this.checklistId = checklistId;\n this.message = message;\n }\n}\n\n/**\n * Everything the closing block of `wp-review-upsert-pr` needs. Data-only, and a class rather than an\n * object literal per CLAUDE.md. The three identifying paths are constructor args; the rest are optional\n * facts about the scan that default to \"nothing\", so a repo with no checklists constructs it in one line.\n */\nexport class ReviewReportInput {\n repoRoot: string;\n featureName: string;\n reviewPath: string; // the branch's review.json — the file the AI must write next\n definedCount: number; // how many checklists pr-gate.checklists defines\n applicableCount: number; // how many of them apply to THIS diff (0 ⇒ the notice, not spawn blocks)\n reviewed: RequiredChecklist[]; // already have a passing verdict on this branch\n formatErrors: string[]; // verdict files that exist but cannot be read as verdicts\n briefings: ReviewerBriefing[]; // one per applicable checklist, already written to disk\n refused: RefusedReviewer[]; // of the owed ones, those that already ran and said no (see RefusedReviewer)\n /**\n * `--no-optional` was passed: the human has already said to submit without the optional reviews, so the\n * block that offers them is replaced by a one-line statement that they were skipped. It never suppresses\n * a REQUIRED reviewer, and it is not a gate — nothing about what blocks the PR changes.\n */\n skipOptional: boolean;\n /**\n * `experimental.turnOffAllReviewers` is TRUE in `~/.webpieces/config.json`, so NO reviewer ran and\n * none will — the REQUIRED ones included. Nothing else in this class may be read as evidence of that:\n * `applicableCount` is 0 under suppression exactly as it is on a docs-only PR that matched nothing,\n * and those two must never print the same thing.\n *\n * When true this stage prints a LOUD banner naming the flag, the file and every suppressed checklist,\n * and prints NO spawn block. Reviewers cannot be spawned — there are no briefings and no instructions\n * files — so a block telling an agent to spawn them would be an instruction it cannot obey.\n */\n reviewersSuppressed: boolean;\n /**\n * The checklists that WOULD have applied, had they not been suppressed. Empty unless\n * `reviewersSuppressed`. Named, not counted, because the one thing a human must be able to see is\n * WHICH required reviewer was killed.\n */\n suppressed: RequiredChecklist[];\n\n constructor(repoRoot: string, featureName: string, reviewPath: string) {\n this.repoRoot = repoRoot;\n this.featureName = featureName;\n this.reviewPath = reviewPath;\n this.definedCount = 0;\n this.applicableCount = 0;\n this.reviewed = [];\n this.formatErrors = [];\n this.briefings = [];\n this.refused = [];\n this.skipOptional = false;\n this.reviewersSuppressed = false;\n this.suppressed = [];\n }\n}\n\n/**\n * Renders the closing block of `wp-review-upsert-pr` — the checklist verdict, then EXACTLY ONE \"what to do\n * next\".\n *\n * Extracted from the command so it can be asserted on as a rendered string, because the ordering IS the\n * contract. The bug it was extracted to fix: the zero-checklist notice ended with \"Carry on and run: pnpm\n * wp-finish-upsert-pr\", and the block printed directly beneath it said to write review.json first and\n * finish afterwards. Two next-steps, in the wrong order, and an agent that follows instructions literally —\n * which is the entire reason this command prints them — took the first one and opened a PR with no review.\n * That is precisely the failure the three-stage flow exists to prevent (reported on PR #519).\n *\n * The invariants, enforced by review-report.spec.ts:\n * 1. `wp-finish-upsert-pr` is named as a thing to run EXACTLY ONCE in the whole block.\n * 2. The review.json instruction comes BEFORE it — and BEFORE the spawn blocks (see nextSteps).\n * 3. With zero checklists the all-clear precedes any configuration guidance.\n * 4. REQUIRED reviewers are spawned unasked; OPTIONAL ones are only ever OFFERED, in one batched\n * question. The two never share a step, because one instruction says \"do it\" and the other says\n * \"ask first\", and an agent reading a merged list will act on the stronger of the two.\n *\n * Pure string building, no I/O. `@injectable(bindingScopeValues.Singleton)` so it is injected by type.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class ReviewReport {\n constructor(\n private readonly checklistNotice: ChecklistNotice,\n private readonly reviewerInstructions: ReviewerInstructionsService,\n ) {}\n\n render(input: ReviewReportInput): string {\n return '\\n' + SEP + this.header(input) + SEP\n + this.scanVerdict(input)\n + this.nextSteps(input);\n }\n\n /**\n * Name what this block is actually about. A repo with reviewers owed is being told to SPAWN; a repo with\n * none is not, and promising subagents it does not have is the same kind of noise as explaining checklist\n * configuration to a repo that configured none. Keyed on what is actually ACTIONABLE rather than on the\n * applicable count: every applicable checklist already having a verdict means nothing to spawn, and so\n * does a branch whose only outstanding reviews are optional ones the human already waved off.\n *\n * \"spawn\" and \"ask about\" are separate headings because they are separate obligations. A branch owing\n * only optional reviews has nothing the AI may do unilaterally, and a heading that says SPAWN is the\n * single line most likely to make it do exactly that.\n */\n private header(input: ReviewReportInput): string {\n // FIRST, and unconditional: with the kill switch on there is nothing to spawn and nothing to\n // offer, so every heading below would be true-but-misleading. The one thing a reader must take\n // from the first line of this block is that no reviewer looked at this branch.\n if (input.reviewersSuppressed) return '② ⚫ ALL REVIEWERS SUPPRESSED — review, then finish\\n';\n if (this.requiredOwed(input).length > 0) return '② Review, spawn subagent reviewers, then finish\\n';\n if (this.offerableOwed(input).length > 0) return '② Review, offer the optional reviewers, then finish\\n';\n return '② Review, then finish\\n';\n }\n\n /**\n * What the SCAN found: either \"nothing applies here\" or the already-reviewed / unreadable-verdict lines.\n * Verdicts, not instructions — every instruction lives in nextSteps() below, so no line up here can be\n * mistaken for the next action.\n */\n private scanVerdict(input: ReviewReportInput): string {\n // BEFORE the zero-applicable notice, which would otherwise say \"nothing matched this diff\" — the\n // single most misleading sentence available here, because plenty matched and every one of them was\n // switched off.\n if (input.reviewersSuppressed) return '\\n' + this.suppressionBanner(input);\n if (input.applicableCount === 0) return '\\n' + this.checklistNotice.build(input.definedCount);\n const lines: string[] = [];\n // The prohibition rides on the REUSE line itself, not only in the all-clear below, because the\n // all-clear is not printed when anything is still owed — and \"some reviewers are reused, others\n // must be spawned\" is exactly the shape in which an agent re-spawns the reused ones too.\n for (const r of input.reviewed) {\n lines.push(` ✓ ${r.subagent} — already reviewed on this branch; verdict STANDS, do NOT re-spawn (review-${r.id}.json)`);\n }\n // A verdict file that EXISTS but is unreadable as a verdict is called out here. Without it this\n // reports the checklist as simply owed, and the AI re-runs a reviewer that already ran instead of\n // correcting the file sitting right there.\n for (const e of input.formatErrors) lines.push(` ⛔ ${e}`);\n lines.push(...this.skippedLines(input));\n if (this.actionableOwed(input).length === 0) lines.push('', this.allClear(input));\n if (lines.length === 0) return '';\n return '\\n' + lines.join('\\n') + '\\n';\n }\n\n /**\n * THE LOUD BANNER. The one output in this whole flow that says an unreviewed PR is about to be posted.\n *\n * Everything in it is there because a reader has to be able to act on it or audit it: the FLAG name and\n * the FILE it was read from (the only actionable thing — nothing in any repo turns this off), the COUNT\n * split by required/optional, and every suppressed checklist BY NAME with REQUIRED marked. A count\n * alone would leave \"which required reviewer did I just skip?\" unanswerable at the one moment it is\n * being answered.\n *\n * It deliberately does NOT name `wp-finish-upsert-pr` — the closing step already does, exactly once,\n * and that \"exactly once\" is an invariant this class's docstring records having been bought with a bug.\n */\n private suppressionBanner(input: ReviewReportInput): string {\n const required = input.suppressed.filter((r: RequiredChecklist): boolean => r.required);\n const lines: string[] = [\n '⚫ ALL REVIEWER SUBAGENTS ARE SWITCHED OFF ON THIS MACHINE. Nothing reviewed this branch.',\n '',\n ` ${input.suppressed.length} checklist(s) matched this diff and were SUPPRESSED — `\n + `${required.length} of them REQUIRED:`,\n ];\n for (const r of input.suppressed) {\n lines.push(` • ${r.subagent}${r.required ? ' (REQUIRED — suppressed anyway)' : ' (optional)'}`);\n }\n lines.push(\n '',\n ` Switched off by: experimental.${HOME_KEY_TURN_OFF_ALL_REVIEWERS}: true`,\n ` Read from: ~/${HOME_CONFIG_DIR}/${HOME_CONFIG_FILE} (machine-local; no repo config can set this)`,\n '',\n ' There is NOTHING to spawn — no reviewer was briefed and no instructions file was written,',\n ' so any attempt to spawn one has nothing to read. Do NOT hand-write a verdict file in their',\n ' place: a fabricated verdict is worse than the absent one this flag deliberately produces.',\n '',\n ' The dashboard is the whole review product for this PR, and the suppression is carried into',\n ' the PR body — which is the squash-merge commit body — so main\\'s history records it.',\n ' To get the reviewers back, set that key to false (or delete it) and re-run this command.',\n );\n return lines.join('\\n') + '\\n';\n }\n\n /**\n * `--no-optional`, stated as a VERDICT rather than left silent. Named individually, not just counted: the\n * whole reason the human is allowed to skip these is that they know this diff, and the only way they can\n * catch \"wait, not THAT one\" is to see which ones went unreviewed.\n */\n private skippedLines(input: ReviewReportInput): string[] {\n const skipped = this.optionalOwed(input);\n if (!input.skipOptional || skipped.length === 0) return [];\n return [\n '',\n ` ⏭️ ${skipped.length} OPTIONAL checklist(s) matched this diff and were SKIPPED (--no-optional):`,\n ...skipped.map((b: ReviewerBriefing): string => ` ${b.subagent} — ${this.why(b)}`),\n ' Not blocking. Drop the flag and re-run this command to offer them after all.',\n ];\n }\n\n /**\n * The all-clear, then the RULE that makes it actionable.\n *\n * \"nothing to spawn\" on its own is a description of the current state, and an agent that has just been\n * told a state — rather than a rule — treats re-spawning as a judgement call it is entitled to make. It\n * then makes it, reasoning (correctly, on the facts) that the carried-forward verdicts judged an earlier\n * tree. Reviews here are once per branch BY CONSTRUCTION: a passing review-<id>.json satisfies its\n * checklist for the branch's whole life, and `wp-finish-upsert-pr` never archives one the way it archives\n * review.json. So the reuse is deliberate — it is what keeps post-PR iteration from re-paying for every\n * matched reviewer — and the output has to say so, because the alternative reading is the expensive one.\n *\n * The overwrite warning is not decoration. A re-spawned reviewer writes to the SAME verdict path, so a\n * gratuitous re-run does not merely cost a subagent — it destroys the verdict that was already banked.\n *\n * It must NOT claim everything was reviewed when optional reviews were skipped — that is the one sentence\n * that would turn a deliberate skip into a false record of a review that happened.\n */\n private allClear(input: ReviewReportInput): string {\n const headline = input.skipOptional && this.optionalOwed(input).length > 0\n ? '✅ Nothing left to spawn — every REQUIRED checklist is reviewed (optional ones skipped above).'\n : '✅ Every checklist that applies is already reviewed — nothing to spawn.';\n return headline + '\\n' + this.oncePerBranchRule();\n }\n\n /**\n * The once-per-branch rule, stated as a prohibition rather than left to be inferred from a ✅.\n *\n * Printed whenever verdicts are being reused — which is every iteration of a PR after the first, i.e. the\n * majority of stage-② runs on any branch that gets review feedback.\n */\n private oncePerBranchRule(): string {\n return (\n ' Reviews here are ONCE PER BRANCH: a passing verdict carries forward to every later iteration\\n' +\n ' of this PR, deliberately, so post-PR edits cost no reviewer tokens. Do NOT re-spawn a reviewer\\n' +\n ' listed above to \"re-check\" the newer code — it burns a full subagent run AND overwrites the\\n' +\n ' verdict file it already wrote. The only reviewers you may spawn are ones a STEP below names.'\n );\n }\n\n /**\n * The ONE instruction block, and the last thing the stage prints. Numbered rather than prose-linked\n * (\"Then… Finally…\") so that skipping step 1 is visibly skipping a step, and worded so no earlier line\n * can be mistaken for the real next action.\n *\n * review.json is STEP 1 and the spawn blocks are STEP 2 — that ORDER is the contract, not a preference.\n * This block used to print the spawn blocks first and then say to write review.json \"WHILE any reviewer\n * subagents above are still running\", which does not merely permit spawning first, it instructs it.\n * Harmless for a reviewer that only reads the diff; wrong for one that judges the PR's stated INTENT —\n * its title, summary or risk level — because review.json is the only place that intent lives. Such a\n * reviewer either finds no file (a false RED and a wasted reviewer run) or, on a second run of this\n * stage on the same branch, finds the PREVIOUS run's file and validates a title that no longer exists —\n * a false GREEN, with nothing in the output saying which of the two happened. A consuming repo had to\n * write itself a rule telling its agents to DISOBEY this block to work around it.\n *\n * What the old ordering bought was overlap on a single local file write, not a subagent round-trip.\n *\n * The schema hint is rendered by ReviewJsonService — the single renderer — so the shape printed here\n * can never drift from the shape `wp-finish-upsert-pr` validates.\n */\n private nextSteps(input: ReviewReportInput): string {\n const required = this.requiredOwed(input);\n const offerable = this.offerableOwed(input);\n // Numbered by what is actually PRINTED, so the numbers a reader sees are 1..n with no gaps: write\n // review.json, then a spawn step only if anything must run, then an offer step only if anything may.\n let step = 1;\n const write = this.writeReviewStep(input.reviewPath, step++);\n // The wait block goes under the LAST reviewer-listing step, and only there. Printed under both\n // it would be two \"what to do next\" instructions in one output, which is the defect this\n // method's docstring describes — an agent reading top to bottom obeys the first one it meets.\n const spawn = required.length === 0 ? '' : this.spawnStep(input, required, step++, offerable.length === 0);\n const offer = offerable.length === 0 ? '' : this.offerStep(input, offerable, step++, true);\n return '\\n' + SEP\n + `▶ NEXT — ${step} steps, in this order. Step 1 is NOT optional:\\n` + SEP + '\\n'\n + write + spawn + offer + this.finishStep(step, required.length + offerable.length > 0);\n }\n\n private writeReviewStep(reviewPath: string, step: number): string {\n return (\n `STEP ${step} — review your own changes, then write the review file. Write it FIRST — BEFORE you spawn\\n` +\n ' anything below. finish REFUSES without it, and a reviewer subagent may READ it: a\\n' +\n ' checklist that judges the PR title, summary or risk level reads exactly this file, so\\n' +\n ' writing it afterwards races that reviewer into seeing nothing — or, on a re-run of this\\n' +\n ' stage, into judging the PREVIOUS run\\'s review of code that has since changed.\\n\\n' +\n reviewJsonSchemaHint(reviewPath) + '\\n\\n'\n );\n }\n\n /**\n * The REQUIRED reviewers — one copy-paste block each, and nothing at all when none is owed. The prompt is\n * deliberately a POINTER and nothing else: the generated instructions file is the contract, so anything\n * restated here is a second copy that can go stale — which is exactly how a removed `success` field\n * outlived its own removal in print.\n *\n * These are spawned WITHOUT asking. They are the checklists the repo declared `required: true`, which is\n * the repo saying the decision was already made; putting them to the human again would re-open a question\n * the config exists to settle.\n */\n private spawnStep(input: ReviewReportInput, owed: readonly ReviewerBriefing[], step: number, withAwait: boolean): string {\n const lines: string[] = [\n `STEP ${step} — only once that file is written, spawn these ${owed.length} REQUIRED reviewer subagent(s) — a`,\n ' SEPARATE one each. They block the PR, so do NOT ask whether to run them. You may NOT',\n ' review your own work, and you may NOT write a reviewer\\'s verdict file on its behalf.',\n '',\n ];\n lines.push(...this.refusedWarning(input, owed));\n for (const b of owed) lines.push(...this.oneSpawnBlock(input, b));\n if (withAwait) lines.push(...this.awaitLines());\n lines.push('');\n return lines.join('\\n');\n }\n\n /**\n * How to WAIT once the spawn list has been spawned — printed after the blocks, because it is the\n * next thing to do and nothing before it can be mistaken for it.\n *\n * It is here because the alternative is measured and expensive: `echo .` every three seconds at\n * ~557,000 tokens a turn, 18.3% of every token the fleet spent in the 24h to 2026-09-07 (#874).\n *\n * It names the efficient options and the wasteful one, and then stops (#902). It does NOT prescribe\n * ending the turn: waiting on the subagents you just spawned is something an agent already does\n * routinely, and whether to do that here is a judgement this string cannot make for it.\n *\n * It names no other stage. `finishStep` below is the ONE place this whole block names\n * `wp-finish-upsert-pr`, and a second mention here would be a second \"what to do next\" instruction\n * for an agent reading top to bottom — the exact defect the class docstring above describes.\n */\n private awaitLines(): string[] {\n return [\n ' Then WAIT. Be efficient with tokens: wait on the subagents you just spawned, or block',\n ' in one call with the command below. Do NOT send status checks every few seconds, and',\n ' do NOT run `echo` to keep your turn alive — a turn costs your whole context, ~557k',\n ' tokens.',\n '',\n ' pnpm wp-await-reviews',\n '',\n ' It heartbeats while it waits, returns as soon as the last verdict lands, and prints',\n ' what each reviewer said. If the wait is long it exits asking to be run again.',\n ];\n }\n\n /**\n * STEP n — the OPTIONAL reviewers: listed, never spawned unasked.\n *\n * This is the whole point of `required: false`. A one-line bug fix in a repo whose checklists key on a\n * glob as broad as every TypeScript file otherwise pays for a dozen subagent reviews, and the only party\n * who can judge whether this particular diff is worth them is the human looking at it.\n *\n * ONE batched multi-select question, explicitly. Asked one at a time, a human answering \"no\" nine times\n * is being worn down rather than consulted, and by the third question the cheap thing is to say yes to\n * everything — which is the state this feature exists to leave. The \"None\" option has to be spelled out\n * too: an agent that offers a list without an explicit way to decline it has not really offered a choice.\n *\n * The blocking consequence is stated because it is the one non-obvious part of the contract: `required`\n * governs whether a reviewer must RUN, not whether its answer counts. Choosing to run one and then\n * shrugging off a red verdict would make the whole exercise theater.\n */\n private offerStep(input: ReviewReportInput, offerable: readonly ReviewerBriefing[], step: number, withAwait: boolean): string {\n const lines: string[] = [\n `STEP ${step} — these ${offerable.length} OPTIONAL review checklist(s) matched this diff. They do NOT block the`,\n ' PR, and you may NOT decide for the human whether to run them.',\n '',\n ' ASK THE HUMAN, in ONE multi-select question listing all of them plus an explicit',\n ' \"None — required only\" choice. Do not ask one question per reviewer. Then spawn ONLY',\n ' what they picked, the same way as any other reviewer.',\n '',\n ' If they pick none, that is a complete answer: go straight to the final step. If they',\n ' told you up front to submit without reviews, re-run this stage as',\n ' `pnpm wp-review-upsert-pr --no-optional` and this step disappears.',\n '',\n ' NOTE: whichever ones you DO run, their verdicts count in full — a red verdict from an',\n ' optional reviewer blocks the PR exactly like a required one.',\n '',\n ];\n lines.push(...this.refusedWarning(input, offerable));\n for (const b of offerable) lines.push(...this.oneSpawnBlock(input, b));\n if (withAwait) lines.push(...this.awaitLines());\n lines.push('');\n return lines.join('\\n');\n }\n\n // Said up front, not only beside the block: an agent that has decided to spawn everything listed here\n // needs to know BEFORE it starts that one of these entries is not a spawn-shaped task. Scoped to the\n // group being printed — a refusal among the REQUIRED reviewers is not a caveat on the optional list.\n private refusedWarning(input: ReviewReportInput, group: readonly ReviewerBriefing[]): string[] {\n const ids = new Set(group.map((b: ReviewerBriefing): string => b.checklistId));\n const n = input.refused.filter((r: RefusedReviewer): boolean => ids.has(r.checklistId)).length;\n if (n === 0) return [];\n return [\n ` ${n} of them already ANSWERED and refused (marked ⛔ below). Do not spawn`,\n ' those against unchanged code — fix what they found first; the fix is the prerequisite.',\n '',\n ];\n }\n\n private finishStep(stepNumber: number, anyReviewers: boolean): string {\n // \"every reviewer you ran\" rather than \"every reviewer above\": with an optional list the human may\n // legitimately have run none of them, and a precondition naming reviewers that were declined reads as\n // an unmeetable one.\n const precondition = anyReviewers\n ? 'once every reviewer you ran has written its verdict file'\n : 'once that file exists';\n return (\n `STEP ${stepNumber} — only ${precondition}, run: pnpm wp-finish-upsert-pr\\n` +\n ' (The build gate is already green for this commit — finish reuses it unless HEAD moves.)\\n\\n'\n );\n }\n\n // The briefings with no passing verdict yet — the ONE definition of \"owed\", shared by the header, the\n // scan verdict and the step numbering, so they cannot disagree about whether there is anything to spawn.\n private owedReviewers(input: ReviewReportInput): ReviewerBriefing[] {\n const reviewedIds = new Set(input.reviewed.map((r: RequiredChecklist): string => r.id));\n return input.briefings.filter((b: ReviewerBriefing): boolean => !reviewedIds.has(b.checklistId));\n }\n\n // Owed AND blocking — spawned without asking.\n private requiredOwed(input: ReviewReportInput): ReviewerBriefing[] {\n return this.owedReviewers(input).filter((b: ReviewerBriefing): boolean => b.required);\n }\n\n // Owed and optional. Still listed under `--no-optional` (as a skip verdict), just never as a step.\n private optionalOwed(input: ReviewReportInput): ReviewerBriefing[] {\n return this.owedReviewers(input).filter((b: ReviewerBriefing): boolean => !b.required);\n }\n\n // The optional ones the human is actually to be ASKED about — none, once they have already answered.\n private offerableOwed(input: ReviewReportInput): ReviewerBriefing[] {\n return input.skipOptional ? [] : this.optionalOwed(input);\n }\n\n // Everything the AI still has to act on. Distinct from `owedReviewers`: a skipped optional checklist is\n // owed a verdict it will never get, and treating it as pending work is what would print a spawn\n // instruction for a review the human just declined.\n private actionableOwed(input: ReviewReportInput): ReviewerBriefing[] {\n return [...this.requiredOwed(input), ...this.offerableOwed(input)];\n }\n\n /**\n * One reviewer's block. A reviewer that already REFUSED gets the SAME spawn coordinates but a different\n * lead-in, because the action before spawning is different: its own words are printed, and the spawn is\n * explicitly conditioned on having fixed the finding first.\n *\n * It keeps its spawn block rather than being dropped from the list, because the reviewer genuinely does\n * still owe a fresh verdict — dropping it would leave nothing anywhere saying how to get one. What must\n * not happen is a bare \"spawn this\" that reads identically to a reviewer that never ran, which is the\n * loop this exists to break.\n */\n private oneSpawnBlock(input: ReviewReportInput, b: ReviewerBriefing): string[] {\n const instructionsFile = this.reviewerInstructions.pathFor(input.repoRoot, input.featureName, b.subagent);\n return [\n ...this.leadIn(input, b),\n ` subagent_type: ${b.subagent}`,\n ' prompt: Read your instructions file FIRST and follow it exactly:',\n ` ${instructionsFile}`,\n '',\n ];\n }\n\n // The lines above the spawn coordinates: normally just why this reviewer is in scope; for one that\n // already refused, its verdict verbatim plus the order the two actions must happen in.\n private leadIn(input: ReviewReportInput, b: ReviewerBriefing): string[] {\n const refusal = input.refused.find((r: RefusedReviewer): boolean => r.checklistId === b.checklistId);\n if (!refusal) return [` ▶ ${b.subagent} — ${this.why(b)}`, ...this.docLine(b)];\n return [\n ` ⛔ ${b.subagent} — ALREADY REVIEWED THIS BRANCH AND REFUSED. It will refuse again on unchanged code.`,\n ` ${refusal.message}`,\n ' FIX THE FINDING FIRST (or record a human-authored override). ONLY THEN spawn it again, to',\n ' write a fresh verdict:',\n ];\n }\n\n /**\n * The checklist's guidance doc, for OPTIONAL reviewers only.\n *\n * \"4 file(s) matched\" plus a broad glob does not tell a human what the review would actually look AT,\n * and they are being asked to decide exactly that. Omitted for required reviewers: there is no decision\n * to inform there — the reviewer runs either way, and the doc is already in its instructions file.\n */\n private docLine(b: ReviewerBriefing): string[] {\n if (b.required || b.docPath === '') return [];\n return [` reviews against: ${b.docPath}`];\n }\n\n // Why this one is in scope. A patternless checklist is NOT \"matched\" — it always runs, over the whole\n // diff, and saying so is what tells a repo its checklist is firing on docs-only PRs by design.\n private why(b: ReviewerBriefing): string {\n if (b.matchedPatterns.length === 0) {\n return `ALWAYS RUNS (no \"patterns\" configured), whole diff in scope — ${b.myFiles.length} file(s)`;\n }\n return `${b.myFiles.length} file(s) matched ${b.matchedPatterns.map((p: string): string => `\"${p}\"`).join(', ')}`;\n }\n}\n"]}
|