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.
- package/README.md +30 -4
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +2 -1
- package/assets/orchestrator.md +6 -10
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
- package/docs/review-integration.md +1 -1
- package/extensions/gentle-ai.ts +530 -71
- package/lib/agent-home.ts +8 -0
- package/lib/native-review-cli.ts +15 -0
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +34 -5
- package/lib/sdd-preflight.ts +2 -2
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +15 -0
- package/runtime/review-integration-v2.mjs +34 -5
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +14 -13
- package/skills/_shared/review-ledger-contract.md +6 -0
- package/skills/issue-creation/SKILL.md +53 -93
- package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
- package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
- package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +46 -46
- package/tests/gentle-ai.test.ts +3 -2
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/native-review-capability-contract.test.ts +18 -1
- package/tests/orchestrator-budget.test.ts +2 -2
- package/tests/orchestrator-rdd-ownership.test.ts +1 -1
- package/tests/package-manifest.test.ts +123 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-contract-prompt.test.ts +139 -0
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +211 -9
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/runtime-harness.mjs +1 -0
- package/tests/writer-edit-surface-scope.test.ts +153 -17
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /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
package/extensions/gentle-ai.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
465
|
-
const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)])
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
500
|
+
type AllowedEditSurfaceEntry = {
|
|
501
|
+
source: string;
|
|
502
|
+
value: string;
|
|
503
|
+
isWholeEntryBackticked: boolean;
|
|
504
|
+
isValidMarkdownSyntax: boolean;
|
|
505
|
+
};
|
|
498
506
|
|
|
499
|
-
/**
|
|
500
|
-
function
|
|
501
|
-
|
|
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
|
|
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):
|
|
522
|
-
const lines = following.split(/\r?\n/)
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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 (
|
|
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
|
-
|
|
4194
|
-
|
|
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:
|
|
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
|
|
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,
|