pi-pr-review 1.10.7 → 1.11.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.11.0](https://github.com/10ego/pi-pr-review/compare/v1.10.7...v1.11.0) (2026-07-17)
4
+
5
+
6
+ ### Features
7
+
8
+ * submit APPROVE review event with configurable priority gate ([#35](https://github.com/10ego/pi-pr-review/issues/35)) ([ec47045](https://github.com/10ego/pi-pr-review/commit/ec47045245541c9182ac7e1b120765bbc5e77696))
9
+
3
10
  ## [1.10.7](https://github.com/10ego/pi-pr-review/compare/v1.10.6...v1.10.7) (2026-07-17)
4
11
 
5
12
 
package/README.md CHANGED
@@ -8,7 +8,7 @@ Give it a PR number and it will:
8
8
  - run focused review passes in parallel using models you choose;
9
9
  - validate candidate findings before reporting them;
10
10
  - render a structured review with severity, location, confidence, and verdict;
11
- - optionally publish one safe GitHub `COMMENT` review with inline comments.
11
+ - optionally publish one safe GitHub review with inline comments (`COMMENT` by default; gated `APPROVE` when configured).
12
12
 
13
13
  The default review prioritizes P0–P2 defects and allows up to three minor findings. Use `--full` for exhaustive convention, maintainability, and minor coverage.
14
14
 
@@ -114,6 +114,8 @@ Common settings:
114
114
  /pr-review-config tools=read,bash,grep,find,ls
115
115
  /pr-review-config auto_post_reviews=true
116
116
  /pr-review-config allow_stale_publish=false
117
+ /pr-review-config allow_stale_approvals=true
118
+ /pr-review-config approve_max_priority_level=P2
117
119
  /pr-review-config medium=unset
118
120
  ```
119
121
 
@@ -152,7 +154,9 @@ Example:
152
154
  },
153
155
  "tools": ["read", "bash", "grep", "find", "ls"],
154
156
  "autoPostReviews": false,
155
- "allowStalePublish": true
157
+ "allowStalePublish": true,
158
+ "allowStaleApprovals": false,
159
+ "approveMaxPriorityLevel": "off"
156
160
  }
157
161
  ```
158
162
 
@@ -188,7 +192,7 @@ If the agent's final review is not valid exact-contract JSON, the extension logs
188
192
 
189
193
  You can publish the cache later with `/pr-review-publish 123`, or directly ask the agent to “post the inline review,” “post it as an inline review,” or “publish the review for PR #123.” The extension handles that request directly before an agent turn. `/pr-review-publish` and a matching direct request publish only the cache; they never start or rerun review agents. Unnumbered direct requests select the latest cached review for the current repository. Only fresh interactive/RPC input can use the direct path.
190
194
 
191
- Every authorized publish path builds one `COMMENT` payload and sends at most one GitHub review `POST`; it never submits `APPROVE` or `REQUEST_CHANGES` and never retries a rejected write with a fallback POST. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. All other findings that pass content validation stay in the top-level review body, including nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow. Stale or authorized non-open reviews are body-only.
195
+ Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; it never submits `REQUEST_CHANGES` or retries a rejected write with a fallback POST. It emits `COMMENT` by default, or a gated `APPROVE` when configured below. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. All other findings that pass content validation stay in the top-level review body, including nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow. Stale or authorized non-open reviews are body-only. A stale review additionally requires `allowStaleApprovals: true` before it may record `APPROVE`.
192
196
 
193
197
  The same safety gates apply to every path: captured posting authority, exact repository/PR/review binding, safe locations, no reserved review markers, bounded bodies and payloads, current-head and stale policy, draft and lifecycle checks, non-open authorization, same-head duplicate detection, and a final head check. Unknown or invalid states fail closed before a write.
194
198
 
@@ -198,7 +202,7 @@ Stale publication is enabled by default through `allowStalePublish: true`; disab
198
202
  /pr-review-publish 123 --allow-stale
199
203
  ```
200
204
 
201
- A matching direct request permits stale publication. Inline comments are always disabled for stale reviews because the original anchors may no longer be valid; the body identifies both the reviewed and current commit. The session-backed cache survives extension reloads and session resumes and remains bound to the originating session instance and repository.
205
+ A matching direct request permits stale publication. Inline comments are always disabled for stale reviews because the original anchors may no longer be valid; the body identifies both the reviewed and current commit. A stale publication remains `COMMENT` by default even when `approveMaxPriorityLevel` qualifies it; set trusted `allowStaleApprovals: true` before starting the review to permit its eligible `APPROVE`. Authorized non-open publications may record `APPROVE` when the priority gate qualifies the review. The session-backed cache survives extension reloads and session resumes and remains bound to the originating session instance and repository.
202
206
 
203
207
  ## Optional verification
204
208
 
@@ -247,7 +251,16 @@ Each finding includes:
247
251
  | `P3` | Low-priority improvement. |
248
252
  | `nit` | Trivial or optional. |
249
253
 
250
- The verdict is `request_changes` only when a validated P0 or P1 finding exists. Otherwise it is `approve` or `comment`. The displayed verdict is advisory even when the review is published, because publication always uses the GitHub `COMMENT` event.
254
+ The verdict is `request_changes` only when a validated P0 or P1 finding exists. Otherwise it is `approve` or `comment`. By default, publication uses the GitHub `COMMENT` event. When `approveMaxPriorityLevel` is set to `P2`, `P3`, or `nit`, a review whose verdict is `approve` and whose findings are all at or below that level is published as a GitHub `APPROVE` event instead. A stale publication additionally requires `allowStaleApprovals: true`; authorized non-open publications do not.
255
+
256
+ | Setting | Behavior |
257
+ |---|---|
258
+ | `off` (default) | Always `COMMENT`; never auto-approve. |
259
+ | `P2` | `APPROVE` if verdict is `approve` and all findings are P2/P3/nit. |
260
+ | `P3` | `APPROVE` if verdict is `approve` and all findings are P3/nit. |
261
+ | `nit` | `APPROVE` only if verdict is `approve` and all findings are nits. |
262
+
263
+ Configure it with `/pr-review-config approve_max_priority_level=P2`. To permit an eligible stale review to approve, also set `/pr-review-config allow_stale_approvals=true` before starting the review. Both settings follow the same user/project overlay pattern as `autoPostReviews`.
251
264
 
252
265
  ## Safety and cost
253
266
 
@@ -75,8 +75,11 @@ import {
75
75
  type ToolPolicy,
76
76
  } from "../lib/pr-review-policy.ts";
77
77
  import {
78
+ resolveAllowStaleApprovalsSetting,
78
79
  resolveAllowStalePublishSetting,
79
80
  resolveAutoPostSetting,
81
+ resolveApproveMaxPriorityLevelSetting,
82
+ type ApproveMaxPriorityLevel,
80
83
  } from "../lib/pr-review-publish.ts";
81
84
  import { monotonicNow } from "../lib/pr-review-telemetry.ts";
82
85
  import {
@@ -131,6 +134,10 @@ interface PrReviewConfig {
131
134
  autoPostReviews: boolean;
132
135
  /** Permit stale publication as body-only with reviewed/current SHAs disclosed. Enabled by default. */
133
136
  allowStalePublish: boolean;
137
+ /** Permit otherwise-qualified stale reviews to record APPROVE. Disabled by default. */
138
+ allowStaleApprovals: boolean;
139
+ /** Maximum severity finding that permits an APPROVE event (off disables auto-approve). */
140
+ approveMaxPriorityLevel: ApproveMaxPriorityLevel;
134
141
  /** Trusted user-level named verification profiles. Project config never overlays these. */
135
142
  verificationBaselines: VerificationBaselines;
136
143
  /** Tools granted to review subagents whose effective policy is configured. */
@@ -218,6 +225,28 @@ function resolveAllowStaleForContext(ctx: Pick<ExtensionContext, "cwd" | "isProj
218
225
  return resolveAllowStalePublishSetting(user, project);
219
226
  }
220
227
 
228
+ function resolveAllowStaleApprovalsForContext(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">) {
229
+ const user = readConfigFile(userConfigPath());
230
+ let project: Partial<PrReviewConfig> | undefined;
231
+ try {
232
+ if (ctx.isProjectTrusted()) project = readConfigFile(projectConfigPath(ctx.cwd));
233
+ } catch {
234
+ /* user config only */
235
+ }
236
+ return resolveAllowStaleApprovalsSetting(user, project);
237
+ }
238
+
239
+ function resolveApproveMaxPriorityForContext(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">) {
240
+ const user = readConfigFile(userConfigPath());
241
+ let project: Partial<PrReviewConfig> | undefined;
242
+ try {
243
+ if (ctx.isProjectTrusted()) project = readConfigFile(projectConfigPath(ctx.cwd));
244
+ } catch {
245
+ /* user config only */
246
+ }
247
+ return resolveApproveMaxPriorityLevelSetting(user, project);
248
+ }
249
+
221
250
  /** User config overlaid by a trusted project, except user-only verification profiles. */
222
251
  function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): PrReviewConfig {
223
252
  const user = readConfigFile(userConfigPath());
@@ -229,6 +258,8 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
229
258
  }
230
259
  const autoPost = resolveAutoPostSetting(user, project);
231
260
  const allowStale = resolveAllowStalePublishSetting(user, project);
261
+ const allowStaleApprovals = resolveAllowStaleApprovalsSetting(user, project);
262
+ const approveMaxPriority = resolveApproveMaxPriorityLevelSetting(user, project);
232
263
  return {
233
264
  tiers: { ...(user.tiers ?? {}), ...(project.tiers ?? {}) },
234
265
  fallbacks: { ...normalizeFallbacks(user.fallbacks, true), ...normalizeFallbacks(project.fallbacks, true) },
@@ -242,6 +273,8 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
242
273
  },
243
274
  autoPostReviews: autoPost.valid ? autoPost.value : false,
244
275
  allowStalePublish: allowStale.valid ? allowStale.value : false,
276
+ allowStaleApprovals: allowStaleApprovals.valid ? allowStaleApprovals.value : false,
277
+ approveMaxPriorityLevel: approveMaxPriority.valid ? approveMaxPriority.value : "off",
245
278
  // Verification is intentionally user-owned. Never accept a profile from
246
279
  // the repository-controlled project overlay, even for trusted projects.
247
280
  verificationBaselines: resolveUserVerificationBaselines(user, project),
@@ -254,6 +287,8 @@ function readUserConfig(): PrReviewConfig {
254
287
  const raw = readConfigFile(userConfigPath());
255
288
  const autoPost = resolveAutoPostSetting(raw);
256
289
  const allowStale = resolveAllowStalePublishSetting(raw);
290
+ const allowStaleApprovals = resolveAllowStaleApprovalsSetting(raw);
291
+ const approveMaxPriority = resolveApproveMaxPriorityLevelSetting(raw);
257
292
  return {
258
293
  tiers: { ...(raw.tiers ?? {}) },
259
294
  fallbacks: normalizeFallbacks(raw.fallbacks),
@@ -261,6 +296,8 @@ function readUserConfig(): PrReviewConfig {
261
296
  toolPolicies: normalizeToolPolicies(raw.toolPolicies),
262
297
  autoPostReviews: autoPost.valid ? autoPost.value : false,
263
298
  allowStalePublish: allowStale.valid ? allowStale.value : false,
299
+ allowStaleApprovals: allowStaleApprovals.valid ? allowStaleApprovals.value : false,
300
+ approveMaxPriorityLevel: approveMaxPriority.valid ? approveMaxPriority.value : "off",
264
301
  verificationBaselines: resolveUserVerificationBaselines(raw),
265
302
  tools: raw.tools ?? [...DEFAULT_TOOLS],
266
303
  };
@@ -1682,6 +1719,8 @@ interface ConfigPatch {
1682
1719
  toolPolicies: Partial<Record<Tier, ToolPolicy | null>>;
1683
1720
  autoPostReviews?: boolean;
1684
1721
  allowStalePublish?: boolean;
1722
+ allowStaleApprovals?: boolean;
1723
+ approveMaxPriorityLevel?: ApproveMaxPriorityLevel;
1685
1724
  tools?: string[];
1686
1725
  }
1687
1726
 
@@ -1726,11 +1765,30 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
1726
1765
  if (value === "true") patch.allowStalePublish = true;
1727
1766
  else if (value === "false") patch.allowStalePublish = false;
1728
1767
  else errors.push(`invalid ${key} "${value}" (expected true|false)`);
1768
+ } else if (key === "allow_stale_approvals" || key === "allowstaleapprovals" || key === "allow-stale-approvals") {
1769
+ if (value === "true") patch.allowStaleApprovals = true;
1770
+ else if (value === "false") patch.allowStaleApprovals = false;
1771
+ else errors.push(`invalid ${key} "${value}" (expected true|false)`);
1772
+ } else if (
1773
+ key === "approve_max_priority_level" ||
1774
+ key === "approvemaxprioritylevel" ||
1775
+ key === "approve-max-priority-level"
1776
+ ) {
1777
+ const normalized = value.toLowerCase();
1778
+ if (normalized === "off") {
1779
+ patch.approveMaxPriorityLevel = "off";
1780
+ } else if (["p2", "p3"].includes(normalized)) {
1781
+ patch.approveMaxPriorityLevel = normalized.toUpperCase() as ApproveMaxPriorityLevel;
1782
+ } else if (normalized === "nit") {
1783
+ patch.approveMaxPriorityLevel = "nit";
1784
+ } else {
1785
+ errors.push(`invalid ${key} "${value}" (expected off|P2|P3|nit)`);
1786
+ }
1729
1787
  } else if (key === "tools") {
1730
1788
  patch.tools = splitCommaList(value);
1731
1789
  } else {
1732
1790
  errors.push(
1733
- `unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|<tier>_thinking|<tier>_tool_policy|auto_post_reviews|allow_stale_publish|tools)`,
1791
+ `unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|<tier>_thinking|<tier>_tool_policy|auto_post_reviews|allow_stale_publish|allow_stale_approvals|approve_max_priority_level|tools)`,
1734
1792
  );
1735
1793
  }
1736
1794
  }
@@ -1741,6 +1799,8 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
1741
1799
  Object.keys(patch.toolPolicies).length > 0 ||
1742
1800
  patch.autoPostReviews !== undefined ||
1743
1801
  patch.allowStalePublish !== undefined ||
1802
+ patch.allowStaleApprovals !== undefined ||
1803
+ patch.approveMaxPriorityLevel !== undefined ||
1744
1804
  patch.tools !== undefined;
1745
1805
  return { patch, hasChanges, errors };
1746
1806
  }
@@ -1753,6 +1813,8 @@ function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewCon
1753
1813
  toolPolicies: normalizeToolPolicies(base.toolPolicies),
1754
1814
  autoPostReviews: base.autoPostReviews,
1755
1815
  allowStalePublish: base.allowStalePublish,
1816
+ allowStaleApprovals: base.allowStaleApprovals,
1817
+ approveMaxPriorityLevel: base.approveMaxPriorityLevel,
1756
1818
  verificationBaselines: { ...base.verificationBaselines },
1757
1819
  tools: [...base.tools],
1758
1820
  };
@@ -1780,6 +1842,8 @@ function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewCon
1780
1842
  }
1781
1843
  if (patch.autoPostReviews !== undefined) next.autoPostReviews = patch.autoPostReviews;
1782
1844
  if (patch.allowStalePublish !== undefined) next.allowStalePublish = patch.allowStalePublish;
1845
+ if (patch.allowStaleApprovals !== undefined) next.allowStaleApprovals = patch.allowStaleApprovals;
1846
+ if (patch.approveMaxPriorityLevel !== undefined) next.approveMaxPriorityLevel = patch.approveMaxPriorityLevel;
1783
1847
  if (patch.tools) next.tools = patch.tools;
1784
1848
  return next;
1785
1849
  }
@@ -1805,6 +1869,8 @@ function summarizeConfig(
1805
1869
  }
1806
1870
  const autoPost = resolveAutoPostForContext(ctx);
1807
1871
  const allowStale = resolveAllowStaleForContext(ctx);
1872
+ const allowStaleApprovals = resolveAllowStaleApprovalsForContext(ctx);
1873
+ const approveMaxPriority = resolveApproveMaxPriorityForContext(ctx);
1808
1874
  const lines = [
1809
1875
  `# PR review config${changed ? " updated" : ""}`,
1810
1876
  "",
@@ -1814,8 +1880,10 @@ function summarizeConfig(
1814
1880
  (t) =>
1815
1881
  `| \`${t}\` | ${user.tiers[t] ? `\`${user.tiers[t]}\`` : "_unset_"} | ${effective.tiers[t] ? `\`${effective.tiers[t]}\`` : "_pi default_"} | ${TIER_PURPOSE[t]} |`,
1816
1882
  ),
1817
- `| \`autoPostReviews\` | \`${user.autoPostReviews}\` | \`${effective.autoPostReviews}\` (${autoPost.source}) | automatically post one GitHub \`COMMENT\` review; default \`false\` |`,
1883
+ `| \`autoPostReviews\` | \`${user.autoPostReviews}\` | \`${effective.autoPostReviews}\` (${autoPost.source}) | automatically post one GitHub review; default \`false\` |`,
1818
1884
  `| \`allowStalePublish\` | \`${user.allowStalePublish}\` | \`${effective.allowStalePublish}\` (${allowStale.source}) | permit body-only stale publication with reviewed/current SHAs; default \`true\` |`,
1885
+ `| \`allowStaleApprovals\` | \`${user.allowStaleApprovals}\` | \`${effective.allowStaleApprovals}\` (${allowStaleApprovals.source}) | permit qualified stale reviews to record APPROVE; default \`false\` |`,
1886
+ `| \`approveMaxPriorityLevel\` | \`${user.approveMaxPriorityLevel}\` | \`${effective.approveMaxPriorityLevel}\` (${approveMaxPriority.source}) | max severity for auto-APPROVE; \`off\` posts COMMENT only; default \`off\` |`,
1819
1887
  `| \`verificationBaselines\` | \`${Object.keys(user.verificationBaselines).length} configured\` | user scope only | strict named argv profiles; project overlays ignored |`,
1820
1888
  `| \`tools\` | \`${user.tools.join(",")}\` | \`${effective.tools.join(",")}\` | allowlist used when policy is \`configured\` |`,
1821
1889
  "",
@@ -1839,6 +1907,14 @@ function summarizeConfig(
1839
1907
  else if (allowStale.source === "project") {
1840
1908
  lines.push("Stale publication is controlled by the trusted project overlay; this command edits user config only.");
1841
1909
  }
1910
+ if (!allowStaleApprovals.valid) lines.push(`Stale approval config error: ${allowStaleApprovals.error}`);
1911
+ else if (allowStaleApprovals.source === "project") {
1912
+ lines.push("Stale approval is controlled by the trusted project overlay; this command edits user config only.");
1913
+ }
1914
+ if (!approveMaxPriority.valid) lines.push(`Auto-approve priority config error: ${approveMaxPriority.error}`);
1915
+ else if (approveMaxPriority.source === "project") {
1916
+ lines.push("Auto-approve priority is controlled by the trusted project overlay; this command edits user config only.");
1917
+ }
1842
1918
  lines.push(
1843
1919
  "",
1844
1920
  "## Usage",
@@ -1851,6 +1927,10 @@ function summarizeConfig(
1851
1927
  "- Disable automatic GitHub review posting: `/pr-review-config auto_post_reviews=false`",
1852
1928
  "- Disable stale publication: `/pr-review-config allow_stale_publish=false`",
1853
1929
  "- Enable stale publication (default): `/pr-review-config allow_stale_publish=true`",
1930
+ "- Permit qualified stale reviews to record APPROVE: `/pr-review-config allow_stale_approvals=true`",
1931
+ "- Keep stale reviews as COMMENT (default): `/pr-review-config allow_stale_approvals=false`",
1932
+ "- Enable auto-approve for low-severity reviews: `/pr-review-config approve_max_priority_level=P2`",
1933
+ "- Disable auto-approve (default): `/pr-review-config approve_max_priority_level=off`",
1854
1934
  "- Set tier tool policy: `/pr-review-config light_tool_policy=none`",
1855
1935
  "- Clear a tier: `/pr-review-config medium=unset`",
1856
1936
  "- Clear fallback chain: `/pr-review-config heavy_fallbacks=unset`",
@@ -1987,6 +2067,20 @@ function configMenuItems(cfg: PrReviewConfig, available: string[]): SettingItem[
1987
2067
  currentValue: String(cfg.allowStalePublish),
1988
2068
  values: ["true", "false"],
1989
2069
  },
2070
+ {
2071
+ id: "allow_stale_approvals",
2072
+ label: "stale approval setting",
2073
+ description: "Permit otherwise-qualified stale reviews to record APPROVE. Disabled by default.",
2074
+ currentValue: String(cfg.allowStaleApprovals),
2075
+ values: ["false", "true"],
2076
+ },
2077
+ {
2078
+ id: "approve_max_priority_level",
2079
+ label: "auto-approve priority gate",
2080
+ description: "Maximum severity that permits an APPROVE event (off = COMMENT only). Enter/Space cycles values.",
2081
+ currentValue: String(cfg.approveMaxPriorityLevel),
2082
+ values: ["off", "P2", "P3", "nit"],
2083
+ },
1990
2084
  {
1991
2085
  id: "tools",
1992
2086
  label: "configured tool allowlist",
@@ -2021,6 +2115,7 @@ async function showConfigMenu(
2021
2115
  }
2022
2116
  settingsList.updateValue("auto_post_reviews", String(draft.autoPostReviews));
2023
2117
  settingsList.updateValue("allow_stale_publish", String(draft.allowStalePublish));
2118
+ settingsList.updateValue("allow_stale_approvals", String(draft.allowStaleApprovals));
2024
2119
  settingsList.updateValue("tools", draft.tools.join(","));
2025
2120
  };
2026
2121
 
@@ -2054,6 +2149,17 @@ async function showConfigMenu(
2054
2149
  draft.autoPostReviews = newValue === "true";
2055
2150
  } else if (id === "allow_stale_publish") {
2056
2151
  draft.allowStalePublish = newValue === "true";
2152
+ } else if (id === "allow_stale_approvals") {
2153
+ draft.allowStaleApprovals = newValue === "true";
2154
+ } else if (id === "approve_max_priority_level") {
2155
+ const normalized = newValue.toLowerCase();
2156
+ if (normalized === "off") {
2157
+ draft.approveMaxPriorityLevel = "off";
2158
+ } else if (["p2", "p3"].includes(normalized)) {
2159
+ draft.approveMaxPriorityLevel = normalized.toUpperCase() as ApproveMaxPriorityLevel;
2160
+ } else if (normalized === "nit") {
2161
+ draft.approveMaxPriorityLevel = "nit";
2162
+ }
2057
2163
  } else if (id === "tools") {
2058
2164
  draft.tools = splitCommaList(newValue);
2059
2165
  } else {
@@ -2068,7 +2174,11 @@ async function showConfigMenu(
2068
2174
  ? String(draft.autoPostReviews)
2069
2175
  : id === "allow_stale_publish"
2070
2176
  ? String(draft.allowStalePublish)
2071
- : isFallbackKey(id)
2177
+ : id === "allow_stale_approvals"
2178
+ ? String(draft.allowStaleApprovals)
2179
+ : id === "approve_max_priority_level"
2180
+ ? String(draft.approveMaxPriorityLevel)
2181
+ : isFallbackKey(id)
2072
2182
  ? (draft.fallbacks[tierFromCompoundKey(id)]?.join(",") ?? "(none)")
2073
2183
  : isThinkingKey(id)
2074
2184
  ? (draft.thinkingLevels[tierFromCompoundKey(id)] ?? INHERIT_THINKING)
@@ -2095,6 +2205,26 @@ async function showConfigMenu(
2095
2205
  } else {
2096
2206
  ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info");
2097
2207
  }
2208
+ } else if (id === "allow_stale_approvals") {
2209
+ const effective = resolveAllowStaleApprovalsForContext(ctx);
2210
+ if (effective.source === "project") {
2211
+ ctx.ui.notify(
2212
+ `User allowStaleApprovals saved as ${shown}, but trusted project config remains effective at ${effective.value}. Edit ${projectConfigPath(ctx.cwd)}.`,
2213
+ "warning",
2214
+ );
2215
+ } else {
2216
+ ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info");
2217
+ }
2218
+ } else if (id === "approve_max_priority_level") {
2219
+ const effective = resolveApproveMaxPriorityForContext(ctx);
2220
+ if (effective.source === "project") {
2221
+ ctx.ui.notify(
2222
+ `User approveMaxPriorityLevel saved as ${shown}, but trusted project config remains effective at ${effective.value}. Edit ${projectConfigPath(ctx.cwd)}.`,
2223
+ "warning",
2224
+ );
2225
+ } else {
2226
+ ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info");
2227
+ }
2098
2228
  } else {
2099
2229
  ctx.ui.notify(`PR review config: ${id} = ${shown}`, "info");
2100
2230
  }
@@ -30,13 +30,16 @@ import {
30
30
  parsePublishMode,
31
31
  parsePublishableReview,
32
32
  publishPullReview,
33
+ resolveAllowStaleApprovalsSetting,
33
34
  resolveAllowStalePublishSetting,
34
35
  resolveAutoPostSetting,
36
+ resolveApproveMaxPriorityLevelSetting,
35
37
  resolveRepositoryBinding,
36
38
  restoreCompletedReviewBranch,
37
39
  shouldPublishReview,
38
40
  validateReviewInvocation,
39
41
  type AutoPostResolution,
42
+ type ApproveMaxPriorityLevelResolution,
40
43
  type CompletedReviewRecord,
41
44
  type CompletedReviewSessionIdentity,
42
45
  type ReviewInvocation,
@@ -336,11 +339,14 @@ function readJsonObject(filePath: string): ConfigReadResult {
336
339
  interface PublishingConfigResolution {
337
340
  autoPost: AutoPostResolution;
338
341
  allowStale: AutoPostResolution;
342
+ allowStaleApprovals: AutoPostResolution;
343
+ approveMaxPriority: ApproveMaxPriorityLevelResolution;
339
344
  }
340
345
 
341
346
  function invalidPublishingConfig(source: "user" | "project", error: string): PublishingConfigResolution {
342
347
  const invalid = { value: false, valid: false, source, error } as const;
343
- return { autoPost: invalid, allowStale: invalid };
348
+ const invalidLevel = { value: "off" as const, valid: false, source, error } as const;
349
+ return { autoPost: invalid, allowStale: invalid, allowStaleApprovals: invalid, approveMaxPriority: invalidLevel };
344
350
  }
345
351
 
346
352
  function resolvePublishingConfig(ctx: ExtensionContext): PublishingConfigResolution {
@@ -358,6 +364,8 @@ function resolvePublishingConfig(ctx: ExtensionContext): PublishingConfigResolut
358
364
  return {
359
365
  autoPost: resolveAutoPostSetting(user.value, project?.value),
360
366
  allowStale: resolveAllowStalePublishSetting(user.value, project?.value),
367
+ allowStaleApprovals: resolveAllowStaleApprovalsSetting(user.value, project?.value),
368
+ approveMaxPriority: resolveApproveMaxPriorityLevelSetting(user.value, project?.value),
361
369
  };
362
370
  }
363
371
 
@@ -367,7 +375,8 @@ function notifyPublishResult(
367
375
  ctx: ExtensionContext,
368
376
  ): void {
369
377
  if (result.status === "posted") {
370
- ctx.ui.notify(`PR review posted as COMMENT (${source})${result.url ? `: ${result.url}` : ""}`, "info");
378
+ const label = result.event === "APPROVE" ? "APPROVE" : "COMMENT";
379
+ ctx.ui.notify(`PR review posted as ${label} (${source})${result.url ? `: ${result.url}` : ""}`, "info");
371
380
  } else if (result.status === "posted_degraded") {
372
381
  ctx.ui.notify(`PR review posted (${source}): ${result.message}${result.url ? ` ${result.url}` : ""}`, "warning");
373
382
  } else if (result.status === "skipped_duplicate") {
@@ -415,6 +424,8 @@ async function publishCompletedReview(
415
424
  headSha,
416
425
  allowNonOpen: record.invocation.allowNonOpen,
417
426
  allowStale,
427
+ allowStaleApprovals: record.invocation.allowStaleApprovals,
428
+ approveMaxPriorityLevel: record.invocation.approveMaxPriorityLevel,
418
429
  expectedRepository: record.repository,
419
430
  review: record.review,
420
431
  });
@@ -674,6 +685,8 @@ export default function registerReviewTable(
674
685
  source,
675
686
  ctx,
676
687
  publishingConfig.allowStale.valid && publishingConfig.allowStale.value,
688
+ publishingConfig.allowStaleApprovals.valid && publishingConfig.allowStaleApprovals.value,
689
+ publishingConfig.approveMaxPriority.valid ? publishingConfig.approveMaxPriority.value : "off",
677
690
  );
678
691
  if (!gate.accepted) {
679
692
  ctx.ui.notify(`Invalid /pr-review invocation: ${gate.error}`, "error");
@@ -2,6 +2,7 @@ import * as path from "node:path";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
4
  ReviewInvocationGate,
5
+ type ApproveMaxPriorityLevel,
5
6
  type AutoPostResolution,
6
7
  type PublishModeParseResult,
7
8
  type ReviewInvocation,
@@ -103,11 +104,19 @@ export class ReviewLoopCoordinator {
103
104
  source: ReviewLoopInputSource,
104
105
  ctx: Pick<ExtensionContext, "cwd" | "sessionManager">,
105
106
  allowStalePublish = true,
107
+ allowStaleApprovals = false,
108
+ approveMaxPriorityLevel: ApproveMaxPriorityLevel = "off",
106
109
  ): { accepted: boolean; error?: string } {
107
110
  if (source !== "interactive" && source !== "rpc") {
108
111
  return { accepted: false, error: "/pr-review must be initiated directly by an interactive or RPC user" };
109
112
  }
110
- const started = this.invocationGate.begin(parsed, autoPost, allowStalePublish);
113
+ const started = this.invocationGate.begin(
114
+ parsed,
115
+ autoPost,
116
+ allowStalePublish,
117
+ allowStaleApprovals,
118
+ approveMaxPriorityLevel,
119
+ );
111
120
  if (!started.accepted) return started;
112
121
  const current = sessionBinding(ctx);
113
122
  this.binding = {
@@ -6,6 +6,88 @@ export type PublishMode = "auto" | "force" | "disabled";
6
6
  export type AutoPostSource = "default" | "user" | "project";
7
7
  export type CompletionAction = "continue_tools" | "accept_final" | "clear_invocation";
8
8
 
9
+ /** Maximum severity that may appear in a review for auto-APPROVE to be granted. */
10
+ export type ApproveMaxPriorityLevel = "off" | "P2" | "P3" | "nit";
11
+
12
+ const APPROVE_PRIORITY_LEVELS = ["P2", "P3", "nit"] as const;
13
+ const APPROVE_PRIORITY_RANK: Record<string, number> = {
14
+ P0: 4,
15
+ P1: 3,
16
+ P2: 2,
17
+ P3: 1,
18
+ nit: 0,
19
+ NIT: 0,
20
+ };
21
+
22
+ export interface ApproveMaxPriorityLevelResolution {
23
+ readonly value: ApproveMaxPriorityLevel;
24
+ readonly valid: boolean;
25
+ readonly source: AutoPostSource;
26
+ readonly error?: string;
27
+ }
28
+
29
+ function isValidApproveLevel(value: unknown): value is ApproveMaxPriorityLevel {
30
+ return value === "off" || (typeof value === "string" && APPROVE_PRIORITY_LEVELS.includes(value as (typeof APPROVE_PRIORITY_LEVELS)[number]));
31
+ }
32
+
33
+ /** Resolve the auto-approve priority gate with trusted project config overlaying user config. */
34
+ export function resolveApproveMaxPriorityLevelSetting(
35
+ user: unknown,
36
+ trustedProject?: unknown,
37
+ ): ApproveMaxPriorityLevelResolution {
38
+ if (hasOwn(trustedProject, "approveMaxPriorityLevel")) {
39
+ const value = (trustedProject as { approveMaxPriorityLevel?: unknown }).approveMaxPriorityLevel;
40
+ return isValidApproveLevel(value)
41
+ ? { value, valid: true, source: "project" }
42
+ : {
43
+ value: "off",
44
+ valid: false,
45
+ source: "project",
46
+ error: `project approveMaxPriorityLevel must be one of: off, ${APPROVE_PRIORITY_LEVELS.join(", ")}`,
47
+ };
48
+ }
49
+ if (hasOwn(user, "approveMaxPriorityLevel")) {
50
+ const value = (user as { approveMaxPriorityLevel?: unknown }).approveMaxPriorityLevel;
51
+ return isValidApproveLevel(value)
52
+ ? { value, valid: true, source: "user" }
53
+ : {
54
+ value: "off",
55
+ valid: false,
56
+ source: "user",
57
+ error: `user approveMaxPriorityLevel must be one of: off, ${APPROVE_PRIORITY_LEVELS.join(", ")}`,
58
+ };
59
+ }
60
+ return { value: "off", valid: true, source: "default" };
61
+ }
62
+
63
+ /** Whether all findings in a review are at or below the configured maximum priority. */
64
+ export function findingsWithinApproveMaxPriority(
65
+ review: ReviewLike,
66
+ level: ApproveMaxPriorityLevel,
67
+ ): boolean {
68
+ if (level === "off") return false;
69
+ const maxRank = APPROVE_PRIORITY_RANK[level];
70
+ if (maxRank === undefined) return false;
71
+ const findings = Array.isArray(review.findings) ? review.findings : [];
72
+ return findings.every(
73
+ (finding) => (APPROVE_PRIORITY_RANK[String(finding.severity ?? "").toUpperCase()] ?? Infinity) <= maxRank,
74
+ );
75
+ }
76
+
77
+ /** Decide whether a review should be published as APPROVE instead of COMMENT. */
78
+ export function shouldApproveReview(
79
+ review: ReviewLike,
80
+ approveMaxPriorityLevel: ApproveMaxPriorityLevel,
81
+ ): boolean {
82
+ const findings = Array.isArray(review.findings) ? review.findings : [];
83
+ return (
84
+ review.verdict === "approve" &&
85
+ approveMaxPriorityLevel !== "off" &&
86
+ findings.every((finding) => !finding.blocking) &&
87
+ findingsWithinApproveMaxPriority(review, approveMaxPriorityLevel)
88
+ );
89
+ }
90
+
9
91
  export function classifyAssistantCompletion(
10
92
  stopReason: string | undefined,
11
93
  hasToolCall: boolean,
@@ -83,6 +165,36 @@ export function resolveAllowStalePublishSetting(
83
165
  return { value: true, valid: true, source: "default" };
84
166
  }
85
167
 
168
+ /** Resolve whether an otherwise-qualified stale review may record APPROVE. Disabled by default. */
169
+ export function resolveAllowStaleApprovalsSetting(
170
+ user: unknown,
171
+ trustedProject?: unknown,
172
+ ): AutoPostResolution {
173
+ if (hasOwn(trustedProject, "allowStaleApprovals")) {
174
+ const value = (trustedProject as { allowStaleApprovals?: unknown }).allowStaleApprovals;
175
+ return typeof value === "boolean"
176
+ ? { value, valid: true, source: "project" }
177
+ : {
178
+ value: false,
179
+ valid: false,
180
+ source: "project",
181
+ error: "project allowStaleApprovals must be a boolean",
182
+ };
183
+ }
184
+ if (hasOwn(user, "allowStaleApprovals")) {
185
+ const value = (user as { allowStaleApprovals?: unknown }).allowStaleApprovals;
186
+ return typeof value === "boolean"
187
+ ? { value, valid: true, source: "user" }
188
+ : {
189
+ value: false,
190
+ valid: false,
191
+ source: "user",
192
+ error: "user allowStaleApprovals must be a boolean",
193
+ };
194
+ }
195
+ return { value: false, valid: true, source: "default" };
196
+ }
197
+
86
198
  export interface PublishModeParseResult {
87
199
  matched: boolean;
88
200
  mode?: PublishMode;
@@ -125,8 +237,12 @@ export interface ReviewInvocation {
125
237
  readonly allowNonOpen: boolean;
126
238
  /** Trusted stale-publication setting captured before review execution begins. */
127
239
  readonly allowStalePublish: boolean;
240
+ /** Trusted stale-approval setting captured before review execution begins. */
241
+ readonly allowStaleApprovals: boolean;
128
242
  /** Trusted automatic-posting decision captured before review execution begins. */
129
243
  readonly autoPost: Readonly<AutoPostResolution>;
244
+ /** Trusted auto-approve priority gate captured before review execution begins. */
245
+ readonly approveMaxPriorityLevel: ApproveMaxPriorityLevel;
130
246
  }
131
247
 
132
248
  export interface ReviewPublicationDecision {
@@ -222,6 +338,8 @@ export class ReviewInvocationGate {
222
338
  parsed: PublishModeParseResult,
223
339
  autoPost: AutoPostResolution,
224
340
  allowStalePublish = true,
341
+ allowStaleApprovals = false,
342
+ approveMaxPriorityLevel: ApproveMaxPriorityLevel = "off",
225
343
  ): { accepted: boolean; error?: string } {
226
344
  if (!parsed.matched) return { accepted: false, error: "not a pr-review invocation" };
227
345
  if (this.active) {
@@ -241,7 +359,9 @@ export class ReviewInvocationGate {
241
359
  prNumber: parsed.prNumber,
242
360
  allowNonOpen: parsed.allowNonOpen === true,
243
361
  allowStalePublish,
362
+ allowStaleApprovals,
244
363
  autoPost: snapshot,
364
+ approveMaxPriorityLevel,
245
365
  });
246
366
  this.currentPhase = "reviewing";
247
367
  return { accepted: true };
@@ -297,6 +417,8 @@ export function githubApiArgs(hostname: string, ...args: string[]): string[] {
297
417
  }
298
418
 
299
419
  export const REVIEW_EVENT = "COMMENT" as const;
420
+ export const APPROVE_EVENT = "APPROVE" as const;
421
+ export type ReviewEventType = typeof REVIEW_EVENT | typeof APPROVE_EVENT;
300
422
  export const MAX_INLINE_COMMENTS = 50;
301
423
  const MAX_BODY_BYTES = 65_536;
302
424
  const MAX_PAYLOAD_BYTES = 900_000;
@@ -315,20 +437,21 @@ export interface PublishComment {
315
437
 
316
438
  export interface PullReviewPayload {
317
439
  commit_id: string;
318
- event: typeof REVIEW_EVENT;
440
+ event: ReviewEventType;
319
441
  body: string;
320
442
  comments?: PublishComment[];
321
443
  }
322
444
 
323
- /** Build the only GitHub review payload this package can emit. */
445
+ /** Build the GitHub review payload, optionally with an APPROVE event. */
324
446
  export function buildPullReviewPayload(
325
447
  headSha: string,
326
448
  body: string,
327
449
  comments: PublishComment[],
450
+ event: ReviewEventType = REVIEW_EVENT,
328
451
  ): PullReviewPayload {
329
452
  return {
330
453
  commit_id: headSha,
331
- event: REVIEW_EVENT,
454
+ event,
332
455
  body,
333
456
  ...(comments.length > 0 ? { comments } : {}),
334
457
  };
@@ -438,7 +561,8 @@ function parsePersistedInvocation(value: unknown): ReviewInvocation | undefined
438
561
  !Number.isInteger(value.prNumber) ||
439
562
  Number(value.prNumber) <= 0 ||
440
563
  typeof value.allowNonOpen !== "boolean" ||
441
- (value.allowStalePublish !== undefined && typeof value.allowStalePublish !== "boolean")
564
+ (value.allowStalePublish !== undefined && typeof value.allowStalePublish !== "boolean") ||
565
+ (value.allowStaleApprovals !== undefined && typeof value.allowStaleApprovals !== "boolean")
442
566
  ) {
443
567
  return undefined;
444
568
  }
@@ -459,12 +583,18 @@ function parsePersistedInvocation(value: unknown): ReviewInvocation | undefined
459
583
  // Schema v2 records created before this setting existed inherit the new
460
584
  // safe default: stale publication is body-only with both SHAs disclosed.
461
585
  allowStalePublish: typeof value.allowStalePublish === "boolean" ? value.allowStalePublish : true,
586
+ // Legacy records never opt into merge-relevant stale approvals.
587
+ allowStaleApprovals: typeof value.allowStaleApprovals === "boolean" ? value.allowStaleApprovals : false,
462
588
  autoPost: {
463
589
  value: autoPost.value,
464
590
  valid: autoPost.valid,
465
591
  source: autoPost.source as AutoPostSource,
466
592
  ...(typeof autoPost.error === "string" ? { error: autoPost.error } : {}),
467
593
  },
594
+ // Schema v2 records created before this setting existed inherit the safe
595
+ // default: auto-approve is disabled (publication uses COMMENT only).
596
+ approveMaxPriorityLevel:
597
+ isValidApproveLevel(value.approveMaxPriorityLevel) ? value.approveMaxPriorityLevel : "off",
468
598
  };
469
599
  }
470
600
 
@@ -1077,6 +1207,7 @@ function buildLosslessReviewPayload(input: {
1077
1207
  changedFiles?: readonly ChangedFileLike[];
1078
1208
  bodyPreamble?: string;
1079
1209
  diagnostics?: readonly string[];
1210
+ event?: ReviewEventType;
1080
1211
  }): { payload?: PullReviewPayload; diagnostics: string[]; errors: string[] } {
1081
1212
  const diagnostics = [...(input.diagnostics ?? [])];
1082
1213
  if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(input.commitId)) {
@@ -1109,7 +1240,12 @@ function buildLosslessReviewPayload(input: {
1109
1240
  if (Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
1110
1241
  return { diagnostics, errors: ["final review body exceeds 65536 UTF-8 bytes"] };
1111
1242
  }
1112
- const payload = buildPullReviewPayload(input.commitId.toLowerCase(), body, selected.comments);
1243
+ const payload = buildPullReviewPayload(
1244
+ input.commitId.toLowerCase(),
1245
+ body,
1246
+ selected.comments,
1247
+ input.event ?? REVIEW_EVENT,
1248
+ );
1113
1249
  if (Buffer.byteLength(JSON.stringify(payload), "utf8") > MAX_PAYLOAD_BYTES) {
1114
1250
  return { diagnostics, errors: ["review payload is too large"] };
1115
1251
  }
@@ -1339,6 +1475,7 @@ export type PublishStatus =
1339
1475
  export interface PublishResult {
1340
1476
  status: PublishStatus;
1341
1477
  message: string;
1478
+ event?: ReviewEventType;
1342
1479
  reviewId?: number;
1343
1480
  url?: string;
1344
1481
  reconciled?: boolean;
@@ -1369,10 +1506,22 @@ export async function publishPullReview(input: {
1369
1506
  headSha: string;
1370
1507
  allowNonOpen: boolean;
1371
1508
  allowStale?: boolean;
1509
+ allowStaleApprovals?: boolean;
1510
+ approveMaxPriorityLevel?: ApproveMaxPriorityLevel;
1372
1511
  expectedRepository?: RepositoryBinding;
1373
1512
  review: ReviewLike;
1374
1513
  }): Promise<PublishResult> {
1375
- const { cwd, prNumber, headSha, allowNonOpen, allowStale = false, expectedRepository, review } = input;
1514
+ const {
1515
+ cwd,
1516
+ prNumber,
1517
+ headSha,
1518
+ allowNonOpen,
1519
+ allowStale = false,
1520
+ allowStaleApprovals = false,
1521
+ approveMaxPriorityLevel = "off",
1522
+ expectedRepository,
1523
+ review,
1524
+ } = input;
1376
1525
  if (!Number.isInteger(prNumber) || prNumber <= 0) return { status: "failed", message: "invalid PR number" };
1377
1526
  if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(headSha)) return { status: "failed", message: "invalid head SHA" };
1378
1527
  const normalizedHeadSha = headSha.toLowerCase();
@@ -1452,12 +1601,18 @@ export async function publishPullReview(input: {
1452
1601
  changedFileLookupFailed = true;
1453
1602
  }
1454
1603
  }
1604
+ // Stale publication authorization is independent from merge-relevant stale
1605
+ // approval. The latter requires its own explicit frozen config opt-in.
1606
+ const isApprove =
1607
+ (!headPlan.stale || allowStaleApprovals) &&
1608
+ shouldApproveReview(validatedReview, approveMaxPriorityLevel);
1455
1609
  const built = buildLosslessReviewPayload({
1456
1610
  review: validatedReview,
1457
1611
  commitId: headPlan.commitId,
1458
1612
  markerHeadSha: normalizedHeadSha,
1459
1613
  allowInlineComments,
1460
1614
  changedFiles,
1615
+ ...(isApprove ? { event: APPROVE_EVENT } : {}),
1461
1616
  ...(changedFileLookupFailed ? { diagnostics: [CHANGED_FILE_LOOKUP_DIAGNOSTIC] } : {}),
1462
1617
  ...(headPlan.stale
1463
1618
  ? { bodyPreamble: buildStaleReviewNotice(headPlan.reviewedHeadSha, headPlan.currentHeadSha) }
@@ -1497,6 +1652,7 @@ export async function publishPullReview(input: {
1497
1652
  ? `; ${CHANGED_FILE_LOOKUP_DIAGNOSTIC}`
1498
1653
  : `; ${built.diagnostics.length} inline finding${built.diagnostics.length === 1 ? "" : "s"} kept in the summary: ${built.diagnostics.join("; ")}`;
1499
1654
  const degraded = !isOpen || headPlan.stale || built.diagnostics.length > 0;
1655
+ const eventLabel = payload.event === APPROVE_EVENT ? "APPROVE" : "COMMENT";
1500
1656
  const post = await runGh(
1501
1657
  githubApiArgs(hostname, "--method", "POST", `repos/${repository}/pulls/${prNumber}/reviews`, "--input", "-"),
1502
1658
  cwd,
@@ -1512,10 +1668,11 @@ export async function publishPullReview(input: {
1512
1668
  return {
1513
1669
  status: degraded ? "posted_degraded" : "posted",
1514
1670
  message: headPlan.stale
1515
- ? `body-only stale COMMENT review posted (${headPlan.reviewedHeadSha} -> ${headPlan.currentHeadSha})`
1671
+ ? `body-only stale ${eventLabel} review posted (${headPlan.reviewedHeadSha} -> ${headPlan.currentHeadSha})`
1516
1672
  : isOpen
1517
- ? `GitHub COMMENT review posted${inlineWarning}`
1518
- : "body-only COMMENT review posted for non-open PR",
1673
+ ? `GitHub ${eventLabel} review posted${inlineWarning}`
1674
+ : `body-only ${eventLabel} review posted for non-open PR`,
1675
+ event: payload.event,
1519
1676
  reviewId: response.id,
1520
1677
  url: response.html_url,
1521
1678
  };
@@ -1525,7 +1682,8 @@ export async function publishPullReview(input: {
1525
1682
  if (await hasExistingMarker(cwd, hostname, repository, prNumber, identity, normalizedHeadSha)) {
1526
1683
  return {
1527
1684
  status: degraded ? "posted_degraded" : "posted",
1528
- message: `GitHub review found during failure reconciliation${inlineWarning}`,
1685
+ message: `GitHub ${eventLabel} review found during failure reconciliation${inlineWarning}`,
1686
+ event: payload.event,
1529
1687
  reconciled: true,
1530
1688
  };
1531
1689
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.10.7",
3
+ "version": "1.11.0",
4
4
  "description": "Parallel AI code review for GitHub pull requests in the Pi coding agent, with model-agnostic tiered subagents, structured findings, optional verification, and safe COMMENT-only publishing.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -53,7 +53,7 @@ Tier→model mapping is set with `/pr-review-config`; if a tier is unset the sub
53
53
 
54
54
  Arguments for this run: `$@`
55
55
  - `$1` is the PR number (required).
56
- - `--comment` explicitly requests one GitHub `COMMENT` review for this run, even when automatic posting is disabled.
56
+ - `--comment` explicitly requests one GitHub review for this run, even when automatic posting is disabled. It is `COMMENT` by default and may be a gated `APPROVE` when approval configuration qualifies the final review.
57
57
  - `--no-comment` suppresses posting for this run, even when automatic posting is enabled.
58
58
  - With no review-mode flag, use the balanced five-pass default.
59
59
  - `--balanced` explicitly selects the same balanced default for backward compatibility.
@@ -99,7 +99,7 @@ gh api --hostname "$repo_host" user --jq .login
99
99
 
100
100
  **Duplicate-review reconciliation.** Prior issue comments and formal review bodies are authoritative only when authored by the current `gh` identity and containing the exact marker form `<!-- pi-pr-review: {"schema":1,"headRefOid":"<full-head-SHA>"} -->`. If the marker SHA differs from the current `headRefOid`, the PR has new commits since the previous review, so continue and review the current diff. If prior content is unmarked, treat it as unknown/stale and continue; it cannot prove the current head was reviewed. The publishing extension performs an additional identity-scoped, paginated duplicate check immediately before any write.
101
101
 
102
- Otherwise continue and set final `disposition: "reviewed"`. For closed/merged PRs that were explicitly confirmed or allowed with `--include-closed`/`--review-closed`, review the fetched base↔head diff normally. If publication is enabled, the extension folds inline findings into one body-only formal `COMMENT` review; the orchestrator still emits only JSON.
102
+ Otherwise continue and set final `disposition: "reviewed"`. For closed/merged PRs that were explicitly confirmed or allowed with `--include-closed`/`--review-closed`, review the fetched base↔head diff normally. If publication is enabled, the extension folds inline findings into one body-only formal review; it is `COMMENT` by default and may be a gated `APPROVE`. The orchestrator still emits only JSON.
103
103
 
104
104
  ## Step 2 — Gather project convention files
105
105
 
@@ -196,7 +196,7 @@ The orchestrator must never call `gh` to post comments or reviews. Always finish
196
196
 
197
197
  After exact-contract validation, the extension caches one validated completed review per repository and PR in the current Pi session. `autoPostReviews` and `--comment` publish that cached review after completion; `--no-comment` suppresses publication for the run. `/pr-review-publish` and a matching direct request publish only the cache and never start or rerun review agents. On a later turn, the extension intercepts that direct input before an agent turn and permits stale publication without asking the orchestrator to recreate the review.
198
198
 
199
- Every authorized publish path builds one `COMMENT` payload and sends at most one GitHub review `POST`. The event is always `COMMENT`, never `APPROVE` or `REQUEST_CHANGES`. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. All other findings that pass content validation stay in the top-level review body, including nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow. Stale reviews and authorized closed or merged reviews are body-only. A failed write never triggers a fallback POST.
199
+ Every authorized publish path builds one GitHub review payload and sends at most one review `POST`. It is `COMMENT` by default, or `APPROVE` only when the trusted approval configuration qualifies an `approve` verdict; it never submits `REQUEST_CHANGES`. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. All other findings that pass content validation stay in the top-level review body, including nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow. Stale reviews and authorized closed or merged reviews are body-only. A stale review may record a qualified `APPROVE` only with the separate trusted `allowStaleApprovals: true` opt-in captured before review execution. A failed write never triggers a fallback POST.
200
200
 
201
201
  Every path retains the same safety gates: captured posting authority, exact repository/PR/review binding, safe locations, no reserved review markers, bounded bodies and payloads, current-head and stale policy, draft and lifecycle checks, non-open authorization, same-head duplicate detection, and a final head check. Unknown lifecycle states and unconfirmed non-open writes fail closed. The session-backed cache survives extension reloads and session resumes but remains bound to the originating session instance and repository. If the captured stale setting disabled publication, the user may explicitly run `/pr-review-publish <PR-NUM> --allow-stale`. Never rerun the review merely to change posting intent, and never attempt a direct GitHub write yourself.
202
202