gentle-pi 2.3.0 → 2.5.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 +195 -11
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +21 -10
- package/assets/orchestrator.md +8 -12
- 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/delegated-verification.md +25 -0
- package/docs/review-integration.md +1 -1
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +1421 -143
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agent-home.ts +8 -0
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +103 -12
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +92 -13
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/sdd-preflight.ts +2 -2
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +102 -11
- package/runtime/review-integration-v2.mjs +92 -13
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +18 -13
- package/skills/_shared/review-ledger-contract.md +9 -1
- package/skills/issue-creation/SKILL.md +53 -93
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- 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-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +31 -14
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +33 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +71 -2
- package/tests/orchestrator-rdd-ownership.test.ts +10 -1
- package/tests/package-manifest.test.ts +134 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +142 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-routing.test.ts +229 -11
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/runtime-harness.mjs +1 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -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,6 @@
|
|
|
1
|
+
import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
|
|
1
2
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
4
|
import {
|
|
4
5
|
existsSync,
|
|
5
6
|
lstatSync,
|
|
@@ -29,6 +30,7 @@ import type {
|
|
|
29
30
|
ToolCallEventResult,
|
|
30
31
|
} from "@earendil-works/pi-coding-agent";
|
|
31
32
|
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
33
|
+
import { resolveGentlePiAgentHome } from "../lib/agent-home.ts";
|
|
32
34
|
import {
|
|
33
35
|
ensureSddPreflight,
|
|
34
36
|
getSddPreflightPreferences,
|
|
@@ -71,7 +73,12 @@ import {
|
|
|
71
73
|
ReviewHostRelayError,
|
|
72
74
|
reviewHostRelaySlots,
|
|
73
75
|
reviewProviderRoleVectorSlots,
|
|
76
|
+
resolveReviewHostRelaySubmission,
|
|
77
|
+
runReviewHostRelayReviewerGroup,
|
|
74
78
|
runReviewHostRelaySlot,
|
|
79
|
+
submitReviewHostRelayPreparedResult,
|
|
80
|
+
type ReviewHostRelayPreparedResult,
|
|
81
|
+
type ReviewHostRelayRequest,
|
|
75
82
|
type ReviewHostRelayRunner,
|
|
76
83
|
type ReviewHostRelaySlot,
|
|
77
84
|
type ReviewProviderRoleVectorSlot,
|
|
@@ -101,10 +108,15 @@ import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, r
|
|
|
101
108
|
import {
|
|
102
109
|
GentleAiDevBinaryOverrideError,
|
|
103
110
|
registerGentleAiDevBinary,
|
|
111
|
+
resolveGentleAiBinary,
|
|
104
112
|
resolveGentleAiDevBinaryOverride,
|
|
105
113
|
unregisterGentleAiDevBinary,
|
|
106
114
|
type GentleAiDevBinaryOverride,
|
|
107
115
|
} from "../lib/gentle-ai-binary.ts";
|
|
116
|
+
import {
|
|
117
|
+
spawnTelemetryTrigger,
|
|
118
|
+
type TelemetryTriggerSpawn,
|
|
119
|
+
} from "../lib/telemetry-trigger.ts";
|
|
108
120
|
import {
|
|
109
121
|
createNativeReviewCli,
|
|
110
122
|
createNodeExecFileAdapter,
|
|
@@ -136,7 +148,21 @@ import {
|
|
|
136
148
|
type NativeReviewModeSource,
|
|
137
149
|
type NativeReviewProcessDiagnostics,
|
|
138
150
|
type NativeStartResult,
|
|
151
|
+
type NativeReviewAssessRequest,
|
|
152
|
+
type ExecFileAdapter,
|
|
153
|
+
type ExecFileResult,
|
|
139
154
|
} from "../lib/native-review-cli.ts";
|
|
155
|
+
import {
|
|
156
|
+
verificationPlan,
|
|
157
|
+
resolveWriterProfile,
|
|
158
|
+
RDD_LINE,
|
|
159
|
+
VERIFICATION_TIER,
|
|
160
|
+
NATIVE_REVIEW_OUTCOME,
|
|
161
|
+
type RddLine,
|
|
162
|
+
type VerificationTier,
|
|
163
|
+
type ReviewAssessmentV1,
|
|
164
|
+
type NativeReviewOutcome,
|
|
165
|
+
} from "../lib/review-risk-assessment.ts";
|
|
140
166
|
import {
|
|
141
167
|
assertReviewApprovedAcknowledgementExecuteV1,
|
|
142
168
|
decodeReviewLastEventClosureV1,
|
|
@@ -147,14 +173,26 @@ import {
|
|
|
147
173
|
type ReviewStatusV3,
|
|
148
174
|
} from "../lib/review-integration-v2.ts";
|
|
149
175
|
import { reconcileUnknownReviewLastEventCapture } from "../lib/review-last-event-controller.ts";
|
|
150
|
-
import {
|
|
176
|
+
import { acquireChildStandingReviewPermissionClient, type ChildStandingReviewPermissionClient } from "../lib/review-session-standing-permission-ipc.ts";
|
|
177
|
+
import { isPiConsentV3, presentReviewConsentUi } from "../lib/review-consent-ui.ts";
|
|
178
|
+
import {
|
|
179
|
+
captureReviewSessionIdentity,
|
|
180
|
+
grantReviewSessionPermission,
|
|
181
|
+
hasReviewSessionPermission,
|
|
182
|
+
resolveCanonicalGitRepositoryIdentity,
|
|
183
|
+
revokeReviewSessionPermission,
|
|
184
|
+
reviewSessionPermissionEpoch,
|
|
185
|
+
revokeReviewSessionPermissionsForSession,
|
|
186
|
+
sameReviewSessionIdentity,
|
|
187
|
+
type ReviewSessionIdentity,
|
|
188
|
+
} from "../lib/review-session-standing-permission.ts";
|
|
151
189
|
|
|
152
190
|
const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations";
|
|
153
191
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
154
192
|
const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
|
|
155
193
|
|
|
156
194
|
function gentlePiAgentHome(): string {
|
|
157
|
-
return
|
|
195
|
+
return resolveGentlePiAgentHome();
|
|
158
196
|
}
|
|
159
197
|
|
|
160
198
|
function sddGlobalAssetDriftCount(): number {
|
|
@@ -461,17 +499,19 @@ const SUBAGENTS_PACKAGE_NAMES = ["pi-subagents-j0k3r", "pi-subagents"] as const;
|
|
|
461
499
|
const SUBAGENT_RUN_TOOL = "subagent_run";
|
|
462
500
|
const BOUNDED_WRITER_AGENT_NAMES = ["gentle-ai-worker", "worker"] as const;
|
|
463
501
|
const ALLOWED_EDIT_SURFACES_HEADING = /^## Allowed edit surfaces[ \t]*$/gim;
|
|
464
|
-
const MARKDOWN_HEADING_LINE =
|
|
465
|
-
const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)])
|
|
502
|
+
const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6} /;
|
|
503
|
+
const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)]) +/;
|
|
466
504
|
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.";
|
|
505
|
+
"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
506
|
|
|
469
|
-
function isTaskScopedRepositoryRelativePath(value: string): boolean {
|
|
507
|
+
function isTaskScopedRepositoryRelativePath(value: string, isWholeEntryBackticked: boolean): boolean {
|
|
470
508
|
const normalized = value.replace(/\\/g, "/");
|
|
471
509
|
if (
|
|
472
510
|
normalized.length === 0 ||
|
|
473
511
|
isAbsolute(value) ||
|
|
474
|
-
/^(?:[A-Za-z]:|\/|~)/.test(normalized)
|
|
512
|
+
/^(?:[A-Za-z]:|\/|~)/.test(normalized) ||
|
|
513
|
+
/\p{Cc}|\p{Zl}|\p{Zp}/u.test(normalized) ||
|
|
514
|
+
(/\p{White_Space}/u.test(normalized) && !isWholeEntryBackticked)
|
|
475
515
|
) {
|
|
476
516
|
return false;
|
|
477
517
|
}
|
|
@@ -481,7 +521,6 @@ function isTaskScopedRepositoryRelativePath(value: string): boolean {
|
|
|
481
521
|
withoutCurrentDirectory.length === 0 ||
|
|
482
522
|
withoutCurrentDirectory === "." ||
|
|
483
523
|
withoutCurrentDirectory.startsWith("/") ||
|
|
484
|
-
/\s/.test(withoutCurrentDirectory) ||
|
|
485
524
|
withoutCurrentDirectory.split("/").some((segment) => segment === "..")
|
|
486
525
|
) {
|
|
487
526
|
return false;
|
|
@@ -490,56 +529,37 @@ function isTaskScopedRepositoryRelativePath(value: string): boolean {
|
|
|
490
529
|
return !/[?*\[\]{}]/.test(withoutCurrentDirectory.split("/")[0]);
|
|
491
530
|
}
|
|
492
531
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
532
|
+
type AllowedEditSurfaceEntry = {
|
|
533
|
+
source: string;
|
|
534
|
+
value: string;
|
|
535
|
+
isWholeEntryBackticked: boolean;
|
|
536
|
+
isValidMarkdownSyntax: boolean;
|
|
537
|
+
};
|
|
498
538
|
|
|
499
|
-
/**
|
|
500
|
-
function
|
|
501
|
-
|
|
539
|
+
/** Reads one entry and records whether backticks delimit the whole path. */
|
|
540
|
+
function readSurfaceEntry(line: string): AllowedEditSurfaceEntry {
|
|
541
|
+
const withoutListMarker = line.replace(MARKDOWN_LIST_MARKER, "");
|
|
542
|
+
const backticked = withoutListMarker.match(/^`([^`]+)`$/);
|
|
543
|
+
return {
|
|
544
|
+
source: line,
|
|
545
|
+
value: backticked?.[1] ?? withoutListMarker,
|
|
546
|
+
isWholeEntryBackticked: backticked !== null,
|
|
547
|
+
isValidMarkdownSyntax:
|
|
548
|
+
!/^(?:[-*+]|\d+[.)])$/.test(line) && (!withoutListMarker.includes("`") || backticked !== null),
|
|
549
|
+
};
|
|
502
550
|
}
|
|
503
551
|
|
|
504
552
|
/**
|
|
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.
|
|
553
|
+
* Reads every non-empty line until the next Markdown heading as an edit surface.
|
|
554
|
+
* A prose line cannot terminate this section: it must fail validation instead.
|
|
520
555
|
*/
|
|
521
|
-
function readAllowedEditSurfaceEntries(following: string):
|
|
522
|
-
const lines = following.split(/\r?\n/)
|
|
556
|
+
function readAllowedEditSurfaceEntries(following: string): AllowedEditSurfaceEntry[] {
|
|
557
|
+
const lines = following.split(/\r?\n/);
|
|
523
558
|
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;
|
|
559
|
+
return (headingIndex === -1 ? lines : lines.slice(0, headingIndex))
|
|
560
|
+
.map((line) => line.replace(/ +$/g, ""))
|
|
561
|
+
.filter((line) => line.length > 0)
|
|
562
|
+
.map((line) => readSurfaceEntry(line.replace(/^ {0,3}/, "")));
|
|
543
563
|
}
|
|
544
564
|
|
|
545
565
|
function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean {
|
|
@@ -553,9 +573,19 @@ function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean {
|
|
|
553
573
|
for (const heading of headings) {
|
|
554
574
|
const bodyStart = (heading.index ?? 0) + heading[0].length;
|
|
555
575
|
const entries = readAllowedEditSurfaceEntries(value.slice(bodyStart));
|
|
556
|
-
if (
|
|
576
|
+
if (
|
|
577
|
+
entries.length === 0 ||
|
|
578
|
+
!entries.every(
|
|
579
|
+
(entry) =>
|
|
580
|
+
entry.isValidMarkdownSyntax &&
|
|
581
|
+
!/\p{Cc}|\p{Zl}|\p{Zp}/u.test(entry.source) &&
|
|
582
|
+
isTaskScopedRepositoryRelativePath(entry.value, entry.isWholeEntryBackticked),
|
|
583
|
+
)
|
|
584
|
+
) {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
557
587
|
|
|
558
|
-
const uniqueEntries = [...new Set(entries)].sort();
|
|
588
|
+
const uniqueEntries = [...new Set(entries.map((entry) => entry.value))].sort();
|
|
559
589
|
if (
|
|
560
590
|
expectedEntries &&
|
|
561
591
|
(expectedEntries.length !== uniqueEntries.length ||
|
|
@@ -673,21 +703,288 @@ function renderBackgroundSubagentsStatusLine(
|
|
|
673
703
|
return `Background subagent policy: ${background.policy} (capability: ${background.capability})`;
|
|
674
704
|
}
|
|
675
705
|
|
|
676
|
-
|
|
677
|
-
|
|
706
|
+
/**
|
|
707
|
+
* A `status` object is only trusted when `effective` is exactly `on`/`off`
|
|
708
|
+
* and `source` is one of the exported `NATIVE_REVIEW_MODE_SOURCE` values.
|
|
709
|
+
* `resolveRddModeStatus` only ever produces a value shaped like this, but
|
|
710
|
+
* `renderRddStatusLine` validates at the render boundary anyway -- a
|
|
711
|
+
* malformed or partial object (a bad decode upstream, a future field
|
|
712
|
+
* change, a hand-built test fixture) must fail closed to the "unknown"
|
|
713
|
+
* line, never render an unrecognized value verbatim.
|
|
714
|
+
*/
|
|
715
|
+
function isValidRddModeStatus(
|
|
716
|
+
status: NativeReviewModeStatus | undefined,
|
|
717
|
+
): status is NativeReviewModeStatus {
|
|
718
|
+
if (status === undefined || status === null || typeof status !== "object") return false;
|
|
719
|
+
if (status.effective !== "on" && status.effective !== "off") return false;
|
|
720
|
+
const validSources: readonly string[] = Object.values(NATIVE_REVIEW_MODE_SOURCE);
|
|
721
|
+
return typeof status.source === "string" && validSources.includes(status.source);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Renders the receipt-driven-development status line rendered next to
|
|
726
|
+
* `Background subagent policy` (gentle-pi#661). Renders the fail-closed
|
|
727
|
+
* "unknown" line whenever `status` is not a validated on/off status with a
|
|
728
|
+
* recognized source -- `undefined` (the native reader could not answer:
|
|
729
|
+
* binary absent, timed out, aborted, or a native CLI failure) or any
|
|
730
|
+
* malformed/partial object. This is a pure render, never a native call, so
|
|
731
|
+
* it never throws.
|
|
732
|
+
*/
|
|
733
|
+
function renderRddStatusLine(
|
|
734
|
+
status: NativeReviewModeStatus | undefined,
|
|
735
|
+
): string {
|
|
736
|
+
return isValidRddModeStatus(status)
|
|
737
|
+
? `Receipt-driven development: ${status.effective} (decided by ${status.source})`
|
|
738
|
+
: "Receipt-driven development: unknown (native status unavailable)";
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// The primary-session prompt awaits this on every non-SDD, non-named agent
|
|
742
|
+
// start, so an unbounded native read would stall session start behind a
|
|
743
|
+
// hung `gentle-ai` child (gentle-pi#661 native-review escalation). The
|
|
744
|
+
// production call site (before_agent_start) passes
|
|
745
|
+
// `AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS)`; resolveRddModeStatus also
|
|
746
|
+
// races the call against that same signal itself (not just the CLI's own
|
|
747
|
+
// signal handling) so an abort is honored even against a stub/mock
|
|
748
|
+
// reviewMode that ignores its `signal` argument, as tests do.
|
|
749
|
+
const RDD_STATUS_TIMEOUT_MS = 3000;
|
|
750
|
+
// Repeated session/agent-start builds within this window reuse the last
|
|
751
|
+
// resolved status instead of respawning the native binary. Deliberately
|
|
752
|
+
// memoizes a failed/undefined resolution too (a sustained outage should not
|
|
753
|
+
// retry every agent start), trading a slower recovery signal for far fewer
|
|
754
|
+
// spawns; the one-shot notify below still surfaces a sustained outage.
|
|
755
|
+
const RDD_STATUS_MEMO_TTL_MS = 30_000;
|
|
756
|
+
const rddStatusMemo = new Map<string, { readonly status: NativeReviewModeStatus | undefined; readonly expiresAt: number }>();
|
|
757
|
+
|
|
758
|
+
/** @internal test seam: clears the per-cwd RDD status memo. */
|
|
759
|
+
function clearRddStatusMemoForTesting(): void {
|
|
760
|
+
rddStatusMemo.clear();
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// gentle-pi#668 (corrected): last-known outcome for ONE candidate, keyed by
|
|
764
|
+
// repository realpath AND targetIdentity -- never repository alone, or one
|
|
765
|
+
// candidate's outcome would leak into every other candidate's `assess` call.
|
|
766
|
+
// Only declined/unavailable are ever written (from ANSWER_CONSENT); `closed`
|
|
767
|
+
// is never written/derived -- pass it explicitly. A missing entry reads back
|
|
768
|
+
// `undefined`, treated as `unknown` (fail closed, exactly like `off`).
|
|
769
|
+
const nativeReviewOutcomeByCandidate = new Map<string, "declined" | "unavailable">();
|
|
770
|
+
|
|
771
|
+
function nativeReviewOutcomeMemoKey(cwd: string, targetIdentity: string): string {
|
|
772
|
+
try {
|
|
773
|
+
return `${realpathSync(cwd)}\u0000${targetIdentity}`;
|
|
774
|
+
} catch {
|
|
775
|
+
return `${cwd}\u0000${targetIdentity}`;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function recordNativeReviewOutcome(cwd: string, targetIdentity: string, outcome: "declined" | "unavailable"): void {
|
|
780
|
+
nativeReviewOutcomeByCandidate.set(nativeReviewOutcomeMemoKey(cwd, targetIdentity), outcome);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Returns the recorded outcome for exactly this candidate, or `undefined` when none is recorded. */
|
|
784
|
+
function readNativeReviewOutcome(cwd: string, targetIdentity: string): "declined" | "unavailable" | undefined {
|
|
785
|
+
return nativeReviewOutcomeByCandidate.get(nativeReviewOutcomeMemoKey(cwd, targetIdentity));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** @internal test seam: clears the per-candidate native review outcome memo. */
|
|
789
|
+
function clearNativeReviewOutcomeMemoForTesting(): void {
|
|
790
|
+
nativeReviewOutcomeByCandidate.clear();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Best-effort current-candidate target identity for `assess` (gentle-pi#668):
|
|
794
|
+
// reuses `targetStatus`, a native call this tool already makes elsewhere.
|
|
795
|
+
async function readCurrentTargetIdentityBestEffort(
|
|
796
|
+
nativeReviewCli: Pick<NativeReviewCli, "targetStatus"> | null | undefined,
|
|
797
|
+
cwd: string,
|
|
798
|
+
signal?: AbortSignal,
|
|
799
|
+
): Promise<string | undefined> {
|
|
800
|
+
if (nativeReviewCli?.targetStatus === undefined) return undefined;
|
|
801
|
+
try {
|
|
802
|
+
const status = await nativeReviewCli.targetStatus({ cwd, ...(signal === undefined ? {} : { signal }) });
|
|
803
|
+
return status.applicability === "current_target" ? status.targetIdentity : undefined;
|
|
804
|
+
} catch {
|
|
805
|
+
return undefined;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function rddAbortRejection(signal: AbortSignal): Promise<never> {
|
|
810
|
+
return new Promise((_resolve, reject) => {
|
|
811
|
+
if (signal.aborted) {
|
|
812
|
+
reject(signal.reason ?? new Error("aborted"));
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
signal.addEventListener("abort", () => reject(signal.reason ?? new Error("aborted")), { once: true });
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function readRddModeStatusOnce(
|
|
820
|
+
nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
|
|
821
|
+
cwd: string,
|
|
822
|
+
signal?: AbortSignal,
|
|
823
|
+
): Promise<NativeReviewModeStatus | undefined> {
|
|
824
|
+
if (!nativeReviewCli?.reviewMode) return undefined;
|
|
825
|
+
try {
|
|
826
|
+
const call = nativeReviewCli.reviewMode({ cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, signal });
|
|
827
|
+
const result = signal === undefined ? await call : await Promise.race([call, rddAbortRejection(signal)]);
|
|
828
|
+
return result.status;
|
|
829
|
+
} catch {
|
|
830
|
+
return undefined;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// gentle-pi#662: read-only combined native risk assessment plus the computed
|
|
835
|
+
// verification plan (`lib/review-risk-assessment.ts`), for the `gentle_review`
|
|
836
|
+
// tool's `assess` operation. Never throws: an unavailable/failed native
|
|
837
|
+
// assess call (older binary without the verb, timeout, malformed response)
|
|
838
|
+
// resolves to the `unassessable` tier, which `verificationPlan` treats the
|
|
839
|
+
// same as `high` -- the fail-closed rule from gentle-pi#662.
|
|
840
|
+
interface ReviewAssessmentPlanDetails {
|
|
841
|
+
schema: "gentle-pi.review-assessment-plan/v1";
|
|
842
|
+
risk: VerificationTier;
|
|
843
|
+
reasons: readonly { code: string; path: string; detail: string }[];
|
|
844
|
+
changedPaths: number;
|
|
845
|
+
changedLines: number;
|
|
846
|
+
candidate: { kind: string; baseRef: string | undefined } | null;
|
|
847
|
+
rddLine: RddLine;
|
|
848
|
+
nativeReviewOutcome: NativeReviewOutcome;
|
|
849
|
+
// gentle-pi#668: where nativeReviewOutcome came from -- explicit (caller
|
|
850
|
+
// passed it), derived (matched this exact candidate), or unknown.
|
|
851
|
+
outcome_source: "explicit" | "derived" | "unknown";
|
|
852
|
+
writerProfile: "small" | "large";
|
|
853
|
+
plan: {
|
|
854
|
+
writerSelfVerification: boolean;
|
|
855
|
+
structuralReadbackOnly: boolean;
|
|
856
|
+
independentVerifier: boolean;
|
|
857
|
+
reason: string;
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function resolveReviewAssessmentPlan(
|
|
862
|
+
nativeReviewCli: Pick<NativeReviewCli, "reviewMode" | "assess" | "targetStatus"> | null | undefined,
|
|
863
|
+
cwd: string,
|
|
864
|
+
input: ReviewAssessInput,
|
|
865
|
+
signal?: AbortSignal,
|
|
866
|
+
): Promise<ReviewAssessmentPlanDetails> {
|
|
867
|
+
if (input.baseRef !== undefined && input.committedOnly !== true) throw new Error("Review assess baseRef requires committedOnly: true");
|
|
868
|
+
if (input.baseRef === undefined && input.committedOnly !== undefined) throw new Error("Review assess committedOnly requires an explicit baseRef");
|
|
869
|
+
|
|
870
|
+
const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
|
|
871
|
+
const rddLine: RddLine = isValidRddModeStatus(status) ? status.effective : RDD_LINE.UNKNOWN;
|
|
872
|
+
const writerProfile = resolveWriterProfile({
|
|
873
|
+
...(input.writerModelId === undefined ? {} : { model: { id: input.writerModelId } }),
|
|
874
|
+
thinking: input.writerEffort,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
let assessment: ReviewAssessmentV1 | undefined;
|
|
878
|
+
let unassessableDetail: string | undefined;
|
|
879
|
+
if (nativeReviewCli?.assess === undefined) {
|
|
880
|
+
unassessableDetail = "native review assess is unavailable: the installed gentle-ai binary does not expose the assess command.";
|
|
881
|
+
} else {
|
|
882
|
+
try {
|
|
883
|
+
const request: NativeReviewAssessRequest = {
|
|
884
|
+
cwd,
|
|
885
|
+
...(input.baseRef === undefined ? {} : { baseRef: input.baseRef, committedOnly: true as const }),
|
|
886
|
+
...(signal === undefined ? {} : { signal }),
|
|
887
|
+
};
|
|
888
|
+
assessment = await nativeReviewCli.assess(request);
|
|
889
|
+
} catch (error) {
|
|
890
|
+
unassessableDetail = `native review assess failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const risk: VerificationTier = assessment?.risk ?? VERIFICATION_TIER.UNASSESSABLE;
|
|
895
|
+
// gentle-pi#668: explicit always wins; otherwise derive only for THIS
|
|
896
|
+
// candidate's own target identity, never repository-only. `closed` is
|
|
897
|
+
// never derived.
|
|
898
|
+
const targetIdentity = input.nativeReviewOutcome === undefined ? await readCurrentTargetIdentityBestEffort(nativeReviewCli, cwd, signal) : undefined;
|
|
899
|
+
const derived = targetIdentity === undefined ? undefined : readNativeReviewOutcome(cwd, targetIdentity);
|
|
900
|
+
const nativeReviewOutcome: NativeReviewOutcome = input.nativeReviewOutcome ?? derived ?? NATIVE_REVIEW_OUTCOME.UNKNOWN;
|
|
901
|
+
const outcomeSource: "explicit" | "derived" | "unknown" = input.nativeReviewOutcome !== undefined ? "explicit" : derived === undefined ? "unknown" : "derived";
|
|
902
|
+
const plan = verificationPlan({ rddLine, risk, writerProfile, nativeReviewOutcome });
|
|
903
|
+
return {
|
|
904
|
+
schema: "gentle-pi.review-assessment-plan/v1",
|
|
905
|
+
risk,
|
|
906
|
+
reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: "native-assess-unavailable", path: "", detail: unassessableDetail }]),
|
|
907
|
+
changedPaths: assessment?.changedPaths ?? 0,
|
|
908
|
+
changedLines: assessment?.changedLines ?? 0,
|
|
909
|
+
candidate: assessment === undefined ? null : { kind: assessment.candidate.kind, baseRef: assessment.candidate.baseRef },
|
|
910
|
+
rddLine,
|
|
911
|
+
nativeReviewOutcome,
|
|
912
|
+
outcome_source: outcomeSource,
|
|
913
|
+
writerProfile,
|
|
914
|
+
plan,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
let rddStatusUnavailableWarned = false;
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Best-effort native RDD mode status read for prompt rendering, memoized per
|
|
922
|
+
* cwd for `RDD_STATUS_MEMO_TTL_MS`. Reuses the `reviewMode` STATUS reader
|
|
923
|
+
* (`gentle-ai review mode status --json`, decoded to
|
|
924
|
+
* `NativeReviewModeStatus`) that the `/gentle:review-mode` command also
|
|
925
|
+
* calls. Never throws and never hangs past `signal`'s deadline when one is
|
|
926
|
+
* given: an absent binary, a timed-out/aborted process, or a native CLI
|
|
927
|
+
* failure all resolve to `undefined`. `ctx` is optional and used only for a
|
|
928
|
+
* one-shot (per process) UI notice when the read is swallowed, so a
|
|
929
|
+
* sustained native outage is observable beyond the rendered "unknown" line.
|
|
930
|
+
*/
|
|
931
|
+
async function resolveRddModeStatus(
|
|
932
|
+
nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
|
|
933
|
+
cwd: string,
|
|
934
|
+
signal?: AbortSignal,
|
|
935
|
+
now: () => number = Date.now,
|
|
936
|
+
ctx?: Pick<ExtensionContext, "hasUI" | "ui">,
|
|
937
|
+
): Promise<NativeReviewModeStatus | undefined> {
|
|
938
|
+
const nowMs = now();
|
|
939
|
+
const cached = rddStatusMemo.get(cwd);
|
|
940
|
+
if (cached !== undefined && cached.expiresAt > nowMs) return cached.status;
|
|
941
|
+
const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
|
|
942
|
+
rddStatusMemo.set(cwd, { status, expiresAt: nowMs + RDD_STATUS_MEMO_TTL_MS });
|
|
943
|
+
if (status === undefined && !rddStatusUnavailableWarned) {
|
|
944
|
+
rddStatusUnavailableWarned = true;
|
|
945
|
+
if (ctx?.hasUI) {
|
|
946
|
+
ctx.ui.notify(
|
|
947
|
+
"Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt renders \"unknown\" until this recovers; this notice will not repeat this session.",
|
|
948
|
+
"warning",
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return status;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Resolves and renders the RDD status line for a production call site in one call. */
|
|
956
|
+
async function resolveRddStatusLine(
|
|
957
|
+
nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
|
|
958
|
+
cwd: string,
|
|
959
|
+
signal?: AbortSignal,
|
|
960
|
+
now: () => number = Date.now,
|
|
961
|
+
ctx?: Pick<ExtensionContext, "hasUI" | "ui">,
|
|
962
|
+
): Promise<string> {
|
|
963
|
+
return renderRddStatusLine(await resolveRddModeStatus(nativeReviewCli, cwd, signal, now, ctx));
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Rendered prompts are memoized per background policy/capability/RDD-status
|
|
967
|
+
// key for the process lifetime; the assets bytes themselves are read once
|
|
968
|
+
// per key. `rddStatusLine` defaults to the "unknown" fallback line (the
|
|
969
|
+
// longest of the three renderable forms), not "", so the default no-argument
|
|
970
|
+
// render IS the worst case the canonical 8 KiB budget in
|
|
971
|
+
// tests/orchestrator-budget.test.ts measures; assets/orchestrator.md is
|
|
972
|
+
// sized with that worst case already included. Production still resolves
|
|
973
|
+
// and passes the real line (on/off/unknown) via resolveRddStatusLine.
|
|
678
974
|
const orchestratorPromptCache = new Map<string, string>();
|
|
679
975
|
function getOrchestratorPrompt(
|
|
680
976
|
cwd: string = process.cwd(),
|
|
681
977
|
activeTools?: readonly string[],
|
|
978
|
+
rddStatusLine: string = renderRddStatusLine(undefined),
|
|
682
979
|
): string {
|
|
683
980
|
const background: BackgroundSubagentsRendering = {
|
|
684
981
|
policy: loadBackgroundSubagentsPolicy(cwd),
|
|
685
982
|
capability: resolveBackgroundSubagentsCapability(cwd, activeTools),
|
|
686
983
|
};
|
|
687
|
-
const cacheKey = `${background.policy}:${background.capability}`;
|
|
984
|
+
const cacheKey = `${background.policy}:${background.capability}:${rddStatusLine}`;
|
|
688
985
|
let prompt = orchestratorPromptCache.get(cacheKey);
|
|
689
986
|
if (prompt === undefined) {
|
|
690
|
-
prompt = renderOrchestratorPrompt(ASSETS_DIR, background);
|
|
987
|
+
prompt = renderOrchestratorPrompt(ASSETS_DIR, background, rddStatusLine);
|
|
691
988
|
orchestratorPromptCache.set(cacheKey, prompt);
|
|
692
989
|
}
|
|
693
990
|
return prompt;
|
|
@@ -696,16 +993,76 @@ function getOrchestratorPrompt(
|
|
|
696
993
|
function renderOrchestratorPrompt(
|
|
697
994
|
assetsDir: string,
|
|
698
995
|
background: BackgroundSubagentsRendering = DEFAULT_BACKGROUND_SUBAGENTS_RENDERING,
|
|
996
|
+
rddStatusLine: string = renderRddStatusLine(undefined),
|
|
699
997
|
): string {
|
|
998
|
+
const backgroundPolicyBlock = `${renderBackgroundSubagentsStatusLine(background)}\n${rddStatusLine}`;
|
|
700
999
|
return readFileSync(join(assetsDir, "orchestrator.md"), "utf8")
|
|
701
1000
|
.replaceAll("{{GENTLE_PI_ASSETS_ROOT}}", assetsDir)
|
|
702
1001
|
.replaceAll(
|
|
703
1002
|
"{{GENTLE_PI_BACKGROUND_POLICY}}",
|
|
704
|
-
|
|
1003
|
+
backgroundPolicyBlock,
|
|
705
1004
|
)
|
|
706
1005
|
.trim();
|
|
707
1006
|
}
|
|
708
1007
|
|
|
1008
|
+
// gentle-pi#560 / gentle-ai#4056, #4057: Gentle AI stopped writing a
|
|
1009
|
+
// runtime-specific review execution contract into Pi's generated
|
|
1010
|
+
// APPEND_SYSTEM composition on 2026-08-01. This package now injects the
|
|
1011
|
+
// mirrored provider contract bundle's own `orchestration/pi.md` text
|
|
1012
|
+
// instead, read once from the package-local mirror
|
|
1013
|
+
// (contracts/review-provider-contract-mirror/) and cached as the fully
|
|
1014
|
+
// rendered fragment for the process lifetime. It is deliberately NOT folded
|
|
1015
|
+
// into getOrchestratorPrompt/orchestratorPromptCache: that core prompt is
|
|
1016
|
+
// pinned at an 8192-byte budget (tests/orchestrator-budget.test.ts).
|
|
1017
|
+
const PROVIDER_CONTRACT_MIRROR_ROOT = join(PACKAGE_ROOT, "contracts", "review-provider-contract-mirror");
|
|
1018
|
+
const PROVIDER_CONTRACT_LOCK_FILE = "provider-contract.lock.json";
|
|
1019
|
+
const PI_ORCHESTRATION_RUNTIME = "pi";
|
|
1020
|
+
|
|
1021
|
+
let reviewContractPromptFragmentCache: string | null | undefined;
|
|
1022
|
+
let reviewContractPromptMissingWarned = false;
|
|
1023
|
+
|
|
1024
|
+
// Verifies the mirrored orchestration/pi.md bytes against the lock's digest before injection (gentle-ai R1/R3).
|
|
1025
|
+
function readMirroredReviewContractFragment(mirrorRoot: string = PROVIDER_CONTRACT_MIRROR_ROOT): string | null {
|
|
1026
|
+
try {
|
|
1027
|
+
const lockPath = join(mirrorRoot, PROVIDER_CONTRACT_LOCK_FILE);
|
|
1028
|
+
const lock = JSON.parse(readFileSync(lockPath, "utf8")) as {
|
|
1029
|
+
contract_semver?: unknown;
|
|
1030
|
+
entries?: Record<string, unknown>;
|
|
1031
|
+
};
|
|
1032
|
+
if (typeof lock.contract_semver !== "string" || lock.contract_semver === "") return null;
|
|
1033
|
+
const expectedSha256 = lock.entries?.[`orchestration/${PI_ORCHESTRATION_RUNTIME}.md`];
|
|
1034
|
+
if (typeof expectedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(expectedSha256)) return null;
|
|
1035
|
+
const contractPath = join(mirrorRoot, `v${lock.contract_semver}`, "bundle", "orchestration", `${PI_ORCHESTRATION_RUNTIME}.md`);
|
|
1036
|
+
const rawBytes = readFileSync(contractPath);
|
|
1037
|
+
const actualSha256 = createHash("sha256").update(rawBytes).digest("hex");
|
|
1038
|
+
if (!timingSafeEqual(Buffer.from(expectedSha256, "hex"), Buffer.from(actualSha256, "hex"))) return null;
|
|
1039
|
+
const text = rawBytes.toString("utf8").trim();
|
|
1040
|
+
if (text.length === 0) return null;
|
|
1041
|
+
return `## Gentle AI review execution contract (mirrored provider bundle ${lock.contract_semver})\n\n${text}`;
|
|
1042
|
+
} catch {
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function loadReviewContractPromptFragment(
|
|
1048
|
+
ctx: Pick<ExtensionContext, "hasUI" | "ui">,
|
|
1049
|
+
mirrorRoot: string = PROVIDER_CONTRACT_MIRROR_ROOT,
|
|
1050
|
+
): string | null {
|
|
1051
|
+
if (reviewContractPromptFragmentCache === undefined) {
|
|
1052
|
+
reviewContractPromptFragmentCache = readMirroredReviewContractFragment(mirrorRoot);
|
|
1053
|
+
}
|
|
1054
|
+
if (reviewContractPromptFragmentCache === null && !reviewContractPromptMissingWarned) {
|
|
1055
|
+
reviewContractPromptMissingWarned = true;
|
|
1056
|
+
if (ctx.hasUI) {
|
|
1057
|
+
ctx.ui.notify(
|
|
1058
|
+
"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.",
|
|
1059
|
+
"warning",
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return reviewContractPromptFragmentCache;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
709
1066
|
async function pathExists(path: string): Promise<boolean> {
|
|
710
1067
|
try {
|
|
711
1068
|
await access(path);
|
|
@@ -742,6 +1099,7 @@ function buildGentlePrompt(
|
|
|
742
1099
|
persona: PersonaMode,
|
|
743
1100
|
cwd: string = process.cwd(),
|
|
744
1101
|
activeTools?: readonly string[],
|
|
1102
|
+
rddStatusLine: string = renderRddStatusLine(undefined),
|
|
745
1103
|
): string {
|
|
746
1104
|
const personaPrompt =
|
|
747
1105
|
persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT;
|
|
@@ -776,7 +1134,7 @@ Harness principles:
|
|
|
776
1134
|
- Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
|
|
777
1135
|
- Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
|
|
778
1136
|
|
|
779
|
-
${getOrchestratorPrompt(cwd, activeTools)}`;
|
|
1137
|
+
${getOrchestratorPrompt(cwd, activeTools, rddStatusLine)}`;
|
|
780
1138
|
}
|
|
781
1139
|
|
|
782
1140
|
// Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
|
|
@@ -2589,6 +2947,9 @@ const REVIEW_CONTROLLER_OPERATION = {
|
|
|
2589
2947
|
RECONCILE_AUTHORITY: "reconcile-authority",
|
|
2590
2948
|
REPAIR_LEGACY_ALIAS: "repair-legacy-alias",
|
|
2591
2949
|
REPAIR: "repair",
|
|
2950
|
+
// gentle-pi#662: read-only native risk assessment (gentle-ai#4295). Never
|
|
2951
|
+
// mutates review authority state and never requires a lineageId.
|
|
2952
|
+
ASSESS: "assess",
|
|
2592
2953
|
} as const;
|
|
2593
2954
|
|
|
2594
2955
|
type ReviewControllerOperation =
|
|
@@ -2635,7 +2996,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
|
|
|
2635
2996
|
},
|
|
2636
2997
|
input: {
|
|
2637
2998
|
type: "string",
|
|
2638
|
-
description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. Legacy controller input remains separate.",
|
|
2999
|
+
description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. ASSESS accepts an optional object with baseRef, committedOnly, writerModelId, writerEffort, and nativeReviewOutcome (gentle-pi#662/#668); omitting writerModelId and writerEffort assesses the ambient working tree and fails closed to a small writer profile (never large) because the writer's actual profile is unknown to this call. nativeReviewOutcome (one of closed, declined, unavailable, unknown) tells ASSESS whether the native review actually closed for this candidate: when Receipt-driven development reads on but the review was declined for this candidate, is unavailable, or its outcome is unknown, ASSESS falls back to the exact risk-gated plan it returns when RDD is off, re-enabling the separate verifier -- a decline is candidate-scoped and never lowers the bar below the RDD-off path. Omitting it lets ASSESS try to derive declined/unavailable from what this process itself recorded for this exact candidate (never a different one, and never from repository state alone), failing closed to unknown when it cannot; `closed` is never derived -- pass it explicitly, and only right after acknowledging the approved review for this same candidate. The returned outcome_source (explicit|derived|unknown) says which of these produced the value. Legacy controller input remains separate.",
|
|
2639
3000
|
},
|
|
2640
3001
|
outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
|
|
2641
3002
|
inputPath: { type: "string", description: "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired." },
|
|
@@ -2687,6 +3048,25 @@ interface ReviewCaptureParameters {
|
|
|
2687
3048
|
workspaceRoot?: string;
|
|
2688
3049
|
}
|
|
2689
3050
|
|
|
3051
|
+
const REVIEW_CAPTURE_GROUP_PARAMETERS = {
|
|
3052
|
+
type: "object",
|
|
3053
|
+
additionalProperties: false,
|
|
3054
|
+
required: ["lineageId", "collectBindings"],
|
|
3055
|
+
properties: {
|
|
3056
|
+
lineageId: { type: "string", minLength: 1, description: "Exact lineage from the current provider-issued collect transition." },
|
|
3057
|
+
collectBindings: { type: "array", minItems: 1, items: { type: "string", minLength: 1 }, description: "Ordered JSON-serialized exact copies of the complete current materialize reviewer collect set." },
|
|
3058
|
+
reviewerRunAcknowledged: { type: "boolean", description: "Required after the one group forecast; authorizes exactly the forecast reviewer runs." },
|
|
3059
|
+
workspaceRoot: { type: "string", description: "Optional explicit existing Git worktree root, resolved with the controller's worktree confinement semantics." },
|
|
3060
|
+
},
|
|
3061
|
+
} as const;
|
|
3062
|
+
|
|
3063
|
+
interface ReviewCaptureGroupParameters {
|
|
3064
|
+
lineageId: string;
|
|
3065
|
+
collectBindings: readonly string[];
|
|
3066
|
+
reviewerRunAcknowledged?: boolean;
|
|
3067
|
+
workspaceRoot?: string;
|
|
3068
|
+
}
|
|
3069
|
+
|
|
2690
3070
|
const REVIEW_SCOPE_PARAMETERS = {
|
|
2691
3071
|
type: "object",
|
|
2692
3072
|
additionalProperties: false,
|
|
@@ -2704,6 +3084,51 @@ interface ReviewScopeParameters {
|
|
|
2704
3084
|
cursor?: number;
|
|
2705
3085
|
}
|
|
2706
3086
|
|
|
3087
|
+
// gentle-pi#662: read-only native risk assessment, gating the separate
|
|
3088
|
+
// verifier on native risk instead of a task-description judgment when the
|
|
3089
|
+
// rendered `Receipt-driven development:` line is `off` or `unknown`. Exposed
|
|
3090
|
+
// as `gentle_review` operation `assess` (not a dedicated tool), taking its
|
|
3091
|
+
// optional fields through the controller's existing generic `input` JSON
|
|
3092
|
+
// string, exactly like START's `{"mode":...,"baseRef":...}`.
|
|
3093
|
+
interface ReviewAssessInput {
|
|
3094
|
+
baseRef?: string;
|
|
3095
|
+
committedOnly?: boolean;
|
|
3096
|
+
writerModelId?: string;
|
|
3097
|
+
writerEffort?: string;
|
|
3098
|
+
// gentle-pi#668: the caller's own known outcome for this candidate.
|
|
3099
|
+
// Omitted tries to auto-derive declined/unavailable for this EXACT
|
|
3100
|
+
// candidate's own target identity (never a different one); `closed` is
|
|
3101
|
+
// never auto-derived -- pass it explicitly.
|
|
3102
|
+
nativeReviewOutcome?: NativeReviewOutcome;
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome {
|
|
3106
|
+
return typeof value === "string" && (Object.values(NATIVE_REVIEW_OUTCOME) as readonly string[]).includes(value);
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
function parseReviewAssessInput(operation: ReviewControllerOperation, raw: string | undefined): ReviewAssessInput {
|
|
3110
|
+
if (raw === undefined) return {};
|
|
3111
|
+
const value = parseControllerJson(raw, operation);
|
|
3112
|
+
const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome"]);
|
|
3113
|
+
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
|
|
3114
|
+
if (unexpected !== undefined) throw new Error(`Review controller ${operation} input does not accept ${unexpected}`);
|
|
3115
|
+
const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome } = value;
|
|
3116
|
+
if (baseRef !== undefined && typeof baseRef !== "string") throw new Error(`Review controller ${operation} input baseRef must be a string`);
|
|
3117
|
+
if (committedOnly !== undefined && typeof committedOnly !== "boolean") throw new Error(`Review controller ${operation} input committedOnly must be a boolean`);
|
|
3118
|
+
if (writerModelId !== undefined && typeof writerModelId !== "string") throw new Error(`Review controller ${operation} input writerModelId must be a string`);
|
|
3119
|
+
if (writerEffort !== undefined && typeof writerEffort !== "string") throw new Error(`Review controller ${operation} input writerEffort must be a string`);
|
|
3120
|
+
if (nativeReviewOutcome !== undefined && !isNativeReviewOutcome(nativeReviewOutcome)) {
|
|
3121
|
+
throw new Error(`Review controller ${operation} input nativeReviewOutcome must be one of ${Object.values(NATIVE_REVIEW_OUTCOME).join(", ")}`);
|
|
3122
|
+
}
|
|
3123
|
+
return {
|
|
3124
|
+
...(baseRef === undefined ? {} : { baseRef: baseRef as string }),
|
|
3125
|
+
...(committedOnly === undefined ? {} : { committedOnly: committedOnly as boolean }),
|
|
3126
|
+
...(writerModelId === undefined ? {} : { writerModelId: writerModelId as string }),
|
|
3127
|
+
...(writerEffort === undefined ? {} : { writerEffort: writerEffort as string }),
|
|
3128
|
+
...(nativeReviewOutcome === undefined ? {} : { nativeReviewOutcome: nativeReviewOutcome as NativeReviewOutcome }),
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
|
|
2707
3132
|
interface ReviewControllerParameters {
|
|
2708
3133
|
operation: ReviewControllerOperation;
|
|
2709
3134
|
lineageId?: string;
|
|
@@ -2748,7 +3173,7 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
|
|
|
2748
3173
|
if (unexpected !== undefined || typeof value.selectionBinding !== "string" || !Array.isArray(value.intendedUntracked) || (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string")) throw new Error("Review intended-untracked selection accepts exactly selectionBinding and intendedUntracked, with optional workspaceRoot");
|
|
2749
3174
|
return { operation: value.operation, selectionBinding: value.selectionBinding, intendedUntracked: value.intendedUntracked, ...(typeof value.workspaceRoot === "string" ? { workspaceRoot: value.workspaceRoot } : {}) };
|
|
2750
3175
|
}
|
|
2751
|
-
const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, REVIEW_CONTROLLER_OPERATION.STATUS, REVIEW_CONTROLLER_OPERATION.EXPORT, REVIEW_CONTROLLER_OPERATION.IMPORT, REVIEW_CONTROLLER_OPERATION.INSPECT, REVIEW_CONTROLLER_OPERATION.RESET, REVIEW_CONTROLLER_OPERATION.RECOVER, REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, REVIEW_CONTROLLER_OPERATION.ABANDON, REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY, REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY, REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS, REVIEW_CONTROLLER_OPERATION.REPAIR].includes(value.operation as ReviewControllerOperation);
|
|
3176
|
+
const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, REVIEW_CONTROLLER_OPERATION.STATUS, REVIEW_CONTROLLER_OPERATION.EXPORT, REVIEW_CONTROLLER_OPERATION.IMPORT, REVIEW_CONTROLLER_OPERATION.INSPECT, REVIEW_CONTROLLER_OPERATION.RESET, REVIEW_CONTROLLER_OPERATION.RECOVER, REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, REVIEW_CONTROLLER_OPERATION.ABANDON, REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY, REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY, REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS, REVIEW_CONTROLLER_OPERATION.REPAIR, REVIEW_CONTROLLER_OPERATION.ASSESS].includes(value.operation as ReviewControllerOperation);
|
|
2752
3177
|
if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
|
|
2753
3178
|
throw new Error("Review controller requires a lineageId");
|
|
2754
3179
|
}
|
|
@@ -2788,6 +3213,23 @@ function parseReviewCaptureParameters(value: unknown): ReviewCaptureParameters {
|
|
|
2788
3213
|
};
|
|
2789
3214
|
}
|
|
2790
3215
|
|
|
3216
|
+
function parseReviewCaptureGroupParameters(value: unknown): ReviewCaptureGroupParameters {
|
|
3217
|
+
if (!isRecord(value)) throw new Error("Review capture group parameters must be an object");
|
|
3218
|
+
const allowed = new Set(["lineageId", "collectBindings", "reviewerRunAcknowledged", "workspaceRoot"]);
|
|
3219
|
+
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
|
|
3220
|
+
if (unexpected !== undefined) throw new Error(`Review capture group does not accept ${unexpected}`);
|
|
3221
|
+
if (!isCanonicalProcessString(value.lineageId)) throw new Error("Review capture group requires an exact non-empty lineageId");
|
|
3222
|
+
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");
|
|
3223
|
+
if (value.reviewerRunAcknowledged !== undefined && typeof value.reviewerRunAcknowledged !== "boolean") throw new Error("Review capture group reviewerRunAcknowledged must be boolean");
|
|
3224
|
+
if (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string") throw new Error("Review capture group workspaceRoot must be a string");
|
|
3225
|
+
return {
|
|
3226
|
+
lineageId: value.lineageId,
|
|
3227
|
+
collectBindings: [...value.collectBindings],
|
|
3228
|
+
...(value.reviewerRunAcknowledged === undefined ? {} : { reviewerRunAcknowledged: value.reviewerRunAcknowledged }),
|
|
3229
|
+
...(value.workspaceRoot === undefined ? {} : { workspaceRoot: value.workspaceRoot }),
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
|
|
2791
3233
|
function requiredControllerString(
|
|
2792
3234
|
parameters: ReviewControllerParameters,
|
|
2793
3235
|
key: "idempotencyKey" | "transition" | "command" | "input" | "outputPath" | "inputPath" | "operationId",
|
|
@@ -3015,6 +3457,15 @@ async function resolveReviewModeGate(
|
|
|
3015
3457
|
}
|
|
3016
3458
|
}
|
|
3017
3459
|
|
|
3460
|
+
// gentle-pi#185: a native CLI without negotiated STATUS support (no
|
|
3461
|
+
// `targetStatus`, or a version-incompatible provider) hits this boundary
|
|
3462
|
+
// before any candidate-view restoration is attempted, so it can never
|
|
3463
|
+
// reproduce the #176 empty-registry failure — but the boundary's own
|
|
3464
|
+
// `next_action` was a machine token with nothing a human or an agent could
|
|
3465
|
+
// run. `remediation_command` names the exact upstream command that
|
|
3466
|
+
// re-establishes negotiated STATUS for this session's Pi host identity.
|
|
3467
|
+
const NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND = "gentle-ai review status --cwd <repo> --contract gentle-ai.review-integration/v2 --agent pi --next-transition";
|
|
3468
|
+
|
|
3018
3469
|
function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<string, unknown> {
|
|
3019
3470
|
return {
|
|
3020
3471
|
operation,
|
|
@@ -3023,6 +3474,7 @@ function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<s
|
|
|
3023
3474
|
...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { mutation_performed: false }),
|
|
3024
3475
|
inventory_complete: false,
|
|
3025
3476
|
next_action: "require-upstream-read-only-native-status-inventory",
|
|
3477
|
+
remediation_command: NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND,
|
|
3026
3478
|
evidence: {
|
|
3027
3479
|
native_contract: "gentle-ai/2.1.4",
|
|
3028
3480
|
general_status: "unsupported",
|
|
@@ -3073,6 +3525,23 @@ function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown
|
|
|
3073
3525
|
next_action: "require-complete-native-authority-inventory",
|
|
3074
3526
|
};
|
|
3075
3527
|
}
|
|
3528
|
+
// gentle-pi#599: a negotiated STATUS/inspect request the native provider
|
|
3529
|
+
// rejects with a decoded failure/v2 envelope (for example a preflight
|
|
3530
|
+
// `invalid_request` refusal for a nested foreign Git repository) used to
|
|
3531
|
+
// fall through to the generic outcome below, discarding the envelope's own
|
|
3532
|
+
// cause, code, retry_safe, and next_action -- the one piece of information
|
|
3533
|
+
// that makes the refusal actionable (pass the intended nested repo as
|
|
3534
|
+
// workspaceRoot). `nativeOperationFailure` already renders this exact
|
|
3535
|
+
// failure-envelope shape faithfully for every mutating operation; reuse it
|
|
3536
|
+
// here instead of masking the refusal as opaque authority-inventory
|
|
3537
|
+
// corruption.
|
|
3538
|
+
if (error instanceof NativeReviewIntegrationError) {
|
|
3539
|
+
return {
|
|
3540
|
+
...nativeOperationFailure(operation, error),
|
|
3541
|
+
outcome: "native-status-unavailable",
|
|
3542
|
+
inventory_complete: false,
|
|
3543
|
+
};
|
|
3544
|
+
}
|
|
3076
3545
|
return {
|
|
3077
3546
|
operation,
|
|
3078
3547
|
status: "blocked",
|
|
@@ -3111,7 +3580,6 @@ function missingNativeMaintenanceInputs(operation: NativeMaintenanceOperation, i
|
|
|
3111
3580
|
...missing,
|
|
3112
3581
|
...(Array.isArray(input.capturedLensResults) && input.capturedLensResults.every((entry) => isCanonicalProcessString(entry)) ? [] : ["capturedLensResults"]),
|
|
3113
3582
|
...(typeof input.findingsPresent === "boolean" ? [] : ["findingsPresent"]),
|
|
3114
|
-
...(typeof input.evidenceRecordsPresent === "boolean" ? [] : ["evidenceRecordsPresent"]),
|
|
3115
3583
|
];
|
|
3116
3584
|
}
|
|
3117
3585
|
|
|
@@ -3121,7 +3589,7 @@ function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, in
|
|
|
3121
3589
|
}
|
|
3122
3590
|
|
|
3123
3591
|
function nativeMaintenanceAuthorization(operation: NativeMaintenanceOperation, input: Record<string, unknown>): string {
|
|
3124
|
-
if (operation === "abandon") return nativeReviewAbandonAuthorization({ lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true,
|
|
3592
|
+
if (operation === "abandon") return nativeReviewAbandonAuthorization({ lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, actor: String(input.actor), reason: String(input.reason) });
|
|
3125
3593
|
if (operation === "quarantineLegacy") return nativeReviewLegacyQuarantineAuthorization({ repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason) });
|
|
3126
3594
|
return nativeReviewReconcileAuthorization({ predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }) });
|
|
3127
3595
|
}
|
|
@@ -3148,7 +3616,7 @@ async function executeNativeAuthorityMaintenance(
|
|
|
3148
3616
|
}
|
|
3149
3617
|
try {
|
|
3150
3618
|
const result = nativeOperation === "abandon"
|
|
3151
|
-
? await nativeReviewCli.abandon!({ cwd, lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true,
|
|
3619
|
+
? await nativeReviewCli.abandon!({ cwd, lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
|
|
3152
3620
|
: nativeOperation === "quarantineLegacy"
|
|
3153
3621
|
? await nativeReviewCli.quarantineLegacy!({ cwd, repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
|
|
3154
3622
|
: await nativeReviewCli.reconcileAuthority!({ cwd, predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) });
|
|
@@ -3362,6 +3830,19 @@ function mapNativeTargetStatus(operation: ReviewControllerOperation, status: Rev
|
|
|
3362
3830
|
required_status_action: "Use only the provider-selected recovery disposition; do not substitute scope_changed, invalidated, or escalated.",
|
|
3363
3831
|
};
|
|
3364
3832
|
}
|
|
3833
|
+
// gentle-pi#627: a stale managed-asset set stops the transition with the
|
|
3834
|
+
// exact `gentle-ai sync` invocation that resolves it. Render that command
|
|
3835
|
+
// as the one actionable next step; every other reason code keeps rendering
|
|
3836
|
+
// as a plain blocked result.
|
|
3837
|
+
if (status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "managed_assets_outdated" && status.nextTransition.continuation !== undefined) {
|
|
3838
|
+
return {
|
|
3839
|
+
operation,
|
|
3840
|
+
status: "blocked",
|
|
3841
|
+
result: status.raw,
|
|
3842
|
+
...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }),
|
|
3843
|
+
hint: `run ${status.nextTransition.continuation.command}`,
|
|
3844
|
+
};
|
|
3845
|
+
}
|
|
3365
3846
|
return {
|
|
3366
3847
|
operation,
|
|
3367
3848
|
status: status.action === "start" ? "ready" : "blocked",
|
|
@@ -3530,6 +4011,8 @@ function nativeStatusInputRejection(reason: string, field?: string): Record<stri
|
|
|
3530
4011
|
}
|
|
3531
4012
|
|
|
3532
4013
|
const PENDING_REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
|
|
4014
|
+
const REVIEW_SESSION_PERMISSION_STATUS_KEY = "gentle-review-session-permission";
|
|
4015
|
+
const REVIEW_SESSION_PERMISSION_STATUS_TEXT = "reviews allowed for this session";
|
|
3533
4016
|
|
|
3534
4017
|
type PendingReviewConsentSessionKey = string | symbol;
|
|
3535
4018
|
|
|
@@ -3564,7 +4047,13 @@ const PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT = 32;
|
|
|
3564
4047
|
*/
|
|
3565
4048
|
export class PendingReviewConsentRegistry {
|
|
3566
4049
|
private readonly sessions = new Map<PendingReviewConsentSessionKey, Map<string, PendingReviewConsent>>();
|
|
3567
|
-
|
|
4050
|
+
// gentle-pi#455: a binding id is globally unique (randomUUID), so its live
|
|
4051
|
+
// owner and its stale disposition are tracked by binding id alone. This
|
|
4052
|
+
// index lets answer-consent resolve and atomically take exactly once a
|
|
4053
|
+
// binding another active Pi session's START created; the per-session map
|
|
4054
|
+
// above stays authoritative for session-scoped listings and shutdown cleanup.
|
|
4055
|
+
private readonly byBinding = new Map<string, PendingReviewConsentSessionKey>();
|
|
4056
|
+
private readonly staleDispositions = new Map<string, PendingReviewConsentDisposition>();
|
|
3568
4057
|
|
|
3569
4058
|
get(sessionKey: PendingReviewConsentSessionKey): Map<string, PendingReviewConsent> | undefined {
|
|
3570
4059
|
return this.sessions.get(sessionKey);
|
|
@@ -3579,34 +4068,46 @@ export class PendingReviewConsentRegistry {
|
|
|
3579
4068
|
return pending;
|
|
3580
4069
|
}
|
|
3581
4070
|
|
|
4071
|
+
// Registers a freshly created pending binding under its owning session and
|
|
4072
|
+
// the cross-session binding index in one step so the two never drift.
|
|
4073
|
+
add(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): void {
|
|
4074
|
+
this.ensure(sessionKey).set(pending.id, pending);
|
|
4075
|
+
this.byBinding.set(pending.id, sessionKey);
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
// Resolves a live binding to its owning session regardless of which
|
|
4079
|
+
// session asks, so answer-consent can reach a binding another session's
|
|
4080
|
+
// START created (gentle-pi#455).
|
|
4081
|
+
resolve(bindingId: string): { sessionKey: PendingReviewConsentSessionKey; pending: PendingReviewConsent } | undefined {
|
|
4082
|
+
const sessionKey = this.byBinding.get(bindingId);
|
|
4083
|
+
const pending = sessionKey === undefined ? undefined : this.sessions.get(sessionKey)?.get(bindingId);
|
|
4084
|
+
return pending === undefined ? undefined : { sessionKey, pending };
|
|
4085
|
+
}
|
|
4086
|
+
|
|
3582
4087
|
private remove(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
|
|
3583
4088
|
const session = this.sessions.get(sessionKey);
|
|
3584
4089
|
if (session?.get(pending.id) !== pending) return false;
|
|
3585
4090
|
session.delete(pending.id);
|
|
3586
4091
|
if (session.size === 0) this.sessions.delete(sessionKey);
|
|
4092
|
+
if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id);
|
|
3587
4093
|
return true;
|
|
3588
4094
|
}
|
|
3589
4095
|
|
|
3590
|
-
private rememberDisposition(
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
this.staleDispositions.set(sessionKey, stale);
|
|
3595
|
-
}
|
|
3596
|
-
stale.delete(pending.id);
|
|
3597
|
-
stale.set(pending.id, disposition);
|
|
3598
|
-
while (stale.size > PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT) stale.delete(stale.keys().next().value!);
|
|
4096
|
+
private rememberDisposition(pending: PendingReviewConsent, disposition: PendingReviewConsentDisposition): void {
|
|
4097
|
+
this.staleDispositions.delete(pending.id);
|
|
4098
|
+
this.staleDispositions.set(pending.id, disposition);
|
|
4099
|
+
while (this.staleDispositions.size > PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT) this.staleDispositions.delete(this.staleDispositions.keys().next().value!);
|
|
3599
4100
|
}
|
|
3600
4101
|
|
|
3601
4102
|
consume(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
|
|
3602
4103
|
if (!this.remove(sessionKey, pending)) return false;
|
|
3603
|
-
this.rememberDisposition(
|
|
4104
|
+
this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED);
|
|
3604
4105
|
return true;
|
|
3605
4106
|
}
|
|
3606
4107
|
|
|
3607
4108
|
expire(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
|
|
3608
4109
|
if (!this.remove(sessionKey, pending)) return false;
|
|
3609
|
-
this.rememberDisposition(
|
|
4110
|
+
this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED);
|
|
3610
4111
|
return true;
|
|
3611
4112
|
}
|
|
3612
4113
|
|
|
@@ -3614,14 +4115,14 @@ export class PendingReviewConsentRegistry {
|
|
|
3614
4115
|
this.remove(sessionKey, pending);
|
|
3615
4116
|
}
|
|
3616
4117
|
|
|
3617
|
-
staleDisposition(
|
|
3618
|
-
return this.staleDispositions.get(
|
|
4118
|
+
staleDisposition(binding: string): PendingReviewConsentDisposition | undefined {
|
|
4119
|
+
return this.staleDispositions.get(binding);
|
|
3619
4120
|
}
|
|
3620
4121
|
|
|
3621
4122
|
take(sessionKey: PendingReviewConsentSessionKey): PendingReviewConsent[] {
|
|
3622
4123
|
const session = this.sessions.get(sessionKey);
|
|
3623
4124
|
this.sessions.delete(sessionKey);
|
|
3624
|
-
this.
|
|
4125
|
+
if (session !== undefined) for (const pending of session.values()) if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id);
|
|
3625
4126
|
return session === undefined ? [] : [...session.values()];
|
|
3626
4127
|
}
|
|
3627
4128
|
}
|
|
@@ -3629,6 +4130,37 @@ export class PendingReviewConsentRegistry {
|
|
|
3629
4130
|
const processPendingReviewConsentRegistry = new PendingReviewConsentRegistry();
|
|
3630
4131
|
const processRetainedNativeStatusSelections = new Map<PendingReviewConsentSessionKey, Map<string, RetainedNativeStatusSelection>>();
|
|
3631
4132
|
|
|
4133
|
+
// gentle-pi#556 / gentle-ai#4051: nesting depth of named-agent (SDD phase
|
|
4134
|
+
// executor or other subagent) starts vs. ends for a session. Starts and
|
|
4135
|
+
// ends are paired so a subagent's own loop end never leaves the primary
|
|
4136
|
+
// loop's `agent_end` preflight suppressed for the rest of the session: a
|
|
4137
|
+
// named-agent start increments the depth, a matching end decrements it,
|
|
4138
|
+
// and a fresh primary-loop start resets it to 0.
|
|
4139
|
+
const processAgentEndSubagentDepth = new Map<PendingReviewConsentSessionKey, number>();
|
|
4140
|
+
|
|
4141
|
+
// Target identities already nudged once per session, so the read-only
|
|
4142
|
+
// `agent_end` preflight reminder fires at most once per unreviewed candidate.
|
|
4143
|
+
const processAgentEndPreflightNudgedTargets = new Map<PendingReviewConsentSessionKey, Set<string>>();
|
|
4144
|
+
|
|
4145
|
+
// gentle-pi#568: the target identity negotiated STATUS reported at
|
|
4146
|
+
// `session_start`, before this session touched the worktree. A candidate
|
|
4147
|
+
// already present at that point is the user's own pre-session work, not
|
|
4148
|
+
// something this session produced, so `agent_end` must not treat it as an
|
|
4149
|
+
// unreviewed candidate this session should be reminded about.
|
|
4150
|
+
const processAgentEndSessionBaseline = new Map<PendingReviewConsentSessionKey, string>();
|
|
4151
|
+
|
|
4152
|
+
// gentle-pi#677: gentle-ai#4309 owns anonymous usage telemetry end to end;
|
|
4153
|
+
// Pi only nudges it once per process. This is a plain process-lifetime
|
|
4154
|
+
// guard, not a session-keyed map, because the nudge is meant to fire at most
|
|
4155
|
+
// once no matter how many primary-session `before_agent_start` events this
|
|
4156
|
+
// process observes.
|
|
4157
|
+
let processTelemetryTriggerAttempted = false;
|
|
4158
|
+
|
|
4159
|
+
/** Testing-only reset for the once-per-process telemetry trigger guard. */
|
|
4160
|
+
function resetTelemetryTriggerGuardForTesting(): void {
|
|
4161
|
+
processTelemetryTriggerAttempted = false;
|
|
4162
|
+
}
|
|
4163
|
+
|
|
3632
4164
|
function pendingReviewConsentSessionKey(context: ExtensionContext | undefined, fallbackKey: symbol): PendingReviewConsentSessionKey {
|
|
3633
4165
|
try {
|
|
3634
4166
|
const sessionManager = (context as unknown as { sessionManager?: { getSessionId?: () => unknown } } | undefined)?.sessionManager;
|
|
@@ -3686,6 +4218,61 @@ function reviewConsentDigest(consent: ReviewConsentEnvelope): string {
|
|
|
3686
4218
|
return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
|
|
3687
4219
|
}
|
|
3688
4220
|
|
|
4221
|
+
function reviewSessionManagerAndId(context: ExtensionContext): { manager: object; sessionId: string } | undefined {
|
|
4222
|
+
try {
|
|
4223
|
+
const manager = context.sessionManager as unknown as { getSessionId?: () => unknown };
|
|
4224
|
+
const sessionId = manager.getSessionId?.();
|
|
4225
|
+
if (typeof manager !== "object" || manager === null || typeof sessionId !== "string" || sessionId.length === 0) return undefined;
|
|
4226
|
+
return { manager, sessionId };
|
|
4227
|
+
} catch {
|
|
4228
|
+
return undefined;
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
|
|
4232
|
+
function isDirectOrdinaryReviewStart(parametersValue: unknown): boolean {
|
|
4233
|
+
try {
|
|
4234
|
+
const parameters = parseReviewControllerParameters(parametersValue);
|
|
4235
|
+
if (parameters.operation !== REVIEW_CONTROLLER_OPERATION.START) return false;
|
|
4236
|
+
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
4237
|
+
return input.mode === REVIEW_MODE.ORDINARY;
|
|
4238
|
+
} catch {
|
|
4239
|
+
return false;
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
function isHostReviewConsentEligibleOperation(parametersValue: unknown): boolean {
|
|
4244
|
+
if (isDirectOrdinaryReviewStart(parametersValue)) return true;
|
|
4245
|
+
try {
|
|
4246
|
+
return parseReviewControllerParameters(parametersValue).operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED;
|
|
4247
|
+
} catch {
|
|
4248
|
+
return false;
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
|
|
4252
|
+
function completedGrantedReviewConsent(outcome: Record<string, unknown>): boolean {
|
|
4253
|
+
if (
|
|
4254
|
+
outcome.operation !== REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT ||
|
|
4255
|
+
outcome.status !== undefined ||
|
|
4256
|
+
outcome.outcome !== undefined ||
|
|
4257
|
+
outcome.native_invocation_attempted === false ||
|
|
4258
|
+
outcome.mutation_performed === false ||
|
|
4259
|
+
outcome.lineage_created === false ||
|
|
4260
|
+
!isCanonicalProcessString(outcome.workspace_root)
|
|
4261
|
+
) return false;
|
|
4262
|
+
const result = outcome.result;
|
|
4263
|
+
if (!isRecord(result)) return false;
|
|
4264
|
+
const nonnegativeInteger = (value: unknown): boolean => Number.isInteger(value) && Number(value) >= 0;
|
|
4265
|
+
return isCanonicalProcessString(result.lineage_id) &&
|
|
4266
|
+
isCanonicalProcessString(result.state) &&
|
|
4267
|
+
isCanonicalProcessString(result.risk_tier) &&
|
|
4268
|
+
Array.isArray(result.selected_lenses) && result.selected_lenses.every(isCanonicalProcessString) &&
|
|
4269
|
+
nonnegativeInteger(result.changed_files) &&
|
|
4270
|
+
nonnegativeInteger(result.original_changed_lines) &&
|
|
4271
|
+
nonnegativeInteger(result.correction_budget) &&
|
|
4272
|
+
(result.action === "created" || result.action === "resumed" || result.action === "replayed" || result.action === "closed") &&
|
|
4273
|
+
typeof result.lenses_required === "boolean";
|
|
4274
|
+
}
|
|
4275
|
+
|
|
3689
4276
|
// gentle-pi#516: a binding this session does not hold (already answered,
|
|
3690
4277
|
// expired, or issued by another Pi session or process) used to fall through
|
|
3691
4278
|
// to the plain negotiated STATUS, which reads exactly like a healthy pre-start
|
|
@@ -3731,6 +4318,29 @@ function staleConsentBindingOutcome(operation: ReviewControllerOperation, bindin
|
|
|
3731
4318
|
};
|
|
3732
4319
|
}
|
|
3733
4320
|
|
|
4321
|
+
// gentle-pi#455 correction: cross-session resolution looks a binding up by
|
|
4322
|
+
// its opaque id alone, so it must independently confirm the answering
|
|
4323
|
+
// invocation addresses the same repository its owning START minted it for.
|
|
4324
|
+
// This is a typed, non-bearer refusal in the same shape family as the
|
|
4325
|
+
// stale-binding outcome -- it never runs the mode gate or native
|
|
4326
|
+
// answerConsent, and never consumes the binding, so it stays answerable from
|
|
4327
|
+
// the correct repository afterwards.
|
|
4328
|
+
function consentBindingRepositoryMismatchOutcome(operation: ReviewControllerOperation, binding: string, mintingRepositoryCwd: string, answeringRepositoryCwd: string): Record<string, unknown> {
|
|
4329
|
+
return {
|
|
4330
|
+
operation,
|
|
4331
|
+
status: "blocked",
|
|
4332
|
+
outcome: "consent-binding-repository-mismatch",
|
|
4333
|
+
consent_binding: binding,
|
|
4334
|
+
diagnostics: {
|
|
4335
|
+
code: "consent-binding-repository-mismatch",
|
|
4336
|
+
message: `consent binding ${binding} was minted for repository ${mintingRepositoryCwd}, not ${answeringRepositoryCwd}. Answer this binding from a session addressing ${mintingRepositoryCwd}; do not resend this binding from a different repository.`,
|
|
4337
|
+
},
|
|
4338
|
+
native_invocation_attempted: false,
|
|
4339
|
+
...nativeStartPreAuthorityRejection(),
|
|
4340
|
+
next_action: "answer-from-minting-repository",
|
|
4341
|
+
};
|
|
4342
|
+
}
|
|
4343
|
+
|
|
3734
4344
|
function assertNativeStartCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void {
|
|
3735
4345
|
candidateView.verify();
|
|
3736
4346
|
if (
|
|
@@ -3776,7 +4386,7 @@ function completeNativeStart(
|
|
|
3776
4386
|
}
|
|
3777
4387
|
|
|
3778
4388
|
function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_review_capture", error: unknown): Record<string, unknown> {
|
|
3779
|
-
const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; auditRecord?: unknown; launchAttempted?: unknown; candidateViewPreNative?: unknown; failureEnvelope?: { raw?: unknown; mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown } };
|
|
4389
|
+
const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; auditRecord?: unknown; launchAttempted?: unknown; candidateViewPreNative?: unknown; failureEnvelope?: { raw?: unknown; mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown; code?: unknown; continuation?: { command?: unknown } } };
|
|
3780
4390
|
if (isRecord(value.failureEnvelope) && isRecord(value.failureEnvelope.raw)) {
|
|
3781
4391
|
const mutationOutcome = value.failureEnvelope.mutationOutcome;
|
|
3782
4392
|
return {
|
|
@@ -3790,6 +4400,12 @@ function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_r
|
|
|
3790
4400
|
: { mutation_performed: false, mutation_outcome: "none" }),
|
|
3791
4401
|
...(typeof value.failureEnvelope.replayability === "string" ? { replayability: value.failureEnvelope.replayability } : {}),
|
|
3792
4402
|
...(typeof value.failureEnvelope.nextAction === "string" ? { next_action: value.failureEnvelope.nextAction } : {}),
|
|
4403
|
+
// gentle-pi#627: START's preflight failure envelope for a stale
|
|
4404
|
+
// managed-asset set carries a top-level continuation; render its
|
|
4405
|
+
// `gentle-ai sync` command as the one actionable next step.
|
|
4406
|
+
...(value.failureEnvelope.code === "managed_assets_outdated" && typeof value.failureEnvelope.continuation?.command === "string"
|
|
4407
|
+
? { hint: `run ${value.failureEnvelope.continuation.command}` }
|
|
4408
|
+
: {}),
|
|
3793
4409
|
};
|
|
3794
4410
|
}
|
|
3795
4411
|
// Every consent binding guard runs before the provider is launched, so this
|
|
@@ -4069,9 +4685,15 @@ function requiresExplicitTargetLifecycleRoot(requested: string | undefined, sess
|
|
|
4069
4685
|
// The runner is injectable for tests only; production always uses the real
|
|
4070
4686
|
// relay in lib/review-host-relay.ts.
|
|
4071
4687
|
let activeReviewHostRelayRunner: ReviewHostRelayRunner = runReviewHostRelaySlot;
|
|
4688
|
+
let activeReviewHostRelayReviewerGroupRunner = runReviewHostRelayReviewerGroup;
|
|
4689
|
+
let activeReviewHostRelaySubmissionRunner = submitReviewHostRelayPreparedResult;
|
|
4072
4690
|
function setReviewHostRelayRunnerForTesting(runner?: ReviewHostRelayRunner): void {
|
|
4073
4691
|
activeReviewHostRelayRunner = runner ?? runReviewHostRelaySlot;
|
|
4074
4692
|
}
|
|
4693
|
+
function setReviewHostRelayGroupRunnersForTesting(reviewerGroup?: typeof runReviewHostRelayReviewerGroup, submission?: typeof submitReviewHostRelayPreparedResult): void {
|
|
4694
|
+
activeReviewHostRelayReviewerGroupRunner = reviewerGroup ?? runReviewHostRelayReviewerGroup;
|
|
4695
|
+
activeReviewHostRelaySubmissionRunner = submission ?? submitReviewHostRelayPreparedResult;
|
|
4696
|
+
}
|
|
4075
4697
|
|
|
4076
4698
|
const REVIEW_HOST_RELAY_RETRY_ACTION =
|
|
4077
4699
|
"Call fresh STATUS and submit only an exact reoffered one-slot binding; never replay this capture from transcript inference.";
|
|
@@ -4166,23 +4788,27 @@ function decodeRelayLastEventClosure(submission: string): ReviewLastEventClosure
|
|
|
4166
4788
|
}
|
|
4167
4789
|
|
|
4168
4790
|
async function reconcileUnknownReviewCaptureFailure(
|
|
4169
|
-
error: unknown,
|
|
4791
|
+
error: unknown | undefined,
|
|
4170
4792
|
nativeReviewCli: NativeReviewCli,
|
|
4171
4793
|
cwd: string,
|
|
4172
4794
|
binding: ReviewLastEventClosureBinding,
|
|
4173
4795
|
selections: Map<string, RetainedNativeStatusSelection>,
|
|
4174
4796
|
route: RetainedNativeCaptureRoute | undefined,
|
|
4797
|
+
expectedReviewCaptureSuffix?: readonly string[],
|
|
4798
|
+
agent?: "pi",
|
|
4175
4799
|
): Promise<Record<string, unknown>> {
|
|
4176
|
-
const failure = nativeOperationFailure("gentle_review_capture", error);
|
|
4177
|
-
if (!nativeMutationRequiresStatus(error)) return failure;
|
|
4800
|
+
const failure = error === undefined ? undefined : nativeOperationFailure("gentle_review_capture", error);
|
|
4801
|
+
if (error !== undefined && !nativeMutationRequiresStatus(error)) return failure;
|
|
4178
4802
|
try {
|
|
4179
|
-
const
|
|
4803
|
+
const selector = agent === undefined ? route : { ...route, agent };
|
|
4804
|
+
const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, selector);
|
|
4180
4805
|
syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef);
|
|
4806
|
+
if (expectedReviewCaptureSuffix !== undefined && !hasExactReviewCaptureSuffix(status, expectedReviewCaptureSuffix)) return captureGroupAuthorityDrift(status);
|
|
4181
4807
|
return {
|
|
4182
4808
|
tool: "gentle_review_capture",
|
|
4183
4809
|
status: "reconciled",
|
|
4184
4810
|
outcome: "native-capture-outcome-unknown",
|
|
4185
|
-
native_failure: failure,
|
|
4811
|
+
...(failure === undefined ? {} : { native_failure: failure }),
|
|
4186
4812
|
lineage_id: binding.lineageId,
|
|
4187
4813
|
target_identity: status.targetIdentity,
|
|
4188
4814
|
provider_action: status.action,
|
|
@@ -4190,11 +4816,8 @@ async function reconcileUnknownReviewCaptureFailure(
|
|
|
4190
4816
|
result: status.raw,
|
|
4191
4817
|
};
|
|
4192
4818
|
} catch (statusError) {
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
outcome: "native-capture-status-reconciliation-failed",
|
|
4196
|
-
reconciliation_failure: nativeOperationFailure("gentle_review_capture", statusError),
|
|
4197
|
-
};
|
|
4819
|
+
const reconciliationFailure = nativeOperationFailure("gentle_review_capture", statusError);
|
|
4820
|
+
return { ...(failure ?? reconciliationFailure), outcome: "native-capture-status-reconciliation-failed", reconciliation_failure: reconciliationFailure };
|
|
4198
4821
|
}
|
|
4199
4822
|
}
|
|
4200
4823
|
|
|
@@ -4239,10 +4862,10 @@ async function executeReviewHostRelayCapture(
|
|
|
4239
4862
|
},
|
|
4240
4863
|
};
|
|
4241
4864
|
} catch (error) {
|
|
4242
|
-
if (!(error instanceof ReviewHostRelayError)) return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route);
|
|
4865
|
+
if (!(error instanceof ReviewHostRelayError)) return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT);
|
|
4243
4866
|
if (error.mutationOutcome === "unknown") {
|
|
4244
4867
|
return {
|
|
4245
|
-
...(await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route)),
|
|
4868
|
+
...(await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT)),
|
|
4246
4869
|
failure: reviewHostRelayFailureReport(error),
|
|
4247
4870
|
reason: error.message,
|
|
4248
4871
|
};
|
|
@@ -4397,7 +5020,7 @@ function clearReviewTransportProbeForTesting(nativeReviewCli: NativeReviewCli |
|
|
|
4397
5020
|
}
|
|
4398
5021
|
|
|
4399
5022
|
function hostTransportUnavailable(
|
|
4400
|
-
operation: ReviewControllerOperation | "gentle_review_capture",
|
|
5023
|
+
operation: ReviewControllerOperation | "gentle_review_capture" | "gentle_review_capture_group",
|
|
4401
5024
|
transport: ReviewTransportRefusal,
|
|
4402
5025
|
): Record<string, unknown> {
|
|
4403
5026
|
// #535: a provider-printed raw `gentle-ai review ...` continuation is a dead
|
|
@@ -4406,7 +5029,7 @@ function hostTransportUnavailable(
|
|
|
4406
5029
|
// refusal therefore names the continuation that runs in this surface (the
|
|
4407
5030
|
// gentle_review / gentle_review_capture wrapper tools) while the provider's
|
|
4408
5031
|
// own diagnostic stays intact in relay_transport as evidence.
|
|
4409
|
-
const isCapture = operation === "gentle_review_capture";
|
|
5032
|
+
const isCapture = operation === "gentle_review_capture" || operation === "gentle_review_capture_group";
|
|
4410
5033
|
return {
|
|
4411
5034
|
...(isCapture ? { tool: operation } : { operation }),
|
|
4412
5035
|
status: "blocked",
|
|
@@ -4418,9 +5041,9 @@ function hostTransportUnavailable(
|
|
|
4418
5041
|
wrapper_continuation: {
|
|
4419
5042
|
tool: "gentle_review",
|
|
4420
5043
|
operation: REVIEW_CONTROLLER_OPERATION.INSPECT,
|
|
4421
|
-
...(isCapture ? { then:
|
|
5044
|
+
...(isCapture ? { then: operation } : {}),
|
|
4422
5045
|
},
|
|
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
|
|
5046
|
+
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
5047
|
};
|
|
4425
5048
|
}
|
|
4426
5049
|
|
|
@@ -4453,6 +5076,45 @@ async function negotiatedStatusForHostTransport(
|
|
|
4453
5076
|
}
|
|
4454
5077
|
}
|
|
4455
5078
|
|
|
5079
|
+
// gentle-pi#568: resolves the current negotiated review STATUS for a session,
|
|
5080
|
+
// under the exact guards `agent_end` uses to decide whether to nudge: a
|
|
5081
|
+
// native review CLI with both `reviewMode` and `targetStatus`, a UI-bearing
|
|
5082
|
+
// context, and RDD effectively on. Returns `undefined` on any missing guard,
|
|
5083
|
+
// an effective-off mode, or any STATUS error or transport refusal, so both
|
|
5084
|
+
// `session_start` (recording a baseline) and `agent_end` (deciding whether to
|
|
5085
|
+
// nudge) resolve the same target identity through the same path.
|
|
5086
|
+
async function resolveNegotiatedReviewStatusForSession(
|
|
5087
|
+
nativeReviewCli: NativeReviewCli | null,
|
|
5088
|
+
ctx: ExtensionContext,
|
|
5089
|
+
sessionKey: PendingReviewConsentSessionKey,
|
|
5090
|
+
): Promise<ReviewStatusV3 | undefined> {
|
|
5091
|
+
if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return undefined;
|
|
5092
|
+
if (ctx.hasUI !== true) return undefined;
|
|
5093
|
+
let modeEffective: "on" | "off";
|
|
5094
|
+
try {
|
|
5095
|
+
const mode = await nativeReviewCli.reviewMode({ cwd: ctx.cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS });
|
|
5096
|
+
modeEffective = mode.status.effective;
|
|
5097
|
+
} catch {
|
|
5098
|
+
return undefined;
|
|
5099
|
+
}
|
|
5100
|
+
if (modeEffective === "off") return undefined;
|
|
5101
|
+
try {
|
|
5102
|
+
const retainedSelections = ((key: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(key) ?? processRetainedNativeStatusSelections.set(key, new Map()).get(key)!)(sessionKey);
|
|
5103
|
+
const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: ctx.cwd }, retainedSelections, ctx.cwd);
|
|
5104
|
+
return negotiated.status;
|
|
5105
|
+
} catch {
|
|
5106
|
+
return undefined;
|
|
5107
|
+
}
|
|
5108
|
+
}
|
|
5109
|
+
|
|
5110
|
+
// gentle-pi#556 / gentle-ai#4051: the exact once-per-candidate reminder sent
|
|
5111
|
+
// through `agent_end`. It never runs START itself, so it names the one
|
|
5112
|
+
// supported continuation (gentle_review inspect) and defers the resulting
|
|
5113
|
+
// consent envelope to the human.
|
|
5114
|
+
function renderAgentEndReviewPreflightMessage(targetIdentity: string): string {
|
|
5115
|
+
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. An eligible interactive Pi host may resolve consent directly with its own three-action UI. If gentle_review instead returns an unresolved gentle-ai.review-integration.consent/v3 envelope, relay that original two-choice provider envelope to the human losslessly. Never answer consent from model prose or tool arguments.\n\nThis extension never runs START itself. This reminder is sent once per candidate.`;
|
|
5116
|
+
}
|
|
5117
|
+
|
|
4456
5118
|
function canonicalReviewCaptureBinding(value: unknown): string {
|
|
4457
5119
|
if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") return JSON.stringify(value);
|
|
4458
5120
|
if (Array.isArray(value)) return `[${value.map((entry) => canonicalReviewCaptureBinding(entry)).join(",")}]`;
|
|
@@ -4476,8 +5138,18 @@ function exactCollectArgument(input: ReviewCollectInputV3, name: string): string
|
|
|
4476
5138
|
return matches.length === 1 ? matches[0]!.value : undefined;
|
|
4477
5139
|
}
|
|
4478
5140
|
|
|
5141
|
+
// The intended-untracked collect input arrived with status/v6 and every later
|
|
5142
|
+
// status version keeps it; matching one exact version left every workspace
|
|
5143
|
+
// with untracked files unable to start a review once gentle-ai answered v7
|
|
5144
|
+
// (gentle-pi#610, gentle-ai#4187).
|
|
5145
|
+
const INTENDED_UNTRACKED_STATUS_SCHEMA = /^gentle-ai\.review-integration\.status\/v(\d+)$/;
|
|
5146
|
+
function statusCarriesIntendedUntrackedSelection(schema: unknown): boolean {
|
|
5147
|
+
const match = typeof schema === "string" ? INTENDED_UNTRACKED_STATUS_SCHEMA.exec(schema) : null;
|
|
5148
|
+
return match !== null && Number(match[1]) >= 6;
|
|
5149
|
+
}
|
|
5150
|
+
|
|
4479
5151
|
function reviewIntendedUntrackedInput(status: ReviewStatusV3): ReviewCollectInputV3 | undefined {
|
|
4480
|
-
if (status.raw.schema
|
|
5152
|
+
if (!statusCarriesIntendedUntrackedSelection(status.raw.schema) || status.nextTransition?.kind !== "collect") return undefined;
|
|
4481
5153
|
const matches = (status.nextTransition.collect?.inputs ?? []).filter((input) => {
|
|
4482
5154
|
const value = input.submission?.values[0];
|
|
4483
5155
|
return input.name === "intended_untracked_selection" && input.schema === "gentle-ai.review-intended-untracked-selection/v1" && input.captureOperation === "external.select_intended_untracked" && input.submission?.operationToken === "status" && input.submission.values.length === 1 && value?.slot === "intended_untracked_selection" && value.domain === "schema_bound_json";
|
|
@@ -4491,11 +5163,11 @@ function publicReviewCaptureBindings(status: ReviewStatusV3): readonly PublicRev
|
|
|
4491
5163
|
return (status.nextTransition.collect?.inputs ?? []).filter((input) => input.captureOperation !== "external.select_intended_untracked").map((input) => ({ collectBinding: canonicalReviewCaptureBinding(input) }));
|
|
4492
5164
|
}
|
|
4493
5165
|
|
|
4494
|
-
function captureBindingRejected(reason: string): Record<string, unknown> {
|
|
5166
|
+
function captureBindingRejected(reason: string, group = false): Record<string, unknown> {
|
|
4495
5167
|
return {
|
|
4496
|
-
tool: "gentle_review_capture",
|
|
5168
|
+
tool: group ? "gentle_review_capture_group" : "gentle_review_capture",
|
|
4497
5169
|
status: "blocked",
|
|
4498
|
-
outcome: "capture-binding-rejected",
|
|
5170
|
+
outcome: group ? "capture-group-rejected" : "capture-binding-rejected",
|
|
4499
5171
|
reason,
|
|
4500
5172
|
mutation_performed: false,
|
|
4501
5173
|
mutation_outcome: "none",
|
|
@@ -4557,6 +5229,89 @@ function isSelectedReviewCapture(value: SelectedReviewCapture | Record<string, u
|
|
|
4557
5229
|
return "input" in value && "binding" in value;
|
|
4558
5230
|
}
|
|
4559
5231
|
|
|
5232
|
+
function captureGroupRejected(reason: string): Record<string, unknown> { return captureBindingRejected(reason, true); }
|
|
5233
|
+
|
|
5234
|
+
function hasExactReviewCaptureSuffix(status: ReviewStatusV3, expected: readonly string[]): boolean {
|
|
5235
|
+
const current = status.nextTransition?.kind === "collect"
|
|
5236
|
+
? (status.nextTransition.collect?.inputs ?? []).filter((input) => input.captureOperation === "review.capture-result").map(canonicalReviewCaptureBinding)
|
|
5237
|
+
: [];
|
|
5238
|
+
return current.length === expected.length && current.every((binding, index) => binding === expected[index]);
|
|
5239
|
+
}
|
|
5240
|
+
|
|
5241
|
+
function captureGroupAuthorityDrift(status: ReviewStatusV3): Record<string, unknown> {
|
|
5242
|
+
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 };
|
|
5243
|
+
}
|
|
5244
|
+
|
|
5245
|
+
interface SelectedReviewCaptureGroup {
|
|
5246
|
+
slots: readonly ReviewHostRelaySlot[];
|
|
5247
|
+
binding: ReviewLastEventClosureBinding;
|
|
5248
|
+
}
|
|
5249
|
+
|
|
5250
|
+
function selectExactReviewCaptureGroup(
|
|
5251
|
+
status: ReviewStatusV3,
|
|
5252
|
+
lineageId: string,
|
|
5253
|
+
canonicalBindings: readonly string[],
|
|
5254
|
+
): SelectedReviewCaptureGroup | Record<string, unknown> {
|
|
5255
|
+
const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : [];
|
|
5256
|
+
const slots = reviewHostRelaySlots(inputs);
|
|
5257
|
+
if (inputs.length === 0 || slots.length !== inputs.length) {
|
|
5258
|
+
return captureGroupRejected("current STATUS does not offer an exclusively materialize reviewer capture group");
|
|
5259
|
+
}
|
|
5260
|
+
const currentBindings = inputs.map((input) => canonicalReviewCaptureBinding(input));
|
|
5261
|
+
if (new Set(currentBindings).size !== currentBindings.length || canonicalBindings.length !== currentBindings.length || canonicalBindings.some((binding, index) => binding !== currentBindings[index])) {
|
|
5262
|
+
return captureGroupRejected("collectBindings must be the complete distinct current reviewer group in exact provider order");
|
|
5263
|
+
}
|
|
5264
|
+
const first = selectExactReviewCapture(status, lineageId, currentBindings[0]!);
|
|
5265
|
+
if (!isSelectedReviewCapture(first)) return captureGroupRejected(String(first.reason ?? "current STATUS rejected a reviewer binding"));
|
|
5266
|
+
const expectedRevision = exactCollectArgument(inputs[0]!, "expected-revision");
|
|
5267
|
+
const repositoryContext = exactCollectArgument(inputs[0]!, "repository-context");
|
|
5268
|
+
const statusTargetIdentity = status.targetIdentity;
|
|
5269
|
+
const currentRepositoryContext = status.repositoryContext;
|
|
5270
|
+
if (!isCanonicalProcessString(expectedRevision) || !isCanonicalProcessString(repositoryContext) || currentRepositoryContext === undefined || expectedRevision !== currentRepositoryContext.revision) {
|
|
5271
|
+
return captureGroupRejected("current STATUS does not bind one matching expected revision and repository context for the reviewer group");
|
|
5272
|
+
}
|
|
5273
|
+
if (currentRepositoryContext.handle !== repositoryContext || currentRepositoryContext.targetIdentity !== statusTargetIdentity) {
|
|
5274
|
+
return captureGroupRejected("current STATUS repository context does not match the reviewer group binding");
|
|
5275
|
+
}
|
|
5276
|
+
const lenses = new Set<string>(), orders = new Set<string>(), subjectHashes = new Set<string>();
|
|
5277
|
+
for (let index = 0; index < inputs.length; index += 1) {
|
|
5278
|
+
const input = inputs[index]!, slot = slots[index]!, subject = input.artifactSubject;
|
|
5279
|
+
const slotLineage = exactCollectArgument(input, "lineage"), target = exactCollectArgument(input, "target");
|
|
5280
|
+
const revision = exactCollectArgument(input, "expected-revision"), context = exactCollectArgument(input, "repository-context");
|
|
5281
|
+
const subjectHash = exactCollectArgument(input, "subject-hash"), order = slot.order, lens = slot.lens;
|
|
5282
|
+
if (
|
|
5283
|
+
subject === undefined || slot.submission === undefined || slotLineage !== lineageId || target !== statusTargetIdentity
|
|
5284
|
+
|| revision !== expectedRevision || context !== repositoryContext || subjectHash !== subject.subjectHash || order === undefined || lens === undefined
|
|
5285
|
+
|| subject.lineageId !== lineageId || subject.authorityRevision !== expectedRevision || subject.targetIdentity !== statusTargetIdentity
|
|
5286
|
+
|| lens !== subject.lens || String(subject.selectedOrder) !== order
|
|
5287
|
+
) return captureGroupRejected("current STATUS carries an incomplete or mismatched materialize reviewer binding");
|
|
5288
|
+
try { resolveReviewHostRelaySubmission(slot.submission); } catch { return captureGroupRejected("current STATUS carries an invalid provider reviewer submission descriptor"); }
|
|
5289
|
+
const value = slot.submission.values[0];
|
|
5290
|
+
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)) {
|
|
5291
|
+
return captureGroupRejected("current STATUS carries duplicate or invalid reviewer slot identities");
|
|
5292
|
+
}
|
|
5293
|
+
lenses.add(lens); orders.add(order); subjectHashes.add(subject.subjectHash);
|
|
5294
|
+
}
|
|
5295
|
+
return { slots, binding: first.binding };
|
|
5296
|
+
}
|
|
5297
|
+
|
|
5298
|
+
function reviewHostRelayGroupFailure(
|
|
5299
|
+
error: ReviewHostRelayError,
|
|
5300
|
+
slots: readonly ReviewHostRelaySlot[],
|
|
5301
|
+
prepared: readonly ReviewHostRelayPreparedResult[],
|
|
5302
|
+
submitted: number,
|
|
5303
|
+
): Record<string, unknown> {
|
|
5304
|
+
return {
|
|
5305
|
+
tool: "gentle_review_capture_group",
|
|
5306
|
+
status: "blocked",
|
|
5307
|
+
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",
|
|
5308
|
+
reason: error.message,
|
|
5309
|
+
failure: reviewHostRelayFailureReport(error),
|
|
5310
|
+
...reviewHostRelayGroupProgress(slots, prepared, submitted),
|
|
5311
|
+
next_action: error.kind === REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED ? REVIEW_HOST_RELAY_REFUSED_ACTION : REVIEW_HOST_RELAY_RETRY_ACTION,
|
|
5312
|
+
};
|
|
5313
|
+
}
|
|
5314
|
+
|
|
4560
5315
|
async function executeReviewCaptureOperation(
|
|
4561
5316
|
parametersValue: unknown,
|
|
4562
5317
|
sessionCwd: string,
|
|
@@ -4670,6 +5425,118 @@ async function executeReviewCaptureOperation(
|
|
|
4670
5425
|
return captureBindingRejected(`unsupported provider capture operation: ${selected.input.captureOperation}`);
|
|
4671
5426
|
}
|
|
4672
5427
|
|
|
5428
|
+
function reviewHostRelayGroupDiagnostics(slots: readonly ReviewHostRelaySlot[], prepared: readonly ReviewHostRelayPreparedResult[], count: number): readonly Record<string, unknown>[] {
|
|
5429
|
+
return slots.slice(0, count).map((slot, index) => ({
|
|
5430
|
+
...(slot.lens === undefined ? {} : { lens: slot.lens }),
|
|
5431
|
+
...(slot.order === undefined ? {} : { order: slot.order }),
|
|
5432
|
+
...(slot.subjectHash === undefined ? {} : { subject_hash: slot.subjectHash }),
|
|
5433
|
+
prompt_bytes: prepared[index]?.promptByteLength,
|
|
5434
|
+
result_bytes: prepared[index]?.resultByteLength,
|
|
5435
|
+
}));
|
|
5436
|
+
}
|
|
5437
|
+
|
|
5438
|
+
function reviewHostRelayGroupProgress(
|
|
5439
|
+
slots: readonly ReviewHostRelaySlot[],
|
|
5440
|
+
prepared: readonly ReviewHostRelayPreparedResult[],
|
|
5441
|
+
submitted: number,
|
|
5442
|
+
uncertain = false,
|
|
5443
|
+
): Record<string, unknown> {
|
|
5444
|
+
const outcome = submitted === 0 ? uncertain ? "unknown" : "none" : uncertain ? "partial_unknown" : submitted === slots.length ? "completed" : "partial";
|
|
5445
|
+
return {
|
|
5446
|
+
prepared_reviewers: prepared.length,
|
|
5447
|
+
submitted_reviewers: submitted,
|
|
5448
|
+
host_relay: { transport: "pi_host_relay", reviewers: reviewHostRelayGroupDiagnostics(slots, prepared, submitted) },
|
|
5449
|
+
...(submitted === 0 && uncertain ? { mutation_outcome: outcome } : { mutation_performed: submitted > 0, mutation_outcome: outcome }),
|
|
5450
|
+
};
|
|
5451
|
+
}
|
|
5452
|
+
|
|
5453
|
+
async function executeReviewCaptureGroupOperation(
|
|
5454
|
+
parametersValue: unknown,
|
|
5455
|
+
sessionCwd: string,
|
|
5456
|
+
nativeReviewCli: NativeReviewCli | null,
|
|
5457
|
+
signal?: AbortSignal,
|
|
5458
|
+
candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
|
|
5459
|
+
retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
|
|
5460
|
+
requireRegisteredRoute = false,
|
|
5461
|
+
): Promise<Record<string, unknown>> {
|
|
5462
|
+
const parameters = parseReviewCaptureGroupParameters(parametersValue);
|
|
5463
|
+
if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) return { ...captureGroupRejected("native target STATUS is unavailable"), outcome: "native-status-unsupported" };
|
|
5464
|
+
const canonicalBindings = parameters.collectBindings.map((binding) => parseCanonicalReviewCaptureBinding(binding));
|
|
5465
|
+
const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
|
|
5466
|
+
const routes = canonicalBindings.map((binding) => readRetainedNativeCaptureRoute(retainedUntrackedSelections, binding));
|
|
5467
|
+
const route = routes[0];
|
|
5468
|
+
if (requireRegisteredRoute && (route === undefined || routes.some((candidate) => candidate === undefined || candidate.workspaceRoot !== cwd || candidate.lineageId !== parameters.lineageId || candidate.baseRef !== route.baseRef))) {
|
|
5469
|
+
return captureGroupRejected("collectBindings are unknown, expired, or belong to different session routes");
|
|
5470
|
+
}
|
|
5471
|
+
const freshStatus = () => negotiatedStatusForHostTransport(nativeReviewCli, {
|
|
5472
|
+
cwd, lineageId: parameters.lineageId,
|
|
5473
|
+
...(route?.baseRef === undefined ? {} : { baseRef: route.baseRef, committedOnly: true }),
|
|
5474
|
+
...readRetainedNativeUntrackedSelection(retainedUntrackedSelections, cwd, parameters.lineageId),
|
|
5475
|
+
...(signal === undefined ? {} : { signal }),
|
|
5476
|
+
}, retainedUntrackedSelections, cwd);
|
|
5477
|
+
let status: ReviewStatusV3;
|
|
5478
|
+
try {
|
|
5479
|
+
const negotiated = await freshStatus();
|
|
5480
|
+
if (negotiated.transport !== undefined) return hostTransportUnavailable("gentle_review_capture_group", negotiated.transport);
|
|
5481
|
+
status = negotiated.status!;
|
|
5482
|
+
} catch (error) {
|
|
5483
|
+
return { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "native-status-failed" };
|
|
5484
|
+
}
|
|
5485
|
+
const group = selectExactReviewCaptureGroup(status, parameters.lineageId, canonicalBindings);
|
|
5486
|
+
if (!("slots" in group && "binding" in group)) return group;
|
|
5487
|
+
if (parameters.reviewerRunAcknowledged !== true) {
|
|
5488
|
+
return {
|
|
5489
|
+
tool: "gentle_review_capture_group",
|
|
5490
|
+
status: "blocked",
|
|
5491
|
+
outcome: "reviewer-model-run-forecast",
|
|
5492
|
+
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) },
|
|
5493
|
+
mutation_performed: false,
|
|
5494
|
+
mutation_outcome: "none",
|
|
5495
|
+
};
|
|
5496
|
+
}
|
|
5497
|
+
const requests: readonly ReviewHostRelayRequest[] = group.slots.map((slot) => ({
|
|
5498
|
+
captureArgumentTokens: slot.captureArgumentTokens,
|
|
5499
|
+
targetCwd: cwd,
|
|
5500
|
+
submission: slot.submission!,
|
|
5501
|
+
...(signal === undefined ? {} : { signal }),
|
|
5502
|
+
}));
|
|
5503
|
+
let prepared: readonly ReviewHostRelayPreparedResult[];
|
|
5504
|
+
try {
|
|
5505
|
+
prepared = await activeReviewHostRelayReviewerGroupRunner(requests);
|
|
5506
|
+
if (prepared.length !== requests.length) throw new Error("Pi host relay reviewer group returned a different number of prepared results");
|
|
5507
|
+
} catch (error) {
|
|
5508
|
+
return error instanceof ReviewHostRelayError
|
|
5509
|
+
? reviewHostRelayGroupFailure(error, group.slots, [], 0)
|
|
5510
|
+
: { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "pi-host-relay-reviewer-group-failed" };
|
|
5511
|
+
}
|
|
5512
|
+
for (let index = 0; index < prepared.length; index += 1) {
|
|
5513
|
+
let current: SelectedReviewCapture | Record<string, unknown>;
|
|
5514
|
+
try {
|
|
5515
|
+
const negotiated = await freshStatus();
|
|
5516
|
+
if (negotiated.transport !== undefined) return { ...hostTransportUnavailable("gentle_review_capture_group", negotiated.transport), ...reviewHostRelayGroupProgress(group.slots, prepared, index) };
|
|
5517
|
+
if (!hasExactReviewCaptureSuffix(negotiated.status!, canonicalBindings.slice(index))) return { ...captureGroupAuthorityDrift(negotiated.status!), ...reviewHostRelayGroupProgress(group.slots, prepared, index) };
|
|
5518
|
+
current = selectExactReviewCapture(negotiated.status!, parameters.lineageId, canonicalBindings[index]!);
|
|
5519
|
+
} catch (error) {
|
|
5520
|
+
return { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "native-status-failed", ...reviewHostRelayGroupProgress(group.slots, prepared, index) };
|
|
5521
|
+
}
|
|
5522
|
+
if (!isSelectedReviewCapture(current)) return { ...captureGroupRejected(String(current.reason ?? "current STATUS rejected a reviewer binding")), ...reviewHostRelayGroupProgress(group.slots, prepared, index) };
|
|
5523
|
+
try {
|
|
5524
|
+
const result = await activeReviewHostRelaySubmissionRunner(prepared[index]!);
|
|
5525
|
+
const closure = decodeRelayLastEventClosure(result.submission);
|
|
5526
|
+
if (closure !== undefined) {
|
|
5527
|
+
const closed = mapAndClearLastEventClosure(closure, current.binding, retainedUntrackedSelections, cwd);
|
|
5528
|
+
return { ...closed, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index + 1) };
|
|
5529
|
+
}
|
|
5530
|
+
} catch (error) {
|
|
5531
|
+
if (error instanceof ReviewHostRelayError && error.mutationOutcome !== "unknown") return reviewHostRelayGroupFailure(error, group.slots, prepared, index);
|
|
5532
|
+
const reconciled = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, current.binding, retainedUntrackedSelections, route, undefined, REVIEW_HOST_AGENT);
|
|
5533
|
+
return { ...reconciled, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index, true), ...(error instanceof ReviewHostRelayError ? { failure: reviewHostRelayFailureReport(error), reason: error.message } : {}) };
|
|
5534
|
+
}
|
|
5535
|
+
}
|
|
5536
|
+
const reconciled = await reconcileUnknownReviewCaptureFailure(undefined, nativeReviewCli, cwd, group.binding, retainedUntrackedSelections, route, canonicalBindings.slice(prepared.length), REVIEW_HOST_AGENT);
|
|
5537
|
+
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) };
|
|
5538
|
+
}
|
|
5539
|
+
|
|
4673
5540
|
type DispatchHydrationOutcome =
|
|
4674
5541
|
| { hydrated: true; lineage_id: string; lenses: readonly string[] }
|
|
4675
5542
|
| { hydrated: false; lineage_id: string; reason: string; message: string }
|
|
@@ -4707,7 +5574,6 @@ async function executeReviewControllerOperation(
|
|
|
4707
5574
|
retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
|
|
4708
5575
|
pendingReviewConsentRegistry: PendingReviewConsentRegistry = processPendingReviewConsentRegistry,
|
|
4709
5576
|
pendingReviewConsentFallbackKey: symbol = Symbol("pending-review-consent-fallback"),
|
|
4710
|
-
writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
|
|
4711
5577
|
reviewConsentNow: () => number = Date.now,
|
|
4712
5578
|
reviewConsentScheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void } = setTimeout,
|
|
4713
5579
|
intendedUntrackedSelection?: NativeIntendedUntrackedSelectionSubmission,
|
|
@@ -4732,6 +5598,15 @@ async function executeReviewControllerOperation(
|
|
|
4732
5598
|
next_action: "Use the native `gentle-ai review` CLI (start/finalize/validate/status/recover) against the repository review authority; receipts and canonical artifacts live in the Git common-directory store at .git/gentle-ai/reviews and travel with the repository through normal Git replication.",
|
|
4733
5599
|
};
|
|
4734
5600
|
}
|
|
5601
|
+
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ASSESS) {
|
|
5602
|
+
// Read-only native risk assessment (gentle-ai#4295, gentle-pi#662). Never
|
|
5603
|
+
// mutates, never requires a lineageId, and never routes through
|
|
5604
|
+
// authorizeDestructiveReviewOperation (it returns early for any
|
|
5605
|
+
// operation that is neither RESET nor a maintenance operation).
|
|
5606
|
+
const input = parseReviewAssessInput(parameters.operation, parameters.input);
|
|
5607
|
+
const details = await resolveReviewAssessmentPlan(nativeReviewCli, defaultCwd, input, signal);
|
|
5608
|
+
return { operation: parameters.operation, ...details, ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
|
|
5609
|
+
}
|
|
4735
5610
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS) {
|
|
4736
5611
|
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
4737
5612
|
return await executeNativeLegacyAliasRepair(input, defaultCwd, nativeReviewCli, signal, context);
|
|
@@ -4974,6 +5849,9 @@ async function executeReviewControllerOperation(
|
|
|
4974
5849
|
// The registry owns restoring writability of its 0555 views before
|
|
4975
5850
|
// removal; a terminal approved cleanup keeps the lineage projection.
|
|
4976
5851
|
candidateViews?.cleanupTerminal(parameters.lineageId, "approved", defaultCwd);
|
|
5852
|
+
// gentle-pi#668: `closed` is never auto-derived or recorded here --
|
|
5853
|
+
// a parent that wants the on-path passes nativeReviewOutcome:
|
|
5854
|
+
// "closed" explicitly on its next assess call for this candidate.
|
|
4977
5855
|
return {
|
|
4978
5856
|
operation: parameters.operation,
|
|
4979
5857
|
status: "closed",
|
|
@@ -4997,13 +5875,18 @@ async function executeReviewControllerOperation(
|
|
|
4997
5875
|
if (Object.keys(input).some((key) => key !== "consentBinding" && key !== "answer") || Object.keys(input).length !== 2) throw new Error("Review controller answer-consent input must contain exactly consentBinding and answer");
|
|
4998
5876
|
if (typeof input.consentBinding !== "string" || input.consentBinding.length === 0) throw new Error("Review controller answer-consent requires an opaque consentBinding");
|
|
4999
5877
|
if (input.answer !== "granted" && input.answer !== "declined") throw new Error("Review controller answer-consent answer must be granted or declined");
|
|
5000
|
-
|
|
5878
|
+
// gentle-pi#455: resolve the binding by its opaque id alone, so a
|
|
5879
|
+
// binding one active Pi session's START created is answerable from any
|
|
5880
|
+
// active session presenting it -- not only the session that created it.
|
|
5881
|
+
const resolved = pendingReviewConsentRegistry.resolve(input.consentBinding);
|
|
5882
|
+
const pending = resolved?.pending;
|
|
5883
|
+
const owningSession = resolved?.sessionKey ?? pendingReviewConsentSession;
|
|
5001
5884
|
if (pending === undefined || pending.expiresAt <= reviewConsentNow()) {
|
|
5002
5885
|
const disposition = pending === undefined
|
|
5003
|
-
? pendingReviewConsentRegistry.staleDisposition(
|
|
5886
|
+
? pendingReviewConsentRegistry.staleDisposition(input.consentBinding)
|
|
5004
5887
|
: PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED;
|
|
5005
5888
|
const stale = staleConsentBindingDiagnostics(input.consentBinding, disposition);
|
|
5006
|
-
if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry,
|
|
5889
|
+
if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
|
|
5007
5890
|
if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
|
|
5008
5891
|
try {
|
|
5009
5892
|
const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
|
|
@@ -5016,14 +5899,15 @@ async function executeReviewControllerOperation(
|
|
|
5016
5899
|
return nativeStatusFailed(parameters.operation, error);
|
|
5017
5900
|
}
|
|
5018
5901
|
}
|
|
5019
|
-
|
|
5902
|
+
const answeringRepositoryCwd = realpathSync(defaultCwd);
|
|
5903
|
+
if (answeringRepositoryCwd !== pending.repositoryCwd) return consentBindingRepositoryMismatchOutcome(parameters.operation, input.consentBinding, pending.repositoryCwd, answeringRepositoryCwd);
|
|
5020
5904
|
if (reviewConsentDigest(pending.consent) !== pending.consentDigest) throw new Error("Review controller consent envelope binding changed");
|
|
5021
5905
|
pending.verifyCandidate();
|
|
5022
5906
|
if (nativeReviewCli?.answerConsent === undefined) throw new Error("Native review consent follow-up is unavailable");
|
|
5023
|
-
if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry,
|
|
5907
|
+
if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession)) {
|
|
5024
5908
|
const stale = staleConsentBindingDiagnostics(
|
|
5025
5909
|
input.consentBinding,
|
|
5026
|
-
pendingReviewConsentRegistry.staleDisposition(
|
|
5910
|
+
pendingReviewConsentRegistry.staleDisposition(input.consentBinding),
|
|
5027
5911
|
);
|
|
5028
5912
|
if (nativeReviewCli.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
|
|
5029
5913
|
try {
|
|
@@ -5040,11 +5924,14 @@ async function executeReviewControllerOperation(
|
|
|
5040
5924
|
try {
|
|
5041
5925
|
const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
|
|
5042
5926
|
if (gated !== undefined) {
|
|
5043
|
-
|
|
5927
|
+
// gentle-pi#668: mode disabled for this exact candidate -- keyed by
|
|
5928
|
+
// its targetIdentity, never by repository alone.
|
|
5929
|
+
recordNativeReviewOutcome(pending.authorityCwd, pending.consent.targetIdentity, NATIVE_REVIEW_OUTCOME.UNAVAILABLE);
|
|
5930
|
+
cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
|
|
5044
5931
|
return gated;
|
|
5045
5932
|
}
|
|
5046
5933
|
} catch (error) {
|
|
5047
|
-
cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry,
|
|
5934
|
+
cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
|
|
5048
5935
|
return nativeOperationFailure(parameters.operation, error);
|
|
5049
5936
|
}
|
|
5050
5937
|
// The one-shot binding is consumed before the first answer-path await. Any
|
|
@@ -5058,6 +5945,9 @@ async function executeReviewControllerOperation(
|
|
|
5058
5945
|
...(signal === undefined ? {} : { signal }),
|
|
5059
5946
|
});
|
|
5060
5947
|
if (answered.kind === "declined") {
|
|
5948
|
+
// gentle-pi#668: candidate-scoped decline, keyed by this exact
|
|
5949
|
+
// candidate's targetIdentity, never by repository alone.
|
|
5950
|
+
recordNativeReviewOutcome(pending.authorityCwd, pending.consent.targetIdentity, NATIVE_REVIEW_OUTCOME.DECLINED);
|
|
5061
5951
|
pending.cleanupCandidate();
|
|
5062
5952
|
return {
|
|
5063
5953
|
operation: parameters.operation,
|
|
@@ -5078,15 +5968,6 @@ async function executeReviewControllerOperation(
|
|
|
5078
5968
|
projection: "workspace",
|
|
5079
5969
|
}, retainedUntrackedSelections);
|
|
5080
5970
|
}
|
|
5081
|
-
if (input.answer === "granted") {
|
|
5082
|
-
try {
|
|
5083
|
-
writeReviewConsentLatch(pending.repositoryCwd);
|
|
5084
|
-
} catch (error) {
|
|
5085
|
-
try {
|
|
5086
|
-
context?.ui.notify(`Native review start completed, but Pi could not record the local consent latch: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
5087
|
-
} catch { /* Reporting is best effort; native completion remains authoritative. */ }
|
|
5088
|
-
}
|
|
5089
|
-
}
|
|
5090
5971
|
return completed;
|
|
5091
5972
|
}
|
|
5092
5973
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED) {
|
|
@@ -5106,7 +5987,7 @@ async function executeReviewControllerOperation(
|
|
|
5106
5987
|
const rejected = input === undefined || canonicalReviewCaptureBinding(input) !== canonicalBinding || exactCollectArgument(input, "target_identity") !== status.targetIdentity || exactCollectArgument(input, "projection") !== status.projection.projection || exactCollectArgument(input, "base_tree") !== status.projection.baseTree || exactCollectArgument(input, "candidate_tree") !== status.projection.currentCandidateTree || !Array.isArray(eligible) || selected.reason !== undefined || selected.intendedUntracked!.some((path) => !eligible.includes(path));
|
|
5107
5988
|
if (rejected) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" };
|
|
5108
5989
|
const submission = { argumentTokens: input.submission!.argumentTokens, value: JSON.stringify({ schema: "gentle-ai.review-intended-untracked-selection/v1", untracked_scope: scope, expected_untracked_inventory: inventory, intended_untracked: selected.intendedUntracked }) };
|
|
5109
|
-
const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey,
|
|
5990
|
+
const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, reviewConsentNow, reviewConsentScheduleTimer, submission);
|
|
5110
5991
|
return { ...result, operation: parameters.operation };
|
|
5111
5992
|
}
|
|
5112
5993
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
|
|
@@ -5164,7 +6045,16 @@ async function executeReviewControllerOperation(
|
|
|
5164
6045
|
} catch (error) {
|
|
5165
6046
|
return nativeOperationFailure(parameters.operation, error);
|
|
5166
6047
|
}
|
|
5167
|
-
|
|
6048
|
+
// gentle-pi#323: the replay key must fold in the current candidate
|
|
6049
|
+
// content identity. Without it, a second START with identical
|
|
6050
|
+
// {cwd, lineageId, input, inputPath} reuses a still-live (never
|
|
6051
|
+
// lineage-bound) frozen candidate view from within the consent TTL
|
|
6052
|
+
// window even after the live candidate content changed underneath
|
|
6053
|
+
// it, and dead-ends at candidate-target-projection-drift with no
|
|
6054
|
+
// recovery. Folding in currentCandidateTree makes a content change
|
|
6055
|
+
// mint a fresh replay key -- and therefore a fresh candidate view --
|
|
6056
|
+
// instead of reusing the stale one.
|
|
6057
|
+
const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null, candidateTree: target.projection.currentCandidateTree });
|
|
5168
6058
|
// Synchronously drop any binding whose TTL has already elapsed
|
|
5169
6059
|
// before reusing its retained candidate view, so a fresh-candidate
|
|
5170
6060
|
// retry cannot reuse a view tied to an expired binding and trip
|
|
@@ -5227,14 +6117,14 @@ async function executeReviewControllerOperation(
|
|
|
5227
6117
|
cleanupCandidate: () => {
|
|
5228
6118
|
if (candidateCleaned) return;
|
|
5229
6119
|
candidateCleaned = true;
|
|
5230
|
-
consentCandidateView.cleanup();
|
|
6120
|
+
try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent expiry/teardown still completes. */ }
|
|
5231
6121
|
},
|
|
5232
6122
|
...(retainedUntrackedSelection === undefined ? {} : { untrackedSelection: retainedUntrackedSelection }),
|
|
5233
6123
|
consent: error.consent,
|
|
5234
6124
|
consentDigest,
|
|
5235
6125
|
expiresAt: reviewConsentNow() + PENDING_REVIEW_CONSENT_TTL_MS,
|
|
5236
6126
|
};
|
|
5237
|
-
pendingReviewConsentRegistry.
|
|
6127
|
+
pendingReviewConsentRegistry.add(pendingReviewConsentSession, pending);
|
|
5238
6128
|
pending.expiry = reviewConsentScheduleTimer(
|
|
5239
6129
|
() => expirePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession),
|
|
5240
6130
|
PENDING_REVIEW_CONSENT_TTL_MS,
|
|
@@ -5375,12 +6265,16 @@ async function executeReviewControllerOperation(
|
|
|
5375
6265
|
const effectiveUntrackedSelection = rawStatus === undefined && parameters.lineageId !== undefined
|
|
5376
6266
|
? readRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId)
|
|
5377
6267
|
: untrackedSelection;
|
|
6268
|
+
const retainedCommittedTarget = rawStatus === undefined && parameters.lineageId !== undefined && candidateViews?.hasProjection(parameters.lineageId, defaultCwd)
|
|
6269
|
+
? candidateViews.resolveProjection(parameters.lineageId, defaultCwd)
|
|
6270
|
+
: undefined;
|
|
6271
|
+
const effectiveBaseRef = baseRef ?? (retainedCommittedTarget?.committedOnly === true ? retainedCommittedTarget.baseCommit : undefined);
|
|
5378
6272
|
if (nativeReviewCli?.targetStatus !== undefined) {
|
|
5379
6273
|
try {
|
|
5380
6274
|
const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
|
|
5381
6275
|
cwd: defaultCwd,
|
|
5382
6276
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5383
|
-
...(
|
|
6277
|
+
...(effectiveBaseRef === undefined ? {} : { baseRef: effectiveBaseRef, committedOnly: true }),
|
|
5384
6278
|
...(effectiveUntrackedSelection.untrackedScope === undefined ? {} : effectiveUntrackedSelection),
|
|
5385
6279
|
...(signal === undefined ? {} : { signal }),
|
|
5386
6280
|
}, retainedUntrackedSelections, defaultCwd);
|
|
@@ -5422,11 +6316,15 @@ export const __testing = {
|
|
|
5422
6316
|
nativeStatusUnsupported,
|
|
5423
6317
|
executeReviewControllerOperation,
|
|
5424
6318
|
executeReviewCaptureOperation,
|
|
6319
|
+
executeReviewCaptureGroupOperation,
|
|
5425
6320
|
setReviewHostRelayRunnerForTesting,
|
|
6321
|
+
setReviewHostRelayGroupRunnersForTesting,
|
|
5426
6322
|
clearReviewTransportProbeForTesting,
|
|
5427
6323
|
renderSddModelPanel: renderSddModelPanelForTesting,
|
|
5428
6324
|
getOrchestratorPrompt,
|
|
5429
6325
|
renderOrchestratorPrompt,
|
|
6326
|
+
loadReviewContractPromptFragment,
|
|
6327
|
+
readMirroredReviewContractFragment,
|
|
5430
6328
|
loadBackgroundSubagentsPolicy,
|
|
5431
6329
|
resolveBackgroundSubagentsPolicy,
|
|
5432
6330
|
renderBackgroundSubagentsReport,
|
|
@@ -5435,8 +6333,19 @@ export const __testing = {
|
|
|
5435
6333
|
resolveBackgroundSubagentsCapability,
|
|
5436
6334
|
readActiveToolNames,
|
|
5437
6335
|
renderBackgroundSubagentsStatusLine,
|
|
6336
|
+
renderRddStatusLine,
|
|
6337
|
+
isValidRddModeStatus,
|
|
6338
|
+
resolveRddModeStatus,
|
|
6339
|
+
resolveRddStatusLine,
|
|
6340
|
+
RDD_STATUS_TIMEOUT_MS,
|
|
6341
|
+
RDD_STATUS_MEMO_TTL_MS,
|
|
6342
|
+
clearRddStatusMemoForTesting,
|
|
6343
|
+
readNativeReviewOutcome,
|
|
6344
|
+
recordNativeReviewOutcome,
|
|
6345
|
+
clearNativeReviewOutcomeMemoForTesting,
|
|
5438
6346
|
resolveControllerSddStatus,
|
|
5439
6347
|
resolveStartupControllerSddStatus,
|
|
6348
|
+
resetTelemetryTriggerGuardForTesting,
|
|
5440
6349
|
createGentleAiExtension: createGentleAiExtensionForTesting,
|
|
5441
6350
|
};
|
|
5442
6351
|
|
|
@@ -5470,6 +6379,22 @@ export interface GentleAiRuntimeDependencies {
|
|
|
5470
6379
|
// sleep and without relying on the queued cleanup macrotask firing.
|
|
5471
6380
|
now?: () => number;
|
|
5472
6381
|
scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
|
|
6382
|
+
// The environment the session's child processes inherit; tests inject a
|
|
6383
|
+
// plain object so the handshake declaration is observable without
|
|
6384
|
+
// touching the test runner's own process.env.
|
|
6385
|
+
processEnv?: NodeJS.ProcessEnv;
|
|
6386
|
+
// Package-owned children use this parent-bound channel only to ask whether
|
|
6387
|
+
// their own pending ordinary START may replay a grant locally.
|
|
6388
|
+
childStandingReviewPermissionClient?: Pick<ChildStandingReviewPermissionClient, "requestAuthorization" | "close">;
|
|
6389
|
+
// gentle-pi#677: test-only seams for the telemetry trigger. Production
|
|
6390
|
+
// leaves both undefined: the real package-local resolveGentleAiBinary()
|
|
6391
|
+
// and the real detached child_process spawn run.
|
|
6392
|
+
resolveTelemetryTriggerBinary?: () => string;
|
|
6393
|
+
telemetryTriggerSpawn?: TelemetryTriggerSpawn;
|
|
6394
|
+
// Test-only seam for the foreground `/gentle:telemetry` slash command's
|
|
6395
|
+
// bounded exec; production leaves this undefined and uses the real node
|
|
6396
|
+
// exec-file adapter shared with the rest of the extension.
|
|
6397
|
+
telemetryExecFileAdapter?: ExecFileAdapter;
|
|
5473
6398
|
}
|
|
5474
6399
|
|
|
5475
6400
|
export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void {
|
|
@@ -5478,21 +6403,67 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
5478
6403
|
|
|
5479
6404
|
function createGentleAiExtensionForTesting(
|
|
5480
6405
|
dependencies: GentleAiRuntimeDependencies = {},
|
|
5481
|
-
writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
|
|
5482
6406
|
): (pi: ExtensionAPI) => void {
|
|
5483
6407
|
const nativeReviewCli = dependencies.nativeReviewCli === undefined ? createNativeReviewCli() : dependencies.nativeReviewCli;
|
|
6408
|
+
const childStandingReviewPermissionLease = dependencies.childStandingReviewPermissionClient === undefined
|
|
6409
|
+
? acquireChildStandingReviewPermissionClient(dependencies.processEnv ?? process.env)
|
|
6410
|
+
: undefined;
|
|
6411
|
+
const childStandingReviewPermission = dependencies.childStandingReviewPermissionClient ?? childStandingReviewPermissionLease?.client;
|
|
5484
6412
|
const reviewConsentNow = dependencies.now ?? (() => Date.now());
|
|
5485
6413
|
const reviewConsentScheduleTimer = dependencies.scheduleTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
5486
6414
|
const pendingReviewConsentRegistry = dependencies.pendingReviewConsentRegistry ?? processPendingReviewConsentRegistry;
|
|
6415
|
+
const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary;
|
|
6416
|
+
const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter();
|
|
5487
6417
|
return function gentleAi(pi: ExtensionAPI): void {
|
|
6418
|
+
declareReviewRelayHandshake(dependencies.processEnv ?? process.env);
|
|
5488
6419
|
const pendingReviewConsentFallbackKey = Symbol("pending-review-consent-fallback");
|
|
5489
6420
|
const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
|
|
5490
6421
|
const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events);
|
|
6422
|
+
const permissionEnvironment = dependencies.processEnv ?? process.env;
|
|
5491
6423
|
|
|
5492
|
-
|
|
6424
|
+
const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => {
|
|
6425
|
+
try {
|
|
6426
|
+
(context.ui as unknown as { setStatus?: (key: string, text?: string) => void }).setStatus?.(
|
|
6427
|
+
REVIEW_SESSION_PERMISSION_STATUS_KEY,
|
|
6428
|
+
active ? REVIEW_SESSION_PERMISSION_STATUS_TEXT : undefined,
|
|
6429
|
+
);
|
|
6430
|
+
} catch { /* Status is nonblocking and never permission authority. */ }
|
|
6431
|
+
};
|
|
6432
|
+
const capturePermissionIdentity = (context: ExtensionContext, cwd: string = context.cwd): Promise<ReviewSessionIdentity | undefined> =>
|
|
6433
|
+
captureReviewSessionIdentity({ ...context, cwd }, permissionEnvironment);
|
|
6434
|
+
const refreshReviewSessionPermissionStatus = async (context: ExtensionContext): Promise<ReviewSessionIdentity | undefined> => {
|
|
6435
|
+
const identity = await capturePermissionIdentity(context);
|
|
6436
|
+
setReviewSessionPermissionStatus(context, identity !== undefined && hasReviewSessionPermission(identity));
|
|
6437
|
+
return identity;
|
|
6438
|
+
};
|
|
6439
|
+
const revokeCurrentReviewSessionPermission = (context: ExtensionContext): boolean => {
|
|
6440
|
+
const coordinates = reviewSessionManagerAndId(context);
|
|
6441
|
+
const revoked = coordinates === undefined ? false : revokeReviewSessionPermissionsForSession(coordinates.manager, coordinates.sessionId);
|
|
6442
|
+
setReviewSessionPermissionStatus(context, false);
|
|
6443
|
+
return revoked;
|
|
6444
|
+
};
|
|
6445
|
+
const revokeCurrentRepositoryReviewSessionPermission = async (context: ExtensionContext): Promise<boolean> => {
|
|
6446
|
+
const identity = await capturePermissionIdentity(context);
|
|
6447
|
+
const revoked = identity === undefined ? false : revokeReviewSessionPermission(identity);
|
|
6448
|
+
setReviewSessionPermissionStatus(context, false);
|
|
6449
|
+
return revoked;
|
|
6450
|
+
};
|
|
6451
|
+
|
|
6452
|
+
pi.on("session_shutdown", (event, context) => {
|
|
6453
|
+
// Pi tears down this registry on reload as well as session replacement/quit.
|
|
6454
|
+
try { candidateViews?.cleanupAll(); } catch { /* Preserve failed owned views for later recovery. */ }
|
|
6455
|
+
const reason = (event as { reason?: unknown }).reason;
|
|
6456
|
+
if (reason !== "reload") {
|
|
6457
|
+
if (childStandingReviewPermissionLease !== undefined) childStandingReviewPermissionLease.closeIfCurrent();
|
|
6458
|
+
else childStandingReviewPermission?.close();
|
|
6459
|
+
revokeCurrentReviewSessionPermission(context);
|
|
6460
|
+
}
|
|
5493
6461
|
const sessionKey = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey);
|
|
5494
6462
|
cleanupAllPendingReviewConsents(pendingReviewConsentRegistry, sessionKey);
|
|
5495
6463
|
processRetainedNativeStatusSelections.delete(sessionKey);
|
|
6464
|
+
processAgentEndSubagentDepth.delete(sessionKey);
|
|
6465
|
+
processAgentEndPreflightNudgedTargets.delete(sessionKey);
|
|
6466
|
+
processAgentEndSessionBaseline.delete(sessionKey);
|
|
5496
6467
|
});
|
|
5497
6468
|
|
|
5498
6469
|
pi.registerTool({
|
|
@@ -5508,8 +6479,8 @@ function createGentleAiExtensionForTesting(
|
|
|
5508
6479
|
context as GentleAiRenderContext | undefined,
|
|
5509
6480
|
);
|
|
5510
6481
|
},
|
|
5511
|
-
renderResult(result, options) {
|
|
5512
|
-
return renderGentleAiResult(result, options);
|
|
6482
|
+
renderResult(result, options, theme, context) {
|
|
6483
|
+
return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
|
|
5513
6484
|
},
|
|
5514
6485
|
async execute(_toolCallId, parameters) {
|
|
5515
6486
|
const input = parameters as ReviewScopeParameters;
|
|
@@ -5518,6 +6489,59 @@ function createGentleAiExtensionForTesting(
|
|
|
5518
6489
|
},
|
|
5519
6490
|
});
|
|
5520
6491
|
|
|
6492
|
+
// The lens a reviewer capture runs is inside its collect binding, so the
|
|
6493
|
+
// card can say "review capture · risk" instead of a bare operation name.
|
|
6494
|
+
const lensLabel = (lens: unknown): string | undefined =>
|
|
6495
|
+
typeof lens === "string" && lens.length > 0 ? lens.replace(/^review-/, "") : undefined;
|
|
6496
|
+
const collectBindingLens = (binding: unknown): string | undefined => {
|
|
6497
|
+
if (typeof binding !== "string") return undefined;
|
|
6498
|
+
try {
|
|
6499
|
+
const parsed = JSON.parse(binding) as Record<string, unknown>;
|
|
6500
|
+
const subject = (parsed.artifactSubject ?? parsed.artifact_subject) as Record<string, unknown> | undefined;
|
|
6501
|
+
return lensLabel(subject?.lens);
|
|
6502
|
+
} catch {
|
|
6503
|
+
return undefined;
|
|
6504
|
+
}
|
|
6505
|
+
};
|
|
6506
|
+
const withLenses = (operation: string, lenses: readonly (string | undefined)[]): string => {
|
|
6507
|
+
const named = lenses.filter((lens): lens is string => lens !== undefined);
|
|
6508
|
+
return named.length === 0 ? operation : `${operation} · ${named.join(" · ")}`;
|
|
6509
|
+
};
|
|
6510
|
+
|
|
6511
|
+
pi.registerTool({
|
|
6512
|
+
name: "gentle_review_capture_group",
|
|
6513
|
+
label: "Gentle Review Capture Group",
|
|
6514
|
+
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.",
|
|
6515
|
+
promptSnippet: "Use one complete exact current STATUS materialize reviewer group; acknowledge its forecast before the grouped run.",
|
|
6516
|
+
promptGuidelines: [
|
|
6517
|
+
"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.",
|
|
6518
|
+
"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.",
|
|
6519
|
+
],
|
|
6520
|
+
parameters: REVIEW_CAPTURE_GROUP_PARAMETERS,
|
|
6521
|
+
executionMode: "sequential",
|
|
6522
|
+
renderCall(args, theme, context) {
|
|
6523
|
+
const bindings = (args as { collectBindings?: unknown }).collectBindings;
|
|
6524
|
+
const lenses = Array.isArray(bindings) ? bindings.map(collectBindingLens) : [];
|
|
6525
|
+
return renderGentleAiLifecycleCall(withLenses("review capture group", lenses), theme, context as GentleAiRenderContext | undefined);
|
|
6526
|
+
},
|
|
6527
|
+
renderResult(result, options, theme, context) {
|
|
6528
|
+
return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
|
|
6529
|
+
},
|
|
6530
|
+
async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
|
|
6531
|
+
if (signal?.aborted) throw new Error("Review capture group was cancelled");
|
|
6532
|
+
const details = await executeReviewCaptureGroupOperation(
|
|
6533
|
+
parameters,
|
|
6534
|
+
ctx.cwd,
|
|
6535
|
+
nativeReviewCli,
|
|
6536
|
+
signal,
|
|
6537
|
+
candidateViews,
|
|
6538
|
+
((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
|
|
6539
|
+
true,
|
|
6540
|
+
);
|
|
6541
|
+
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
6542
|
+
},
|
|
6543
|
+
});
|
|
6544
|
+
|
|
5521
6545
|
pi.registerTool({
|
|
5522
6546
|
name: "gentle_review_capture",
|
|
5523
6547
|
label: "Gentle Review Capture",
|
|
@@ -5530,15 +6554,15 @@ function createGentleAiExtensionForTesting(
|
|
|
5530
6554
|
],
|
|
5531
6555
|
parameters: REVIEW_CAPTURE_PARAMETERS,
|
|
5532
6556
|
executionMode: "sequential",
|
|
5533
|
-
renderCall(
|
|
6557
|
+
renderCall(args, theme, context) {
|
|
5534
6558
|
return renderGentleAiLifecycleCall(
|
|
5535
|
-
"review capture",
|
|
6559
|
+
withLenses("review capture", [collectBindingLens((args as { collectBinding?: unknown }).collectBinding)]),
|
|
5536
6560
|
theme,
|
|
5537
6561
|
context as GentleAiRenderContext | undefined,
|
|
5538
6562
|
);
|
|
5539
6563
|
},
|
|
5540
|
-
renderResult(result, options) {
|
|
5541
|
-
return renderGentleAiResult(result, options);
|
|
6564
|
+
renderResult(result, options, theme, context) {
|
|
6565
|
+
return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
|
|
5542
6566
|
},
|
|
5543
6567
|
async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
|
|
5544
6568
|
if (signal?.aborted) throw new Error("Review capture was cancelled");
|
|
@@ -5567,11 +6591,12 @@ function createGentleAiExtensionForTesting(
|
|
|
5567
6591
|
promptGuidelines: [
|
|
5568
6592
|
'Call {"operation":"inspect"} before START. New native ordinary START uses a JSON string such as "{\\"mode\\":\\"ordinary\\"}"; an explicit baseRef must be paired with committedOnly: true to request a committed range, while policyPath remains repository-local. policyHash is legacy compact-only. The controller derives lineage, Git/untracked scope, tier, lenses, authored lines, and budget; the frozen correction budget counts logical corrections, while correction-plan correctionLines count diff lines (one replaced source line is one deletion plus one addition).',
|
|
5569
6593
|
"Use RECONCILE_AUTHORITY only to quarantine one invalid native recovery successor. Supply exact predecessorLineage, expectedPredecessorRevision, successorLineage, expectedSuccessorRevision, actor, and reason values; Pi derives and displays the seven-line native authorization binding for fresh UI approval. The predecessor stays untouched, native returns the durable audit record, and Pi never falls back to RESET or RECOVER.",
|
|
5570
|
-
"Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, capturedLensResults, findingsPresent,
|
|
6594
|
+
"Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, capturedLensResults, findingsPresent, actor, and reason; QUARANTINE_LEGACY accepts only the published malformed freeze-findings diagnostic/disposition. A dual reconciliation may supply only anomalies `unchanged_target,malformed_recovery_authorization` in that exact order. Use REPAIR_LEGACY_ALIAS only with lineage, actor, and reason: Pi freshly reads native inventory and derives repository, revision, diagnostic, disposition, and the exact eight-line binding before interactive approval. `review dispose-result` is unsupported pending design.",
|
|
5571
6595
|
"Lens, refuter, and validator verdicts are admitted natively, never Pi-authored. Use gentle_review_capture with exactly one current provider-owned collectBinding for ordinary native capture; it never follows another transition.",
|
|
5572
6596
|
"For blocked-legacy or blocked-mixed, do not call START repeatedly. Explain invalidation, request explicit user authorization, then call RESET or RECOVER only after authorization. RESET and RECOVER_LOCK route to audited native `gentle-ai review reclaim`; only RESET carries the legacy repositoryId, commonDirHash, inventoryHash, and confirmation challenge. RECOVER routes to native `gentle-ai review recover` with exactly six inputs: predecessorLineage, expectedPredecessorRevision, successorLineage, disposition, actor, and reason. Never send RECOVER the reset challenge and never send it a maintainerAuthorization: Pi reads fresh native target status, pins the predecessor lineage, revision, provider-selected disposition, and target identity, derives the exact six-line native authorization binding, displays it for fresh UI approval, and re-reads status before mutating. Negotiated target status supplies the sole accepted recovery disposition, and a caller-supplied substitute is rejected. Treat a native-input-required envelope as a request for exact values, never as permission to invent them. After a committed native recovery record, INSPECT before any fresh ordinary START.",
|
|
5573
|
-
"A consent-required START
|
|
6597
|
+
"A consent-required START may be resolved inside the eligible interactive Pi host. Its third UI action is host-owned: it runs this envelope's exact provider grant once and allows later fresh validated envelopes only for the same live SessionManager, nonempty session ID, and canonical Git common-directory identity, including sibling worktrees; an unrelated repository requires a new explicit human grant. Revoke removes the current repository grant, while nonreload replacement, quit, and process exit remove all session grants; reload preserves them. It grants no provider mode, verdict, acknowledgement, maintenance, delivery, or cross-repository authority. A package-owned child may ask its parent only with the canonical digest of its exact pending target; the parent binds that digest to the task repository and fails closed otherwise. If the tool returns an unresolved envelope, present the original two provider choices without changing machine tokens, commands, target IDs, or invocations; never add the host action to the decoded provider envelope. After one explicit relayed human answer, call answer-consent exactly once with only consentBinding and answer (`granted` or `declined`). Never create host permission from tool arguments, model prose, child/headless responses, or an uncertain native result. A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START output, the controller calls target-scoped native status once and returns only its declared action. An ambiguous gentle_review_capture outcome independently reconciles once and never replays the capture.",
|
|
5574
6598
|
"Use gentle_review only for native review authority operations; delivery commands follow ordinary repository policy.",
|
|
6599
|
+
'ASSESS (gentle-pi#662/#668) is read-only and needs no lineageId: after a delegated writer returns, call {"operation":"assess"} over its diff and follow the returned plan (writerSelfVerification, structuralReadbackOnly, independentVerifier, reason) instead of judging non-triviality from the task description. Pass input as JSON only to assess a committed range ({"baseRef":"<ref>","committedOnly":true}), to record the writer profile ({"writerModelId":"...", "writerEffort":"..."}), or to state the native review\'s outcome for this candidate ({"nativeReviewOutcome":"closed|declined|unavailable|unknown"}). Omitting writerModelId and writerEffort is treated as a small writer profile (fail closed), never large, because the writer\'s actual profile is then unknown to this call; pass the writer\'s real model id/effort to get credit for a known large profile. The on-path (writer self-verification is the record, no separate verifier) holds only when nativeReviewOutcome is "closed" for this candidate; a decline, an unavailable review, or an omitted/unknown outcome falls back to the exact risk-gated plan RDD off would return, re-enabling the separate verifier -- a decline is candidate-scoped and never lowers the bar below RDD off. "closed" is never inferred: pass it only right after this same caller acknowledged the approved review for this same candidate; omitting nativeReviewOutcome only ever auto-derives declined/unavailable, bound to that exact candidate\'s own target identity, never to a different candidate or to bare repository state. The result\'s outcome_source (explicit|derived|unknown) states which. A failed or unavailable native assessment reports risk "unassessable", verified exactly like "high". This never mutates review authority state.',
|
|
5575
6600
|
],
|
|
5576
6601
|
parameters: REVIEW_CONTROLLER_PARAMETERS,
|
|
5577
6602
|
executionMode: "sequential",
|
|
@@ -5582,26 +6607,99 @@ function createGentleAiExtensionForTesting(
|
|
|
5582
6607
|
context as GentleAiRenderContext | undefined,
|
|
5583
6608
|
);
|
|
5584
6609
|
},
|
|
5585
|
-
renderResult(result, options) {
|
|
5586
|
-
return renderGentleAiResult(result, options);
|
|
6610
|
+
renderResult(result, options, theme, context) {
|
|
6611
|
+
return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
|
|
5587
6612
|
},
|
|
5588
6613
|
async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
|
|
5589
6614
|
if (signal?.aborted) throw new Error("Review controller operation was cancelled");
|
|
5590
6615
|
await authorizeDestructiveReviewOperation(parameters, ctx);
|
|
5591
|
-
const
|
|
6616
|
+
const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
6617
|
+
const retainedSelections = processRetainedNativeStatusSelections.get(sessionKey)
|
|
6618
|
+
?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!;
|
|
6619
|
+
let details = await executeReviewControllerOperation(
|
|
5592
6620
|
parameters,
|
|
5593
6621
|
ctx.cwd,
|
|
5594
6622
|
nativeReviewCli,
|
|
5595
6623
|
signal,
|
|
5596
6624
|
candidateViews,
|
|
5597
6625
|
ctx,
|
|
5598
|
-
|
|
6626
|
+
retainedSelections,
|
|
5599
6627
|
pendingReviewConsentRegistry,
|
|
5600
6628
|
pendingReviewConsentFallbackKey,
|
|
5601
|
-
writeReviewConsentLatch,
|
|
5602
6629
|
reviewConsentNow,
|
|
5603
6630
|
reviewConsentScheduleTimer,
|
|
5604
6631
|
);
|
|
6632
|
+
if (
|
|
6633
|
+
isHostReviewConsentEligibleOperation(parameters) &&
|
|
6634
|
+
details.outcome === "native-review-consent-required" &&
|
|
6635
|
+
typeof details.consent_binding === "string"
|
|
6636
|
+
) {
|
|
6637
|
+
const resolved = pendingReviewConsentRegistry.resolve(details.consent_binding);
|
|
6638
|
+
const pending = resolved?.pending;
|
|
6639
|
+
const eligiblePending = pending !== undefined && isPiConsentV3(pending.consent)
|
|
6640
|
+
? pending
|
|
6641
|
+
: undefined;
|
|
6642
|
+
const answerPendingConsent = async (answer: "granted" | "declined") => executeReviewControllerOperation(
|
|
6643
|
+
{
|
|
6644
|
+
operation: REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT,
|
|
6645
|
+
input: JSON.stringify({ consentBinding: eligiblePending!.id, answer }),
|
|
6646
|
+
workspaceRoot: eligiblePending!.authorityCwd,
|
|
6647
|
+
},
|
|
6648
|
+
ctx.cwd,
|
|
6649
|
+
nativeReviewCli,
|
|
6650
|
+
signal,
|
|
6651
|
+
candidateViews,
|
|
6652
|
+
ctx,
|
|
6653
|
+
retainedSelections,
|
|
6654
|
+
pendingReviewConsentRegistry,
|
|
6655
|
+
pendingReviewConsentFallbackKey,
|
|
6656
|
+
reviewConsentNow,
|
|
6657
|
+
reviewConsentScheduleTimer,
|
|
6658
|
+
);
|
|
6659
|
+
let permissionWorkspaceRoot: string | undefined;
|
|
6660
|
+
try {
|
|
6661
|
+
const parsed = parseReviewControllerParameters(parameters);
|
|
6662
|
+
permissionWorkspaceRoot = resolveReviewControllerWorkspaceRoot(parsed.workspaceRoot, ctx.cwd, candidateViews, parsed.lineageId);
|
|
6663
|
+
} catch {
|
|
6664
|
+
// The successful native operation above remains authoritative; an
|
|
6665
|
+
// unresolvable local binding simply cannot consume host permission.
|
|
6666
|
+
}
|
|
6667
|
+
const initialIdentity = permissionWorkspaceRoot === undefined
|
|
6668
|
+
? undefined
|
|
6669
|
+
: await capturePermissionIdentity(ctx, permissionWorkspaceRoot);
|
|
6670
|
+
if (eligiblePending !== undefined && initialIdentity === undefined) {
|
|
6671
|
+
// A package child has no local standing grant. It may ask its
|
|
6672
|
+
// inherited parent only for the canonical repository identity of
|
|
6673
|
+
// this exact pending target, then replay this local binding once.
|
|
6674
|
+
const repositoryIdentity = permissionWorkspaceRoot === undefined
|
|
6675
|
+
? undefined
|
|
6676
|
+
: await resolveCanonicalGitRepositoryIdentity(permissionWorkspaceRoot);
|
|
6677
|
+
if (repositoryIdentity !== undefined && await childStandingReviewPermission?.requestAuthorization(repositoryIdentity) === true) details = await answerPendingConsent("granted");
|
|
6678
|
+
} else if (eligiblePending !== undefined && initialIdentity !== undefined) {
|
|
6679
|
+
const initialEpoch = reviewSessionPermissionEpoch(initialIdentity);
|
|
6680
|
+
const permissionAlreadyActive = hasReviewSessionPermission(initialIdentity);
|
|
6681
|
+
const selection = initialEpoch === undefined
|
|
6682
|
+
? undefined
|
|
6683
|
+
: permissionAlreadyActive
|
|
6684
|
+
? { kind: "host-session" as const }
|
|
6685
|
+
: await presentReviewConsentUi(ctx, eligiblePending.consent);
|
|
6686
|
+
if (selection !== undefined) {
|
|
6687
|
+
const confirmedIdentity = await capturePermissionIdentity(ctx, permissionWorkspaceRoot);
|
|
6688
|
+
if (initialEpoch !== undefined && confirmedIdentity !== undefined && sameReviewSessionIdentity(initialIdentity, confirmedIdentity) && reviewSessionPermissionEpoch(confirmedIdentity) === initialEpoch) {
|
|
6689
|
+
const answer = selection.kind === "provider" ? selection.answer : "granted";
|
|
6690
|
+
details = await answerPendingConsent(answer);
|
|
6691
|
+
if (!permissionAlreadyActive && selection.kind === "host-session" && completedGrantedReviewConsent(details)) {
|
|
6692
|
+
if (grantReviewSessionPermission(confirmedIdentity, initialEpoch)) {
|
|
6693
|
+
setReviewSessionPermissionStatus(ctx, true);
|
|
6694
|
+
try { ctx.ui.notify("Reviews are allowed for this Pi session and this Git repository.", "info"); } catch { /* Nonblocking indication only. */ }
|
|
6695
|
+
} else {
|
|
6696
|
+
try { ctx.ui.notify("This review started, but the in-memory session permission registry was incompatible, so later candidates will ask again.", "warning"); } catch { /* Best effort. */ }
|
|
6697
|
+
}
|
|
6698
|
+
}
|
|
6699
|
+
}
|
|
6700
|
+
}
|
|
6701
|
+
}
|
|
6702
|
+
}
|
|
5605
6703
|
return {
|
|
5606
6704
|
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
5607
6705
|
details,
|
|
@@ -5613,7 +6711,11 @@ function createGentleAiExtensionForTesting(
|
|
|
5613
6711
|
return ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installSddAssets(cwd, false), applyModelConfig: async () => applySavedModelConfig(ctx) }, { promptFields });
|
|
5614
6712
|
}
|
|
5615
6713
|
|
|
5616
|
-
pi.on("session_start", async (
|
|
6714
|
+
pi.on("session_start", async (event, ctx) => {
|
|
6715
|
+
try { candidateViews?.sweepOrphans(ctx.cwd); } catch { /* Ownership sweeping must not block startup. */ }
|
|
6716
|
+
const reason = (event as { reason?: unknown }).reason;
|
|
6717
|
+
if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx);
|
|
6718
|
+
await refreshReviewSessionPermissionStatus(ctx);
|
|
5617
6719
|
// Loud, every session: an active dev-binary override means this session
|
|
5618
6720
|
// runs an unpinned gentle-ai. Announce which one before anything else.
|
|
5619
6721
|
try {
|
|
@@ -5650,6 +6752,19 @@ function createGentleAiExtensionForTesting(
|
|
|
5650
6752
|
);
|
|
5651
6753
|
}
|
|
5652
6754
|
}
|
|
6755
|
+
// gentle-pi#568: record the target identity STATUS reports right now,
|
|
6756
|
+
// before this session does anything, as the baseline `agent_end` skips
|
|
6757
|
+
// later. Best-effort and silent: it never notifies and never lets a
|
|
6758
|
+
// STATUS failure fail session start.
|
|
6759
|
+
try {
|
|
6760
|
+
const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
6761
|
+
const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
|
|
6762
|
+
if (status?.targetIdentity !== undefined) {
|
|
6763
|
+
processAgentEndSessionBaseline.set(sessionKey, status.targetIdentity);
|
|
6764
|
+
}
|
|
6765
|
+
} catch {
|
|
6766
|
+
// Baseline recording is best-effort only; never surface or throw.
|
|
6767
|
+
}
|
|
5653
6768
|
});
|
|
5654
6769
|
|
|
5655
6770
|
pi.on("input", async (event, ctx) => {
|
|
@@ -5663,6 +6778,32 @@ function createGentleAiExtensionForTesting(
|
|
|
5663
6778
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
5664
6779
|
const isSddAgent = isSddAgentStartEvent(event);
|
|
5665
6780
|
const isNamedAgent = isNamedAgentStartEvent(event);
|
|
6781
|
+
const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
6782
|
+
if (isSddAgent || isNamedAgent) {
|
|
6783
|
+
processAgentEndSubagentDepth.set(subagentDepthKey, (processAgentEndSubagentDepth.get(subagentDepthKey) ?? 0) + 1);
|
|
6784
|
+
} else {
|
|
6785
|
+
processAgentEndSubagentDepth.set(subagentDepthKey, 0);
|
|
6786
|
+
}
|
|
6787
|
+
// gentle-pi#677: nudge gentle-ai's own telemetry trigger for a primary
|
|
6788
|
+
// session only, reusing the exact isNamedAgent/isSddAgent predicate that
|
|
6789
|
+
// decides the orchestrator prompt below. At most one attempt per
|
|
6790
|
+
// process regardless of how many primary-session before_agent_start
|
|
6791
|
+
// events this process observes; a missing/old binary or a spawn error
|
|
6792
|
+
// must never affect activation, so every failure is swallowed silently.
|
|
6793
|
+
if (!isNamedAgent && !isSddAgent && !processTelemetryTriggerAttempted) {
|
|
6794
|
+
processTelemetryTriggerAttempted = true;
|
|
6795
|
+
try {
|
|
6796
|
+
const executable = resolveTelemetryTriggerBinary();
|
|
6797
|
+
spawnTelemetryTrigger({
|
|
6798
|
+
executable,
|
|
6799
|
+
cwd: ctx.cwd,
|
|
6800
|
+
env: dependencies.processEnv ?? process.env,
|
|
6801
|
+
spawn: dependencies.telemetryTriggerSpawn,
|
|
6802
|
+
});
|
|
6803
|
+
} catch {
|
|
6804
|
+
// Best-effort only; never surfaced and never affects activation.
|
|
6805
|
+
}
|
|
6806
|
+
}
|
|
5666
6807
|
if (isSddAgent && !getSddPreflightPreferences(ctx)) {
|
|
5667
6808
|
await runSddPreflight(ctx);
|
|
5668
6809
|
}
|
|
@@ -5680,14 +6821,77 @@ function createGentleAiExtensionForTesting(
|
|
|
5680
6821
|
prefs?.artifactStore,
|
|
5681
6822
|
), phase)}`
|
|
5682
6823
|
: "";
|
|
6824
|
+
// gentle-pi#661: the RDD status line (and the rest of the gentle prompt)
|
|
6825
|
+
// is built only for the primary session, mirrored on the
|
|
6826
|
+
// reviewContractPrompt condition below -- named/SDD agents never reach
|
|
6827
|
+
// this branch, so no line is resolved or computed for them.
|
|
6828
|
+
// resolveRddStatusLine never throws and never hangs past
|
|
6829
|
+
// RDD_STATUS_TIMEOUT_MS: an absent/timed-out/aborted/failing native
|
|
6830
|
+
// binary renders the fail-closed "unknown" line instead.
|
|
5683
6831
|
const gentlePrompt = isNamedAgent || isSddAgent
|
|
5684
6832
|
? ""
|
|
5685
|
-
: `\n\n${buildGentlePrompt(
|
|
6833
|
+
: `\n\n${buildGentlePrompt(
|
|
6834
|
+
readPersonaMode(ctx.cwd),
|
|
6835
|
+
ctx.cwd,
|
|
6836
|
+
readActiveToolNames(pi),
|
|
6837
|
+
await resolveRddStatusLine(nativeReviewCli, ctx.cwd, AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS), undefined, ctx),
|
|
6838
|
+
)}`;
|
|
6839
|
+
// gentle-pi#560 / gentle-ai#4056, #4057: inject the mirrored provider
|
|
6840
|
+
// contract bundle's review execution contract for the primary session
|
|
6841
|
+
// only, and only when a native review CLI is actually present.
|
|
6842
|
+
const reviewContractPrompt =
|
|
6843
|
+
!isNamedAgent && !isSddAgent && nativeReviewCli !== null
|
|
6844
|
+
? (() => {
|
|
6845
|
+
const fragment = loadReviewContractPromptFragment(ctx);
|
|
6846
|
+
return fragment === null ? "" : `\n\n${fragment}`;
|
|
6847
|
+
})()
|
|
6848
|
+
: "";
|
|
5686
6849
|
return {
|
|
5687
|
-
systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}`,
|
|
6850
|
+
systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}`,
|
|
5688
6851
|
};
|
|
5689
6852
|
});
|
|
5690
6853
|
|
|
6854
|
+
// gentle-pi#556 / gentle-ai#4051: with RDD enabled, the agent could finish
|
|
6855
|
+
// an authorized implementation and report completion without ever running
|
|
6856
|
+
// the review STATUS preflight or offering the consent question. This
|
|
6857
|
+
// handler is read-only and idempotent: it never runs START, never answers
|
|
6858
|
+
// consent, and never writes a file. It only sends one turn-triggering
|
|
6859
|
+
// reminder, at most once per unreviewed target identity per session.
|
|
6860
|
+
// gentle-pi#568: a candidate matching the baseline `session_start`
|
|
6861
|
+
// recorded predates this session's own work and is skipped rather than
|
|
6862
|
+
// nudged, so a worktree already dirty from the user's own edits does not
|
|
6863
|
+
// draw a reminder about work this session never produced.
|
|
6864
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
6865
|
+
if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return;
|
|
6866
|
+
if (ctx.hasUI !== true) return;
|
|
6867
|
+
const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
6868
|
+
const subagentDepth = processAgentEndSubagentDepth.get(sessionKey) ?? 0;
|
|
6869
|
+
if (subagentDepth > 0) {
|
|
6870
|
+
processAgentEndSubagentDepth.set(sessionKey, subagentDepth - 1);
|
|
6871
|
+
return;
|
|
6872
|
+
}
|
|
6873
|
+
const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
|
|
6874
|
+
if (status === undefined) return;
|
|
6875
|
+
if (status.nextTransition?.kind !== "execute" || status.nextTransition.execute.operation !== "review.start") return;
|
|
6876
|
+
const targetIdentity = status.targetIdentity;
|
|
6877
|
+
if (processAgentEndSessionBaseline.get(sessionKey) === targetIdentity) return;
|
|
6878
|
+
let nudged = processAgentEndPreflightNudgedTargets.get(sessionKey);
|
|
6879
|
+
if (nudged === undefined) {
|
|
6880
|
+
nudged = new Set<string>();
|
|
6881
|
+
processAgentEndPreflightNudgedTargets.set(sessionKey, nudged);
|
|
6882
|
+
}
|
|
6883
|
+
if (nudged.has(targetIdentity)) return;
|
|
6884
|
+
nudged.add(targetIdentity);
|
|
6885
|
+
pi.sendMessage(
|
|
6886
|
+
{
|
|
6887
|
+
customType: "gentle-pi.review-preflight",
|
|
6888
|
+
content: renderAgentEndReviewPreflightMessage(targetIdentity),
|
|
6889
|
+
display: true,
|
|
6890
|
+
},
|
|
6891
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
6892
|
+
);
|
|
6893
|
+
});
|
|
6894
|
+
|
|
5691
6895
|
pi.on("tool_call", async (event, ctx) => {
|
|
5692
6896
|
const sensitivePathDenied = evaluateSensitivePathTool(
|
|
5693
6897
|
event.toolName,
|
|
@@ -5901,6 +7105,30 @@ function createGentleAiExtensionForTesting(
|
|
|
5901
7105
|
},
|
|
5902
7106
|
});
|
|
5903
7107
|
|
|
7108
|
+
pi.registerCommand("gentle:review-session-permission", {
|
|
7109
|
+
description: "Show or revoke the process-memory review permission for this exact Pi session and Git repository (status|revoke).",
|
|
7110
|
+
handler: async (args, ctx) => {
|
|
7111
|
+
const subAction = args.trim().length === 0 ? "status" : args.trim();
|
|
7112
|
+
if (subAction !== "status" && subAction !== "revoke") {
|
|
7113
|
+
ctx.ui.notify(`Unknown /gentle:review-session-permission sub-action "${subAction}". Use status or revoke.`, "warning");
|
|
7114
|
+
return;
|
|
7115
|
+
}
|
|
7116
|
+
if (subAction === "revoke") {
|
|
7117
|
+
const revoked = await revokeCurrentRepositoryReviewSessionPermission(ctx);
|
|
7118
|
+
ctx.ui.notify(revoked ? "Review permission revoked for this Git repository in this Pi session. Provider review mode and authority were not changed." : "No review permission is active for this Git repository in this Pi session. Provider review mode and authority were not changed.", "info");
|
|
7119
|
+
return;
|
|
7120
|
+
}
|
|
7121
|
+
const identity = await refreshReviewSessionPermissionStatus(ctx);
|
|
7122
|
+
if (identity === undefined) {
|
|
7123
|
+
ctx.ui.notify("Review session permission is unavailable: it requires the interactive Pi TUI, a non-child session, a nonempty session ID, and a canonical Git worktree.", "info");
|
|
7124
|
+
return;
|
|
7125
|
+
}
|
|
7126
|
+
ctx.ui.notify(hasReviewSessionPermission(identity)
|
|
7127
|
+
? "Reviews are allowed for this Pi session and Git repository. Use /gentle:review-session-permission revoke to ask again."
|
|
7128
|
+
: "Reviews are not pre-authorized for this Pi session; each medium- or high-risk candidate asks normally.", "info");
|
|
7129
|
+
},
|
|
7130
|
+
});
|
|
7131
|
+
|
|
5904
7132
|
pi.registerCommand("gentle:review-mode", {
|
|
5905
7133
|
description: "Show or set the Gentle AI review-driven-development kill switch (status|disable|enable). Every sub-action is user-initiated only; Pi automation never toggles it.",
|
|
5906
7134
|
handler: async (args, ctx) => {
|
|
@@ -5946,6 +7174,56 @@ function createGentleAiExtensionForTesting(
|
|
|
5946
7174
|
},
|
|
5947
7175
|
});
|
|
5948
7176
|
|
|
7177
|
+
// gentle-pi#677: gentle-ai owns telemetry end to end (status, the opt-out
|
|
7178
|
+
// switches, and rate limiting); this command only runs the corresponding
|
|
7179
|
+
// `gentle-ai telemetry <op> --json` in the foreground and relays its
|
|
7180
|
+
// output, so a Pi user never has to leave Pi to check or change it.
|
|
7181
|
+
pi.registerCommand("gentle:telemetry", {
|
|
7182
|
+
description: "Show or change the local Gentle AI telemetry trigger (status|enable|disable|preview); gentle-ai owns the data and the opt-out.",
|
|
7183
|
+
handler: async (args, ctx) => {
|
|
7184
|
+
const subAction = args.trim().length === 0 ? "status" : args.trim();
|
|
7185
|
+
if (subAction !== "status" && subAction !== "enable" && subAction !== "disable" && subAction !== "preview") {
|
|
7186
|
+
ctx.ui.notify(`Unknown /gentle:telemetry sub-action "${subAction}". Use status, enable, disable, or preview.`, "warning");
|
|
7187
|
+
return;
|
|
7188
|
+
}
|
|
7189
|
+
let executable: string;
|
|
7190
|
+
try {
|
|
7191
|
+
executable = resolveTelemetryTriggerBinary();
|
|
7192
|
+
} catch (error) {
|
|
7193
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
7194
|
+
return;
|
|
7195
|
+
}
|
|
7196
|
+
let result: ExecFileResult;
|
|
7197
|
+
try {
|
|
7198
|
+
result = await telemetryExecFileAdapter({
|
|
7199
|
+
file: executable,
|
|
7200
|
+
arguments: ["telemetry", subAction, "--json"],
|
|
7201
|
+
cwd: ctx.cwd,
|
|
7202
|
+
timeoutMs: 5_000,
|
|
7203
|
+
maxBufferBytes: 1024 * 1024,
|
|
7204
|
+
});
|
|
7205
|
+
} catch (error) {
|
|
7206
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
7207
|
+
return;
|
|
7208
|
+
}
|
|
7209
|
+
if (result.exitCode !== 0) {
|
|
7210
|
+
ctx.ui.notify(`gentle-ai telemetry ${subAction} failed (exit ${result.exitCode}): ${(result.stderr || result.stdout || "no output").trim()}`, "error");
|
|
7211
|
+
return;
|
|
7212
|
+
}
|
|
7213
|
+
let relayed: string;
|
|
7214
|
+
try {
|
|
7215
|
+
relayed = JSON.stringify(JSON.parse(result.stdout), null, 2);
|
|
7216
|
+
} catch {
|
|
7217
|
+
relayed = result.stdout.trim();
|
|
7218
|
+
}
|
|
7219
|
+
if (subAction === "disable") {
|
|
7220
|
+
ctx.ui.notify("Gentle AI telemetry disabled.", "info");
|
|
7221
|
+
return;
|
|
7222
|
+
}
|
|
7223
|
+
ctx.ui.notify(relayed, "info");
|
|
7224
|
+
},
|
|
7225
|
+
});
|
|
7226
|
+
|
|
5949
7227
|
// Mirrors gentle:review-mode: a user-owned switch, never an automated one.
|
|
5950
7228
|
// It matters more here than there, because this policy governs whether
|
|
5951
7229
|
// background subagents may be launched at all, so nothing in Pi may write
|