gentle-pi 2.3.0 → 2.4.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.
Files changed (59) hide show
  1. package/README.md +30 -4
  2. package/assets/agents/sdd-explore.md +1 -0
  3. package/assets/orchestrator-delegation.md +2 -1
  4. package/assets/orchestrator.md +6 -10
  5. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  6. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  7. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  10. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  12. package/docs/review-integration.md +1 -1
  13. package/extensions/gentle-ai.ts +530 -71
  14. package/lib/agent-home.ts +8 -0
  15. package/lib/native-review-cli.ts +15 -0
  16. package/lib/provider-contract-bundle.ts +88 -6
  17. package/lib/review-host-relay.ts +146 -60
  18. package/lib/review-integration-v2.ts +34 -5
  19. package/lib/sdd-preflight.ts +2 -2
  20. package/package.json +1 -1
  21. package/runtime/native-review-cli.mjs +15 -0
  22. package/runtime/review-integration-v2.mjs +34 -5
  23. package/scripts/gentle-ai-installer.mjs +10 -10
  24. package/scripts/verify-package-files.mjs +14 -13
  25. package/skills/_shared/review-ledger-contract.md +6 -0
  26. package/skills/issue-creation/SKILL.md +53 -93
  27. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  28. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  29. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  30. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  31. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  32. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  33. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  34. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  35. package/tests/gentle-ai-binary.test.ts +1 -1
  36. package/tests/gentle-ai-installer.test.ts +46 -46
  37. package/tests/gentle-ai.test.ts +3 -2
  38. package/tests/issue-creation-skill.test.ts +103 -0
  39. package/tests/native-review-capability-contract.test.ts +18 -1
  40. package/tests/orchestrator-budget.test.ts +2 -2
  41. package/tests/orchestrator-rdd-ownership.test.ts +1 -1
  42. package/tests/package-manifest.test.ts +123 -9
  43. package/tests/provider-contract-bundle.test.ts +76 -0
  44. package/tests/provider-contract-mirror.test.ts +19 -0
  45. package/tests/review-agent-end-preflight.test.ts +408 -0
  46. package/tests/review-contract-prompt.test.ts +139 -0
  47. package/tests/review-controller.test.ts +1 -1
  48. package/tests/review-host-relay-routing.test.ts +211 -9
  49. package/tests/review-host-relay.test.ts +195 -7
  50. package/tests/review-integration-v2-forward.test.ts +47 -0
  51. package/tests/review-ledger-contract.test.ts +1 -1
  52. package/tests/runtime-harness.mjs +1 -0
  53. package/tests/writer-edit-surface-scope.test.ts +153 -17
  54. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  55. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  56. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  57. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  58. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  59. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { createHash, randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
3
3
  import {
4
4
  existsSync,
5
5
  lstatSync,
@@ -29,6 +29,7 @@ import type {
29
29
  ToolCallEventResult,
30
30
  } from "@earendil-works/pi-coding-agent";
31
31
  import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
32
+ import { resolveGentlePiAgentHome } from "../lib/agent-home.ts";
32
33
  import {
33
34
  ensureSddPreflight,
34
35
  getSddPreflightPreferences,
@@ -71,7 +72,12 @@ import {
71
72
  ReviewHostRelayError,
72
73
  reviewHostRelaySlots,
73
74
  reviewProviderRoleVectorSlots,
75
+ resolveReviewHostRelaySubmission,
76
+ runReviewHostRelayReviewerGroup,
74
77
  runReviewHostRelaySlot,
78
+ submitReviewHostRelayPreparedResult,
79
+ type ReviewHostRelayPreparedResult,
80
+ type ReviewHostRelayRequest,
75
81
  type ReviewHostRelayRunner,
76
82
  type ReviewHostRelaySlot,
77
83
  type ReviewProviderRoleVectorSlot,
@@ -154,7 +160,7 @@ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
154
160
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
155
161
 
156
162
  function gentlePiAgentHome(): string {
157
- return process.env.GENTLE_PI_AGENT_HOME ?? join(homedir(), ".pi", "agent");
163
+ return resolveGentlePiAgentHome();
158
164
  }
159
165
 
160
166
  function sddGlobalAssetDriftCount(): number {
@@ -461,17 +467,19 @@ const SUBAGENTS_PACKAGE_NAMES = ["pi-subagents-j0k3r", "pi-subagents"] as const;
461
467
  const SUBAGENT_RUN_TOOL = "subagent_run";
462
468
  const BOUNDED_WRITER_AGENT_NAMES = ["gentle-ai-worker", "worker"] as const;
463
469
  const ALLOWED_EDIT_SURFACES_HEADING = /^## Allowed edit surfaces[ \t]*$/gim;
464
- const MARKDOWN_HEADING_LINE = /^#{1,6}\s/;
465
- const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)])\s+/;
470
+ const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6} /;
471
+ const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)]) +/;
466
472
  const WRITER_EDIT_SURFACE_REJECTION =
467
- "Writer tasks must include the exact Markdown heading `## Allowed edit surfaces` with narrow repository-relative paths or narrow globs, one per line. The parent must derive or map that canonical block from the delegated task and relaunch the writer; do not accept aliases, and do not ask the human to author paths or globs.";
473
+ "Writer tasks must include the exact Markdown heading `## Allowed edit surfaces` with narrow repository-relative paths or narrow globs, one per line. Every non-empty line belongs to the section until the next canonical Markdown heading and must be a valid surface entry. Paths containing whitespace require whole-entry backticks; begin explanatory prose under the next Markdown heading. The parent must derive or map that canonical block from the delegated task and relaunch the writer; do not accept aliases, and do not ask the human to author paths or globs.";
468
474
 
469
- function isTaskScopedRepositoryRelativePath(value: string): boolean {
475
+ function isTaskScopedRepositoryRelativePath(value: string, isWholeEntryBackticked: boolean): boolean {
470
476
  const normalized = value.replace(/\\/g, "/");
471
477
  if (
472
478
  normalized.length === 0 ||
473
479
  isAbsolute(value) ||
474
- /^(?:[A-Za-z]:|\/|~)/.test(normalized)
480
+ /^(?:[A-Za-z]:|\/|~)/.test(normalized) ||
481
+ /\p{Cc}|\p{Zl}|\p{Zp}/u.test(normalized) ||
482
+ (/\p{White_Space}/u.test(normalized) && !isWholeEntryBackticked)
475
483
  ) {
476
484
  return false;
477
485
  }
@@ -481,7 +489,6 @@ function isTaskScopedRepositoryRelativePath(value: string): boolean {
481
489
  withoutCurrentDirectory.length === 0 ||
482
490
  withoutCurrentDirectory === "." ||
483
491
  withoutCurrentDirectory.startsWith("/") ||
484
- /\s/.test(withoutCurrentDirectory) ||
485
492
  withoutCurrentDirectory.split("/").some((segment) => segment === "..")
486
493
  ) {
487
494
  return false;
@@ -490,56 +497,37 @@ function isTaskScopedRepositoryRelativePath(value: string): boolean {
490
497
  return !/[?*\[\]{}]/.test(withoutCurrentDirectory.split("/")[0]);
491
498
  }
492
499
 
493
- /** Strips a list marker and surrounding backticks off one surface line. */
494
- function readSurfaceEntry(line: string): string {
495
- const entry = line.replace(MARKDOWN_LIST_MARKER, "");
496
- return entry.match(/^`(.+)`$/)?.[1] ?? entry;
497
- }
500
+ type AllowedEditSurfaceEntry = {
501
+ source: string;
502
+ value: string;
503
+ isWholeEntryBackticked: boolean;
504
+ isValidMarkdownSyntax: boolean;
505
+ };
498
506
 
499
- /** A line still reads as a surface entry when nothing but a bare path is left. */
500
- function looksLikeSurfaceEntry(line: string): boolean {
501
- return line.length > 0 && !MARKDOWN_HEADING_LINE.test(line) && !/\s/.test(readSurfaceEntry(line));
507
+ /** Reads one entry and records whether backticks delimit the whole path. */
508
+ function readSurfaceEntry(line: string): AllowedEditSurfaceEntry {
509
+ const withoutListMarker = line.replace(MARKDOWN_LIST_MARKER, "");
510
+ const backticked = withoutListMarker.match(/^`([^`]+)`$/);
511
+ return {
512
+ source: line,
513
+ value: backticked?.[1] ?? withoutListMarker,
514
+ isWholeEntryBackticked: backticked !== null,
515
+ isValidMarkdownSyntax:
516
+ !/^(?:[-*+]|\d+[.)])$/.test(line) && (!withoutListMarker.includes("`") || backticked !== null),
517
+ };
502
518
  }
503
519
 
504
520
  /**
505
- * Reads the surface list that follows an `## Allowed edit surfaces` heading.
506
- *
507
- * A delegated `task` is a whole prompt: the list is normally followed by deeper
508
- * headings (`### Validation commands`, `#### Return`) and by ordinary prose.
509
- * Ending the section only at `#`/`##` swallowed all of that, turned prose into
510
- * surface entries, and rejected valid authorizations (issue #484). A `context`
511
- * value carrying just the heading and its lines had nothing following it, which
512
- * is why the identical surfaces were accepted there.
513
- *
514
- * So the section ends at the next heading of ANY level, and prose closes the
515
- * list inside it. Blank lines never close it: an entry is only ever dropped
516
- * when nothing below it still reads as a surface entry. A line that a caller
517
- * could pass off as a path stays under validation instead of being discarded,
518
- * because discarding is what would let `/etc/passwd` sit under a blank line or
519
- * a paragraph and reach an accepted dispatch.
521
+ * Reads every non-empty line until the next Markdown heading as an edit surface.
522
+ * A prose line cannot terminate this section: it must fail validation instead.
520
523
  */
521
- function readAllowedEditSurfaceEntries(following: string): string[] {
522
- const lines = following.split(/\r?\n/).map((line) => line.trim());
524
+ function readAllowedEditSurfaceEntries(following: string): AllowedEditSurfaceEntry[] {
525
+ const lines = following.split(/\r?\n/);
523
526
  const headingIndex = lines.findIndex((line) => MARKDOWN_HEADING_LINE.test(line));
524
- const section = headingIndex === -1 ? lines : lines.slice(0, headingIndex);
525
- const entries: string[] = [];
526
-
527
- for (const [index, line] of section.entries()) {
528
- if (line.length === 0) continue;
529
- const entry = readSurfaceEntry(line);
530
- if (/\s/.test(entry)) {
531
- // Prose closes the list only when it is genuinely trailing. Anything
532
- // below it that still reads as a path makes the section ambiguous, so
533
- // every non-empty line is validated and the ambiguity is rejected.
534
- if (section.slice(index + 1).some(looksLikeSurfaceEntry)) {
535
- return section.filter((candidate) => candidate.length > 0).map(readSurfaceEntry);
536
- }
537
- break;
538
- }
539
- entries.push(entry);
540
- }
541
-
542
- return entries;
527
+ return (headingIndex === -1 ? lines : lines.slice(0, headingIndex))
528
+ .map((line) => line.replace(/ +$/g, ""))
529
+ .filter((line) => line.length > 0)
530
+ .map((line) => readSurfaceEntry(line.replace(/^ {0,3}/, "")));
543
531
  }
544
532
 
545
533
  function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean {
@@ -553,9 +541,19 @@ function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean {
553
541
  for (const heading of headings) {
554
542
  const bodyStart = (heading.index ?? 0) + heading[0].length;
555
543
  const entries = readAllowedEditSurfaceEntries(value.slice(bodyStart));
556
- if (entries.length === 0 || !entries.every(isTaskScopedRepositoryRelativePath)) return false;
544
+ if (
545
+ entries.length === 0 ||
546
+ !entries.every(
547
+ (entry) =>
548
+ entry.isValidMarkdownSyntax &&
549
+ !/\p{Cc}|\p{Zl}|\p{Zp}/u.test(entry.source) &&
550
+ isTaskScopedRepositoryRelativePath(entry.value, entry.isWholeEntryBackticked),
551
+ )
552
+ ) {
553
+ return false;
554
+ }
557
555
 
558
- const uniqueEntries = [...new Set(entries)].sort();
556
+ const uniqueEntries = [...new Set(entries.map((entry) => entry.value))].sort();
559
557
  if (
560
558
  expectedEntries &&
561
559
  (expectedEntries.length !== uniqueEntries.length ||
@@ -706,6 +704,64 @@ function renderOrchestratorPrompt(
706
704
  .trim();
707
705
  }
708
706
 
707
+ // gentle-pi#560 / gentle-ai#4056, #4057: Gentle AI stopped writing a
708
+ // runtime-specific review execution contract into Pi's generated
709
+ // APPEND_SYSTEM composition on 2026-08-01. This package now injects the
710
+ // mirrored provider contract bundle's own `orchestration/pi.md` text
711
+ // instead, read once from the package-local mirror
712
+ // (contracts/review-provider-contract-mirror/) and cached as the fully
713
+ // rendered fragment for the process lifetime. It is deliberately NOT folded
714
+ // into getOrchestratorPrompt/orchestratorPromptCache: that core prompt is
715
+ // pinned at an 8192-byte budget (tests/orchestrator-budget.test.ts).
716
+ const PROVIDER_CONTRACT_MIRROR_ROOT = join(PACKAGE_ROOT, "contracts", "review-provider-contract-mirror");
717
+ const PROVIDER_CONTRACT_LOCK_FILE = "provider-contract.lock.json";
718
+ const PI_ORCHESTRATION_RUNTIME = "pi";
719
+
720
+ let reviewContractPromptFragmentCache: string | null | undefined;
721
+ let reviewContractPromptMissingWarned = false;
722
+
723
+ // Verifies the mirrored orchestration/pi.md bytes against the lock's digest before injection (gentle-ai R1/R3).
724
+ function readMirroredReviewContractFragment(mirrorRoot: string = PROVIDER_CONTRACT_MIRROR_ROOT): string | null {
725
+ try {
726
+ const lockPath = join(mirrorRoot, PROVIDER_CONTRACT_LOCK_FILE);
727
+ const lock = JSON.parse(readFileSync(lockPath, "utf8")) as {
728
+ contract_semver?: unknown;
729
+ entries?: Record<string, unknown>;
730
+ };
731
+ if (typeof lock.contract_semver !== "string" || lock.contract_semver === "") return null;
732
+ const expectedSha256 = lock.entries?.[`orchestration/${PI_ORCHESTRATION_RUNTIME}.md`];
733
+ if (typeof expectedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(expectedSha256)) return null;
734
+ const contractPath = join(mirrorRoot, `v${lock.contract_semver}`, "bundle", "orchestration", `${PI_ORCHESTRATION_RUNTIME}.md`);
735
+ const rawBytes = readFileSync(contractPath);
736
+ const actualSha256 = createHash("sha256").update(rawBytes).digest("hex");
737
+ if (!timingSafeEqual(Buffer.from(expectedSha256, "hex"), Buffer.from(actualSha256, "hex"))) return null;
738
+ const text = rawBytes.toString("utf8").trim();
739
+ if (text.length === 0) return null;
740
+ return `## Gentle AI review execution contract (mirrored provider bundle ${lock.contract_semver})\n\n${text}`;
741
+ } catch {
742
+ return null;
743
+ }
744
+ }
745
+
746
+ function loadReviewContractPromptFragment(
747
+ ctx: Pick<ExtensionContext, "hasUI" | "ui">,
748
+ mirrorRoot: string = PROVIDER_CONTRACT_MIRROR_ROOT,
749
+ ): string | null {
750
+ if (reviewContractPromptFragmentCache === undefined) {
751
+ reviewContractPromptFragmentCache = readMirroredReviewContractFragment(mirrorRoot);
752
+ }
753
+ if (reviewContractPromptFragmentCache === null && !reviewContractPromptMissingWarned) {
754
+ reviewContractPromptMissingWarned = true;
755
+ if (ctx.hasUI) {
756
+ ctx.ui.notify(
757
+ "Gentle AI review execution contract is unavailable: the mirrored provider bundle is missing, unreadable, or fails digest verification. Review preflight instructions will not be injected this session.",
758
+ "warning",
759
+ );
760
+ }
761
+ }
762
+ return reviewContractPromptFragmentCache;
763
+ }
764
+
709
765
  async function pathExists(path: string): Promise<boolean> {
710
766
  try {
711
767
  await access(path);
@@ -2687,6 +2743,25 @@ interface ReviewCaptureParameters {
2687
2743
  workspaceRoot?: string;
2688
2744
  }
2689
2745
 
2746
+ const REVIEW_CAPTURE_GROUP_PARAMETERS = {
2747
+ type: "object",
2748
+ additionalProperties: false,
2749
+ required: ["lineageId", "collectBindings"],
2750
+ properties: {
2751
+ lineageId: { type: "string", minLength: 1, description: "Exact lineage from the current provider-issued collect transition." },
2752
+ collectBindings: { type: "array", minItems: 1, items: { type: "string", minLength: 1 }, description: "Ordered JSON-serialized exact copies of the complete current materialize reviewer collect set." },
2753
+ reviewerRunAcknowledged: { type: "boolean", description: "Required after the one group forecast; authorizes exactly the forecast reviewer runs." },
2754
+ workspaceRoot: { type: "string", description: "Optional explicit existing Git worktree root, resolved with the controller's worktree confinement semantics." },
2755
+ },
2756
+ } as const;
2757
+
2758
+ interface ReviewCaptureGroupParameters {
2759
+ lineageId: string;
2760
+ collectBindings: readonly string[];
2761
+ reviewerRunAcknowledged?: boolean;
2762
+ workspaceRoot?: string;
2763
+ }
2764
+
2690
2765
  const REVIEW_SCOPE_PARAMETERS = {
2691
2766
  type: "object",
2692
2767
  additionalProperties: false,
@@ -2788,6 +2863,23 @@ function parseReviewCaptureParameters(value: unknown): ReviewCaptureParameters {
2788
2863
  };
2789
2864
  }
2790
2865
 
2866
+ function parseReviewCaptureGroupParameters(value: unknown): ReviewCaptureGroupParameters {
2867
+ if (!isRecord(value)) throw new Error("Review capture group parameters must be an object");
2868
+ const allowed = new Set(["lineageId", "collectBindings", "reviewerRunAcknowledged", "workspaceRoot"]);
2869
+ const unexpected = Object.keys(value).find((key) => !allowed.has(key));
2870
+ if (unexpected !== undefined) throw new Error(`Review capture group does not accept ${unexpected}`);
2871
+ if (!isCanonicalProcessString(value.lineageId)) throw new Error("Review capture group requires an exact non-empty lineageId");
2872
+ if (!Array.isArray(value.collectBindings) || value.collectBindings.length === 0 || value.collectBindings.some((binding) => typeof binding !== "string" || binding.length === 0)) throw new Error("Review capture group requires one or more JSON-serialized collectBindings");
2873
+ if (value.reviewerRunAcknowledged !== undefined && typeof value.reviewerRunAcknowledged !== "boolean") throw new Error("Review capture group reviewerRunAcknowledged must be boolean");
2874
+ if (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string") throw new Error("Review capture group workspaceRoot must be a string");
2875
+ return {
2876
+ lineageId: value.lineageId,
2877
+ collectBindings: [...value.collectBindings],
2878
+ ...(value.reviewerRunAcknowledged === undefined ? {} : { reviewerRunAcknowledged: value.reviewerRunAcknowledged }),
2879
+ ...(value.workspaceRoot === undefined ? {} : { workspaceRoot: value.workspaceRoot }),
2880
+ };
2881
+ }
2882
+
2791
2883
  function requiredControllerString(
2792
2884
  parameters: ReviewControllerParameters,
2793
2885
  key: "idempotencyKey" | "transition" | "command" | "input" | "outputPath" | "inputPath" | "operationId",
@@ -3629,6 +3721,25 @@ export class PendingReviewConsentRegistry {
3629
3721
  const processPendingReviewConsentRegistry = new PendingReviewConsentRegistry();
3630
3722
  const processRetainedNativeStatusSelections = new Map<PendingReviewConsentSessionKey, Map<string, RetainedNativeStatusSelection>>();
3631
3723
 
3724
+ // gentle-pi#556 / gentle-ai#4051: nesting depth of named-agent (SDD phase
3725
+ // executor or other subagent) starts vs. ends for a session. Starts and
3726
+ // ends are paired so a subagent's own loop end never leaves the primary
3727
+ // loop's `agent_end` preflight suppressed for the rest of the session: a
3728
+ // named-agent start increments the depth, a matching end decrements it,
3729
+ // and a fresh primary-loop start resets it to 0.
3730
+ const processAgentEndSubagentDepth = new Map<PendingReviewConsentSessionKey, number>();
3731
+
3732
+ // Target identities already nudged once per session, so the read-only
3733
+ // `agent_end` preflight reminder fires at most once per unreviewed candidate.
3734
+ const processAgentEndPreflightNudgedTargets = new Map<PendingReviewConsentSessionKey, Set<string>>();
3735
+
3736
+ // gentle-pi#568: the target identity negotiated STATUS reported at
3737
+ // `session_start`, before this session touched the worktree. A candidate
3738
+ // already present at that point is the user's own pre-session work, not
3739
+ // something this session produced, so `agent_end` must not treat it as an
3740
+ // unreviewed candidate this session should be reminded about.
3741
+ const processAgentEndSessionBaseline = new Map<PendingReviewConsentSessionKey, string>();
3742
+
3632
3743
  function pendingReviewConsentSessionKey(context: ExtensionContext | undefined, fallbackKey: symbol): PendingReviewConsentSessionKey {
3633
3744
  try {
3634
3745
  const sessionManager = (context as unknown as { sessionManager?: { getSessionId?: () => unknown } } | undefined)?.sessionManager;
@@ -4069,9 +4180,15 @@ function requiresExplicitTargetLifecycleRoot(requested: string | undefined, sess
4069
4180
  // The runner is injectable for tests only; production always uses the real
4070
4181
  // relay in lib/review-host-relay.ts.
4071
4182
  let activeReviewHostRelayRunner: ReviewHostRelayRunner = runReviewHostRelaySlot;
4183
+ let activeReviewHostRelayReviewerGroupRunner = runReviewHostRelayReviewerGroup;
4184
+ let activeReviewHostRelaySubmissionRunner = submitReviewHostRelayPreparedResult;
4072
4185
  function setReviewHostRelayRunnerForTesting(runner?: ReviewHostRelayRunner): void {
4073
4186
  activeReviewHostRelayRunner = runner ?? runReviewHostRelaySlot;
4074
4187
  }
4188
+ function setReviewHostRelayGroupRunnersForTesting(reviewerGroup?: typeof runReviewHostRelayReviewerGroup, submission?: typeof submitReviewHostRelayPreparedResult): void {
4189
+ activeReviewHostRelayReviewerGroupRunner = reviewerGroup ?? runReviewHostRelayReviewerGroup;
4190
+ activeReviewHostRelaySubmissionRunner = submission ?? submitReviewHostRelayPreparedResult;
4191
+ }
4075
4192
 
4076
4193
  const REVIEW_HOST_RELAY_RETRY_ACTION =
4077
4194
  "Call fresh STATUS and submit only an exact reoffered one-slot binding; never replay this capture from transcript inference.";
@@ -4166,23 +4283,25 @@ function decodeRelayLastEventClosure(submission: string): ReviewLastEventClosure
4166
4283
  }
4167
4284
 
4168
4285
  async function reconcileUnknownReviewCaptureFailure(
4169
- error: unknown,
4286
+ error: unknown | undefined,
4170
4287
  nativeReviewCli: NativeReviewCli,
4171
4288
  cwd: string,
4172
4289
  binding: ReviewLastEventClosureBinding,
4173
4290
  selections: Map<string, RetainedNativeStatusSelection>,
4174
4291
  route: RetainedNativeCaptureRoute | undefined,
4292
+ expectedReviewCaptureSuffix?: readonly string[],
4175
4293
  ): Promise<Record<string, unknown>> {
4176
- const failure = nativeOperationFailure("gentle_review_capture", error);
4177
- if (!nativeMutationRequiresStatus(error)) return failure;
4294
+ const failure = error === undefined ? undefined : nativeOperationFailure("gentle_review_capture", error);
4295
+ if (error !== undefined && !nativeMutationRequiresStatus(error)) return failure;
4178
4296
  try {
4179
4297
  const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, route);
4180
4298
  syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef);
4299
+ if (expectedReviewCaptureSuffix !== undefined && !hasExactReviewCaptureSuffix(status, expectedReviewCaptureSuffix)) return captureGroupAuthorityDrift(status);
4181
4300
  return {
4182
4301
  tool: "gentle_review_capture",
4183
4302
  status: "reconciled",
4184
4303
  outcome: "native-capture-outcome-unknown",
4185
- native_failure: failure,
4304
+ ...(failure === undefined ? {} : { native_failure: failure }),
4186
4305
  lineage_id: binding.lineageId,
4187
4306
  target_identity: status.targetIdentity,
4188
4307
  provider_action: status.action,
@@ -4190,11 +4309,8 @@ async function reconcileUnknownReviewCaptureFailure(
4190
4309
  result: status.raw,
4191
4310
  };
4192
4311
  } catch (statusError) {
4193
- return {
4194
- ...failure,
4195
- outcome: "native-capture-status-reconciliation-failed",
4196
- reconciliation_failure: nativeOperationFailure("gentle_review_capture", statusError),
4197
- };
4312
+ const reconciliationFailure = nativeOperationFailure("gentle_review_capture", statusError);
4313
+ return { ...(failure ?? reconciliationFailure), outcome: "native-capture-status-reconciliation-failed", reconciliation_failure: reconciliationFailure };
4198
4314
  }
4199
4315
  }
4200
4316
 
@@ -4397,7 +4513,7 @@ function clearReviewTransportProbeForTesting(nativeReviewCli: NativeReviewCli |
4397
4513
  }
4398
4514
 
4399
4515
  function hostTransportUnavailable(
4400
- operation: ReviewControllerOperation | "gentle_review_capture",
4516
+ operation: ReviewControllerOperation | "gentle_review_capture" | "gentle_review_capture_group",
4401
4517
  transport: ReviewTransportRefusal,
4402
4518
  ): Record<string, unknown> {
4403
4519
  // #535: a provider-printed raw `gentle-ai review ...` continuation is a dead
@@ -4406,7 +4522,7 @@ function hostTransportUnavailable(
4406
4522
  // refusal therefore names the continuation that runs in this surface (the
4407
4523
  // gentle_review / gentle_review_capture wrapper tools) while the provider's
4408
4524
  // own diagnostic stays intact in relay_transport as evidence.
4409
- const isCapture = operation === "gentle_review_capture";
4525
+ const isCapture = operation === "gentle_review_capture" || operation === "gentle_review_capture_group";
4410
4526
  return {
4411
4527
  ...(isCapture ? { tool: operation } : { operation }),
4412
4528
  status: "blocked",
@@ -4418,9 +4534,9 @@ function hostTransportUnavailable(
4418
4534
  wrapper_continuation: {
4419
4535
  tool: "gentle_review",
4420
4536
  operation: REVIEW_CONTROLLER_OPERATION.INSPECT,
4421
- ...(isCapture ? { then: "gentle_review_capture" } : {}),
4537
+ ...(isCapture ? { then: operation } : {}),
4422
4538
  },
4423
- next_action: `Install a native gentle-ai provider that supports \`review status --agent pi\`, then re-enter negotiated STATUS with gentle_review {"operation":"inspect"}${isCapture ? " and resubmit gentle_review_capture with the exact one-slot collectBinding that fresh STATUS returns" : " and follow the transition it returns"}. A provider-printed raw CLI continuation does not run in this runtime, and Pi never falls back to an agent-less lifecycle route.`,
4539
+ next_action: `Install a native gentle-ai provider that supports \`review status --agent pi\`, then re-enter negotiated STATUS with gentle_review {"operation":"inspect"}${!isCapture ? " and follow the transition it returns" : operation === "gentle_review_capture_group" ? " and resubmit gentle_review_capture_group with the complete exact ordered collectBindings that fresh STATUS returns" : " and resubmit gentle_review_capture with the exact one-slot collectBinding that fresh STATUS returns"}. A provider-printed raw CLI continuation does not run in this runtime, and Pi never falls back to an agent-less lifecycle route.`,
4424
4540
  };
4425
4541
  }
4426
4542
 
@@ -4453,6 +4569,45 @@ async function negotiatedStatusForHostTransport(
4453
4569
  }
4454
4570
  }
4455
4571
 
4572
+ // gentle-pi#568: resolves the current negotiated review STATUS for a session,
4573
+ // under the exact guards `agent_end` uses to decide whether to nudge: a
4574
+ // native review CLI with both `reviewMode` and `targetStatus`, a UI-bearing
4575
+ // context, and RDD effectively on. Returns `undefined` on any missing guard,
4576
+ // an effective-off mode, or any STATUS error or transport refusal, so both
4577
+ // `session_start` (recording a baseline) and `agent_end` (deciding whether to
4578
+ // nudge) resolve the same target identity through the same path.
4579
+ async function resolveNegotiatedReviewStatusForSession(
4580
+ nativeReviewCli: NativeReviewCli | null,
4581
+ ctx: ExtensionContext,
4582
+ sessionKey: PendingReviewConsentSessionKey,
4583
+ ): Promise<ReviewStatusV3 | undefined> {
4584
+ if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return undefined;
4585
+ if (ctx.hasUI !== true) return undefined;
4586
+ let modeEffective: "on" | "off";
4587
+ try {
4588
+ const mode = await nativeReviewCli.reviewMode({ cwd: ctx.cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS });
4589
+ modeEffective = mode.status.effective;
4590
+ } catch {
4591
+ return undefined;
4592
+ }
4593
+ if (modeEffective === "off") return undefined;
4594
+ try {
4595
+ const retainedSelections = ((key: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(key) ?? processRetainedNativeStatusSelections.set(key, new Map()).get(key)!)(sessionKey);
4596
+ const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: ctx.cwd }, retainedSelections, ctx.cwd);
4597
+ return negotiated.status;
4598
+ } catch {
4599
+ return undefined;
4600
+ }
4601
+ }
4602
+
4603
+ // gentle-pi#556 / gentle-ai#4051: the exact once-per-candidate reminder sent
4604
+ // through `agent_end`. It never runs START itself, so it names the one
4605
+ // supported continuation (gentle_review inspect) and defers the resulting
4606
+ // consent envelope to the human.
4607
+ function renderAgentEndReviewPreflightMessage(targetIdentity: string): string {
4608
+ return `Receipt-driven development is enabled, and this worktree holds an unreviewed candidate (target ${targetIdentity}). By the review contract entry rule, run the review preflight before reporting completion.\n\nCall the gentle_review tool with {"operation":"inspect"} and follow the transition it returns; it currently offers review.start for this target. Relay the resulting gentle-ai.review-integration.consent/v3 envelope to the human losslessly, and never answer it on the human's behalf.\n\nThis extension never runs START itself. This reminder is sent once per candidate.`;
4609
+ }
4610
+
4456
4611
  function canonicalReviewCaptureBinding(value: unknown): string {
4457
4612
  if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") return JSON.stringify(value);
4458
4613
  if (Array.isArray(value)) return `[${value.map((entry) => canonicalReviewCaptureBinding(entry)).join(",")}]`;
@@ -4491,11 +4646,11 @@ function publicReviewCaptureBindings(status: ReviewStatusV3): readonly PublicRev
4491
4646
  return (status.nextTransition.collect?.inputs ?? []).filter((input) => input.captureOperation !== "external.select_intended_untracked").map((input) => ({ collectBinding: canonicalReviewCaptureBinding(input) }));
4492
4647
  }
4493
4648
 
4494
- function captureBindingRejected(reason: string): Record<string, unknown> {
4649
+ function captureBindingRejected(reason: string, group = false): Record<string, unknown> {
4495
4650
  return {
4496
- tool: "gentle_review_capture",
4651
+ tool: group ? "gentle_review_capture_group" : "gentle_review_capture",
4497
4652
  status: "blocked",
4498
- outcome: "capture-binding-rejected",
4653
+ outcome: group ? "capture-group-rejected" : "capture-binding-rejected",
4499
4654
  reason,
4500
4655
  mutation_performed: false,
4501
4656
  mutation_outcome: "none",
@@ -4557,6 +4712,89 @@ function isSelectedReviewCapture(value: SelectedReviewCapture | Record<string, u
4557
4712
  return "input" in value && "binding" in value;
4558
4713
  }
4559
4714
 
4715
+ function captureGroupRejected(reason: string): Record<string, unknown> { return captureBindingRejected(reason, true); }
4716
+
4717
+ function hasExactReviewCaptureSuffix(status: ReviewStatusV3, expected: readonly string[]): boolean {
4718
+ const current = status.nextTransition?.kind === "collect"
4719
+ ? (status.nextTransition.collect?.inputs ?? []).filter((input) => input.captureOperation === "review.capture-result").map(canonicalReviewCaptureBinding)
4720
+ : [];
4721
+ return current.length === expected.length && current.every((binding, index) => binding === expected[index]);
4722
+ }
4723
+
4724
+ function captureGroupAuthorityDrift(status: ReviewStatusV3): Record<string, unknown> {
4725
+ return { ...captureGroupRejected("authoritative STATUS does not offer exactly the unsubmitted reviewer suffix"), outcome: "capture-group-authority-drift", reconciliation: status.raw, authority_applicability: status.applicability, provider_action: status.action, next_transition: status.nextTransition };
4726
+ }
4727
+
4728
+ interface SelectedReviewCaptureGroup {
4729
+ slots: readonly ReviewHostRelaySlot[];
4730
+ binding: ReviewLastEventClosureBinding;
4731
+ }
4732
+
4733
+ function selectExactReviewCaptureGroup(
4734
+ status: ReviewStatusV3,
4735
+ lineageId: string,
4736
+ canonicalBindings: readonly string[],
4737
+ ): SelectedReviewCaptureGroup | Record<string, unknown> {
4738
+ const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : [];
4739
+ const slots = reviewHostRelaySlots(inputs);
4740
+ if (inputs.length === 0 || slots.length !== inputs.length) {
4741
+ return captureGroupRejected("current STATUS does not offer an exclusively materialize reviewer capture group");
4742
+ }
4743
+ const currentBindings = inputs.map((input) => canonicalReviewCaptureBinding(input));
4744
+ if (new Set(currentBindings).size !== currentBindings.length || canonicalBindings.length !== currentBindings.length || canonicalBindings.some((binding, index) => binding !== currentBindings[index])) {
4745
+ return captureGroupRejected("collectBindings must be the complete distinct current reviewer group in exact provider order");
4746
+ }
4747
+ const first = selectExactReviewCapture(status, lineageId, currentBindings[0]!);
4748
+ if (!isSelectedReviewCapture(first)) return captureGroupRejected(String(first.reason ?? "current STATUS rejected a reviewer binding"));
4749
+ const expectedRevision = exactCollectArgument(inputs[0]!, "expected-revision");
4750
+ const repositoryContext = exactCollectArgument(inputs[0]!, "repository-context");
4751
+ const statusTargetIdentity = status.targetIdentity;
4752
+ const currentRepositoryContext = status.repositoryContext;
4753
+ if (!isCanonicalProcessString(expectedRevision) || !isCanonicalProcessString(repositoryContext) || currentRepositoryContext === undefined || expectedRevision !== currentRepositoryContext.revision) {
4754
+ return captureGroupRejected("current STATUS does not bind one matching expected revision and repository context for the reviewer group");
4755
+ }
4756
+ if (currentRepositoryContext.handle !== repositoryContext || currentRepositoryContext.targetIdentity !== statusTargetIdentity) {
4757
+ return captureGroupRejected("current STATUS repository context does not match the reviewer group binding");
4758
+ }
4759
+ const lenses = new Set<string>(), orders = new Set<string>(), subjectHashes = new Set<string>();
4760
+ for (let index = 0; index < inputs.length; index += 1) {
4761
+ const input = inputs[index]!, slot = slots[index]!, subject = input.artifactSubject;
4762
+ const slotLineage = exactCollectArgument(input, "lineage"), target = exactCollectArgument(input, "target");
4763
+ const revision = exactCollectArgument(input, "expected-revision"), context = exactCollectArgument(input, "repository-context");
4764
+ const subjectHash = exactCollectArgument(input, "subject-hash"), order = slot.order, lens = slot.lens;
4765
+ if (
4766
+ subject === undefined || slot.submission === undefined || slotLineage !== lineageId || target !== statusTargetIdentity
4767
+ || revision !== expectedRevision || context !== repositoryContext || subjectHash !== subject.subjectHash || order === undefined || lens === undefined
4768
+ || subject.lineageId !== lineageId || subject.authorityRevision !== expectedRevision || subject.targetIdentity !== statusTargetIdentity
4769
+ || lens !== subject.lens || String(subject.selectedOrder) !== order
4770
+ ) return captureGroupRejected("current STATUS carries an incomplete or mismatched materialize reviewer binding");
4771
+ try { resolveReviewHostRelaySubmission(slot.submission); } catch { return captureGroupRejected("current STATUS carries an invalid provider reviewer submission descriptor"); }
4772
+ const value = slot.submission.values[0];
4773
+ if (slot.submission.operationToken !== "capture-result" || value?.slot !== "reviewer_result" || value.domain !== "artifact_path_or_stdin" || lenses.has(lens) || orders.has(order) || subjectHashes.has(subject.subjectHash)) {
4774
+ return captureGroupRejected("current STATUS carries duplicate or invalid reviewer slot identities");
4775
+ }
4776
+ lenses.add(lens); orders.add(order); subjectHashes.add(subject.subjectHash);
4777
+ }
4778
+ return { slots, binding: first.binding };
4779
+ }
4780
+
4781
+ function reviewHostRelayGroupFailure(
4782
+ error: ReviewHostRelayError,
4783
+ slots: readonly ReviewHostRelaySlot[],
4784
+ prepared: readonly ReviewHostRelayPreparedResult[],
4785
+ submitted: number,
4786
+ ): Record<string, unknown> {
4787
+ return {
4788
+ tool: "gentle_review_capture_group",
4789
+ status: "blocked",
4790
+ outcome: error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE ? "pi-host-relay-unavailable" : error.kind === REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED ? "pi-host-relay-handshake-refused" : error.kind === REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT ? "pi-host-relay-timeout" : "pi-host-relay-transport-failure",
4791
+ reason: error.message,
4792
+ failure: reviewHostRelayFailureReport(error),
4793
+ ...reviewHostRelayGroupProgress(slots, prepared, submitted),
4794
+ next_action: error.kind === REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED ? REVIEW_HOST_RELAY_REFUSED_ACTION : REVIEW_HOST_RELAY_RETRY_ACTION,
4795
+ };
4796
+ }
4797
+
4560
4798
  async function executeReviewCaptureOperation(
4561
4799
  parametersValue: unknown,
4562
4800
  sessionCwd: string,
@@ -4670,6 +4908,118 @@ async function executeReviewCaptureOperation(
4670
4908
  return captureBindingRejected(`unsupported provider capture operation: ${selected.input.captureOperation}`);
4671
4909
  }
4672
4910
 
4911
+ function reviewHostRelayGroupDiagnostics(slots: readonly ReviewHostRelaySlot[], prepared: readonly ReviewHostRelayPreparedResult[], count: number): readonly Record<string, unknown>[] {
4912
+ return slots.slice(0, count).map((slot, index) => ({
4913
+ ...(slot.lens === undefined ? {} : { lens: slot.lens }),
4914
+ ...(slot.order === undefined ? {} : { order: slot.order }),
4915
+ ...(slot.subjectHash === undefined ? {} : { subject_hash: slot.subjectHash }),
4916
+ prompt_bytes: prepared[index]?.promptByteLength,
4917
+ result_bytes: prepared[index]?.resultByteLength,
4918
+ }));
4919
+ }
4920
+
4921
+ function reviewHostRelayGroupProgress(
4922
+ slots: readonly ReviewHostRelaySlot[],
4923
+ prepared: readonly ReviewHostRelayPreparedResult[],
4924
+ submitted: number,
4925
+ uncertain = false,
4926
+ ): Record<string, unknown> {
4927
+ const outcome = submitted === 0 ? uncertain ? "unknown" : "none" : uncertain ? "partial_unknown" : submitted === slots.length ? "completed" : "partial";
4928
+ return {
4929
+ prepared_reviewers: prepared.length,
4930
+ submitted_reviewers: submitted,
4931
+ host_relay: { transport: "pi_host_relay", reviewers: reviewHostRelayGroupDiagnostics(slots, prepared, submitted) },
4932
+ ...(submitted === 0 && uncertain ? { mutation_outcome: outcome } : { mutation_performed: submitted > 0, mutation_outcome: outcome }),
4933
+ };
4934
+ }
4935
+
4936
+ async function executeReviewCaptureGroupOperation(
4937
+ parametersValue: unknown,
4938
+ sessionCwd: string,
4939
+ nativeReviewCli: NativeReviewCli | null,
4940
+ signal?: AbortSignal,
4941
+ candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
4942
+ retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
4943
+ requireRegisteredRoute = false,
4944
+ ): Promise<Record<string, unknown>> {
4945
+ const parameters = parseReviewCaptureGroupParameters(parametersValue);
4946
+ if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) return { ...captureGroupRejected("native target STATUS is unavailable"), outcome: "native-status-unsupported" };
4947
+ const canonicalBindings = parameters.collectBindings.map((binding) => parseCanonicalReviewCaptureBinding(binding));
4948
+ const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
4949
+ const routes = canonicalBindings.map((binding) => readRetainedNativeCaptureRoute(retainedUntrackedSelections, binding));
4950
+ const route = routes[0];
4951
+ if (requireRegisteredRoute && (route === undefined || routes.some((candidate) => candidate === undefined || candidate.workspaceRoot !== cwd || candidate.lineageId !== parameters.lineageId || candidate.baseRef !== route.baseRef))) {
4952
+ return captureGroupRejected("collectBindings are unknown, expired, or belong to different session routes");
4953
+ }
4954
+ const freshStatus = () => negotiatedStatusForHostTransport(nativeReviewCli, {
4955
+ cwd, lineageId: parameters.lineageId,
4956
+ ...(route?.baseRef === undefined ? {} : { baseRef: route.baseRef, committedOnly: true }),
4957
+ ...readRetainedNativeUntrackedSelection(retainedUntrackedSelections, cwd, parameters.lineageId),
4958
+ ...(signal === undefined ? {} : { signal }),
4959
+ }, retainedUntrackedSelections, cwd);
4960
+ let status: ReviewStatusV3;
4961
+ try {
4962
+ const negotiated = await freshStatus();
4963
+ if (negotiated.transport !== undefined) return hostTransportUnavailable("gentle_review_capture_group", negotiated.transport);
4964
+ status = negotiated.status!;
4965
+ } catch (error) {
4966
+ return { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "native-status-failed" };
4967
+ }
4968
+ const group = selectExactReviewCaptureGroup(status, parameters.lineageId, canonicalBindings);
4969
+ if (!("slots" in group && "binding" in group)) return group;
4970
+ if (parameters.reviewerRunAcknowledged !== true) {
4971
+ return {
4972
+ tool: "gentle_review_capture_group",
4973
+ status: "blocked",
4974
+ outcome: "reviewer-model-run-forecast",
4975
+ cost_forecast: { transport: "pi_host_relay", model_runs: group.slots.length, lenses: group.slots.map((slot) => slot.lens).filter((lens): lens is string => lens !== undefined) },
4976
+ mutation_performed: false,
4977
+ mutation_outcome: "none",
4978
+ };
4979
+ }
4980
+ const requests: readonly ReviewHostRelayRequest[] = group.slots.map((slot) => ({
4981
+ captureArgumentTokens: slot.captureArgumentTokens,
4982
+ targetCwd: cwd,
4983
+ submission: slot.submission!,
4984
+ ...(signal === undefined ? {} : { signal }),
4985
+ }));
4986
+ let prepared: readonly ReviewHostRelayPreparedResult[];
4987
+ try {
4988
+ prepared = await activeReviewHostRelayReviewerGroupRunner(requests);
4989
+ if (prepared.length !== requests.length) throw new Error("Pi host relay reviewer group returned a different number of prepared results");
4990
+ } catch (error) {
4991
+ return error instanceof ReviewHostRelayError
4992
+ ? reviewHostRelayGroupFailure(error, group.slots, [], 0)
4993
+ : { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "pi-host-relay-reviewer-group-failed" };
4994
+ }
4995
+ for (let index = 0; index < prepared.length; index += 1) {
4996
+ let current: SelectedReviewCapture | Record<string, unknown>;
4997
+ try {
4998
+ const negotiated = await freshStatus();
4999
+ if (negotiated.transport !== undefined) return { ...hostTransportUnavailable("gentle_review_capture_group", negotiated.transport), ...reviewHostRelayGroupProgress(group.slots, prepared, index) };
5000
+ if (!hasExactReviewCaptureSuffix(negotiated.status!, canonicalBindings.slice(index))) return { ...captureGroupAuthorityDrift(negotiated.status!), ...reviewHostRelayGroupProgress(group.slots, prepared, index) };
5001
+ current = selectExactReviewCapture(negotiated.status!, parameters.lineageId, canonicalBindings[index]!);
5002
+ } catch (error) {
5003
+ return { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "native-status-failed", ...reviewHostRelayGroupProgress(group.slots, prepared, index) };
5004
+ }
5005
+ if (!isSelectedReviewCapture(current)) return { ...captureGroupRejected(String(current.reason ?? "current STATUS rejected a reviewer binding")), ...reviewHostRelayGroupProgress(group.slots, prepared, index) };
5006
+ try {
5007
+ const result = await activeReviewHostRelaySubmissionRunner(prepared[index]!);
5008
+ const closure = decodeRelayLastEventClosure(result.submission);
5009
+ if (closure !== undefined) {
5010
+ const closed = mapAndClearLastEventClosure(closure, current.binding, retainedUntrackedSelections, cwd);
5011
+ return { ...closed, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index + 1) };
5012
+ }
5013
+ } catch (error) {
5014
+ if (error instanceof ReviewHostRelayError && error.mutationOutcome !== "unknown") return reviewHostRelayGroupFailure(error, group.slots, prepared, index);
5015
+ const reconciled = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, current.binding, retainedUntrackedSelections, route);
5016
+ return { ...reconciled, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index, true), ...(error instanceof ReviewHostRelayError ? { failure: reviewHostRelayFailureReport(error), reason: error.message } : {}) };
5017
+ }
5018
+ }
5019
+ const reconciled = await reconcileUnknownReviewCaptureFailure(undefined, nativeReviewCli, cwd, group.binding, retainedUntrackedSelections, route, canonicalBindings.slice(prepared.length));
5020
+ return { ...reconciled, tool: "gentle_review_capture_group", outcome: reconciled.outcome === "capture-group-authority-drift" ? reconciled.outcome : reconciled.status === "reconciled" ? "native-reviewer-group-status-reconciled" : "native-reviewer-group-status-reconciliation-failed", ...reviewHostRelayGroupProgress(group.slots, prepared, prepared.length) };
5021
+ }
5022
+
4673
5023
  type DispatchHydrationOutcome =
4674
5024
  | { hydrated: true; lineage_id: string; lenses: readonly string[] }
4675
5025
  | { hydrated: false; lineage_id: string; reason: string; message: string }
@@ -5422,11 +5772,15 @@ export const __testing = {
5422
5772
  nativeStatusUnsupported,
5423
5773
  executeReviewControllerOperation,
5424
5774
  executeReviewCaptureOperation,
5775
+ executeReviewCaptureGroupOperation,
5425
5776
  setReviewHostRelayRunnerForTesting,
5777
+ setReviewHostRelayGroupRunnersForTesting,
5426
5778
  clearReviewTransportProbeForTesting,
5427
5779
  renderSddModelPanel: renderSddModelPanelForTesting,
5428
5780
  getOrchestratorPrompt,
5429
5781
  renderOrchestratorPrompt,
5782
+ loadReviewContractPromptFragment,
5783
+ readMirroredReviewContractFragment,
5430
5784
  loadBackgroundSubagentsPolicy,
5431
5785
  resolveBackgroundSubagentsPolicy,
5432
5786
  renderBackgroundSubagentsReport,
@@ -5493,6 +5847,9 @@ function createGentleAiExtensionForTesting(
5493
5847
  const sessionKey = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey);
5494
5848
  cleanupAllPendingReviewConsents(pendingReviewConsentRegistry, sessionKey);
5495
5849
  processRetainedNativeStatusSelections.delete(sessionKey);
5850
+ processAgentEndSubagentDepth.delete(sessionKey);
5851
+ processAgentEndPreflightNudgedTargets.delete(sessionKey);
5852
+ processAgentEndSessionBaseline.delete(sessionKey);
5496
5853
  });
5497
5854
 
5498
5855
  pi.registerTool({
@@ -5518,6 +5875,38 @@ function createGentleAiExtensionForTesting(
5518
5875
  },
5519
5876
  });
5520
5877
 
5878
+ pi.registerTool({
5879
+ name: "gentle_review_capture_group",
5880
+ label: "Gentle Review Capture Group",
5881
+ description: "Capture one complete provider-issued materialize reviewer group. It validates the exact ordered current collect set, forecasts its bounded model cost, runs reviewers concurrently, and admits outputs one at a time in provider order.",
5882
+ promptSnippet: "Use one complete exact current STATUS materialize reviewer group; acknowledge its forecast before the grouped run.",
5883
+ promptGuidelines: [
5884
+ "Pass only lineageId, the complete ordered collectBindings array from one current STATUS result, and reviewerRunAcknowledged after its forecast. Never mix, reorder, duplicate, or partially select bindings.",
5885
+ "The group materializes and runs independent reviewers concurrently, but rechecks STATUS before every provider-ordered submission. It stops on a closure, correction, drift, or uncertain capture outcome; it never follows another transition or replays a prepared output.",
5886
+ ],
5887
+ parameters: REVIEW_CAPTURE_GROUP_PARAMETERS,
5888
+ executionMode: "sequential",
5889
+ renderCall(_args, theme, context) {
5890
+ return renderGentleAiLifecycleCall("review capture group", theme, context as GentleAiRenderContext | undefined);
5891
+ },
5892
+ renderResult(result, options) {
5893
+ return renderGentleAiResult(result, options);
5894
+ },
5895
+ async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
5896
+ if (signal?.aborted) throw new Error("Review capture group was cancelled");
5897
+ const details = await executeReviewCaptureGroupOperation(
5898
+ parameters,
5899
+ ctx.cwd,
5900
+ nativeReviewCli,
5901
+ signal,
5902
+ candidateViews,
5903
+ ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
5904
+ true,
5905
+ );
5906
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
5907
+ },
5908
+ });
5909
+
5521
5910
  pi.registerTool({
5522
5911
  name: "gentle_review_capture",
5523
5912
  label: "Gentle Review Capture",
@@ -5650,6 +6039,19 @@ function createGentleAiExtensionForTesting(
5650
6039
  );
5651
6040
  }
5652
6041
  }
6042
+ // gentle-pi#568: record the target identity STATUS reports right now,
6043
+ // before this session does anything, as the baseline `agent_end` skips
6044
+ // later. Best-effort and silent: it never notifies and never lets a
6045
+ // STATUS failure fail session start.
6046
+ try {
6047
+ const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
6048
+ const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
6049
+ if (status?.targetIdentity !== undefined) {
6050
+ processAgentEndSessionBaseline.set(sessionKey, status.targetIdentity);
6051
+ }
6052
+ } catch {
6053
+ // Baseline recording is best-effort only; never surface or throw.
6054
+ }
5653
6055
  });
5654
6056
 
5655
6057
  pi.on("input", async (event, ctx) => {
@@ -5663,6 +6065,12 @@ function createGentleAiExtensionForTesting(
5663
6065
  pi.on("before_agent_start", async (event, ctx) => {
5664
6066
  const isSddAgent = isSddAgentStartEvent(event);
5665
6067
  const isNamedAgent = isNamedAgentStartEvent(event);
6068
+ const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
6069
+ if (isSddAgent || isNamedAgent) {
6070
+ processAgentEndSubagentDepth.set(subagentDepthKey, (processAgentEndSubagentDepth.get(subagentDepthKey) ?? 0) + 1);
6071
+ } else {
6072
+ processAgentEndSubagentDepth.set(subagentDepthKey, 0);
6073
+ }
5666
6074
  if (isSddAgent && !getSddPreflightPreferences(ctx)) {
5667
6075
  await runSddPreflight(ctx);
5668
6076
  }
@@ -5683,11 +6091,62 @@ function createGentleAiExtensionForTesting(
5683
6091
  const gentlePrompt = isNamedAgent || isSddAgent
5684
6092
  ? ""
5685
6093
  : `\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd), ctx.cwd, readActiveToolNames(pi))}`;
6094
+ // gentle-pi#560 / gentle-ai#4056, #4057: inject the mirrored provider
6095
+ // contract bundle's review execution contract for the primary session
6096
+ // only, and only when a native review CLI is actually present.
6097
+ const reviewContractPrompt =
6098
+ !isNamedAgent && !isSddAgent && nativeReviewCli !== null
6099
+ ? (() => {
6100
+ const fragment = loadReviewContractPromptFragment(ctx);
6101
+ return fragment === null ? "" : `\n\n${fragment}`;
6102
+ })()
6103
+ : "";
5686
6104
  return {
5687
- systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}`,
6105
+ systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}`,
5688
6106
  };
5689
6107
  });
5690
6108
 
6109
+ // gentle-pi#556 / gentle-ai#4051: with RDD enabled, the agent could finish
6110
+ // an authorized implementation and report completion without ever running
6111
+ // the review STATUS preflight or offering the consent question. This
6112
+ // handler is read-only and idempotent: it never runs START, never answers
6113
+ // consent, and never writes a file. It only sends one turn-triggering
6114
+ // reminder, at most once per unreviewed target identity per session.
6115
+ // gentle-pi#568: a candidate matching the baseline `session_start`
6116
+ // recorded predates this session's own work and is skipped rather than
6117
+ // nudged, so a worktree already dirty from the user's own edits does not
6118
+ // draw a reminder about work this session never produced.
6119
+ pi.on("agent_end", async (_event, ctx) => {
6120
+ if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return;
6121
+ if (ctx.hasUI !== true) return;
6122
+ const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
6123
+ const subagentDepth = processAgentEndSubagentDepth.get(sessionKey) ?? 0;
6124
+ if (subagentDepth > 0) {
6125
+ processAgentEndSubagentDepth.set(sessionKey, subagentDepth - 1);
6126
+ return;
6127
+ }
6128
+ const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
6129
+ if (status === undefined) return;
6130
+ if (status.nextTransition?.kind !== "execute" || status.nextTransition.execute.operation !== "review.start") return;
6131
+ const targetIdentity = status.targetIdentity;
6132
+ if (processAgentEndSessionBaseline.get(sessionKey) === targetIdentity) return;
6133
+ let nudged = processAgentEndPreflightNudgedTargets.get(sessionKey);
6134
+ if (nudged === undefined) {
6135
+ nudged = new Set<string>();
6136
+ processAgentEndPreflightNudgedTargets.set(sessionKey, nudged);
6137
+ }
6138
+ if (nudged.has(targetIdentity)) return;
6139
+ nudged.add(targetIdentity);
6140
+ pi.sendMessage(
6141
+ {
6142
+ customType: "gentle-pi.review-preflight",
6143
+ content: renderAgentEndReviewPreflightMessage(targetIdentity),
6144
+ display: true,
6145
+ },
6146
+ { triggerTurn: true, deliverAs: "followUp" },
6147
+ );
6148
+ });
6149
+
5691
6150
  pi.on("tool_call", async (event, ctx) => {
5692
6151
  const sensitivePathDenied = evaluateSensitivePathTool(
5693
6152
  event.toolName,