gentle-pi 2.4.0 → 2.6.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 +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +39 -11
- package/assets/orchestrator.md +5 -5
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- 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 +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -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 +240 -0
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -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 +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- 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 +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -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 +656 -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 +156 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
package/extensions/gentle-ai.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { consumeReviewMutation, pendingReviewMutation, recordReviewMutation } from "../lib/review-reminder-receipt.ts";
|
|
2
|
+
import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
|
|
3
|
+
import { resolveResearchCapabilities, renderResearchCapabilities } from "../lib/sdd-research-capabilities.ts";
|
|
4
|
+
import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
|
|
1
5
|
import { execFileSync } from "node:child_process";
|
|
2
6
|
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
7
|
import {
|
|
@@ -28,15 +32,20 @@ import type {
|
|
|
28
32
|
ThemeColor,
|
|
29
33
|
ToolCallEventResult,
|
|
30
34
|
} from "@earendil-works/pi-coding-agent";
|
|
31
|
-
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
35
|
+
import { Key, isKeyRelease, matchesKey, truncateToWidth, type KeybindingsManager, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
32
36
|
import { resolveGentlePiAgentHome } from "../lib/agent-home.ts";
|
|
33
37
|
import {
|
|
34
38
|
ensureSddPreflight,
|
|
35
39
|
getSddPreflightPreferences,
|
|
36
|
-
|
|
40
|
+
installPackageAssets,
|
|
41
|
+
getPackageAssetOwner,
|
|
42
|
+
hasPackageAssetOwnerInstallation,
|
|
43
|
+
type PackageAssetOwner,
|
|
37
44
|
isPackageManagedSddAsset,
|
|
38
45
|
isSddPreflightTrigger,
|
|
39
46
|
renderSddPreflightPrompt,
|
|
47
|
+
isParentConfirmedSddPreflightContext,
|
|
48
|
+
SHIPPED_SDD_AGENT_NAMES,
|
|
40
49
|
SDD_PREFLIGHT_FIELDS,
|
|
41
50
|
type SddPreflightField,
|
|
42
51
|
type SddPreflightPreferences,
|
|
@@ -54,13 +63,46 @@ import {
|
|
|
54
63
|
type ModelConfigFileResult,
|
|
55
64
|
type ThinkingLevel,
|
|
56
65
|
} from "../lib/model-routing-authority.ts";
|
|
66
|
+
import {
|
|
67
|
+
bootstrapProfilesFile,
|
|
68
|
+
buildProfileListItems,
|
|
69
|
+
createProfile,
|
|
70
|
+
deleteProfile,
|
|
71
|
+
duplicateProfile,
|
|
72
|
+
formatOrchestratorSelection,
|
|
73
|
+
formatRoutingRow,
|
|
74
|
+
isProfileOrchestratorKey,
|
|
75
|
+
parseProfileExportTextWithDrops,
|
|
76
|
+
PROFILE_ORCHESTRATOR_KEY,
|
|
77
|
+
profileExportPath,
|
|
78
|
+
profileRoutingRows,
|
|
79
|
+
profilesFilePath,
|
|
80
|
+
readProfileOrchestrator,
|
|
81
|
+
readProfilesFileResult,
|
|
82
|
+
renameProfile,
|
|
83
|
+
routingColumnWidths,
|
|
84
|
+
serializeProfileExport,
|
|
85
|
+
setActiveProfile,
|
|
86
|
+
updateProfile,
|
|
87
|
+
writeProfilesFileSync,
|
|
88
|
+
type AgentProfilesFile,
|
|
89
|
+
type ProfileListItem,
|
|
90
|
+
type ProfileRoutingRow,
|
|
91
|
+
type ProfilesParseDrops,
|
|
92
|
+
} from "../lib/agent-profiles.ts";
|
|
93
|
+
import {
|
|
94
|
+
applyOrchestratorSettings,
|
|
95
|
+
readOrchestratorSettings,
|
|
96
|
+
restoreOrchestratorSettings,
|
|
97
|
+
type OrchestratorSettingsReadResult,
|
|
98
|
+
} from "../lib/profiles-orchestrator.ts";
|
|
99
|
+
import { measureAgentsViewLayout, type AgentsViewLayout } from "../lib/agents-view-layout.ts";
|
|
100
|
+
import { NativeChoiceList } from "../lib/native-choice-list.ts";
|
|
101
|
+
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
57
102
|
import {
|
|
58
103
|
parseSddStatusCommandArgs,
|
|
59
104
|
renderNativeSddPhasePrompt,
|
|
60
|
-
renderSddDispatcherMarkdown,
|
|
61
|
-
renderSddStatusMarkdown,
|
|
62
105
|
resolveSddStatus,
|
|
63
|
-
sddStatusSeverity,
|
|
64
106
|
type SddPhase,
|
|
65
107
|
} from "../lib/sdd-status.ts";
|
|
66
108
|
import {
|
|
@@ -71,6 +113,8 @@ import {
|
|
|
71
113
|
REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
|
|
72
114
|
ReviewHostRelayError,
|
|
73
115
|
reviewHostRelaySlots,
|
|
116
|
+
reviewHostRelayUnachievableDetail,
|
|
117
|
+
reviewHostRelayUnachievableReason,
|
|
74
118
|
reviewProviderRoleVectorSlots,
|
|
75
119
|
resolveReviewHostRelaySubmission,
|
|
76
120
|
runReviewHostRelayReviewerGroup,
|
|
@@ -106,15 +150,24 @@ import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
|
|
|
106
150
|
import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, type CandidateView } from "../lib/review-candidate-view.ts";
|
|
107
151
|
import {
|
|
108
152
|
GentleAiDevBinaryOverrideError,
|
|
153
|
+
GENTLE_AI_INSTALL_RECOVERY_COMMAND,
|
|
154
|
+
GENTLE_AI_INSTALL_RECOVERY_INSTRUCTIONS,
|
|
109
155
|
registerGentleAiDevBinary,
|
|
156
|
+
resolveGentleAiBinary,
|
|
110
157
|
resolveGentleAiDevBinaryOverride,
|
|
111
158
|
unregisterGentleAiDevBinary,
|
|
112
159
|
type GentleAiDevBinaryOverride,
|
|
113
160
|
} from "../lib/gentle-ai-binary.ts";
|
|
161
|
+
import {
|
|
162
|
+
spawnTelemetryTrigger,
|
|
163
|
+
type TelemetryTriggerSpawn,
|
|
164
|
+
} from "../lib/telemetry-trigger.ts";
|
|
114
165
|
import {
|
|
115
166
|
createNativeReviewCli,
|
|
116
167
|
createNodeExecFileAdapter,
|
|
168
|
+
decodeNativeSddStatusV2,
|
|
117
169
|
isCanonicalProcessString,
|
|
170
|
+
isNativeReviewUnachievableVerbRefused,
|
|
118
171
|
nativeReviewAbandonAuthorization,
|
|
119
172
|
nativeReviewLegacyAliasRepairAuthorization,
|
|
120
173
|
nativeReviewLegacyQuarantineAuthorization,
|
|
@@ -134,15 +187,32 @@ import {
|
|
|
134
187
|
NATIVE_REVIEW_RECONCILE_ANOMALIES,
|
|
135
188
|
sanitizeForeignNativeReviewDiagnostics,
|
|
136
189
|
type NativeReviewCli,
|
|
190
|
+
type NativeSddStatusV2,
|
|
137
191
|
type NativeIntendedUntrackedSelectionSubmission,
|
|
138
192
|
type NativeReviewAcknowledgeApprovedOutcome,
|
|
139
193
|
type NativeReviewAcknowledgeApprovedRequest,
|
|
140
194
|
type NativeTargetStatusRequest,
|
|
141
195
|
type NativeReviewModeOperation,
|
|
142
196
|
type NativeReviewModeSource,
|
|
197
|
+
type NativeReviewModeStatus,
|
|
143
198
|
type NativeReviewProcessDiagnostics,
|
|
199
|
+
type NativeReviewUnachievableLensCaptureArtifact,
|
|
144
200
|
type NativeStartResult,
|
|
201
|
+
type NativeReviewAssessRequest,
|
|
202
|
+
type ExecFileAdapter,
|
|
203
|
+
type ExecFileResult,
|
|
145
204
|
} from "../lib/native-review-cli.ts";
|
|
205
|
+
import {
|
|
206
|
+
verificationPlan,
|
|
207
|
+
resolveWriterProfile,
|
|
208
|
+
RDD_LINE,
|
|
209
|
+
VERIFICATION_TIER,
|
|
210
|
+
NATIVE_REVIEW_OUTCOME,
|
|
211
|
+
type RddLine,
|
|
212
|
+
type VerificationTier,
|
|
213
|
+
type ReviewAssessmentV1,
|
|
214
|
+
type NativeReviewOutcome,
|
|
215
|
+
} from "../lib/review-risk-assessment.ts";
|
|
146
216
|
import {
|
|
147
217
|
assertReviewApprovedAcknowledgementExecuteV1,
|
|
148
218
|
decodeReviewLastEventClosureV1,
|
|
@@ -153,7 +223,19 @@ import {
|
|
|
153
223
|
type ReviewStatusV3,
|
|
154
224
|
} from "../lib/review-integration-v2.ts";
|
|
155
225
|
import { reconcileUnknownReviewLastEventCapture } from "../lib/review-last-event-controller.ts";
|
|
156
|
-
import {
|
|
226
|
+
import { acquireChildStandingReviewPermissionClient, type ChildStandingReviewPermissionClient } from "../lib/review-session-standing-permission-ipc.ts";
|
|
227
|
+
import { isPiConsentV3, presentReviewConsentUi } from "../lib/review-consent-ui.ts";
|
|
228
|
+
import {
|
|
229
|
+
captureReviewSessionIdentity,
|
|
230
|
+
grantReviewSessionPermission,
|
|
231
|
+
hasReviewSessionPermission,
|
|
232
|
+
resolveCanonicalGitRepositoryIdentity,
|
|
233
|
+
revokeReviewSessionPermission,
|
|
234
|
+
reviewSessionPermissionEpoch,
|
|
235
|
+
revokeReviewSessionPermissionsForSession,
|
|
236
|
+
sameReviewSessionIdentity,
|
|
237
|
+
type ReviewSessionIdentity,
|
|
238
|
+
} from "../lib/review-session-standing-permission.ts";
|
|
157
239
|
|
|
158
240
|
const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations";
|
|
159
241
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -163,8 +245,9 @@ function gentlePiAgentHome(): string {
|
|
|
163
245
|
return resolveGentlePiAgentHome();
|
|
164
246
|
}
|
|
165
247
|
|
|
166
|
-
function
|
|
248
|
+
function packageAssetAudit(owner: PackageAssetOwner): { stale: number; overrides: number } {
|
|
167
249
|
let stale = 0;
|
|
250
|
+
let overrides = 0;
|
|
168
251
|
for (const [assetSubdir, installedSubdir, ownershipPrefix] of [
|
|
169
252
|
["agents", "agents", "agents"],
|
|
170
253
|
["chains", "chains", "chains"],
|
|
@@ -173,7 +256,7 @@ function sddGlobalAssetDriftCount(): number {
|
|
|
173
256
|
const assetDir = join(ASSETS_DIR, assetSubdir);
|
|
174
257
|
if (!existsSync(assetDir)) continue;
|
|
175
258
|
for (const entry of readdirSync(assetDir, { withFileTypes: true })) {
|
|
176
|
-
if (!entry.isFile()) continue;
|
|
259
|
+
if (!entry.isFile() || getPackageAssetOwner(`${ownershipPrefix}/${entry.name}`) !== owner) continue;
|
|
177
260
|
const installedPath = join(gentlePiAgentHome(), installedSubdir, entry.name);
|
|
178
261
|
try {
|
|
179
262
|
if (!existsSync(installedPath)) {
|
|
@@ -186,6 +269,7 @@ function sddGlobalAssetDriftCount(): number {
|
|
|
186
269
|
`${ownershipPrefix}/${entry.name}`,
|
|
187
270
|
)
|
|
188
271
|
) {
|
|
272
|
+
overrides += 1;
|
|
189
273
|
continue;
|
|
190
274
|
}
|
|
191
275
|
const packaged = readFileSync(join(assetDir, entry.name), "utf8");
|
|
@@ -206,26 +290,47 @@ function sddGlobalAssetDriftCount(): number {
|
|
|
206
290
|
}
|
|
207
291
|
}
|
|
208
292
|
}
|
|
209
|
-
return stale;
|
|
293
|
+
return { stale, overrides };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function packageAssetDiagnosticLines(cwd: string): string[] {
|
|
297
|
+
return (["delegation", "review", "sdd"] as const).flatMap((owner) => {
|
|
298
|
+
const label = owner === "sdd" ? "SDD" : owner;
|
|
299
|
+
const onDemand = owner === "sdd" && !hasPackageAssetOwnerInstallation(owner);
|
|
300
|
+
const { stale, overrides } = packageAssetAudit(owner);
|
|
301
|
+
const local = localAgentOverrideCount(cwd, owner);
|
|
302
|
+
const lines = [onDemand
|
|
303
|
+
? `info: Global ${label} assets: on demand (not installed)`
|
|
304
|
+
: `${stale > 0 ? "warn" : "pass"}: Global ${label} assets stale: ${stale} file(s)`];
|
|
305
|
+
if (!onDemand && stale > 0) {
|
|
306
|
+
lines[0] += ` — run /gentle:install-${owner} --force to refresh managed assets`;
|
|
307
|
+
}
|
|
308
|
+
if (overrides > 0) {
|
|
309
|
+
lines.push(`info: Global ${label} user overrides: ${overrides} file(s); preserved, not package drift`);
|
|
310
|
+
}
|
|
311
|
+
if (local > 0) {
|
|
312
|
+
lines.push(`warn: Active ${label} agent overrides: ${local} file(s) — active non-builtin ${label} agents shadow package assets; keep only intentional overrides`);
|
|
313
|
+
}
|
|
314
|
+
return lines;
|
|
315
|
+
});
|
|
210
316
|
}
|
|
211
317
|
|
|
212
|
-
function
|
|
318
|
+
function localAgentOverrideCount(cwd: string, owner: PackageAssetOwner): number {
|
|
213
319
|
const packageSddAgentsDir = join(ASSETS_DIR, "agents");
|
|
214
|
-
const packageSddAgentNames =
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
.
|
|
320
|
+
const packageSddAgentNames = new Set(
|
|
321
|
+
listAgentsFromDir(packageSddAgentsDir, "builtin")
|
|
322
|
+
.filter((agent) =>
|
|
323
|
+
getPackageAssetOwner(
|
|
324
|
+
`agents/${relative(packageSddAgentsDir, agent.filePath).split(sep).join("/")}`,
|
|
325
|
+
) === owner,
|
|
219
326
|
)
|
|
220
|
-
|
|
327
|
+
.map((agent) => agent.name),
|
|
328
|
+
);
|
|
221
329
|
let count = 0;
|
|
222
|
-
for (const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (!existsSync(installedDir)) continue;
|
|
227
|
-
for (const entry of readdirSync(installedDir, { withFileTypes: true })) {
|
|
228
|
-
if (entry.isFile() && packageSddAgentNames.has(entry.name)) count += 1;
|
|
330
|
+
for (const { dir, source, packageManaged } of discoverableNonBuiltinAgentRoots(cwd)) {
|
|
331
|
+
if (packageManaged || !existsSync(dir)) continue;
|
|
332
|
+
for (const agent of listAgentsFromDir(dir, source)) {
|
|
333
|
+
if (packageSddAgentNames.has(agent.name)) count += 1;
|
|
229
334
|
}
|
|
230
335
|
}
|
|
231
336
|
return count;
|
|
@@ -465,7 +570,13 @@ function renderBackgroundSubagentsReport(
|
|
|
465
570
|
|
|
466
571
|
const SUBAGENTS_PACKAGE_NAMES = ["pi-subagents-j0k3r", "pi-subagents"] as const;
|
|
467
572
|
const SUBAGENT_RUN_TOOL = "subagent_run";
|
|
468
|
-
const
|
|
573
|
+
const JUDGMENT_DAY_FIX_AGENT_NAME = "jd-fix-agent";
|
|
574
|
+
const BOUNDED_WRITER_AGENT_NAMES = ["gentle-ai-worker", "worker", JUDGMENT_DAY_FIX_AGENT_NAME] as const;
|
|
575
|
+
const JUDGMENT_DAY_ACTIVATION_HEADING = "## Judgment Day activation";
|
|
576
|
+
const JUDGMENT_DAY_ACTIVATION_SENTENCE = "User explicitly requested Judgment Day.";
|
|
577
|
+
const JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING = "## Exact authorized severe IDs";
|
|
578
|
+
const JUDGMENT_DAY_CORRECTION_BATCH_HEADING = "## Judgment Day correction batch";
|
|
579
|
+
const JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING = "## Exact frozen finding rows";
|
|
469
580
|
const ALLOWED_EDIT_SURFACES_HEADING = /^## Allowed edit surfaces[ \t]*$/gim;
|
|
470
581
|
const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6} /;
|
|
471
582
|
const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)]) +/;
|
|
@@ -569,6 +680,15 @@ function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean {
|
|
|
569
680
|
return hasSection;
|
|
570
681
|
}
|
|
571
682
|
|
|
683
|
+
function sddDispatchAgentName(input: unknown): string | undefined {
|
|
684
|
+
if (!isRecord(input)) return undefined;
|
|
685
|
+
if (typeof input.agent === "string" && SDD_AGENT_NAME_SET.has(input.agent)) return input.agent;
|
|
686
|
+
if (Array.isArray(input.agent) && input.agent.some((agent) => typeof agent === "string" && SDD_AGENT_NAME_SET.has(agent))) {
|
|
687
|
+
return "invalid";
|
|
688
|
+
}
|
|
689
|
+
return undefined;
|
|
690
|
+
}
|
|
691
|
+
|
|
572
692
|
function rejectUnscopedBoundedWriterDispatch(input: unknown): { block: true; reason: string } | undefined {
|
|
573
693
|
if (
|
|
574
694
|
!isRecord(input) ||
|
|
@@ -583,6 +703,165 @@ function rejectUnscopedBoundedWriterDispatch(input: unknown): { block: true; rea
|
|
|
583
703
|
return { block: true, reason: WRITER_EDIT_SURFACE_REJECTION };
|
|
584
704
|
}
|
|
585
705
|
|
|
706
|
+
function hasJudgmentDayFixAgentReference(input: Record<string, unknown>): boolean {
|
|
707
|
+
return input.agent === JUDGMENT_DAY_FIX_AGENT_NAME ||
|
|
708
|
+
(Array.isArray(input.agent) && input.agent.includes(JUDGMENT_DAY_FIX_AGENT_NAME)) ||
|
|
709
|
+
input.agents === JUDGMENT_DAY_FIX_AGENT_NAME ||
|
|
710
|
+
(Array.isArray(input.agents) && input.agents.includes(JUDGMENT_DAY_FIX_AGENT_NAME));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function canonicalJudgmentDaySectionBodies(value: unknown, heading: string): string[][] {
|
|
714
|
+
if (typeof value !== "string") return [];
|
|
715
|
+
const headingPattern = new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "gm");
|
|
716
|
+
return [...value.matchAll(headingPattern)].map((match) => {
|
|
717
|
+
const body = value.slice((match.index ?? 0) + match[0].length);
|
|
718
|
+
const nextHeading = body.search(/^ {0,3}#{1,6} /m);
|
|
719
|
+
return body
|
|
720
|
+
.slice(0, nextHeading === -1 ? undefined : nextHeading)
|
|
721
|
+
.split(/\r?\n/)
|
|
722
|
+
.filter((line) => line.length > 0);
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const JUDGMENT_DAY_SEVERE_ID_ENTRY = /^- `(JD-[A-Z][A-Z0-9]*-\d+)`$/;
|
|
727
|
+
const JUDGMENT_DAY_CORRECTION_ROUND_ENTRY = /^Round: [12] of 2\.$/;
|
|
728
|
+
const JUDGMENT_DAY_FROZEN_LEDGER_SHA256_ENTRY = /^Frozen ledger SHA-256: `([0-9a-f]{64})`$/;
|
|
729
|
+
const JUDGMENT_DAY_FROZEN_ROW_FIELDS = [
|
|
730
|
+
"id",
|
|
731
|
+
"lens",
|
|
732
|
+
"location",
|
|
733
|
+
"severity",
|
|
734
|
+
"status_at_freeze",
|
|
735
|
+
"evidence_class",
|
|
736
|
+
"evidence_claim",
|
|
737
|
+
] as const;
|
|
738
|
+
const JUDGMENT_DAY_FIX_SECTION_HEADINGS = [
|
|
739
|
+
JUDGMENT_DAY_ACTIVATION_HEADING,
|
|
740
|
+
JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING,
|
|
741
|
+
JUDGMENT_DAY_CORRECTION_BATCH_HEADING,
|
|
742
|
+
JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING,
|
|
743
|
+
"## Allowed edit surfaces",
|
|
744
|
+
] as const;
|
|
745
|
+
|
|
746
|
+
function canonicalJudgmentDaySevereIds(entries: readonly string[]): string[] | undefined {
|
|
747
|
+
const ids = entries.map((entry) => entry.match(JUDGMENT_DAY_SEVERE_ID_ENTRY)?.[1]);
|
|
748
|
+
return ids.length > 0 && ids.every((id): id is string => id !== undefined) && new Set(ids).size === ids.length
|
|
749
|
+
? ids
|
|
750
|
+
: undefined;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function canonicalJudgmentDayCorrectionBatchHash(entries: readonly string[]): string | undefined {
|
|
754
|
+
if (entries.length !== 2 || !JUDGMENT_DAY_CORRECTION_ROUND_ENTRY.test(entries[0]!)) return undefined;
|
|
755
|
+
return entries[1]!.match(JUDGMENT_DAY_FROZEN_LEDGER_SHA256_ENTRY)?.[1];
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function isNonEmptyJudgmentDayFrozenField(value: unknown): value is string {
|
|
759
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function parseCanonicalJudgmentDayFrozenRows(
|
|
763
|
+
entries: readonly string[],
|
|
764
|
+
authorizedIds: readonly string[],
|
|
765
|
+
): Record<string, unknown>[] | undefined {
|
|
766
|
+
if (entries.length !== authorizedIds.length) return undefined;
|
|
767
|
+
const rows: Record<string, unknown>[] = [];
|
|
768
|
+
const rowIds: string[] = [];
|
|
769
|
+
for (const entry of entries) {
|
|
770
|
+
let row: unknown;
|
|
771
|
+
try {
|
|
772
|
+
row = JSON.parse(entry) as unknown;
|
|
773
|
+
} catch {
|
|
774
|
+
return undefined;
|
|
775
|
+
}
|
|
776
|
+
if (!isRecord(row)) return undefined;
|
|
777
|
+
const keys = Object.keys(row);
|
|
778
|
+
if (keys.length !== JUDGMENT_DAY_FROZEN_ROW_FIELDS.length ||
|
|
779
|
+
!JUDGMENT_DAY_FROZEN_ROW_FIELDS.every((field) => field in row) ||
|
|
780
|
+
!isNonEmptyJudgmentDayFrozenField(row.id) ||
|
|
781
|
+
!JUDGMENT_DAY_SEVERE_ID_ENTRY.test(`- \`${row.id}\``) ||
|
|
782
|
+
row.lens !== "judgment-day" ||
|
|
783
|
+
!isNonEmptyJudgmentDayFrozenField(row.location) ||
|
|
784
|
+
(row.severity !== "BLOCKER" && row.severity !== "CRITICAL") ||
|
|
785
|
+
row.status_at_freeze !== "open" ||
|
|
786
|
+
!isNonEmptyJudgmentDayFrozenField(row.evidence_class) ||
|
|
787
|
+
!isNonEmptyJudgmentDayFrozenField(row.evidence_claim)
|
|
788
|
+
) return undefined;
|
|
789
|
+
rows.push(row);
|
|
790
|
+
rowIds.push(row.id);
|
|
791
|
+
}
|
|
792
|
+
return new Set(rowIds).size === rowIds.length &&
|
|
793
|
+
rowIds.every((id, index) => id === authorizedIds[index])
|
|
794
|
+
? rows
|
|
795
|
+
: undefined;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function hasCanonicalJudgmentDayFixSectionOrder(...values: unknown[]): boolean {
|
|
799
|
+
const dispatch = values.filter((value): value is string => typeof value === "string").join("\n");
|
|
800
|
+
let previousPosition = -1;
|
|
801
|
+
for (const heading of JUDGMENT_DAY_FIX_SECTION_HEADINGS) {
|
|
802
|
+
const matches = [...dispatch.matchAll(new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "gm"))];
|
|
803
|
+
if (matches.length !== 1 || (matches[0]!.index ?? -1) <= previousPosition) return false;
|
|
804
|
+
previousPosition = matches[0]!.index ?? -1;
|
|
805
|
+
}
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function hasCanonicalJudgmentDayFixActivation(...values: unknown[]): boolean {
|
|
810
|
+
const hasMalformedHeading = values.some((value) =>
|
|
811
|
+
typeof value === "string" && value.split(/\r?\n/).some((line) => {
|
|
812
|
+
const candidate = line.match(/^ {0,3}#{1,6} (Judgment Day activation|Exact authorized severe IDs|Judgment Day correction batch|Exact frozen finding rows)[ \t]*$/i);
|
|
813
|
+
return candidate !== null &&
|
|
814
|
+
line !== JUDGMENT_DAY_ACTIVATION_HEADING &&
|
|
815
|
+
line !== JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING &&
|
|
816
|
+
line !== JUDGMENT_DAY_CORRECTION_BATCH_HEADING &&
|
|
817
|
+
line !== JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING;
|
|
818
|
+
}),
|
|
819
|
+
);
|
|
820
|
+
const activationBodies = values.flatMap((value) =>
|
|
821
|
+
canonicalJudgmentDaySectionBodies(value, JUDGMENT_DAY_ACTIVATION_HEADING),
|
|
822
|
+
);
|
|
823
|
+
const severeIdBodies = values.flatMap((value) =>
|
|
824
|
+
canonicalJudgmentDaySectionBodies(value, JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING),
|
|
825
|
+
);
|
|
826
|
+
const correctionBatchBodies = values.flatMap((value) =>
|
|
827
|
+
canonicalJudgmentDaySectionBodies(value, JUDGMENT_DAY_CORRECTION_BATCH_HEADING),
|
|
828
|
+
);
|
|
829
|
+
const frozenFindingRowBodies = values.flatMap((value) =>
|
|
830
|
+
canonicalJudgmentDaySectionBodies(value, JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING),
|
|
831
|
+
);
|
|
832
|
+
const authorizedIds = severeIdBodies.length === 1
|
|
833
|
+
? canonicalJudgmentDaySevereIds(severeIdBodies[0]!)
|
|
834
|
+
: undefined;
|
|
835
|
+
const correctionBatchHash = correctionBatchBodies.length === 1
|
|
836
|
+
? canonicalJudgmentDayCorrectionBatchHash(correctionBatchBodies[0]!)
|
|
837
|
+
: undefined;
|
|
838
|
+
const frozenFindingRows = authorizedIds !== undefined && frozenFindingRowBodies.length === 1
|
|
839
|
+
? parseCanonicalJudgmentDayFrozenRows(frozenFindingRowBodies[0]!, authorizedIds)
|
|
840
|
+
: undefined;
|
|
841
|
+
return !hasMalformedHeading && hasCanonicalJudgmentDayFixSectionOrder(...values) &&
|
|
842
|
+
activationBodies.length === 1 &&
|
|
843
|
+
activationBodies[0]!.length === 1 &&
|
|
844
|
+
activationBodies[0]![0] === JUDGMENT_DAY_ACTIVATION_SENTENCE &&
|
|
845
|
+
authorizedIds !== undefined && correctionBatchHash !== undefined &&
|
|
846
|
+
frozenFindingRows !== undefined && correctionBatchHash === canonicalHash(frozenFindingRows);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const JUDGMENT_DAY_FIX_DISPATCH_REJECTION =
|
|
850
|
+
"Judgment Day fix dispatch requires exactly one `agent: \"jd-fix-agent\"`, one exact `## Judgment Day activation` section containing only `User explicitly requested Judgment Day.`, one non-empty unique canonical `## Exact authorized severe IDs` section, one exact `## Judgment Day correction batch` section with `Round: 1 of 2.` or `Round: 2 of 2.` and the matching canonical lowercase SHA-256 of one exact `## Exact frozen finding rows` section whose BLOCKER/CRITICAL open Judgment Day rows equal the authorized IDs in the same order, and the existing exact `## Allowed edit surfaces` guard. The parent must provide the canonical bounded dispatch; do not infer activation, authorization, or frozen findings.";
|
|
851
|
+
|
|
852
|
+
function rejectInvalidJudgmentDayFixDispatch(input: unknown): { block: true; reason: string } | undefined {
|
|
853
|
+
if (!isRecord(input) || !hasJudgmentDayFixAgentReference(input)) return undefined;
|
|
854
|
+
if (
|
|
855
|
+
input.agent === JUDGMENT_DAY_FIX_AGENT_NAME &&
|
|
856
|
+
!("agents" in input) &&
|
|
857
|
+
hasCanonicalJudgmentDayFixActivation(input.task, input.context) &&
|
|
858
|
+
hasTaskScopedAllowedEditSurfaces(input.task, input.context)
|
|
859
|
+
) {
|
|
860
|
+
return undefined;
|
|
861
|
+
}
|
|
862
|
+
return { block: true, reason: JUDGMENT_DAY_FIX_DISPATCH_REJECTION };
|
|
863
|
+
}
|
|
864
|
+
|
|
586
865
|
/**
|
|
587
866
|
* Roots where an installed subagents package may live. These are the same
|
|
588
867
|
* roots builtinAgentDirs() walks, minus its `/agents` suffix.
|
|
@@ -671,21 +950,288 @@ function renderBackgroundSubagentsStatusLine(
|
|
|
671
950
|
return `Background subagent policy: ${background.policy} (capability: ${background.capability})`;
|
|
672
951
|
}
|
|
673
952
|
|
|
674
|
-
|
|
675
|
-
|
|
953
|
+
/**
|
|
954
|
+
* A `status` object is only trusted when `effective` is exactly `on`/`off`
|
|
955
|
+
* and `source` is one of the exported `NATIVE_REVIEW_MODE_SOURCE` values.
|
|
956
|
+
* `resolveRddModeStatus` only ever produces a value shaped like this, but
|
|
957
|
+
* `renderRddStatusLine` validates at the render boundary anyway -- a
|
|
958
|
+
* malformed or partial object (a bad decode upstream, a future field
|
|
959
|
+
* change, a hand-built test fixture) must fail closed to the "unknown"
|
|
960
|
+
* line, never render an unrecognized value verbatim.
|
|
961
|
+
*/
|
|
962
|
+
function isValidRddModeStatus(
|
|
963
|
+
status: NativeReviewModeStatus | undefined,
|
|
964
|
+
): status is NativeReviewModeStatus {
|
|
965
|
+
if (status === undefined || status === null || typeof status !== "object") return false;
|
|
966
|
+
if (status.effective !== "on" && status.effective !== "off") return false;
|
|
967
|
+
const validSources: readonly string[] = Object.values(NATIVE_REVIEW_MODE_SOURCE);
|
|
968
|
+
return typeof status.source === "string" && validSources.includes(status.source);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Renders the receipt-driven-development status line rendered next to
|
|
973
|
+
* `Background subagent policy` (gentle-pi#661). Renders the fail-closed
|
|
974
|
+
* "unknown" line whenever `status` is not a validated on/off status with a
|
|
975
|
+
* recognized source -- `undefined` (the native reader could not answer:
|
|
976
|
+
* binary absent, timed out, aborted, or a native CLI failure) or any
|
|
977
|
+
* malformed/partial object. This is a pure render, never a native call, so
|
|
978
|
+
* it never throws.
|
|
979
|
+
*/
|
|
980
|
+
function renderRddStatusLine(
|
|
981
|
+
status: NativeReviewModeStatus | undefined,
|
|
982
|
+
): string {
|
|
983
|
+
return isValidRddModeStatus(status)
|
|
984
|
+
? `Receipt-driven development: ${status.effective} (decided by ${status.source})`
|
|
985
|
+
: "Receipt-driven development: unknown (native status unavailable)";
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// The primary-session prompt awaits this on every non-SDD, non-named agent
|
|
989
|
+
// start, so an unbounded native read would stall session start behind a
|
|
990
|
+
// hung `gentle-ai` child (gentle-pi#661 native-review escalation). The
|
|
991
|
+
// production call site (before_agent_start) passes
|
|
992
|
+
// `AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS)`; resolveRddModeStatus also
|
|
993
|
+
// races the call against that same signal itself (not just the CLI's own
|
|
994
|
+
// signal handling) so an abort is honored even against a stub/mock
|
|
995
|
+
// reviewMode that ignores its `signal` argument, as tests do.
|
|
996
|
+
const RDD_STATUS_TIMEOUT_MS = 3000;
|
|
997
|
+
// Repeated session/agent-start builds within this window reuse the last
|
|
998
|
+
// resolved status instead of respawning the native binary. Deliberately
|
|
999
|
+
// memoizes a failed/undefined resolution too (a sustained outage should not
|
|
1000
|
+
// retry every agent start), trading a slower recovery signal for far fewer
|
|
1001
|
+
// spawns; the one-shot notify below still surfaces a sustained outage.
|
|
1002
|
+
const RDD_STATUS_MEMO_TTL_MS = 30_000;
|
|
1003
|
+
const rddStatusMemo = new Map<string, { readonly status: NativeReviewModeStatus | undefined; readonly expiresAt: number }>();
|
|
1004
|
+
|
|
1005
|
+
/** @internal test seam: clears the per-cwd RDD status memo. */
|
|
1006
|
+
function clearRddStatusMemoForTesting(): void {
|
|
1007
|
+
rddStatusMemo.clear();
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// gentle-pi#668 (corrected): last-known outcome for ONE candidate, keyed by
|
|
1011
|
+
// repository realpath AND targetIdentity -- never repository alone, or one
|
|
1012
|
+
// candidate's outcome would leak into every other candidate's `assess` call.
|
|
1013
|
+
// Only declined/unavailable are ever written (from ANSWER_CONSENT); `closed`
|
|
1014
|
+
// is never written/derived -- pass it explicitly. A missing entry reads back
|
|
1015
|
+
// `undefined`, treated as `unknown` (fail closed, exactly like `off`).
|
|
1016
|
+
const nativeReviewOutcomeByCandidate = new Map<string, "declined" | "unavailable">();
|
|
1017
|
+
|
|
1018
|
+
function nativeReviewOutcomeMemoKey(cwd: string, targetIdentity: string): string {
|
|
1019
|
+
try {
|
|
1020
|
+
return `${realpathSync(cwd)}\u0000${targetIdentity}`;
|
|
1021
|
+
} catch {
|
|
1022
|
+
return `${cwd}\u0000${targetIdentity}`;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function recordNativeReviewOutcome(cwd: string, targetIdentity: string, outcome: "declined" | "unavailable"): void {
|
|
1027
|
+
nativeReviewOutcomeByCandidate.set(nativeReviewOutcomeMemoKey(cwd, targetIdentity), outcome);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** Returns the recorded outcome for exactly this candidate, or `undefined` when none is recorded. */
|
|
1031
|
+
function readNativeReviewOutcome(cwd: string, targetIdentity: string): "declined" | "unavailable" | undefined {
|
|
1032
|
+
return nativeReviewOutcomeByCandidate.get(nativeReviewOutcomeMemoKey(cwd, targetIdentity));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** @internal test seam: clears the per-candidate native review outcome memo. */
|
|
1036
|
+
function clearNativeReviewOutcomeMemoForTesting(): void {
|
|
1037
|
+
nativeReviewOutcomeByCandidate.clear();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Best-effort current-candidate target identity for `assess` (gentle-pi#668):
|
|
1041
|
+
// reuses `targetStatus`, a native call this tool already makes elsewhere.
|
|
1042
|
+
async function readCurrentTargetIdentityBestEffort(
|
|
1043
|
+
nativeReviewCli: Pick<NativeReviewCli, "targetStatus"> | null | undefined,
|
|
1044
|
+
cwd: string,
|
|
1045
|
+
signal?: AbortSignal,
|
|
1046
|
+
): Promise<string | undefined> {
|
|
1047
|
+
if (nativeReviewCli?.targetStatus === undefined) return undefined;
|
|
1048
|
+
try {
|
|
1049
|
+
const status = await nativeReviewCli.targetStatus({ cwd, ...(signal === undefined ? {} : { signal }) });
|
|
1050
|
+
return status.applicability === "current_target" ? status.targetIdentity : undefined;
|
|
1051
|
+
} catch {
|
|
1052
|
+
return undefined;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function rddAbortRejection(signal: AbortSignal): Promise<never> {
|
|
1057
|
+
return new Promise((_resolve, reject) => {
|
|
1058
|
+
if (signal.aborted) {
|
|
1059
|
+
reject(signal.reason ?? new Error("aborted"));
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
signal.addEventListener("abort", () => reject(signal.reason ?? new Error("aborted")), { once: true });
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async function readRddModeStatusOnce(
|
|
1067
|
+
nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
|
|
1068
|
+
cwd: string,
|
|
1069
|
+
signal?: AbortSignal,
|
|
1070
|
+
): Promise<NativeReviewModeStatus | undefined> {
|
|
1071
|
+
if (!nativeReviewCli?.reviewMode) return undefined;
|
|
1072
|
+
try {
|
|
1073
|
+
const call = nativeReviewCli.reviewMode({ cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, signal });
|
|
1074
|
+
const result = signal === undefined ? await call : await Promise.race([call, rddAbortRejection(signal)]);
|
|
1075
|
+
return result.status;
|
|
1076
|
+
} catch {
|
|
1077
|
+
return undefined;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// gentle-pi#662: read-only combined native risk assessment plus the computed
|
|
1082
|
+
// verification plan (`lib/review-risk-assessment.ts`), for the `gentle_review`
|
|
1083
|
+
// tool's `assess` operation. Never throws: an unavailable/failed native
|
|
1084
|
+
// assess call (older binary without the verb, timeout, malformed response)
|
|
1085
|
+
// resolves to the `unassessable` tier, which `verificationPlan` treats the
|
|
1086
|
+
// same as `high` -- the fail-closed rule from gentle-pi#662.
|
|
1087
|
+
interface ReviewAssessmentPlanDetails {
|
|
1088
|
+
schema: "gentle-pi.review-assessment-plan/v1";
|
|
1089
|
+
risk: VerificationTier;
|
|
1090
|
+
reasons: readonly { code: string; path: string; detail: string }[];
|
|
1091
|
+
changedPaths: number;
|
|
1092
|
+
changedLines: number;
|
|
1093
|
+
candidate: { kind: string; baseRef: string | undefined } | null;
|
|
1094
|
+
rddLine: RddLine;
|
|
1095
|
+
nativeReviewOutcome: NativeReviewOutcome;
|
|
1096
|
+
// gentle-pi#668: where nativeReviewOutcome came from -- explicit (caller
|
|
1097
|
+
// passed it), derived (matched this exact candidate), or unknown.
|
|
1098
|
+
outcome_source: "explicit" | "derived" | "unknown";
|
|
1099
|
+
writerProfile: "small" | "large";
|
|
1100
|
+
plan: {
|
|
1101
|
+
writerSelfVerification: boolean;
|
|
1102
|
+
structuralReadbackOnly: boolean;
|
|
1103
|
+
independentVerifier: boolean;
|
|
1104
|
+
reason: string;
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async function resolveReviewAssessmentPlan(
|
|
1109
|
+
nativeReviewCli: Pick<NativeReviewCli, "reviewMode" | "assess" | "targetStatus"> | null | undefined,
|
|
1110
|
+
cwd: string,
|
|
1111
|
+
input: ReviewAssessInput,
|
|
1112
|
+
signal?: AbortSignal,
|
|
1113
|
+
): Promise<ReviewAssessmentPlanDetails> {
|
|
1114
|
+
if (input.baseRef !== undefined && input.committedOnly !== true) throw new Error("Review assess baseRef requires committedOnly: true");
|
|
1115
|
+
if (input.baseRef === undefined && input.committedOnly !== undefined) throw new Error("Review assess committedOnly requires an explicit baseRef");
|
|
1116
|
+
|
|
1117
|
+
const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
|
|
1118
|
+
const rddLine: RddLine = isValidRddModeStatus(status) ? status.effective : RDD_LINE.UNKNOWN;
|
|
1119
|
+
const writerProfile = resolveWriterProfile({
|
|
1120
|
+
...(input.writerModelId === undefined ? {} : { model: { id: input.writerModelId } }),
|
|
1121
|
+
thinking: input.writerEffort,
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
let assessment: ReviewAssessmentV1 | undefined;
|
|
1125
|
+
let unassessableDetail: string | undefined;
|
|
1126
|
+
if (nativeReviewCli?.assess === undefined) {
|
|
1127
|
+
unassessableDetail = "native review assess is unavailable: the installed gentle-ai binary does not expose the assess command.";
|
|
1128
|
+
} else {
|
|
1129
|
+
try {
|
|
1130
|
+
const request: NativeReviewAssessRequest = {
|
|
1131
|
+
cwd,
|
|
1132
|
+
...(input.baseRef === undefined ? {} : { baseRef: input.baseRef, committedOnly: true as const }),
|
|
1133
|
+
...(signal === undefined ? {} : { signal }),
|
|
1134
|
+
};
|
|
1135
|
+
assessment = await nativeReviewCli.assess(request);
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
unassessableDetail = `native review assess failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const risk: VerificationTier = assessment?.risk ?? VERIFICATION_TIER.UNASSESSABLE;
|
|
1142
|
+
// gentle-pi#668: explicit always wins; otherwise derive only for THIS
|
|
1143
|
+
// candidate's own target identity, never repository-only. `closed` is
|
|
1144
|
+
// never derived.
|
|
1145
|
+
const targetIdentity = input.nativeReviewOutcome === undefined ? await readCurrentTargetIdentityBestEffort(nativeReviewCli, cwd, signal) : undefined;
|
|
1146
|
+
const derived = targetIdentity === undefined ? undefined : readNativeReviewOutcome(cwd, targetIdentity);
|
|
1147
|
+
const nativeReviewOutcome: NativeReviewOutcome = input.nativeReviewOutcome ?? derived ?? NATIVE_REVIEW_OUTCOME.UNKNOWN;
|
|
1148
|
+
const outcomeSource: "explicit" | "derived" | "unknown" = input.nativeReviewOutcome !== undefined ? "explicit" : derived === undefined ? "unknown" : "derived";
|
|
1149
|
+
const plan = verificationPlan({ rddLine, risk, writerProfile, nativeReviewOutcome });
|
|
1150
|
+
return {
|
|
1151
|
+
schema: "gentle-pi.review-assessment-plan/v1",
|
|
1152
|
+
risk,
|
|
1153
|
+
reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: "native-assess-unavailable", path: "", detail: unassessableDetail }]),
|
|
1154
|
+
changedPaths: assessment?.changedPaths ?? 0,
|
|
1155
|
+
changedLines: assessment?.changedLines ?? 0,
|
|
1156
|
+
candidate: assessment === undefined ? null : { kind: assessment.candidate.kind, baseRef: assessment.candidate.baseRef },
|
|
1157
|
+
rddLine,
|
|
1158
|
+
nativeReviewOutcome,
|
|
1159
|
+
outcome_source: outcomeSource,
|
|
1160
|
+
writerProfile,
|
|
1161
|
+
plan,
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
let rddStatusUnavailableWarned = false;
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Best-effort native RDD mode status read for prompt rendering, memoized per
|
|
1169
|
+
* cwd for `RDD_STATUS_MEMO_TTL_MS`. Reuses the `reviewMode` STATUS reader
|
|
1170
|
+
* (`gentle-ai review mode status --json`, decoded to
|
|
1171
|
+
* `NativeReviewModeStatus`) that the `/gentle:review-mode` command also
|
|
1172
|
+
* calls. Never throws and never hangs past `signal`'s deadline when one is
|
|
1173
|
+
* given: an absent binary, a timed-out/aborted process, or a native CLI
|
|
1174
|
+
* failure all resolve to `undefined`. `ctx` is optional and used only for a
|
|
1175
|
+
* one-shot (per process) UI notice when the read is swallowed, so a
|
|
1176
|
+
* sustained native outage is observable beyond the rendered "unknown" line.
|
|
1177
|
+
*/
|
|
1178
|
+
async function resolveRddModeStatus(
|
|
1179
|
+
nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
|
|
1180
|
+
cwd: string,
|
|
1181
|
+
signal?: AbortSignal,
|
|
1182
|
+
now: () => number = Date.now,
|
|
1183
|
+
ctx?: Pick<ExtensionContext, "hasUI" | "ui">,
|
|
1184
|
+
): Promise<NativeReviewModeStatus | undefined> {
|
|
1185
|
+
const nowMs = now();
|
|
1186
|
+
const cached = rddStatusMemo.get(cwd);
|
|
1187
|
+
if (cached !== undefined && cached.expiresAt > nowMs) return cached.status;
|
|
1188
|
+
const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
|
|
1189
|
+
rddStatusMemo.set(cwd, { status, expiresAt: nowMs + RDD_STATUS_MEMO_TTL_MS });
|
|
1190
|
+
if (status === undefined && !rddStatusUnavailableWarned) {
|
|
1191
|
+
rddStatusUnavailableWarned = true;
|
|
1192
|
+
if (ctx?.hasUI) {
|
|
1193
|
+
ctx.ui.notify(
|
|
1194
|
+
"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.",
|
|
1195
|
+
"warning",
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return status;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/** Resolves and renders the RDD status line for a production call site in one call. */
|
|
1203
|
+
async function resolveRddStatusLine(
|
|
1204
|
+
nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
|
|
1205
|
+
cwd: string,
|
|
1206
|
+
signal?: AbortSignal,
|
|
1207
|
+
now: () => number = Date.now,
|
|
1208
|
+
ctx?: Pick<ExtensionContext, "hasUI" | "ui">,
|
|
1209
|
+
): Promise<string> {
|
|
1210
|
+
return renderRddStatusLine(await resolveRddModeStatus(nativeReviewCli, cwd, signal, now, ctx));
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Rendered prompts are memoized per background policy/capability/RDD-status
|
|
1214
|
+
// key for the process lifetime; the assets bytes themselves are read once
|
|
1215
|
+
// per key. `rddStatusLine` defaults to the "unknown" fallback line (the
|
|
1216
|
+
// longest of the three renderable forms), not "", so the default no-argument
|
|
1217
|
+
// render IS the worst case the canonical 8 KiB budget in
|
|
1218
|
+
// tests/orchestrator-budget.test.ts measures; assets/orchestrator.md is
|
|
1219
|
+
// sized with that worst case already included. Production still resolves
|
|
1220
|
+
// and passes the real line (on/off/unknown) via resolveRddStatusLine.
|
|
676
1221
|
const orchestratorPromptCache = new Map<string, string>();
|
|
677
1222
|
function getOrchestratorPrompt(
|
|
678
1223
|
cwd: string = process.cwd(),
|
|
679
1224
|
activeTools?: readonly string[],
|
|
1225
|
+
rddStatusLine: string = renderRddStatusLine(undefined),
|
|
680
1226
|
): string {
|
|
681
1227
|
const background: BackgroundSubagentsRendering = {
|
|
682
1228
|
policy: loadBackgroundSubagentsPolicy(cwd),
|
|
683
1229
|
capability: resolveBackgroundSubagentsCapability(cwd, activeTools),
|
|
684
1230
|
};
|
|
685
|
-
const cacheKey = `${background.policy}:${background.capability}`;
|
|
1231
|
+
const cacheKey = `${background.policy}:${background.capability}:${rddStatusLine}`;
|
|
686
1232
|
let prompt = orchestratorPromptCache.get(cacheKey);
|
|
687
1233
|
if (prompt === undefined) {
|
|
688
|
-
prompt = renderOrchestratorPrompt(ASSETS_DIR, background);
|
|
1234
|
+
prompt = renderOrchestratorPrompt(ASSETS_DIR, background, rddStatusLine);
|
|
689
1235
|
orchestratorPromptCache.set(cacheKey, prompt);
|
|
690
1236
|
}
|
|
691
1237
|
return prompt;
|
|
@@ -694,12 +1240,14 @@ function getOrchestratorPrompt(
|
|
|
694
1240
|
function renderOrchestratorPrompt(
|
|
695
1241
|
assetsDir: string,
|
|
696
1242
|
background: BackgroundSubagentsRendering = DEFAULT_BACKGROUND_SUBAGENTS_RENDERING,
|
|
1243
|
+
rddStatusLine: string = renderRddStatusLine(undefined),
|
|
697
1244
|
): string {
|
|
1245
|
+
const backgroundPolicyBlock = `${renderBackgroundSubagentsStatusLine(background)}\n${rddStatusLine}`;
|
|
698
1246
|
return readFileSync(join(assetsDir, "orchestrator.md"), "utf8")
|
|
699
1247
|
.replaceAll("{{GENTLE_PI_ASSETS_ROOT}}", assetsDir)
|
|
700
1248
|
.replaceAll(
|
|
701
1249
|
"{{GENTLE_PI_BACKGROUND_POLICY}}",
|
|
702
|
-
|
|
1250
|
+
backgroundPolicyBlock,
|
|
703
1251
|
)
|
|
704
1252
|
.trim();
|
|
705
1253
|
}
|
|
@@ -798,6 +1346,7 @@ function buildGentlePrompt(
|
|
|
798
1346
|
persona: PersonaMode,
|
|
799
1347
|
cwd: string = process.cwd(),
|
|
800
1348
|
activeTools?: readonly string[],
|
|
1349
|
+
rddStatusLine: string = renderRddStatusLine(undefined),
|
|
801
1350
|
): string {
|
|
802
1351
|
const personaPrompt =
|
|
803
1352
|
persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT;
|
|
@@ -832,13 +1381,13 @@ Harness principles:
|
|
|
832
1381
|
- Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
|
|
833
1382
|
- Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
|
|
834
1383
|
|
|
835
|
-
${getOrchestratorPrompt(cwd, activeTools)}`;
|
|
1384
|
+
${getOrchestratorPrompt(cwd, activeTools, rddStatusLine)}`;
|
|
836
1385
|
}
|
|
837
1386
|
|
|
838
1387
|
// Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
|
|
839
1388
|
// between `git` and the subcommand. Short flags may be followed by a separate value token.
|
|
840
1389
|
const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)* `;
|
|
841
|
-
const GIT_PUSH_RE = new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b`);
|
|
1390
|
+
const GIT_PUSH_RE = new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}(push)\b`);
|
|
842
1391
|
|
|
843
1392
|
const DENIED_BASH_PATTERNS: RegExp[] = [
|
|
844
1393
|
// Block rm -rf targeting /, ~ or ~/subdir, $HOME or $HOME/subdir, .. or .
|
|
@@ -865,6 +1414,19 @@ const GUARD_ACTION = {
|
|
|
865
1414
|
type GuardAction = (typeof GUARD_ACTION)[keyof typeof GUARD_ACTION];
|
|
866
1415
|
type GuardClassification = GuardAction | "not-guarded";
|
|
867
1416
|
|
|
1417
|
+
interface GuardMatch {
|
|
1418
|
+
key: GuardedCommandKey;
|
|
1419
|
+
action: GuardAction;
|
|
1420
|
+
triggerIndex: number;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
interface GuardEvaluation {
|
|
1424
|
+
action: GuardClassification;
|
|
1425
|
+
key?: GuardedCommandKey;
|
|
1426
|
+
triggerIndex: number;
|
|
1427
|
+
matches: GuardMatch[];
|
|
1428
|
+
}
|
|
1429
|
+
|
|
868
1430
|
const GUARDED_COMMAND_KEY = {
|
|
869
1431
|
GIT_PUSH: "gitPush",
|
|
870
1432
|
GIT_REBASE: "gitRebase",
|
|
@@ -889,10 +1451,10 @@ interface LoadGuardrailsOptions {
|
|
|
889
1451
|
|
|
890
1452
|
const GUARDED_KEY_PATTERNS: Record<GuardedCommandKey, RegExp> = {
|
|
891
1453
|
gitPush: GIT_PUSH_RE,
|
|
892
|
-
gitRebase: /\bgit\s+rebase\b/,
|
|
893
|
-
gitBranchDeleteForce: /\bgit\s+branch\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)/,
|
|
894
|
-
npmPublish: /\bnpm\s+publish\b/,
|
|
895
|
-
piRemove: /\bpi\s+remove\b/,
|
|
1454
|
+
gitRebase: /\bgit\s+(rebase)\b/,
|
|
1455
|
+
gitBranchDeleteForce: /\bgit\s+(branch)\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\r\n;&|]*--force\b|--force\b[^\r\n;&|]*--delete\b)/,
|
|
1456
|
+
npmPublish: /\bnpm\s+(publish)\b/,
|
|
1457
|
+
piRemove: /\bpi\s+(remove)\b/,
|
|
896
1458
|
};
|
|
897
1459
|
|
|
898
1460
|
const AUTONOMOUS_DEFAULT_ACTIONS: Record<GuardedCommandKey, GuardAction> = {
|
|
@@ -903,6 +1465,14 @@ const AUTONOMOUS_DEFAULT_ACTIONS: Record<GuardedCommandKey, GuardAction> = {
|
|
|
903
1465
|
piRemove: "confirm",
|
|
904
1466
|
};
|
|
905
1467
|
|
|
1468
|
+
const GUARDED_COMMAND_LABELS: Record<GuardedCommandKey, string> = {
|
|
1469
|
+
gitPush: "git push",
|
|
1470
|
+
gitRebase: "git rebase",
|
|
1471
|
+
gitBranchDeleteForce: "forced git branch deletion",
|
|
1472
|
+
npmPublish: "npm publish",
|
|
1473
|
+
piRemove: "pi remove",
|
|
1474
|
+
};
|
|
1475
|
+
|
|
906
1476
|
const SAFE_GUARDRAILS_CONFIG: RuntimeGuardrailsConfig = {
|
|
907
1477
|
autonomousMode: false,
|
|
908
1478
|
guardedCommands: {},
|
|
@@ -918,31 +1488,83 @@ const SAFE_GUARDRAILS_CONFIG: RuntimeGuardrailsConfig = {
|
|
|
918
1488
|
* (applying AUTONOMOUS_DEFAULT_ACTIONS for any key not set in guardedCommands)
|
|
919
1489
|
* 4. No match → "not-guarded"
|
|
920
1490
|
*/
|
|
921
|
-
function
|
|
1491
|
+
function collectGuardedMatches(
|
|
922
1492
|
command: string,
|
|
923
1493
|
config: RuntimeGuardrailsConfig,
|
|
924
|
-
):
|
|
925
|
-
|
|
1494
|
+
): GuardMatch[] {
|
|
1495
|
+
const matches: GuardMatch[] = [];
|
|
1496
|
+
for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [
|
|
1497
|
+
GuardedCommandKey,
|
|
1498
|
+
RegExp,
|
|
1499
|
+
][]) {
|
|
1500
|
+
const globalPattern = new RegExp(pattern.source, `${pattern.flags}g`);
|
|
1501
|
+
for (const match of command.matchAll(globalPattern)) {
|
|
1502
|
+
const action = config.autonomousMode
|
|
1503
|
+
? (config.guardedCommands[key] ?? AUTONOMOUS_DEFAULT_ACTIONS[key])
|
|
1504
|
+
: "confirm";
|
|
1505
|
+
matches.push({
|
|
1506
|
+
key,
|
|
1507
|
+
action,
|
|
1508
|
+
triggerIndex: match.index + match[0].lastIndexOf(match[1]),
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
return matches.sort((left, right) => left.triggerIndex - right.triggerIndex);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
function evaluateGuardedCommand(
|
|
1516
|
+
command: string,
|
|
1517
|
+
config: RuntimeGuardrailsConfig,
|
|
1518
|
+
): GuardEvaluation {
|
|
1519
|
+
const matches = collectGuardedMatches(command, config);
|
|
1520
|
+
|
|
1521
|
+
// Hard denies override every configured action across the complete command.
|
|
926
1522
|
for (const pattern of DENIED_BASH_PATTERNS) {
|
|
927
|
-
|
|
1523
|
+
const denied = pattern.exec(command);
|
|
1524
|
+
if (!denied) continue;
|
|
1525
|
+
const matchedAction = matches.find((match) =>
|
|
1526
|
+
match.triggerIndex >= denied.index && match.triggerIndex < denied.index + denied[0].length,
|
|
1527
|
+
);
|
|
1528
|
+
return {
|
|
1529
|
+
action: "block",
|
|
1530
|
+
key: matchedAction?.key,
|
|
1531
|
+
triggerIndex: matchedAction?.triggerIndex ?? denied.index,
|
|
1532
|
+
matches,
|
|
1533
|
+
};
|
|
928
1534
|
}
|
|
929
1535
|
|
|
930
|
-
//
|
|
931
|
-
|
|
932
|
-
|
|
1536
|
+
// Configured block, then confirmation, then allow win across all matches.
|
|
1537
|
+
const selected = matches.find((match) => match.action === "block")
|
|
1538
|
+
?? matches.find((match) => match.action === "confirm")
|
|
1539
|
+
?? matches.find((match) => match.action === "allow");
|
|
1540
|
+
if (selected) return { ...selected, matches };
|
|
1541
|
+
return { action: "not-guarded", triggerIndex: 0, matches };
|
|
1542
|
+
}
|
|
933
1543
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1544
|
+
function classifyGuardedCommand(
|
|
1545
|
+
command: string,
|
|
1546
|
+
config: RuntimeGuardrailsConfig,
|
|
1547
|
+
): GuardClassification {
|
|
1548
|
+
return evaluateGuardedCommand(command, config).action;
|
|
1549
|
+
}
|
|
939
1550
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
}
|
|
1551
|
+
function guardedCommandPreview(command: string, triggerIndex: number): string {
|
|
1552
|
+
const start = Math.max(0, triggerIndex - 60);
|
|
1553
|
+
const prefix = start > 0 ? "…" : "";
|
|
1554
|
+
return `${prefix}${truncateToWidth(command.slice(start).replace(/\s+/g, " ").trim(), 180 - prefix.length, "…")}`;
|
|
1555
|
+
}
|
|
944
1556
|
|
|
945
|
-
|
|
1557
|
+
/** Confirmation headline for all guarded actions; generic when no key matched. */
|
|
1558
|
+
function guardedCommandTitle(
|
|
1559
|
+
key?: GuardedCommandKey,
|
|
1560
|
+
matches: readonly GuardMatch[] = [],
|
|
1561
|
+
): string {
|
|
1562
|
+
if (matches.length > 1) {
|
|
1563
|
+
return `Allow guarded actions: ${matches.map((match) => GUARDED_COMMAND_LABELS[match.key]).join("; ")}?`;
|
|
1564
|
+
}
|
|
1565
|
+
return key === undefined
|
|
1566
|
+
? "Allow guarded command?"
|
|
1567
|
+
: `Allow guarded ${GUARDED_COMMAND_LABELS[key]}?`;
|
|
946
1568
|
}
|
|
947
1569
|
|
|
948
1570
|
function parseGuardrailsConfigFile(
|
|
@@ -1050,22 +1672,9 @@ const SENSITIVE_PATH_PATTERNS: RegExp[] = [
|
|
|
1050
1672
|
/\.(?:pem|key|p12|pfx)$/,
|
|
1051
1673
|
];
|
|
1052
1674
|
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
"sdd-explore",
|
|
1057
|
-
"sdd-research",
|
|
1058
|
-
"sdd-proposal",
|
|
1059
|
-
"sdd-spec",
|
|
1060
|
-
"sdd-design",
|
|
1061
|
-
"sdd-tasks",
|
|
1062
|
-
"sdd-status",
|
|
1063
|
-
"sdd-apply",
|
|
1064
|
-
"sdd-verify",
|
|
1065
|
-
"sdd-sync",
|
|
1066
|
-
"sdd-archive",
|
|
1067
|
-
] as const;
|
|
1068
|
-
const SDD_AGENT_NAME_SET = new Set<string>(SDD_AGENT_NAMES);
|
|
1675
|
+
const SDD_AGENT_NAME_SET = new Set<string>(SHIPPED_SDD_AGENT_NAMES);
|
|
1676
|
+
const SDD_CHANGE_FLAG = "gentle-sdd-change";
|
|
1677
|
+
const SDD_CHANGE_KEYS = ["changeName", "phase", "workspaceRoot"] as const;
|
|
1069
1678
|
|
|
1070
1679
|
const JUDGMENT_DAY_AGENT_NAMES = [
|
|
1071
1680
|
"jd-judge-a",
|
|
@@ -1074,7 +1683,7 @@ const JUDGMENT_DAY_AGENT_NAMES = [
|
|
|
1074
1683
|
] as const;
|
|
1075
1684
|
|
|
1076
1685
|
const CORE_MODEL_AGENT_NAMES = [
|
|
1077
|
-
...
|
|
1686
|
+
...SHIPPED_SDD_AGENT_NAMES,
|
|
1078
1687
|
...JUDGMENT_DAY_AGENT_NAMES,
|
|
1079
1688
|
] as const;
|
|
1080
1689
|
const CORE_MODEL_AGENT_NAME_SET = new Set<string>(CORE_MODEL_AGENT_NAMES);
|
|
@@ -1116,10 +1725,10 @@ function readStringPath(value: unknown, path: string[]): string | undefined {
|
|
|
1116
1725
|
|
|
1117
1726
|
function isSddAgentStartEvent(event: unknown): boolean {
|
|
1118
1727
|
const candidates = readAgentStartNames(event);
|
|
1119
|
-
if (candidates.some((value) => SDD_AGENT_NAME_SET.has(value))) return true;
|
|
1728
|
+
if (candidates.some((value) => SDD_AGENT_NAME_SET.has(value)) || sddPhaseFromAgentStartEvent(event) !== undefined) return true;
|
|
1120
1729
|
|
|
1121
1730
|
const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
|
|
1122
|
-
return
|
|
1731
|
+
return SHIPPED_SDD_AGENT_NAMES.some((name) => {
|
|
1123
1732
|
const phase = name.replace(/^sdd-/, "");
|
|
1124
1733
|
return new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt);
|
|
1125
1734
|
});
|
|
@@ -1142,19 +1751,107 @@ function isNamedAgentStartEvent(event: unknown): boolean {
|
|
|
1142
1751
|
return readAgentStartNames(event).length > 0;
|
|
1143
1752
|
}
|
|
1144
1753
|
|
|
1145
|
-
function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | undefined {
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
if (name === "sdd-verify") return "verify";
|
|
1149
|
-
if (name === "sdd-sync") return "sync";
|
|
1150
|
-
if (name === "sdd-archive") return "archive";
|
|
1151
|
-
}
|
|
1754
|
+
function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | "remediate" | undefined {
|
|
1755
|
+
const phases = ["apply", "verify", "sync", "archive", "remediate"] as const;
|
|
1756
|
+
const names = readAgentStartNames(event);
|
|
1152
1757
|
const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
|
|
1153
|
-
|
|
1154
|
-
if (
|
|
1155
|
-
if (
|
|
1156
|
-
|
|
1157
|
-
|
|
1758
|
+
const promptPhases = phases.filter((phase) => new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt));
|
|
1759
|
+
if (promptPhases.length > 1) return undefined;
|
|
1760
|
+
if (names.length === 0) return promptPhases[0];
|
|
1761
|
+
return phases.find((phase) => names.every((name) => name === `sdd-${phase}`) &&
|
|
1762
|
+
(promptPhases.length === 0 || promptPhases[0] === phase));
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function resolveSddChangeSelection(serialized: unknown, cwd: string, agentName: string) {
|
|
1766
|
+
if (typeof serialized !== "string") throw new Error("SDD selection must be a JSON string.");
|
|
1767
|
+
let value: unknown;
|
|
1768
|
+
try {
|
|
1769
|
+
value = JSON.parse(serialized);
|
|
1770
|
+
} catch {
|
|
1771
|
+
throw new Error("SDD selection is malformed.");
|
|
1772
|
+
}
|
|
1773
|
+
if (!isRecord(value) || Object.keys(value).sort().join(",") !== (value.phase === "remediate" ? "changeName,failedEvidenceRevision,phase,workspaceRoot" : SDD_CHANGE_KEYS.join(","))) {
|
|
1774
|
+
throw new Error("SDD selection must contain only changeName, workspaceRoot, and phase.");
|
|
1775
|
+
}
|
|
1776
|
+
const { changeName, workspaceRoot, phase } = value;
|
|
1777
|
+
if (typeof changeName !== "string" || changeName.length === 0 ||
|
|
1778
|
+
typeof workspaceRoot !== "string" || workspaceRoot.length === 0 ||
|
|
1779
|
+
(phase !== "apply" && phase !== "verify" && phase !== "sync" && phase !== "archive" && phase !== "remediate")) {
|
|
1780
|
+
throw new Error("SDD selection has an invalid identity.");
|
|
1781
|
+
}
|
|
1782
|
+
if (agentName !== `sdd-${phase}`) throw new Error("SDD selection phase does not match the child agent.");
|
|
1783
|
+
let canonicalCwd: string;
|
|
1784
|
+
let canonicalSelectionRoot: string;
|
|
1785
|
+
try {
|
|
1786
|
+
canonicalCwd = realpathSync(cwd);
|
|
1787
|
+
canonicalSelectionRoot = realpathSync(workspaceRoot);
|
|
1788
|
+
} catch {
|
|
1789
|
+
throw new Error("SDD selection workspaceRoot cannot be resolved.");
|
|
1790
|
+
}
|
|
1791
|
+
if (canonicalCwd !== canonicalSelectionRoot || workspaceRoot !== canonicalSelectionRoot) {
|
|
1792
|
+
throw new Error("SDD selection workspaceRoot does not match the canonical child root.");
|
|
1793
|
+
}
|
|
1794
|
+
if (phase === "remediate" && (typeof value.failedEvidenceRevision !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.failedEvidenceRevision))) throw new Error("Invalid remediation revision");
|
|
1795
|
+
return { changeName, workspaceRoot: canonicalCwd, phase, ...(phase === "remediate" ? { failedEvidenceRevision: value.failedEvidenceRevision as string } : {}) };
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function resolveSddChangeStartup(
|
|
1799
|
+
serialized: unknown,
|
|
1800
|
+
cwd: string,
|
|
1801
|
+
agentName: string,
|
|
1802
|
+
resolver: (options: Parameters<typeof resolveSddStatus>[0]) => ReturnType<typeof resolveSddStatus> = resolveSddStatus,
|
|
1803
|
+
) {
|
|
1804
|
+
const selection = resolveSddChangeSelection(serialized, cwd, agentName);
|
|
1805
|
+
const status = resolver({ cwd: selection.workspaceRoot, workspaceRoot: selection.workspaceRoot, changeName: selection.changeName, includeInstructions: true });
|
|
1806
|
+
if (status.actionContext.workspaceRoot !== selection.workspaceRoot || status.changeName !== selection.changeName) {
|
|
1807
|
+
throw new Error("SDD selection resolver returned a mismatched status.");
|
|
1808
|
+
}
|
|
1809
|
+
return { selection, status };
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
async function resolveSelectedNativeSddChangeStartup(
|
|
1813
|
+
serialized: unknown,
|
|
1814
|
+
cwd: string,
|
|
1815
|
+
agentName: string,
|
|
1816
|
+
native: Pick<NativeReviewCli, "sddStatus"> | null | undefined,
|
|
1817
|
+
localResolver: (options: Parameters<typeof resolveSddStatus>[0]) => ReturnType<typeof resolveSddStatus> = resolveSddStatus,
|
|
1818
|
+
): Promise<{ selection: { changeName: string; workspaceRoot: string; phase: SddPhase | "remediate"; failedEvidenceRevision?: string }; status: NativeSddStatusV2 | ReturnType<typeof resolveSddStatus> }> {
|
|
1819
|
+
const selection = resolveSddChangeSelection(serialized, cwd, agentName);
|
|
1820
|
+
if (selection.phase === "sync") {
|
|
1821
|
+
const status = localResolver({ cwd: selection.workspaceRoot, workspaceRoot: selection.workspaceRoot, changeName: selection.changeName, includeInstructions: true });
|
|
1822
|
+
if (status.actionContext.workspaceRoot !== selection.workspaceRoot || status.changeName !== selection.changeName) {
|
|
1823
|
+
throw new Error("SDD selection resolver returned a mismatched status.");
|
|
1824
|
+
}
|
|
1825
|
+
return { selection, status };
|
|
1826
|
+
}
|
|
1827
|
+
if (native?.sddStatus === undefined) throw new Error("SDD selection native status is unavailable.");
|
|
1828
|
+
let status: NativeSddStatusV2;
|
|
1829
|
+
try {
|
|
1830
|
+
status = decodeNativeSddStatusV2(
|
|
1831
|
+
await native.sddStatus({ changeName: selection.changeName, workspaceRoot: selection.workspaceRoot }),
|
|
1832
|
+
{ changeName: selection.changeName, workspaceRoot: selection.workspaceRoot },
|
|
1833
|
+
);
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
throw new Error(`SDD selection native status is blocked: ${error instanceof Error ? error.message : String(error)}`);
|
|
1836
|
+
}
|
|
1837
|
+
if ((selection.phase !== "remediate" && !(selection.phase in status.dependencies)) || status.phaseInstructions === undefined || !(selection.phase in status.phaseInstructions)) {
|
|
1838
|
+
throw new Error(`SDD selection native status cannot represent phase ${selection.phase}.`);
|
|
1839
|
+
}
|
|
1840
|
+
if (selection.phase === "remediate") {
|
|
1841
|
+
if (status.nextRecommended !== "remediate" || status.remediationState?.failedEvidenceRevision !== selection.failedEvidenceRevision) throw new Error("Stale remediation selection");
|
|
1842
|
+
} else if (status.nextRecommended !== selection.phase || status.dependencies[selection.phase] !== "ready" || status.blockedReasons.length > 0) {
|
|
1843
|
+
throw new Error(`SDD selection native status blocks phase ${selection.phase}; it cannot execute.`);
|
|
1844
|
+
}
|
|
1845
|
+
return { selection, status };
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
function readSddChangeFlag(pi: ExtensionAPI): unknown {
|
|
1849
|
+
try {
|
|
1850
|
+
const value = (pi as unknown as { getFlag?: (name: string) => unknown }).getFlag?.(SDD_CHANGE_FLAG);
|
|
1851
|
+
return value === false ? undefined : value;
|
|
1852
|
+
} catch {
|
|
1853
|
+
return null;
|
|
1854
|
+
}
|
|
1158
1855
|
}
|
|
1159
1856
|
|
|
1160
1857
|
function normalizePolicyPath(value: string): string {
|
|
@@ -1274,7 +1971,8 @@ async function confirmCommand(
|
|
|
1274
1971
|
herdrLifecycle: HerdrConfirmationLifecycle,
|
|
1275
1972
|
): Promise<ToolCallEventResult | undefined> {
|
|
1276
1973
|
const guardrailsConfig = loadRuntimeGuardrailsConfig(ctx.cwd);
|
|
1277
|
-
const
|
|
1974
|
+
const evaluation = evaluateGuardedCommand(command, guardrailsConfig);
|
|
1975
|
+
const { action: classification } = evaluation;
|
|
1278
1976
|
|
|
1279
1977
|
if (classification === "block") {
|
|
1280
1978
|
return {
|
|
@@ -1297,11 +1995,8 @@ async function confirmCommand(
|
|
|
1297
1995
|
"Gentle AI safety policy requires interactive confirmation before this command.",
|
|
1298
1996
|
};
|
|
1299
1997
|
}
|
|
1300
|
-
const
|
|
1301
|
-
|
|
1302
|
-
180,
|
|
1303
|
-
"…",
|
|
1304
|
-
);
|
|
1998
|
+
const title = guardedCommandTitle(evaluation.key, evaluation.matches);
|
|
1999
|
+
const preview = guardedCommandPreview(command, evaluation.triggerIndex);
|
|
1305
2000
|
const requestId = randomUUID();
|
|
1306
2001
|
const emitPermissionRequest = (
|
|
1307
2002
|
state: "waiting" | "approved" | "denied",
|
|
@@ -1320,7 +2015,7 @@ async function confirmCommand(
|
|
|
1320
2015
|
emitPermissionRequest("waiting");
|
|
1321
2016
|
herdrLifecycle.begin();
|
|
1322
2017
|
try {
|
|
1323
|
-
approved = await ctx.ui.confirm(
|
|
2018
|
+
approved = await ctx.ui.confirm(title, preview);
|
|
1324
2019
|
} catch (error) {
|
|
1325
2020
|
confirmationFailed = true;
|
|
1326
2021
|
confirmationError = error;
|
|
@@ -1452,7 +2147,10 @@ function parseModelExport(value: unknown): AgentModelConfig | undefined {
|
|
|
1452
2147
|
}
|
|
1453
2148
|
|
|
1454
2149
|
async function exportSavedModelConfig(ctx: ExtensionContext): Promise<number> {
|
|
1455
|
-
const saved = await
|
|
2150
|
+
const saved = await readModelRoutingAuthorityAsync(
|
|
2151
|
+
modelConfigPath(ctx.cwd),
|
|
2152
|
+
legacyProjectModelConfigPath(ctx.cwd),
|
|
2153
|
+
);
|
|
1456
2154
|
if (saved.status === "invalid") throw new Error(`Invalid model config: ${saved.path}`);
|
|
1457
2155
|
const agents = saved.status === "valid" ? saved.config : {};
|
|
1458
2156
|
const path = modelExportPath(ctx.cwd);
|
|
@@ -1603,6 +2301,49 @@ async function listAgentsFromDirAsync(
|
|
|
1603
2301
|
return entries;
|
|
1604
2302
|
}
|
|
1605
2303
|
|
|
2304
|
+
interface DiscoverableNonBuiltinAgentRoot {
|
|
2305
|
+
dir: string;
|
|
2306
|
+
source: AgentSource;
|
|
2307
|
+
/** The package installer owns this directory, so packageAssetAudit reports it. */
|
|
2308
|
+
packageManaged: boolean;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
function discoverableNonBuiltinAgentRoots(cwd: string): DiscoverableNonBuiltinAgentRoot[] {
|
|
2312
|
+
const globalAgentHome = gentlePiAgentHome();
|
|
2313
|
+
const roots: DiscoverableNonBuiltinAgentRoot[] = [
|
|
2314
|
+
{ dir: join(globalAgentHome, "agents"), source: "user", packageManaged: true },
|
|
2315
|
+
{ dir: join(globalAgentHome, "subagents"), source: "user", packageManaged: false },
|
|
2316
|
+
{ dir: join(homedir(), ".agents"), source: "user", packageManaged: false },
|
|
2317
|
+
{ dir: join(cwd, ".agents"), source: "project", packageManaged: false },
|
|
2318
|
+
{ dir: join(cwd, ".pi", "agents"), source: "project", packageManaged: false },
|
|
2319
|
+
{ dir: join(cwd, ".pi", "subagents"), source: "project", packageManaged: false },
|
|
2320
|
+
];
|
|
2321
|
+
const unique = new Map<string, DiscoverableNonBuiltinAgentRoot>();
|
|
2322
|
+
for (const root of roots) {
|
|
2323
|
+
let canonical: string;
|
|
2324
|
+
try {
|
|
2325
|
+
canonical = realpathSync(root.dir);
|
|
2326
|
+
} catch {
|
|
2327
|
+
canonical = resolve(root.dir);
|
|
2328
|
+
}
|
|
2329
|
+
const existing = unique.get(canonical);
|
|
2330
|
+
if (existing) {
|
|
2331
|
+
// Reinsert so a later alias keeps true later-root precedence even when
|
|
2332
|
+
// another physical root appears between the duplicate entries. A merged
|
|
2333
|
+
// package-managed root must keep its installer-owned path: ownership
|
|
2334
|
+
// updates validate that lexical path against the managed manifest root.
|
|
2335
|
+
const managedRoot = existing.packageManaged ? existing : root.packageManaged ? root : undefined;
|
|
2336
|
+
unique.delete(canonical);
|
|
2337
|
+
unique.set(canonical, {
|
|
2338
|
+
dir: managedRoot?.dir ?? root.dir,
|
|
2339
|
+
source: root.source,
|
|
2340
|
+
packageManaged: managedRoot !== undefined,
|
|
2341
|
+
});
|
|
2342
|
+
} else unique.set(canonical, root);
|
|
2343
|
+
}
|
|
2344
|
+
return [...unique.values()];
|
|
2345
|
+
}
|
|
2346
|
+
|
|
1606
2347
|
function builtinAgentDirs(cwd: string): string[] {
|
|
1607
2348
|
return [
|
|
1608
2349
|
join(PACKAGE_ROOT, "..", "pi-subagents-j0k3r", "agents"),
|
|
@@ -1633,16 +2374,12 @@ async function listBuiltinAgentNamesAsync(cwd: string): Promise<Set<string>> {
|
|
|
1633
2374
|
}
|
|
1634
2375
|
|
|
1635
2376
|
function listDiscoverableAgents(cwd: string): AgentEntry[] {
|
|
1636
|
-
const globalAgentHome = gentlePiAgentHome();
|
|
1637
2377
|
const builtinDirs = builtinAgentDirs(cwd);
|
|
1638
2378
|
const agents = [
|
|
1639
2379
|
...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")),
|
|
1640
|
-
...
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
...listAgentsFromDir(join(cwd, ".agents"), "project"),
|
|
1644
|
-
...listAgentsFromDir(join(cwd, ".pi", "agents"), "project"),
|
|
1645
|
-
...listAgentsFromDir(join(cwd, ".pi", "subagents"), "project"),
|
|
2380
|
+
...discoverableNonBuiltinAgentRoots(cwd).flatMap(({ dir, source }) =>
|
|
2381
|
+
listAgentsFromDir(dir, source),
|
|
2382
|
+
),
|
|
1646
2383
|
];
|
|
1647
2384
|
const byName = new Map<string, AgentEntry>();
|
|
1648
2385
|
for (const agent of agents) byName.set(agent.name, agent);
|
|
@@ -1650,21 +2387,12 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
|
|
|
1650
2387
|
}
|
|
1651
2388
|
|
|
1652
2389
|
async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
|
|
1653
|
-
const globalAgentHome = gentlePiAgentHome();
|
|
1654
2390
|
const builtinDirs = builtinAgentDirs(cwd);
|
|
1655
2391
|
const agents: AgentEntry[] = [];
|
|
1656
2392
|
for (const dir of builtinDirs) {
|
|
1657
2393
|
agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
|
|
1658
2394
|
}
|
|
1659
|
-
const
|
|
1660
|
-
[join(globalAgentHome, "agents"), "user"],
|
|
1661
|
-
[join(globalAgentHome, "subagents"), "user"],
|
|
1662
|
-
[join(homedir(), ".agents"), "user"],
|
|
1663
|
-
[join(cwd, ".agents"), "project"],
|
|
1664
|
-
[join(cwd, ".pi", "agents"), "project"],
|
|
1665
|
-
[join(cwd, ".pi", "subagents"), "project"],
|
|
1666
|
-
];
|
|
1667
|
-
for (const [dir, source] of otherDirs) {
|
|
2395
|
+
for (const { dir, source } of discoverableNonBuiltinAgentRoots(cwd)) {
|
|
1668
2396
|
agents.push(...(await listAgentsFromDirAsync(dir, source)));
|
|
1669
2397
|
}
|
|
1670
2398
|
const byName = new Map<string, AgentEntry>();
|
|
@@ -1781,6 +2509,15 @@ function projectSettingsPath(cwd: string): string {
|
|
|
1781
2509
|
return join(cwd, ".pi", "settings.json");
|
|
1782
2510
|
}
|
|
1783
2511
|
|
|
2512
|
+
/**
|
|
2513
|
+
* Pi's own global settings file, which is where the orchestrator model lives.
|
|
2514
|
+
* Profiles own the three `default*` keys there; nothing else in this extension
|
|
2515
|
+
* reads or writes that file.
|
|
2516
|
+
*/
|
|
2517
|
+
function orchestratorSettingsPath(): string {
|
|
2518
|
+
return join(gentlePiAgentHome(), "settings.json");
|
|
2519
|
+
}
|
|
2520
|
+
|
|
1784
2521
|
function removeLegacyAgentOverridesFromSettings(
|
|
1785
2522
|
settingsPath: string,
|
|
1786
2523
|
settings: Record<string, unknown>,
|
|
@@ -1805,6 +2542,30 @@ function isValidJsonObjectFileOrMissing(path: string): boolean {
|
|
|
1805
2542
|
}
|
|
1806
2543
|
}
|
|
1807
2544
|
|
|
2545
|
+
const PROVIDER_REVIEW_ROLES = ["review-refuter", "review-validator"] as const;
|
|
2546
|
+
|
|
2547
|
+
function isProviderReviewRole(name: string): boolean {
|
|
2548
|
+
return PROVIDER_REVIEW_ROLES.some((role) => role === name);
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
function modelAssignmentNames(cwd: string): string[] {
|
|
2552
|
+
return [...new Set([
|
|
2553
|
+
...PROVIDER_REVIEW_ROLES,
|
|
2554
|
+
...listDiscoverableAgents(cwd).map((agent) => agent.name),
|
|
2555
|
+
])];
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
const PROVIDER_ROUTING_DEFAULT_LABELS = {
|
|
2559
|
+
model: "Pi persisted default model",
|
|
2560
|
+
effort: "Pi persisted default effort",
|
|
2561
|
+
} as const;
|
|
2562
|
+
|
|
2563
|
+
type RoutingDefaultField = keyof typeof PROVIDER_ROUTING_DEFAULT_LABELS;
|
|
2564
|
+
|
|
2565
|
+
function routingDefaultLabel(name: string, field: RoutingDefaultField): string {
|
|
2566
|
+
return isProviderReviewRole(name) ? PROVIDER_ROUTING_DEFAULT_LABELS[field] : "inherit";
|
|
2567
|
+
}
|
|
2568
|
+
|
|
1808
2569
|
function migrateLegacyProjectModelOverrides(cwd: string): number {
|
|
1809
2570
|
const settingsPath = projectSettingsPath(cwd);
|
|
1810
2571
|
if (!existsSync(settingsPath)) return 0;
|
|
@@ -1823,6 +2584,7 @@ function migrateLegacyProjectModelOverrides(cwd: string): number {
|
|
|
1823
2584
|
if (!agentOverrides) return 0;
|
|
1824
2585
|
const agentsByName = new Map(listDiscoverableAgents(cwd).map((agent) => [agent.name, agent]));
|
|
1825
2586
|
const migratableEntries = Object.entries(agentOverrides)
|
|
2587
|
+
.filter(([name]) => !isProviderReviewRole(name))
|
|
1826
2588
|
.map(([name, value]) => ({ name, entry: normalizeRoutingEntry(value) }))
|
|
1827
2589
|
.filter((item): item is { name: string; entry: AgentRoutingEntry } =>
|
|
1828
2590
|
item.entry !== undefined && !isClearRoutingEntry(item.entry),
|
|
@@ -1865,30 +2627,40 @@ export function applyModelConfig(
|
|
|
1865
2627
|
let skipped = 0;
|
|
1866
2628
|
const seenAgents = new Set<string>();
|
|
1867
2629
|
for (const agent of listDiscoverableAgents(cwd)) {
|
|
2630
|
+
if (isProviderReviewRole(agent.name)) continue;
|
|
1868
2631
|
seenAgents.add(agent.name);
|
|
1869
2632
|
const entry = config[agent.name];
|
|
1870
2633
|
if (entry === undefined) {
|
|
1871
2634
|
skipped += 1;
|
|
1872
2635
|
continue;
|
|
1873
2636
|
}
|
|
1874
|
-
if (
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
if (!agent.filePath || !existsSync(agent.filePath)) {
|
|
1878
|
-
skipped += 1;
|
|
2637
|
+
if (agent.source === "builtin") {
|
|
2638
|
+
if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1;
|
|
2639
|
+
else skipped += 1;
|
|
1879
2640
|
continue;
|
|
1880
2641
|
}
|
|
1881
|
-
|
|
1882
|
-
const next = updateFrontmatterRouting(original, entry);
|
|
1883
|
-
if (next === original) {
|
|
2642
|
+
if (!agent.filePath || !existsSync(agent.filePath)) {
|
|
1884
2643
|
skipped += 1;
|
|
1885
|
-
|
|
2644
|
+
} else {
|
|
2645
|
+
const original = readFileSync(agent.filePath, "utf8");
|
|
2646
|
+
const next = updateFrontmatterRouting(original, entry);
|
|
2647
|
+
if (next === original) {
|
|
2648
|
+
skipped += 1;
|
|
2649
|
+
} else {
|
|
2650
|
+
if (!updatePackageManagedSddAgentOwnership(agent.filePath, original, next)) {
|
|
2651
|
+
writeFileSync(agent.filePath, next);
|
|
2652
|
+
}
|
|
2653
|
+
updated += 1;
|
|
2654
|
+
}
|
|
1886
2655
|
}
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
updated += 1;
|
|
2656
|
+
if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1;
|
|
2657
|
+
else skipped += 1;
|
|
1890
2658
|
}
|
|
1891
2659
|
for (const [name, entry] of Object.entries(config)) {
|
|
2660
|
+
if (isProviderReviewRole(name)) continue;
|
|
2661
|
+
// The orchestrator is routing, not an agent: its model lives in Pi's global
|
|
2662
|
+
// settings.json and must never reach subagents.json.
|
|
2663
|
+
if (isProfileOrchestratorKey(name)) continue;
|
|
1892
2664
|
if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
|
|
1893
2665
|
if (updateSubagentModelProfile(cwd, "user", name, entry)) updated += 1;
|
|
1894
2666
|
else skipped += 1;
|
|
@@ -1905,31 +2677,40 @@ export async function applyModelConfigAsync(
|
|
|
1905
2677
|
let skipped = 0;
|
|
1906
2678
|
const seenAgents = new Set<string>();
|
|
1907
2679
|
for (const agent of await listDiscoverableAgentsAsync(cwd)) {
|
|
2680
|
+
if (isProviderReviewRole(agent.name)) continue;
|
|
1908
2681
|
seenAgents.add(agent.name);
|
|
1909
2682
|
const entry = config[agent.name];
|
|
1910
2683
|
if (entry === undefined) {
|
|
1911
2684
|
skipped += 1;
|
|
1912
2685
|
continue;
|
|
1913
2686
|
}
|
|
1914
|
-
if (
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
skipped += 1;
|
|
1920
|
-
continue;
|
|
2687
|
+
if (agent.source === "builtin") {
|
|
2688
|
+
if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry))
|
|
2689
|
+
updated += 1;
|
|
2690
|
+
else skipped += 1;
|
|
2691
|
+
continue;
|
|
1921
2692
|
}
|
|
1922
|
-
|
|
1923
|
-
const next = updateFrontmatterRouting(original, entry);
|
|
1924
|
-
if (next === original) {
|
|
2693
|
+
if (!agent.filePath || !(await pathExists(agent.filePath))) {
|
|
1925
2694
|
skipped += 1;
|
|
1926
|
-
|
|
2695
|
+
} else {
|
|
2696
|
+
const original = await readFile(agent.filePath, "utf8");
|
|
2697
|
+
const next = updateFrontmatterRouting(original, entry);
|
|
2698
|
+
if (next === original) {
|
|
2699
|
+
skipped += 1;
|
|
2700
|
+
} else {
|
|
2701
|
+
if (!updatePackageManagedSddAgentOwnership(agent.filePath, original, next)) {
|
|
2702
|
+
await writeFile(agent.filePath, next);
|
|
2703
|
+
}
|
|
2704
|
+
updated += 1;
|
|
2705
|
+
}
|
|
1927
2706
|
}
|
|
1928
|
-
await
|
|
1929
|
-
|
|
1930
|
-
|
|
2707
|
+
if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry))
|
|
2708
|
+
updated += 1;
|
|
2709
|
+
else skipped += 1;
|
|
1931
2710
|
}
|
|
1932
2711
|
for (const [name, entry] of Object.entries(config)) {
|
|
2712
|
+
if (isProviderReviewRole(name)) continue;
|
|
2713
|
+
if (isProfileOrchestratorKey(name)) continue;
|
|
1933
2714
|
if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
|
|
1934
2715
|
if (await updateSubagentModelProfileAsync(cwd, "user", name, entry))
|
|
1935
2716
|
updated += 1;
|
|
@@ -1957,11 +2738,11 @@ export async function applySavedModelConfig(
|
|
|
1957
2738
|
}
|
|
1958
2739
|
|
|
1959
2740
|
function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
|
|
1960
|
-
return
|
|
1961
|
-
const entry = config[
|
|
1962
|
-
const model = entry?.model ?? "
|
|
1963
|
-
const thinking = entry?.thinking ?? "
|
|
1964
|
-
return `${sanitizeTerminalText(
|
|
2741
|
+
return modelAssignmentNames(cwd).map((name) => {
|
|
2742
|
+
const entry = config[name];
|
|
2743
|
+
const model = entry?.model ?? routingDefaultLabel(name, "model");
|
|
2744
|
+
const thinking = entry?.thinking ?? routingDefaultLabel(name, "effort");
|
|
2745
|
+
return `${sanitizeTerminalText(name)}: model=${sanitizeTerminalText(model)}, effort=${sanitizeTerminalText(thinking)}`;
|
|
1965
2746
|
});
|
|
1966
2747
|
}
|
|
1967
2748
|
|
|
@@ -2329,7 +3110,7 @@ class SddModelPanel implements OverlayComponent {
|
|
|
2329
3110
|
lines.push("");
|
|
2330
3111
|
lines.push(
|
|
2331
3112
|
line(
|
|
2332
|
-
|
|
3113
|
+
`j/k scroll • enter model/save • e effort • i ${isProviderReviewRole(this.rows[this.cursor] ?? "") ? "Pi persisted defaults" : "inherit"} • c custom • x export • r restore • ctrl+s save • esc back`,
|
|
2333
3114
|
"muted",
|
|
2334
3115
|
),
|
|
2335
3116
|
);
|
|
@@ -2361,7 +3142,9 @@ class SddModelPanel implements OverlayComponent {
|
|
|
2361
3142
|
const focused = i === this.modelCursor;
|
|
2362
3143
|
lines.push(
|
|
2363
3144
|
`${this.renderCursor(focused)} ${this.renderText(
|
|
2364
|
-
options[i]
|
|
3145
|
+
options[i] === INHERIT_MODEL && isProviderReviewRole(this.selectedRow)
|
|
3146
|
+
? routingDefaultLabel(this.selectedRow, "model")
|
|
3147
|
+
: (options[i] ?? ""),
|
|
2365
3148
|
focused ? "status" : "text",
|
|
2366
3149
|
)}`,
|
|
2367
3150
|
);
|
|
@@ -2413,7 +3196,9 @@ class SddModelPanel implements OverlayComponent {
|
|
|
2413
3196
|
const focused = i === this.effortCursor;
|
|
2414
3197
|
lines.push(
|
|
2415
3198
|
`${this.renderCursor(focused)} ${this.renderText(
|
|
2416
|
-
THINKING_OPTIONS[i]
|
|
3199
|
+
THINKING_OPTIONS[i] === INHERIT_THINKING && isProviderReviewRole(this.selectedRow)
|
|
3200
|
+
? routingDefaultLabel(this.selectedRow, "effort")
|
|
3201
|
+
: (THINKING_OPTIONS[i] ?? ""),
|
|
2417
3202
|
focused ? "status" : "text",
|
|
2418
3203
|
)}`,
|
|
2419
3204
|
);
|
|
@@ -2426,10 +3211,10 @@ class SddModelPanel implements OverlayComponent {
|
|
|
2426
3211
|
private renderSetAllLabel(row: string): string {
|
|
2427
3212
|
const models = this.rows
|
|
2428
3213
|
.slice(1)
|
|
2429
|
-
.map((name) => this.draft[name]?.model ?? "
|
|
3214
|
+
.map((name) => this.draft[name]?.model ?? routingDefaultLabel(name, "model"));
|
|
2430
3215
|
const efforts = this.rows
|
|
2431
3216
|
.slice(1)
|
|
2432
|
-
.map((name) => this.draft[name]?.thinking ?? "
|
|
3217
|
+
.map((name) => this.draft[name]?.thinking ?? routingDefaultLabel(name, "effort"));
|
|
2433
3218
|
const firstModel = models[0] ?? "inherit";
|
|
2434
3219
|
const firstEffort = efforts[0] ?? "inherit";
|
|
2435
3220
|
const modelLabel = models.every((value) => value === firstModel)
|
|
@@ -2445,8 +3230,8 @@ class SddModelPanel implements OverlayComponent {
|
|
|
2445
3230
|
}
|
|
2446
3231
|
|
|
2447
3232
|
private renderAgentLabel(row: string): string {
|
|
2448
|
-
const model = this.draft[row]?.model ?? "
|
|
2449
|
-
const effort = this.draft[row]?.thinking ?? "
|
|
3233
|
+
const model = this.draft[row]?.model ?? routingDefaultLabel(row, "model");
|
|
3234
|
+
const effort = this.draft[row]?.thinking ?? routingDefaultLabel(row, "effort");
|
|
2450
3235
|
return `${this.renderText(sanitizeTerminalText(row).padEnd(20), "text")} ${this.renderText("model=", "muted")}${this.renderText(model, "status")}${this.renderText(
|
|
2451
3236
|
", effort=",
|
|
2452
3237
|
"muted",
|
|
@@ -2471,7 +3256,7 @@ async function showSddModelPanel(
|
|
|
2471
3256
|
config: AgentModelConfig,
|
|
2472
3257
|
): Promise<ModelPanelResult> {
|
|
2473
3258
|
const modelOptions = await getPiModelOptions(ctx);
|
|
2474
|
-
const agents =
|
|
3259
|
+
const agents = modelAssignmentNames(ctx.cwd);
|
|
2475
3260
|
return ctx.ui.custom<ModelPanelResult>(
|
|
2476
3261
|
(_tui, theme, _keybindings, done) =>
|
|
2477
3262
|
new SddModelPanel(config, modelOptions, agents, done, theme),
|
|
@@ -2489,7 +3274,10 @@ async function showSddModelPanel(
|
|
|
2489
3274
|
|
|
2490
3275
|
async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
2491
3276
|
migrateLegacyProjectModelOverrides(ctx.cwd);
|
|
2492
|
-
const savedConfig = await
|
|
3277
|
+
const savedConfig = await readModelRoutingAuthorityAsync(
|
|
3278
|
+
modelConfigPath(ctx.cwd),
|
|
3279
|
+
legacyProjectModelConfigPath(ctx.cwd),
|
|
3280
|
+
);
|
|
2493
3281
|
if (savedConfig.status === "invalid") {
|
|
2494
3282
|
ctx.ui.notify(
|
|
2495
3283
|
`el Gentleman cannot open model config because ${savedConfig.path} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`,
|
|
@@ -2569,9 +3357,9 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
2569
3357
|
}
|
|
2570
3358
|
if (result.agent === "all") {
|
|
2571
3359
|
const next: AgentModelConfig = { ...config };
|
|
2572
|
-
for (const
|
|
2573
|
-
next[
|
|
2574
|
-
...(next[
|
|
3360
|
+
for (const name of modelAssignmentNames(ctx.cwd)) {
|
|
3361
|
+
next[name] = {
|
|
3362
|
+
...(next[name] ?? {}),
|
|
2575
3363
|
model,
|
|
2576
3364
|
};
|
|
2577
3365
|
}
|
|
@@ -2602,6 +3390,667 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
2602
3390
|
);
|
|
2603
3391
|
}
|
|
2604
3392
|
|
|
3393
|
+
type ProfilesPanelResult =
|
|
3394
|
+
| { type: "apply"; name: string }
|
|
3395
|
+
| { type: "create" }
|
|
3396
|
+
| { type: "update"; name: string }
|
|
3397
|
+
| { type: "duplicate"; name: string }
|
|
3398
|
+
| { type: "rename"; name: string }
|
|
3399
|
+
| { type: "delete"; name: string }
|
|
3400
|
+
| { type: "export"; name: string }
|
|
3401
|
+
| { type: "import" }
|
|
3402
|
+
| { type: "close" };
|
|
3403
|
+
|
|
3404
|
+
const PROFILES_PANEL_MIN_BODY_ROWS = 6;
|
|
3405
|
+
|
|
3406
|
+
type ProfilesPanelPointerLayout = AgentsViewLayout & { listTop: number };
|
|
3407
|
+
|
|
3408
|
+
function hasOwnProfile(profiles: Record<string, unknown>, name: string): boolean {
|
|
3409
|
+
return Object.prototype.hasOwnProperty.call(profiles, name);
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
function profilesErrorMessage(error: unknown): string {
|
|
3413
|
+
return error instanceof Error ? error.message : String(error);
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
/** Keep a detail-pane scroll offset inside the bounds of its own content. */
|
|
3417
|
+
function clampDetailScroll(offset: number, lineCount: number, bodyRows: number): number {
|
|
3418
|
+
return Math.max(0, Math.min(offset, Math.max(0, lineCount - bodyRows)));
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
// Left profile list, right detail panes. Rendering and pointer routing share
|
|
3422
|
+
// one measured layout; the list rows keep native keyboard, hover, press,
|
|
3423
|
+
// click, and wheel handling through NativeChoiceList. The frame takes the full
|
|
3424
|
+
// overlay, so its height follows the terminal rows the same way AgentsView does.
|
|
3425
|
+
class ProfilesPanel implements OverlayComponent {
|
|
3426
|
+
private completed = false;
|
|
3427
|
+
private pointerLayout: ProfilesPanelPointerLayout | undefined;
|
|
3428
|
+
private detailScroll = 0;
|
|
3429
|
+
private lastDetailLineCount = 0;
|
|
3430
|
+
private lastDetailRows = 0;
|
|
3431
|
+
private lastSelectedId: string | undefined;
|
|
3432
|
+
readonly list: NativeChoiceList<ProfileListItem>;
|
|
3433
|
+
private readonly file: AgentProfilesFile;
|
|
3434
|
+
private readonly currentConfig: AgentModelConfig;
|
|
3435
|
+
private readonly done: (result: ProfilesPanelResult) => void;
|
|
3436
|
+
private readonly theme: Theme | undefined;
|
|
3437
|
+
private readonly rows: () => number;
|
|
3438
|
+
private readonly orchestratorSettings: OrchestratorSettingsReadResult;
|
|
3439
|
+
|
|
3440
|
+
constructor(
|
|
3441
|
+
file: AgentProfilesFile,
|
|
3442
|
+
currentConfig: AgentModelConfig,
|
|
3443
|
+
done: (result: ProfilesPanelResult) => void,
|
|
3444
|
+
keybindings: KeybindingsManager | undefined,
|
|
3445
|
+
theme: Theme | undefined,
|
|
3446
|
+
selectedName: string | undefined,
|
|
3447
|
+
rows: () => number,
|
|
3448
|
+
orchestratorSettings: OrchestratorSettingsReadResult,
|
|
3449
|
+
) {
|
|
3450
|
+
this.file = file;
|
|
3451
|
+
this.currentConfig = currentConfig;
|
|
3452
|
+
this.done = done;
|
|
3453
|
+
this.theme = theme;
|
|
3454
|
+
this.rows = rows;
|
|
3455
|
+
this.orchestratorSettings = orchestratorSettings;
|
|
3456
|
+
const items = buildProfileListItems(file);
|
|
3457
|
+
this.list = new NativeChoiceList<ProfileListItem>(
|
|
3458
|
+
items,
|
|
3459
|
+
{
|
|
3460
|
+
selectedPrefix: (text) => this.renderText(text, "accent"),
|
|
3461
|
+
selectedText: (text) => this.renderText(text, "accent"),
|
|
3462
|
+
description: (text) => this.renderText(text, "muted"),
|
|
3463
|
+
hoverBackground: (text) => (this.theme ? this.theme.bg("toolPendingBg", text) : text),
|
|
3464
|
+
},
|
|
3465
|
+
keybindings,
|
|
3466
|
+
);
|
|
3467
|
+
this.list.onSelect = (item) => this.finish({ type: "apply", name: item.id });
|
|
3468
|
+
this.list.onCancel = () => this.finish({ type: "close" });
|
|
3469
|
+
const selected = selectedName ? items.findIndex((item) => item.id === selectedName) : -1;
|
|
3470
|
+
if (selected >= 0) this.list.setSelectedIndex(selected);
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
invalidate(): void {
|
|
3474
|
+
this.pointerLayout = undefined;
|
|
3475
|
+
this.list.invalidate();
|
|
3476
|
+
}
|
|
3477
|
+
|
|
3478
|
+
handleInput(data: string): void {
|
|
3479
|
+
if (isKeyRelease(data)) return;
|
|
3480
|
+
if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
|
|
3481
|
+
this.finish({ type: "close" });
|
|
3482
|
+
return;
|
|
3483
|
+
}
|
|
3484
|
+
const name = this.list.getSelectedItem()?.id;
|
|
3485
|
+
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("j"))) {
|
|
3486
|
+
this.scrollDetail(this.pageRows());
|
|
3487
|
+
return;
|
|
3488
|
+
}
|
|
3489
|
+
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("k"))) {
|
|
3490
|
+
this.scrollDetail(-this.pageRows());
|
|
3491
|
+
return;
|
|
3492
|
+
}
|
|
3493
|
+
// Agents-view line scroll: j/k move the routing one line at a time. The
|
|
3494
|
+
// arrow keys stay with the profile list, exactly as the letters below stay
|
|
3495
|
+
// profile actions.
|
|
3496
|
+
if (data === "j") {
|
|
3497
|
+
this.scrollDetail(1);
|
|
3498
|
+
return;
|
|
3499
|
+
}
|
|
3500
|
+
if (data === "k") {
|
|
3501
|
+
this.scrollDetail(-1);
|
|
3502
|
+
return;
|
|
3503
|
+
}
|
|
3504
|
+
if (data === "c") return this.finish({ type: "create" });
|
|
3505
|
+
if (data === "i") return this.finish({ type: "import" });
|
|
3506
|
+
if (!name) return this.list.handleInput(data);
|
|
3507
|
+
if (data === "s") return this.finish({ type: "update", name });
|
|
3508
|
+
if (data === "d") return this.finish({ type: "duplicate", name });
|
|
3509
|
+
if (data === "r") return this.finish({ type: "rename", name });
|
|
3510
|
+
if (data === "x") return this.finish({ type: "delete", name });
|
|
3511
|
+
if (data === "e") return this.finish({ type: "export", name });
|
|
3512
|
+
this.list.handleInput(data);
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
3516
|
+
const layout = this.pointerLayout;
|
|
3517
|
+
if (!layout) return undefined;
|
|
3518
|
+
const inBody = event.y >= layout.listTop && event.y < layout.listTop + layout.bodyRows;
|
|
3519
|
+
if (
|
|
3520
|
+
inBody &&
|
|
3521
|
+
event.type === "wheel" &&
|
|
3522
|
+
event.wheelDelta &&
|
|
3523
|
+
layout.mode === "panes" &&
|
|
3524
|
+
event.x >= layout.threadX &&
|
|
3525
|
+
event.x < layout.threadX + layout.threadWidth
|
|
3526
|
+
) {
|
|
3527
|
+
this.scrollDetail(event.wheelDelta);
|
|
3528
|
+
return { handled: true, render: true };
|
|
3529
|
+
}
|
|
3530
|
+
if (!inBody) return undefined;
|
|
3531
|
+
if (event.x < layout.listX || event.x >= layout.listX + layout.listWidth) return undefined;
|
|
3532
|
+
return this.list.handleMouse({
|
|
3533
|
+
...event,
|
|
3534
|
+
x: event.x - layout.listX,
|
|
3535
|
+
y: event.y - layout.listTop,
|
|
3536
|
+
width: layout.listWidth,
|
|
3537
|
+
height: layout.bodyRows,
|
|
3538
|
+
});
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
render(width: number): string[] {
|
|
3542
|
+
const safeWidth = Math.max(1, width);
|
|
3543
|
+
const listWidth = measureAgentsViewLayout(safeWidth, PROFILES_PANEL_MIN_BODY_ROWS + 3).listWidth;
|
|
3544
|
+
const listLines = this.list.render(listWidth);
|
|
3545
|
+
// The frame fills the overlay, and the overlay fills the terminal, so the
|
|
3546
|
+
// body height comes from the terminal rows rather than from the content.
|
|
3547
|
+
const rows = Math.max(PROFILES_PANEL_MIN_BODY_ROWS + 3, Math.floor(this.rows()));
|
|
3548
|
+
const layout = measureAgentsViewLayout(safeWidth, rows);
|
|
3549
|
+
if (layout.mode === "fallback" || layout.width === 0) {
|
|
3550
|
+
this.pointerLayout = undefined;
|
|
3551
|
+
return [];
|
|
3552
|
+
}
|
|
3553
|
+
const selectedId = this.list.getSelectedItem()?.id;
|
|
3554
|
+
if (selectedId !== this.lastSelectedId) {
|
|
3555
|
+
this.lastSelectedId = selectedId;
|
|
3556
|
+
this.detailScroll = 0;
|
|
3557
|
+
}
|
|
3558
|
+
const detailLines = this.renderDetailLines(layout.threadWidth, layout.mode === "panes");
|
|
3559
|
+
this.lastDetailLineCount = detailLines.length;
|
|
3560
|
+
this.lastDetailRows = layout.bodyRows;
|
|
3561
|
+
this.detailScroll = clampDetailScroll(this.detailScroll, detailLines.length, layout.bodyRows);
|
|
3562
|
+
this.pointerLayout = { ...layout, listTop: 1 };
|
|
3563
|
+
const rule = "─".repeat(Math.max(0, layout.width - 2));
|
|
3564
|
+
const lines: string[] = [this.renderText(`╭${rule}╮`, "border")];
|
|
3565
|
+
for (let row = 0; row < layout.bodyRows; row += 1) {
|
|
3566
|
+
lines.push(this.renderBodyRow(row, layout, listLines, detailLines));
|
|
3567
|
+
}
|
|
3568
|
+
lines.push(this.renderFooterRow(layout.width));
|
|
3569
|
+
lines.push(this.renderText(`╰${rule}╯`, "border"));
|
|
3570
|
+
return lines;
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
private scrollDetail(delta: number): void {
|
|
3574
|
+
this.detailScroll = clampDetailScroll(
|
|
3575
|
+
this.detailScroll + Math.trunc(delta),
|
|
3576
|
+
this.lastDetailLineCount,
|
|
3577
|
+
this.lastDetailRows,
|
|
3578
|
+
);
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
private pageRows(): number {
|
|
3582
|
+
return Math.max(1, this.lastDetailRows - 1);
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
private finish(result: ProfilesPanelResult): void {
|
|
3586
|
+
if (this.completed) return;
|
|
3587
|
+
this.completed = true;
|
|
3588
|
+
this.list.setDisabled(true);
|
|
3589
|
+
this.done(result);
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
private renderBodyRow(
|
|
3593
|
+
row: number,
|
|
3594
|
+
layout: AgentsViewLayout,
|
|
3595
|
+
listLines: string[],
|
|
3596
|
+
detailLines: string[],
|
|
3597
|
+
): string {
|
|
3598
|
+
if (layout.mode === "panes") {
|
|
3599
|
+
return [
|
|
3600
|
+
this.renderText("│", "border"),
|
|
3601
|
+
" ",
|
|
3602
|
+
this.fitPaneLine(listLines[row] ?? "", layout.listWidth),
|
|
3603
|
+
" ",
|
|
3604
|
+
this.renderText("│", "border"),
|
|
3605
|
+
" ",
|
|
3606
|
+
this.fitPaneLine(detailLines[this.detailScroll + row] ?? "", layout.threadWidth),
|
|
3607
|
+
this.renderText("│", "border"),
|
|
3608
|
+
].join("");
|
|
3609
|
+
}
|
|
3610
|
+
return [
|
|
3611
|
+
this.renderText("│", "border"),
|
|
3612
|
+
" ",
|
|
3613
|
+
this.fitPaneLine(listLines[row] ?? "", layout.listWidth),
|
|
3614
|
+
" ",
|
|
3615
|
+
this.renderText("│", "border"),
|
|
3616
|
+
].join("");
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
private renderFooterRow(width: number): string {
|
|
3620
|
+
const hints =
|
|
3621
|
+
"enter apply · c create · s update · d duplicate · r rename · x delete · e export · i import · j/k line · ctrl+j/k page · esc close";
|
|
3622
|
+
return [
|
|
3623
|
+
this.renderText("│", "border"),
|
|
3624
|
+
" ",
|
|
3625
|
+
this.fitPaneLine(this.renderText(hints, "muted"), width - 4),
|
|
3626
|
+
" ",
|
|
3627
|
+
this.renderText("│", "border"),
|
|
3628
|
+
].join("");
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
private renderDetailLines(width: number, showDetail: boolean): string[] {
|
|
3632
|
+
if (!showDetail) return [];
|
|
3633
|
+
const name = this.list.getSelectedItem()?.id;
|
|
3634
|
+
if (!name || !hasOwnProfile(this.file.profiles, name)) {
|
|
3635
|
+
return [this.renderLine("No profile selected.", width, "muted")];
|
|
3636
|
+
}
|
|
3637
|
+
const config = this.file.profiles[name];
|
|
3638
|
+
const profileRows = profileRoutingRows(config);
|
|
3639
|
+
const currentRows = profileRoutingRows(this.currentConfig);
|
|
3640
|
+
// One shared measurement across both tables, so the same agent sits in the
|
|
3641
|
+
// same column whether it comes from the profile or from models.json.
|
|
3642
|
+
const widths = routingColumnWidths(profileRows, currentRows);
|
|
3643
|
+
return [
|
|
3644
|
+
this.renderLine(name === this.file.active ? `${name} (active)` : name, width, "title"),
|
|
3645
|
+
this.renderLine(
|
|
3646
|
+
`orchestrator ${formatOrchestratorSelection(readProfileOrchestrator(config))}`,
|
|
3647
|
+
width,
|
|
3648
|
+
"text",
|
|
3649
|
+
),
|
|
3650
|
+
this.renderLine(`now ${this.effectiveOrchestratorLabel()}`, width, "muted"),
|
|
3651
|
+
"",
|
|
3652
|
+
this.renderLine("Profile routing", width, "accent"),
|
|
3653
|
+
...this.indentLines(this.routingLines(profileRows, widths), width),
|
|
3654
|
+
"",
|
|
3655
|
+
this.renderLine("Current routing (models.json)", width, "accent"),
|
|
3656
|
+
...this.indentLines(this.routingLines(currentRows, widths), width),
|
|
3657
|
+
];
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
private effectiveOrchestratorLabel(): string {
|
|
3661
|
+
const settings = this.orchestratorSettings;
|
|
3662
|
+
if (settings.status === "invalid") {
|
|
3663
|
+
return `unreadable (${sanitizeTerminalText(settings.reason)})`;
|
|
3664
|
+
}
|
|
3665
|
+
return formatOrchestratorSelection(settings.status === "valid" ? settings.entry : undefined);
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
private routingLines(
|
|
3669
|
+
rows: ProfileRoutingRow[],
|
|
3670
|
+
widths: { agent: number; model: number },
|
|
3671
|
+
): string[] {
|
|
3672
|
+
if (rows.length === 0) {
|
|
3673
|
+
return ["No routing entries — every agent inherits its default model."];
|
|
3674
|
+
}
|
|
3675
|
+
return rows.map((row) => formatRoutingRow(row, widths));
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
private indentLines(lines: string[], width: number): string[] {
|
|
3679
|
+
return lines.map((line) => this.renderLine(` ${line}`, width, "text"));
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
private fitPaneLine(line: string, width: number): string {
|
|
3683
|
+
const visible = stripAnsi(line);
|
|
3684
|
+
if (visible.length > width) {
|
|
3685
|
+
return truncateToWidth(visible, Math.max(1, width), "…", true);
|
|
3686
|
+
}
|
|
3687
|
+
return `${line}${" ".repeat(Math.max(0, width - visible.length))}`;
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
private renderLine(text: string, width: number, tone?: PanelTone): string {
|
|
3691
|
+
const safe = truncateToWidth(
|
|
3692
|
+
sanitizeTerminalText(text),
|
|
3693
|
+
Math.max(1, width),
|
|
3694
|
+
"…",
|
|
3695
|
+
true,
|
|
3696
|
+
);
|
|
3697
|
+
return tone ? this.renderText(safe, tone) : safe;
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
private renderText(text: string, tone: PanelTone): string {
|
|
3701
|
+
const safe = sanitizeTerminalText(text);
|
|
3702
|
+
if (!this.theme) return safe;
|
|
3703
|
+
return this.theme.fg(PANEL_TONE_COLOR[tone], safe);
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
async function showProfilesPanel(
|
|
3708
|
+
ctx: ExtensionContext,
|
|
3709
|
+
file: AgentProfilesFile,
|
|
3710
|
+
currentConfig: AgentModelConfig,
|
|
3711
|
+
selectedName?: string,
|
|
3712
|
+
): Promise<ProfilesPanelResult> {
|
|
3713
|
+
// Read once, for the panel lifetime. The orchestrator shown as "now" is the
|
|
3714
|
+
// state the panel opened on, not a value that changes mid-panel.
|
|
3715
|
+
const orchestratorSettings = readOrchestratorSettings(orchestratorSettingsPath());
|
|
3716
|
+
return ctx.ui.custom<ProfilesPanelResult>(
|
|
3717
|
+
(tui, theme, keybindings, done) => {
|
|
3718
|
+
const panel = new ProfilesPanel(
|
|
3719
|
+
file,
|
|
3720
|
+
currentConfig,
|
|
3721
|
+
done,
|
|
3722
|
+
keybindings,
|
|
3723
|
+
theme,
|
|
3724
|
+
selectedName,
|
|
3725
|
+
() => Math.max(0, tui.terminal.rows),
|
|
3726
|
+
orchestratorSettings,
|
|
3727
|
+
);
|
|
3728
|
+
const container = createNativeFullscreenInteraction({
|
|
3729
|
+
keyboardTarget: panel,
|
|
3730
|
+
requestRender: () => tui.requestRender(),
|
|
3731
|
+
mouseObserver: panel.list.createMouseObserver(() => tui.requestRender()),
|
|
3732
|
+
});
|
|
3733
|
+
container.addChild(panel);
|
|
3734
|
+
return container;
|
|
3735
|
+
},
|
|
3736
|
+
{
|
|
3737
|
+
overlay: true,
|
|
3738
|
+
overlayOptions: {
|
|
3739
|
+
anchor: "center",
|
|
3740
|
+
width: "100%",
|
|
3741
|
+
maxHeight: "100%",
|
|
3742
|
+
margin: 0,
|
|
3743
|
+
},
|
|
3744
|
+
},
|
|
3745
|
+
);
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
function reportProfilesDrops(ctx: ExtensionContext, path: string, drops: ProfilesParseDrops): void {
|
|
3749
|
+
// Dropped names are by definition the ones that failed validation, so they are
|
|
3750
|
+
// untrusted input reaching the terminal and must be sanitized like any other
|
|
3751
|
+
// externally supplied text.
|
|
3752
|
+
const parts: string[] = [];
|
|
3753
|
+
if (drops.droppedProfiles.length > 0) {
|
|
3754
|
+
parts.push(`profiles: ${drops.droppedProfiles.map((name) => sanitizeTerminalText(name)).join(", ")}`);
|
|
3755
|
+
}
|
|
3756
|
+
if (drops.droppedAgents.length > 0) {
|
|
3757
|
+
parts.push(
|
|
3758
|
+
`routing entries: ${drops.droppedAgents.map(({ profile, agent }) => `${sanitizeTerminalText(profile)}/${sanitizeTerminalText(agent)}`).join(", ")}`,
|
|
3759
|
+
);
|
|
3760
|
+
}
|
|
3761
|
+
if (drops.droppedActive !== undefined) {
|
|
3762
|
+
parts.push(`active marker: ${sanitizeTerminalText(drops.droppedActive)}`);
|
|
3763
|
+
}
|
|
3764
|
+
if (parts.length > 0) {
|
|
3765
|
+
ctx.ui.notify(
|
|
3766
|
+
`el Gentleman dropped invalid entries while loading ${sanitizeTerminalText(path)} — ${parts.join("; ")}.`,
|
|
3767
|
+
"warning",
|
|
3768
|
+
);
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3772
|
+
async function runProfilesPanelAction(
|
|
3773
|
+
ctx: ExtensionContext,
|
|
3774
|
+
path: string,
|
|
3775
|
+
file: AgentProfilesFile,
|
|
3776
|
+
result: Exclude<ProfilesPanelResult, { type: "close" }>,
|
|
3777
|
+
): Promise<AgentProfilesFile> {
|
|
3778
|
+
switch (result.type) {
|
|
3779
|
+
case "apply": {
|
|
3780
|
+
if (!hasOwnProfile(file.profiles, result.name)) return file;
|
|
3781
|
+
const normalized = normalizeModelConfig(file.profiles[result.name]) ?? {};
|
|
3782
|
+
const orchestratorEntry = readProfileOrchestrator(normalized);
|
|
3783
|
+
// Applying spans three files — the store, models.json, and Pi's global
|
|
3784
|
+
// settings.json — and there is no cross-file rename, so order the writes to
|
|
3785
|
+
// keep the store truthful and compensate on failure: claim the profile in
|
|
3786
|
+
// the store first, then materialise routing, then the orchestrator. A claim
|
|
3787
|
+
// that fails leaves routing untouched; anything that fails after the claim
|
|
3788
|
+
// restores the previous claim and, when the previously active profile is
|
|
3789
|
+
// known, the routing that profile implies.
|
|
3790
|
+
const claimed = setActiveProfile(file, result.name);
|
|
3791
|
+
try {
|
|
3792
|
+
writeProfilesFileSync(path, claimed);
|
|
3793
|
+
} catch (error) {
|
|
3794
|
+
ctx.ui.notify(
|
|
3795
|
+
`el Gentleman could not update ${sanitizeTerminalText(path)}: ${profilesErrorMessage(error)}`,
|
|
3796
|
+
"warning",
|
|
3797
|
+
);
|
|
3798
|
+
return file;
|
|
3799
|
+
}
|
|
3800
|
+
const previousActiveConfig =
|
|
3801
|
+
file.active !== undefined && hasOwnProfile(file.profiles, file.active)
|
|
3802
|
+
? normalizeModelConfig(file.profiles[file.active]) ?? {}
|
|
3803
|
+
: undefined;
|
|
3804
|
+
// Set only when the orchestrator write succeeded, so the revert knows it has
|
|
3805
|
+
// something to undo. A rollback closure (not a previous-bytes value) is used
|
|
3806
|
+
// because "the file did not exist before" is a real state that must restore
|
|
3807
|
+
// by removing the file, and `undefined` bytes cannot carry that distinction.
|
|
3808
|
+
let orchestratorRollback: (() => void) | undefined;
|
|
3809
|
+
const revertClaim = async (routingWritten: boolean): Promise<AgentProfilesFile> => {
|
|
3810
|
+
let restored = previousActiveConfig === undefined ? "" : "routing";
|
|
3811
|
+
if (routingWritten && previousActiveConfig !== undefined) {
|
|
3812
|
+
try {
|
|
3813
|
+
await writeModelConfigAsync(ctx.cwd, previousActiveConfig);
|
|
3814
|
+
} catch {
|
|
3815
|
+
restored = "";
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
if (orchestratorRollback) {
|
|
3819
|
+
try {
|
|
3820
|
+
orchestratorRollback();
|
|
3821
|
+
restored = restored === "" ? "settings" : `${restored} and settings`;
|
|
3822
|
+
} catch {
|
|
3823
|
+
restored = restored === "" ? "" : restored;
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
try {
|
|
3827
|
+
writeProfilesFileSync(path, file);
|
|
3828
|
+
restored = restored === "" ? "active marker" : `${restored} and active marker`;
|
|
3829
|
+
} catch {
|
|
3830
|
+
restored = restored === "" ? "nothing" : restored;
|
|
3831
|
+
}
|
|
3832
|
+
const unresolved =
|
|
3833
|
+
routingWritten && previousActiveConfig === undefined
|
|
3834
|
+
? ` ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} still holds this profile's routing because no previously active profile was recorded to restore.`
|
|
3835
|
+
: "";
|
|
3836
|
+
ctx.ui.notify(
|
|
3837
|
+
`el Gentleman could not apply profile "${result.name}". Restored: ${restored}.${unresolved}`,
|
|
3838
|
+
"warning",
|
|
3839
|
+
);
|
|
3840
|
+
return file;
|
|
3841
|
+
};
|
|
3842
|
+
try {
|
|
3843
|
+
await writeModelConfigAsync(ctx.cwd, normalized);
|
|
3844
|
+
} catch (error) {
|
|
3845
|
+
ctx.ui.notify(
|
|
3846
|
+
`el Gentleman could not write ${sanitizeTerminalText(modelConfigPath(ctx.cwd))}: ${profilesErrorMessage(error)}`,
|
|
3847
|
+
"warning",
|
|
3848
|
+
);
|
|
3849
|
+
return revertClaim(false);
|
|
3850
|
+
}
|
|
3851
|
+
const applyResult = await applySavedModelConfig(ctx);
|
|
3852
|
+
if (applyResult.invalidPath) {
|
|
3853
|
+
return revertClaim(true);
|
|
3854
|
+
}
|
|
3855
|
+
let orchestratorNote = "";
|
|
3856
|
+
if (orchestratorEntry !== undefined) {
|
|
3857
|
+
const settingsPath = orchestratorSettingsPath();
|
|
3858
|
+
const written = applyOrchestratorSettings(settingsPath, orchestratorEntry);
|
|
3859
|
+
if (written.status === "invalid") {
|
|
3860
|
+
ctx.ui.notify(
|
|
3861
|
+
`el Gentleman could not set the orchestrator from profile "${result.name}": ${sanitizeTerminalText(written.reason)}. ${sanitizeTerminalText(settingsPath)} was left unchanged.`,
|
|
3862
|
+
"warning",
|
|
3863
|
+
);
|
|
3864
|
+
return revertClaim(true);
|
|
3865
|
+
}
|
|
3866
|
+
if (written.status === "written") {
|
|
3867
|
+
const previous = written.previous;
|
|
3868
|
+
orchestratorRollback = () => restoreOrchestratorSettings(settingsPath, previous);
|
|
3869
|
+
orchestratorNote = `\nOrchestrator set to ${formatOrchestratorSelection(orchestratorEntry)} in ${sanitizeTerminalText(settingsPath)}.`;
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
ctx.ui.notify(
|
|
3873
|
+
[
|
|
3874
|
+
`el Gentleman applied profile "${result.name}" — ${applyResult.updated} agent${applyResult.updated === 1 ? "" : "s"} updated.`,
|
|
3875
|
+
"New routing takes effect on the next subagent launch.",
|
|
3876
|
+
].join("\n") + orchestratorNote,
|
|
3877
|
+
"info",
|
|
3878
|
+
);
|
|
3879
|
+
return claimed;
|
|
3880
|
+
}
|
|
3881
|
+
case "create": {
|
|
3882
|
+
const name = await ctx.ui.input("New profile name", "e.g. deep-work");
|
|
3883
|
+
if (name === undefined) return file;
|
|
3884
|
+
try {
|
|
3885
|
+
const next = createProfile(file, name.trim(), {});
|
|
3886
|
+
writeProfilesFileSync(path, next);
|
|
3887
|
+
return next;
|
|
3888
|
+
} catch (error) {
|
|
3889
|
+
ctx.ui.notify(`Profile not created: ${profilesErrorMessage(error)}`, "warning");
|
|
3890
|
+
return file;
|
|
3891
|
+
}
|
|
3892
|
+
}
|
|
3893
|
+
case "update": {
|
|
3894
|
+
const current = await readModelConfigAsync(ctx.cwd);
|
|
3895
|
+
// A profile is a complete snapshot, so capturing the current routing also
|
|
3896
|
+
// captures the orchestrator the routing is running under. A settings file
|
|
3897
|
+
// that cannot be read leaves the snapshot without an orchestrator entry
|
|
3898
|
+
// rather than inventing one.
|
|
3899
|
+
const settings = readOrchestratorSettings(orchestratorSettingsPath());
|
|
3900
|
+
const snapshot: AgentModelConfig = cloneModelConfig(current);
|
|
3901
|
+
if (settings.status === "valid" && settings.entry !== undefined) {
|
|
3902
|
+
snapshot[PROFILE_ORCHESTRATOR_KEY] = { ...settings.entry };
|
|
3903
|
+
}
|
|
3904
|
+
try {
|
|
3905
|
+
const next = updateProfile(file, result.name, snapshot);
|
|
3906
|
+
writeProfilesFileSync(path, next);
|
|
3907
|
+
ctx.ui.notify(
|
|
3908
|
+
`el Gentleman updated profile "${result.name}" from the current routing in ${modelConfigPath(ctx.cwd)}.`,
|
|
3909
|
+
"info",
|
|
3910
|
+
);
|
|
3911
|
+
return next;
|
|
3912
|
+
} catch (error) {
|
|
3913
|
+
ctx.ui.notify(`Profile not updated: ${profilesErrorMessage(error)}`, "warning");
|
|
3914
|
+
return file;
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
case "duplicate": {
|
|
3918
|
+
const name = await ctx.ui.input(`Duplicate profile "${result.name}" as`, `${result.name}-copy`);
|
|
3919
|
+
if (name === undefined) return file;
|
|
3920
|
+
try {
|
|
3921
|
+
const next = duplicateProfile(file, result.name, name.trim());
|
|
3922
|
+
writeProfilesFileSync(path, next);
|
|
3923
|
+
return next;
|
|
3924
|
+
} catch (error) {
|
|
3925
|
+
ctx.ui.notify(`Profile not duplicated: ${profilesErrorMessage(error)}`, "warning");
|
|
3926
|
+
return file;
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
case "rename": {
|
|
3930
|
+
const name = await ctx.ui.input(`Rename profile "${result.name}" to`, result.name);
|
|
3931
|
+
if (name === undefined) return file;
|
|
3932
|
+
try {
|
|
3933
|
+
const next = renameProfile(file, result.name, name.trim());
|
|
3934
|
+
writeProfilesFileSync(path, next);
|
|
3935
|
+
return next;
|
|
3936
|
+
} catch (error) {
|
|
3937
|
+
ctx.ui.notify(`Profile not renamed: ${profilesErrorMessage(error)}`, "warning");
|
|
3938
|
+
return file;
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
case "delete": {
|
|
3942
|
+
const approved = await ctx.ui.confirm(
|
|
3943
|
+
"Delete profile?",
|
|
3944
|
+
`Delete profile "${result.name}" from ${path}? The routing in ${modelConfigPath(ctx.cwd)} is not changed.`,
|
|
3945
|
+
);
|
|
3946
|
+
if (!approved) return file;
|
|
3947
|
+
try {
|
|
3948
|
+
const next = deleteProfile(file, result.name);
|
|
3949
|
+
writeProfilesFileSync(path, next);
|
|
3950
|
+
return next;
|
|
3951
|
+
} catch (error) {
|
|
3952
|
+
ctx.ui.notify(`Profile not deleted: ${profilesErrorMessage(error)}`, "warning");
|
|
3953
|
+
return file;
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
case "export": {
|
|
3957
|
+
if (!hasOwnProfile(file.profiles, result.name)) return file;
|
|
3958
|
+
const exportPath = profileExportPath(gentleAiConfigHome());
|
|
3959
|
+
try {
|
|
3960
|
+
const text = serializeProfileExport(result.name, file.profiles[result.name]);
|
|
3961
|
+
await mkdir(dirname(exportPath), { recursive: true });
|
|
3962
|
+
await writeFile(exportPath, text);
|
|
3963
|
+
ctx.ui.notify(`el Gentleman exported profile "${result.name}" to ${exportPath}.`, "info");
|
|
3964
|
+
} catch (error) {
|
|
3965
|
+
ctx.ui.notify(`Profile export failed: ${profilesErrorMessage(error)}`, "warning");
|
|
3966
|
+
}
|
|
3967
|
+
return file;
|
|
3968
|
+
}
|
|
3969
|
+
case "import": {
|
|
3970
|
+
const importPath = profileExportPath(gentleAiConfigHome());
|
|
3971
|
+
let text: string;
|
|
3972
|
+
try {
|
|
3973
|
+
text = await readFile(importPath, "utf8");
|
|
3974
|
+
} catch {
|
|
3975
|
+
ctx.ui.notify(`Profile import failed: ${importPath} is missing or unreadable.`, "warning");
|
|
3976
|
+
return file;
|
|
3977
|
+
}
|
|
3978
|
+
const parsed = parseProfileExportTextWithDrops(text);
|
|
3979
|
+
if (!parsed) {
|
|
3980
|
+
ctx.ui.notify(
|
|
3981
|
+
`Profile import failed: ${importPath} is not a valid agent-model profile export.`,
|
|
3982
|
+
"warning",
|
|
3983
|
+
);
|
|
3984
|
+
return file;
|
|
3985
|
+
}
|
|
3986
|
+
if (parsed.droppedAgents.length > 0) {
|
|
3987
|
+
ctx.ui.notify(
|
|
3988
|
+
`el Gentleman dropped invalid routing entries while importing profile "${parsed.name}": ${parsed.droppedAgents.join(", ")}.`,
|
|
3989
|
+
"warning",
|
|
3990
|
+
);
|
|
3991
|
+
}
|
|
3992
|
+
if (hasOwnProfile(file.profiles, parsed.name)) {
|
|
3993
|
+
const approved = await ctx.ui.confirm(
|
|
3994
|
+
"Replace existing profile?",
|
|
3995
|
+
`Profile "${parsed.name}" already exists in ${path}. Replace its routing with the import?`,
|
|
3996
|
+
);
|
|
3997
|
+
if (!approved) return file;
|
|
3998
|
+
}
|
|
3999
|
+
try {
|
|
4000
|
+
const next = hasOwnProfile(file.profiles, parsed.name)
|
|
4001
|
+
? updateProfile(file, parsed.name, parsed.config)
|
|
4002
|
+
: createProfile(file, parsed.name, parsed.config);
|
|
4003
|
+
writeProfilesFileSync(path, next);
|
|
4004
|
+
const entries = Object.keys(parsed.config).length;
|
|
4005
|
+
ctx.ui.notify(
|
|
4006
|
+
`el Gentleman imported profile "${parsed.name}" (${entries} routing ${entries === 1 ? "entry" : "entries"}) from ${importPath}.`,
|
|
4007
|
+
"info",
|
|
4008
|
+
);
|
|
4009
|
+
return next;
|
|
4010
|
+
} catch (error) {
|
|
4011
|
+
ctx.ui.notify(`Profile import failed: ${profilesErrorMessage(error)}`, "warning");
|
|
4012
|
+
return file;
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
|
|
4019
|
+
const path = profilesFilePath(gentleAiConfigHome());
|
|
4020
|
+
const read = readProfilesFileResult(path);
|
|
4021
|
+
if (read.status === "invalid") {
|
|
4022
|
+
ctx.ui.notify(
|
|
4023
|
+
`el Gentleman cannot open agent profiles because ${path} is invalid JSON or not a profiles file. Fix or remove the file, then run /gentle:profiles again.`,
|
|
4024
|
+
"warning",
|
|
4025
|
+
);
|
|
4026
|
+
return;
|
|
4027
|
+
}
|
|
4028
|
+
let file: AgentProfilesFile;
|
|
4029
|
+
if (read.status === "missing") {
|
|
4030
|
+
file = bootstrapProfilesFile(await readModelConfigAsync(ctx.cwd));
|
|
4031
|
+
try {
|
|
4032
|
+
writeProfilesFileSync(path, file);
|
|
4033
|
+
} catch (error) {
|
|
4034
|
+
ctx.ui.notify(
|
|
4035
|
+
`el Gentleman could not create ${path}: ${profilesErrorMessage(error)}`,
|
|
4036
|
+
"warning",
|
|
4037
|
+
);
|
|
4038
|
+
return;
|
|
4039
|
+
}
|
|
4040
|
+
ctx.ui.notify(`el Gentleman seeded the "current" profile in ${path} from the saved model routing.`, "info");
|
|
4041
|
+
} else {
|
|
4042
|
+
file = read.file;
|
|
4043
|
+
reportProfilesDrops(ctx, path, read.drops);
|
|
4044
|
+
}
|
|
4045
|
+
let selectedName: string | undefined;
|
|
4046
|
+
let result = await showProfilesPanel(ctx, file, await readModelConfigAsync(ctx.cwd), selectedName);
|
|
4047
|
+
while (result.type !== "close") {
|
|
4048
|
+
selectedName = "name" in result ? result.name : undefined;
|
|
4049
|
+
file = await runProfilesPanelAction(ctx, path, file, result);
|
|
4050
|
+
result = await showProfilesPanel(ctx, file, await readModelConfigAsync(ctx.cwd), selectedName);
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
4053
|
+
|
|
2605
4054
|
async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
|
|
2606
4055
|
const current = readPersonaMode(ctx.cwd);
|
|
2607
4056
|
const selected = await ctx.ui.select(
|
|
@@ -2645,6 +4094,9 @@ const REVIEW_CONTROLLER_OPERATION = {
|
|
|
2645
4094
|
RECONCILE_AUTHORITY: "reconcile-authority",
|
|
2646
4095
|
REPAIR_LEGACY_ALIAS: "repair-legacy-alias",
|
|
2647
4096
|
REPAIR: "repair",
|
|
4097
|
+
// gentle-pi#662: read-only native risk assessment (gentle-ai#4295). Never
|
|
4098
|
+
// mutates review authority state and never requires a lineageId.
|
|
4099
|
+
ASSESS: "assess",
|
|
2648
4100
|
} as const;
|
|
2649
4101
|
|
|
2650
4102
|
type ReviewControllerOperation =
|
|
@@ -2676,7 +4128,8 @@ const REVIEW_CONTROLLER_PARAMETERS = {
|
|
|
2676
4128
|
description: "Bounded review lineage identifier. A failed start creates no lineage; do not use it with status or advance.",
|
|
2677
4129
|
},
|
|
2678
4130
|
selectionBinding: { type: "string", description: "Opaque provider-issued pre-lineage intended-untracked selection binding." },
|
|
2679
|
-
intendedUntracked: { type: "array", items: { type: "string" }, description:
|
|
4131
|
+
intendedUntracked: { type: "array", items: { type: "string" }, description: 'Repository-relative paths selected from the provider binding; on inspect they are accepted only with untrackedScope "select".' },
|
|
4132
|
+
untrackedScope: { type: "string", enum: ["exclude", "select"], description: 'Inspect-only: resolve the intended-untracked selection stop in one call. "exclude" excludes every eligible untracked path and forbids intendedUntracked; "select" includes exactly the supplied intendedUntracked paths.' },
|
|
2680
4133
|
changeName: {
|
|
2681
4134
|
type: "string",
|
|
2682
4135
|
description: "Canonical OpenSpec change name required to resolve a recovered authority during lifecycle validate.",
|
|
@@ -2691,7 +4144,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
|
|
|
2691
4144
|
},
|
|
2692
4145
|
input: {
|
|
2693
4146
|
type: "string",
|
|
2694
|
-
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.",
|
|
4147
|
+
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.",
|
|
2695
4148
|
},
|
|
2696
4149
|
outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
|
|
2697
4150
|
inputPath: { type: "string", description: "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired." },
|
|
@@ -2779,11 +4232,57 @@ interface ReviewScopeParameters {
|
|
|
2779
4232
|
cursor?: number;
|
|
2780
4233
|
}
|
|
2781
4234
|
|
|
4235
|
+
// gentle-pi#662: read-only native risk assessment, gating the separate
|
|
4236
|
+
// verifier on native risk instead of a task-description judgment when the
|
|
4237
|
+
// rendered `Receipt-driven development:` line is `off` or `unknown`. Exposed
|
|
4238
|
+
// as `gentle_review` operation `assess` (not a dedicated tool), taking its
|
|
4239
|
+
// optional fields through the controller's existing generic `input` JSON
|
|
4240
|
+
// string, exactly like START's `{"mode":...,"baseRef":...}`.
|
|
4241
|
+
interface ReviewAssessInput {
|
|
4242
|
+
baseRef?: string;
|
|
4243
|
+
committedOnly?: boolean;
|
|
4244
|
+
writerModelId?: string;
|
|
4245
|
+
writerEffort?: string;
|
|
4246
|
+
// gentle-pi#668: the caller's own known outcome for this candidate.
|
|
4247
|
+
// Omitted tries to auto-derive declined/unavailable for this EXACT
|
|
4248
|
+
// candidate's own target identity (never a different one); `closed` is
|
|
4249
|
+
// never auto-derived -- pass it explicitly.
|
|
4250
|
+
nativeReviewOutcome?: NativeReviewOutcome;
|
|
4251
|
+
}
|
|
4252
|
+
|
|
4253
|
+
function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome {
|
|
4254
|
+
return typeof value === "string" && (Object.values(NATIVE_REVIEW_OUTCOME) as readonly string[]).includes(value);
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4257
|
+
function parseReviewAssessInput(operation: ReviewControllerOperation, raw: string | undefined): ReviewAssessInput {
|
|
4258
|
+
if (raw === undefined) return {};
|
|
4259
|
+
const value = parseControllerJson(raw, operation);
|
|
4260
|
+
const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome"]);
|
|
4261
|
+
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
|
|
4262
|
+
if (unexpected !== undefined) throw new Error(`Review controller ${operation} input does not accept ${unexpected}`);
|
|
4263
|
+
const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome } = value;
|
|
4264
|
+
if (baseRef !== undefined && typeof baseRef !== "string") throw new Error(`Review controller ${operation} input baseRef must be a string`);
|
|
4265
|
+
if (committedOnly !== undefined && typeof committedOnly !== "boolean") throw new Error(`Review controller ${operation} input committedOnly must be a boolean`);
|
|
4266
|
+
if (writerModelId !== undefined && typeof writerModelId !== "string") throw new Error(`Review controller ${operation} input writerModelId must be a string`);
|
|
4267
|
+
if (writerEffort !== undefined && typeof writerEffort !== "string") throw new Error(`Review controller ${operation} input writerEffort must be a string`);
|
|
4268
|
+
if (nativeReviewOutcome !== undefined && !isNativeReviewOutcome(nativeReviewOutcome)) {
|
|
4269
|
+
throw new Error(`Review controller ${operation} input nativeReviewOutcome must be one of ${Object.values(NATIVE_REVIEW_OUTCOME).join(", ")}`);
|
|
4270
|
+
}
|
|
4271
|
+
return {
|
|
4272
|
+
...(baseRef === undefined ? {} : { baseRef: baseRef as string }),
|
|
4273
|
+
...(committedOnly === undefined ? {} : { committedOnly: committedOnly as boolean }),
|
|
4274
|
+
...(writerModelId === undefined ? {} : { writerModelId: writerModelId as string }),
|
|
4275
|
+
...(writerEffort === undefined ? {} : { writerEffort: writerEffort as string }),
|
|
4276
|
+
...(nativeReviewOutcome === undefined ? {} : { nativeReviewOutcome: nativeReviewOutcome as NativeReviewOutcome }),
|
|
4277
|
+
};
|
|
4278
|
+
}
|
|
4279
|
+
|
|
2782
4280
|
interface ReviewControllerParameters {
|
|
2783
4281
|
operation: ReviewControllerOperation;
|
|
2784
4282
|
lineageId?: string;
|
|
2785
4283
|
selectionBinding?: string;
|
|
2786
4284
|
intendedUntracked?: readonly string[];
|
|
4285
|
+
untrackedScope?: NativeStartUntrackedScope;
|
|
2787
4286
|
changeName?: string;
|
|
2788
4287
|
idempotencyKey?: string;
|
|
2789
4288
|
transition?: string;
|
|
@@ -2823,13 +4322,50 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
|
|
|
2823
4322
|
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");
|
|
2824
4323
|
return { operation: value.operation, selectionBinding: value.selectionBinding, intendedUntracked: value.intendedUntracked, ...(typeof value.workspaceRoot === "string" ? { workspaceRoot: value.workspaceRoot } : {}) };
|
|
2825
4324
|
}
|
|
2826
|
-
|
|
4325
|
+
// gentle-pi#706: top-level untrackedScope/intendedUntracked resolve the
|
|
4326
|
+
// intended-untracked stop through inspect alone. intendedUntracked is not a
|
|
4327
|
+
// standalone selector: inspect accepts it only for an explicit select scope.
|
|
4328
|
+
const hasIntendedUntracked = "intendedUntracked" in value;
|
|
4329
|
+
if (hasIntendedUntracked && value.operation !== REVIEW_CONTROLLER_OPERATION.INSPECT) {
|
|
4330
|
+
throw new Error(
|
|
4331
|
+
`Review controller ${value.operation} does not accept intendedUntracked; it is accepted only by inspect with untrackedScope select`,
|
|
4332
|
+
);
|
|
4333
|
+
}
|
|
4334
|
+
if (value.untrackedScope !== undefined) {
|
|
4335
|
+
if (value.operation !== REVIEW_CONTROLLER_OPERATION.INSPECT)
|
|
4336
|
+
throw new Error(
|
|
4337
|
+
`Review controller ${value.operation} does not accept untrackedScope; it is accepted only by inspect`,
|
|
4338
|
+
);
|
|
4339
|
+
if (
|
|
4340
|
+
value.untrackedScope !== NATIVE_START_UNTRACKED_SCOPE.EXCLUDE &&
|
|
4341
|
+
value.untrackedScope !== NATIVE_START_UNTRACKED_SCOPE.SELECT
|
|
4342
|
+
)
|
|
4343
|
+
throw new Error(
|
|
4344
|
+
"Review controller inspect untrackedScope must be exclude or select",
|
|
4345
|
+
);
|
|
4346
|
+
}
|
|
4347
|
+
if (hasIntendedUntracked) {
|
|
4348
|
+
if (value.untrackedScope !== NATIVE_START_UNTRACKED_SCOPE.SELECT) {
|
|
4349
|
+
throw new Error(
|
|
4350
|
+
"Review controller inspect intendedUntracked requires untrackedScope select",
|
|
4351
|
+
);
|
|
4352
|
+
}
|
|
4353
|
+
if (!Array.isArray(value.intendedUntracked)) {
|
|
4354
|
+
throw new Error(
|
|
4355
|
+
"Review controller inspect intendedUntracked must be an array of repository-relative paths",
|
|
4356
|
+
);
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4359
|
+
|
|
4360
|
+
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);
|
|
2827
4361
|
if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
|
|
2828
4362
|
throw new Error("Review controller requires a lineageId");
|
|
2829
4363
|
}
|
|
2830
4364
|
const parameters: ReviewControllerParameters = {
|
|
2831
4365
|
operation: value.operation,
|
|
2832
4366
|
...(typeof value.lineageId === "string" ? { lineageId: value.lineageId } : {}),
|
|
4367
|
+
...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && value.untrackedScope !== undefined ? { untrackedScope: value.untrackedScope } : {}),
|
|
4368
|
+
...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && value.intendedUntracked !== undefined ? { intendedUntracked: [...value.intendedUntracked] } : {}),
|
|
2833
4369
|
};
|
|
2834
4370
|
for (const key of ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource", "workspaceRoot"] as const) {
|
|
2835
4371
|
const optional = value[key];
|
|
@@ -3107,6 +4643,15 @@ async function resolveReviewModeGate(
|
|
|
3107
4643
|
}
|
|
3108
4644
|
}
|
|
3109
4645
|
|
|
4646
|
+
// gentle-pi#185: a native CLI without negotiated STATUS support (no
|
|
4647
|
+
// `targetStatus`, or a version-incompatible provider) hits this boundary
|
|
4648
|
+
// before any candidate-view restoration is attempted, so it can never
|
|
4649
|
+
// reproduce the #176 empty-registry failure — but the boundary's own
|
|
4650
|
+
// `next_action` was a machine token with nothing a human or an agent could
|
|
4651
|
+
// run. `remediation_command` names the exact upstream command that
|
|
4652
|
+
// re-establishes negotiated STATUS for this session's Pi host identity.
|
|
4653
|
+
const NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND = "gentle-ai review status --cwd <repo> --contract gentle-ai.review-integration/v2 --agent pi --next-transition";
|
|
4654
|
+
|
|
3110
4655
|
function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<string, unknown> {
|
|
3111
4656
|
return {
|
|
3112
4657
|
operation,
|
|
@@ -3115,6 +4660,7 @@ function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<s
|
|
|
3115
4660
|
...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { mutation_performed: false }),
|
|
3116
4661
|
inventory_complete: false,
|
|
3117
4662
|
next_action: "require-upstream-read-only-native-status-inventory",
|
|
4663
|
+
remediation_command: NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND,
|
|
3118
4664
|
evidence: {
|
|
3119
4665
|
native_contract: "gentle-ai/2.1.4",
|
|
3120
4666
|
general_status: "unsupported",
|
|
@@ -3126,8 +4672,8 @@ function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<s
|
|
|
3126
4672
|
// Bundled and source module instances can coexist, making instanceof insufficient.
|
|
3127
4673
|
function asNativeReviewCliError(error: unknown): { code: string; diagnostics: NativeReviewProcessDiagnostics } | undefined {
|
|
3128
4674
|
if (error instanceof NativeReviewCliError) return error;
|
|
3129
|
-
if (!(error
|
|
3130
|
-
const value = error as
|
|
4675
|
+
if (!isRecord(error) || error.name !== "NativeReviewCliError") return undefined;
|
|
4676
|
+
const value = error as { code?: unknown; diagnostics?: unknown };
|
|
3131
4677
|
if (typeof value.code !== "string") return undefined;
|
|
3132
4678
|
const diagnostics = sanitizeForeignNativeReviewDiagnostics(value.diagnostics);
|
|
3133
4679
|
return diagnostics === undefined || value.code !== diagnostics.error_code ? undefined : { code: value.code, diagnostics };
|
|
@@ -3149,7 +4695,9 @@ function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation,
|
|
|
3149
4695
|
...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { lineage_created: false, mutation_performed: false, mutation_outcome: "none" }),
|
|
3150
4696
|
inventory_complete: false,
|
|
3151
4697
|
diagnostics,
|
|
3152
|
-
|
|
4698
|
+
reason: `The verified package-local binary is unavailable. ${GENTLE_AI_INSTALL_RECOVERY_INSTRUCTIONS} This does not prove install lifecycle scripts were disabled.`,
|
|
4699
|
+
recovery_command: GENTLE_AI_INSTALL_RECOVERY_COMMAND,
|
|
4700
|
+
next_action: GENTLE_AI_INSTALL_RECOVERY_INSTRUCTIONS,
|
|
3153
4701
|
};
|
|
3154
4702
|
}
|
|
3155
4703
|
|
|
@@ -3165,6 +4713,23 @@ function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown
|
|
|
3165
4713
|
next_action: "require-complete-native-authority-inventory",
|
|
3166
4714
|
};
|
|
3167
4715
|
}
|
|
4716
|
+
// gentle-pi#599: a negotiated STATUS/inspect request the native provider
|
|
4717
|
+
// rejects with a decoded failure/v2 envelope (for example a preflight
|
|
4718
|
+
// `invalid_request` refusal for a nested foreign Git repository) used to
|
|
4719
|
+
// fall through to the generic outcome below, discarding the envelope's own
|
|
4720
|
+
// cause, code, retry_safe, and next_action -- the one piece of information
|
|
4721
|
+
// that makes the refusal actionable (pass the intended nested repo as
|
|
4722
|
+
// workspaceRoot). `nativeOperationFailure` already renders this exact
|
|
4723
|
+
// failure-envelope shape faithfully for every mutating operation; reuse it
|
|
4724
|
+
// here instead of masking the refusal as opaque authority-inventory
|
|
4725
|
+
// corruption.
|
|
4726
|
+
if (error instanceof NativeReviewIntegrationError) {
|
|
4727
|
+
return {
|
|
4728
|
+
...nativeOperationFailure(operation, error),
|
|
4729
|
+
outcome: "native-status-unavailable",
|
|
4730
|
+
inventory_complete: false,
|
|
4731
|
+
};
|
|
4732
|
+
}
|
|
3168
4733
|
return {
|
|
3169
4734
|
operation,
|
|
3170
4735
|
status: "blocked",
|
|
@@ -3203,7 +4768,6 @@ function missingNativeMaintenanceInputs(operation: NativeMaintenanceOperation, i
|
|
|
3203
4768
|
...missing,
|
|
3204
4769
|
...(Array.isArray(input.capturedLensResults) && input.capturedLensResults.every((entry) => isCanonicalProcessString(entry)) ? [] : ["capturedLensResults"]),
|
|
3205
4770
|
...(typeof input.findingsPresent === "boolean" ? [] : ["findingsPresent"]),
|
|
3206
|
-
...(typeof input.evidenceRecordsPresent === "boolean" ? [] : ["evidenceRecordsPresent"]),
|
|
3207
4771
|
];
|
|
3208
4772
|
}
|
|
3209
4773
|
|
|
@@ -3213,7 +4777,7 @@ function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, in
|
|
|
3213
4777
|
}
|
|
3214
4778
|
|
|
3215
4779
|
function nativeMaintenanceAuthorization(operation: NativeMaintenanceOperation, input: Record<string, unknown>): string {
|
|
3216
|
-
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,
|
|
4780
|
+
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) });
|
|
3217
4781
|
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) });
|
|
3218
4782
|
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 }) });
|
|
3219
4783
|
}
|
|
@@ -3240,7 +4804,7 @@ async function executeNativeAuthorityMaintenance(
|
|
|
3240
4804
|
}
|
|
3241
4805
|
try {
|
|
3242
4806
|
const result = nativeOperation === "abandon"
|
|
3243
|
-
? 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,
|
|
4807
|
+
? 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 }) })
|
|
3244
4808
|
: nativeOperation === "quarantineLegacy"
|
|
3245
4809
|
? 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 }) })
|
|
3246
4810
|
: 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 }) });
|
|
@@ -3454,6 +5018,31 @@ function mapNativeTargetStatus(operation: ReviewControllerOperation, status: Rev
|
|
|
3454
5018
|
required_status_action: "Use only the provider-selected recovery disposition; do not substitute scope_changed, invalidated, or escalated.",
|
|
3455
5019
|
};
|
|
3456
5020
|
}
|
|
5021
|
+
// gentle-pi#627: a stale managed-asset set stops the transition with the
|
|
5022
|
+
// exact `gentle-ai sync` invocation that resolves it. Render that command
|
|
5023
|
+
// as the one actionable next step; every other reason code keeps rendering
|
|
5024
|
+
// as a plain blocked result.
|
|
5025
|
+
if (status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "managed_assets_outdated" && status.nextTransition.continuation !== undefined) {
|
|
5026
|
+
return {
|
|
5027
|
+
operation,
|
|
5028
|
+
status: "blocked",
|
|
5029
|
+
result: status.raw,
|
|
5030
|
+
...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }),
|
|
5031
|
+
hint: `run ${status.nextTransition.continuation.command}`,
|
|
5032
|
+
};
|
|
5033
|
+
}
|
|
5034
|
+
// gentle-pi#638: an unachievable-lens stop carries the exact withdraw command for every declared slot, mirroring the managed_assets_outdated precedent. A restart that never saw the collect offer still finds its way back from this hint alone.
|
|
5035
|
+
// gentle-pi#822: when the caller asked about one lineage, render only that lineage's withdraw command; the first entry may belong to an unrelated lineage, so an unmatched request omits the hint instead of surfacing a potentially unrelated withdraw command. Without a requested lineage the first entry stays the fallback.
|
|
5036
|
+
if (status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "unachievable_lens_slot" && status.nextTransition.unachievableLensSlots !== undefined) {
|
|
5037
|
+
const withdrawSlot = requestedLineageId === undefined ? status.nextTransition.unachievableLensSlots[0] : status.nextTransition.unachievableLensSlots.find((slot) => slot.withdraw.binding.lineageId === requestedLineageId);
|
|
5038
|
+
return {
|
|
5039
|
+
operation,
|
|
5040
|
+
status: "blocked",
|
|
5041
|
+
result: status.raw,
|
|
5042
|
+
...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }),
|
|
5043
|
+
...(withdrawSlot === undefined ? {} : { hint: `run ${withdrawSlot.withdraw.command}` }),
|
|
5044
|
+
};
|
|
5045
|
+
}
|
|
3457
5046
|
return {
|
|
3458
5047
|
operation,
|
|
3459
5048
|
status: status.action === "start" ? "ready" : "blocked",
|
|
@@ -3547,6 +5136,12 @@ interface RetainedNativeUntrackedSelection {
|
|
|
3547
5136
|
readonly untrackedScope: NativeStartUntrackedScope;
|
|
3548
5137
|
readonly expectedUntrackedInventory: string;
|
|
3549
5138
|
readonly intendedUntracked: readonly string[];
|
|
5139
|
+
readonly submission?: NativeIntendedUntrackedSelectionSubmission;
|
|
5140
|
+
}
|
|
5141
|
+
|
|
5142
|
+
interface RetainedPreLineageNativeUntrackedSelection extends RetainedNativeUntrackedSelection {
|
|
5143
|
+
readonly targetIdentity: string;
|
|
5144
|
+
readonly candidateTree: string;
|
|
3550
5145
|
}
|
|
3551
5146
|
|
|
3552
5147
|
interface RetainedNativeCaptureRoute { readonly workspaceRoot: string; readonly lineageId: string; readonly baseRef?: string; readonly committedOnly?: true; }
|
|
@@ -3622,6 +5217,8 @@ function nativeStatusInputRejection(reason: string, field?: string): Record<stri
|
|
|
3622
5217
|
}
|
|
3623
5218
|
|
|
3624
5219
|
const PENDING_REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
|
|
5220
|
+
const REVIEW_SESSION_PERMISSION_STATUS_KEY = "gentle-review-session-permission";
|
|
5221
|
+
const REVIEW_SESSION_PERMISSION_STATUS_TEXT = "reviews allowed for this session";
|
|
3625
5222
|
|
|
3626
5223
|
type PendingReviewConsentSessionKey = string | symbol;
|
|
3627
5224
|
|
|
@@ -3656,7 +5253,13 @@ const PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT = 32;
|
|
|
3656
5253
|
*/
|
|
3657
5254
|
export class PendingReviewConsentRegistry {
|
|
3658
5255
|
private readonly sessions = new Map<PendingReviewConsentSessionKey, Map<string, PendingReviewConsent>>();
|
|
3659
|
-
|
|
5256
|
+
// gentle-pi#455: a binding id is globally unique (randomUUID), so its live
|
|
5257
|
+
// owner and its stale disposition are tracked by binding id alone. This
|
|
5258
|
+
// index lets answer-consent resolve and atomically take exactly once a
|
|
5259
|
+
// binding another active Pi session's START created; the per-session map
|
|
5260
|
+
// above stays authoritative for session-scoped listings and shutdown cleanup.
|
|
5261
|
+
private readonly byBinding = new Map<string, PendingReviewConsentSessionKey>();
|
|
5262
|
+
private readonly staleDispositions = new Map<string, PendingReviewConsentDisposition>();
|
|
3660
5263
|
|
|
3661
5264
|
get(sessionKey: PendingReviewConsentSessionKey): Map<string, PendingReviewConsent> | undefined {
|
|
3662
5265
|
return this.sessions.get(sessionKey);
|
|
@@ -3671,34 +5274,46 @@ export class PendingReviewConsentRegistry {
|
|
|
3671
5274
|
return pending;
|
|
3672
5275
|
}
|
|
3673
5276
|
|
|
5277
|
+
// Registers a freshly created pending binding under its owning session and
|
|
5278
|
+
// the cross-session binding index in one step so the two never drift.
|
|
5279
|
+
add(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): void {
|
|
5280
|
+
this.ensure(sessionKey).set(pending.id, pending);
|
|
5281
|
+
this.byBinding.set(pending.id, sessionKey);
|
|
5282
|
+
}
|
|
5283
|
+
|
|
5284
|
+
// Resolves a live binding to its owning session regardless of which
|
|
5285
|
+
// session asks, so answer-consent can reach a binding another session's
|
|
5286
|
+
// START created (gentle-pi#455).
|
|
5287
|
+
resolve(bindingId: string): { sessionKey: PendingReviewConsentSessionKey; pending: PendingReviewConsent } | undefined {
|
|
5288
|
+
const sessionKey = this.byBinding.get(bindingId);
|
|
5289
|
+
const pending = sessionKey === undefined ? undefined : this.sessions.get(sessionKey)?.get(bindingId);
|
|
5290
|
+
return pending === undefined ? undefined : { sessionKey, pending };
|
|
5291
|
+
}
|
|
5292
|
+
|
|
3674
5293
|
private remove(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
|
|
3675
5294
|
const session = this.sessions.get(sessionKey);
|
|
3676
5295
|
if (session?.get(pending.id) !== pending) return false;
|
|
3677
5296
|
session.delete(pending.id);
|
|
3678
5297
|
if (session.size === 0) this.sessions.delete(sessionKey);
|
|
5298
|
+
if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id);
|
|
3679
5299
|
return true;
|
|
3680
5300
|
}
|
|
3681
5301
|
|
|
3682
|
-
private rememberDisposition(
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
this.staleDispositions.set(sessionKey, stale);
|
|
3687
|
-
}
|
|
3688
|
-
stale.delete(pending.id);
|
|
3689
|
-
stale.set(pending.id, disposition);
|
|
3690
|
-
while (stale.size > PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT) stale.delete(stale.keys().next().value!);
|
|
5302
|
+
private rememberDisposition(pending: PendingReviewConsent, disposition: PendingReviewConsentDisposition): void {
|
|
5303
|
+
this.staleDispositions.delete(pending.id);
|
|
5304
|
+
this.staleDispositions.set(pending.id, disposition);
|
|
5305
|
+
while (this.staleDispositions.size > PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT) this.staleDispositions.delete(this.staleDispositions.keys().next().value!);
|
|
3691
5306
|
}
|
|
3692
5307
|
|
|
3693
5308
|
consume(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
|
|
3694
5309
|
if (!this.remove(sessionKey, pending)) return false;
|
|
3695
|
-
this.rememberDisposition(
|
|
5310
|
+
this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED);
|
|
3696
5311
|
return true;
|
|
3697
5312
|
}
|
|
3698
5313
|
|
|
3699
5314
|
expire(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
|
|
3700
5315
|
if (!this.remove(sessionKey, pending)) return false;
|
|
3701
|
-
this.rememberDisposition(
|
|
5316
|
+
this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED);
|
|
3702
5317
|
return true;
|
|
3703
5318
|
}
|
|
3704
5319
|
|
|
@@ -3706,14 +5321,14 @@ export class PendingReviewConsentRegistry {
|
|
|
3706
5321
|
this.remove(sessionKey, pending);
|
|
3707
5322
|
}
|
|
3708
5323
|
|
|
3709
|
-
staleDisposition(
|
|
3710
|
-
return this.staleDispositions.get(
|
|
5324
|
+
staleDisposition(binding: string): PendingReviewConsentDisposition | undefined {
|
|
5325
|
+
return this.staleDispositions.get(binding);
|
|
3711
5326
|
}
|
|
3712
5327
|
|
|
3713
5328
|
take(sessionKey: PendingReviewConsentSessionKey): PendingReviewConsent[] {
|
|
3714
5329
|
const session = this.sessions.get(sessionKey);
|
|
3715
5330
|
this.sessions.delete(sessionKey);
|
|
3716
|
-
this.
|
|
5331
|
+
if (session !== undefined) for (const pending of session.values()) if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id);
|
|
3717
5332
|
return session === undefined ? [] : [...session.values()];
|
|
3718
5333
|
}
|
|
3719
5334
|
}
|
|
@@ -3729,16 +5344,17 @@ const processRetainedNativeStatusSelections = new Map<PendingReviewConsentSessio
|
|
|
3729
5344
|
// and a fresh primary-loop start resets it to 0.
|
|
3730
5345
|
const processAgentEndSubagentDepth = new Map<PendingReviewConsentSessionKey, number>();
|
|
3731
5346
|
|
|
3732
|
-
//
|
|
3733
|
-
//
|
|
3734
|
-
|
|
5347
|
+
// gentle-pi#677: gentle-ai#4309 owns anonymous usage telemetry end to end;
|
|
5348
|
+
// Pi only nudges it once per process. This is a plain process-lifetime
|
|
5349
|
+
// guard, not a session-keyed map, because the nudge is meant to fire at most
|
|
5350
|
+
// once no matter how many primary-session `before_agent_start` events this
|
|
5351
|
+
// process observes.
|
|
5352
|
+
let processTelemetryTriggerAttempted = false;
|
|
3735
5353
|
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
// unreviewed candidate this session should be reminded about.
|
|
3741
|
-
const processAgentEndSessionBaseline = new Map<PendingReviewConsentSessionKey, string>();
|
|
5354
|
+
/** Testing-only reset for the once-per-process telemetry trigger guard. */
|
|
5355
|
+
function resetTelemetryTriggerGuardForTesting(): void {
|
|
5356
|
+
processTelemetryTriggerAttempted = false;
|
|
5357
|
+
}
|
|
3742
5358
|
|
|
3743
5359
|
function pendingReviewConsentSessionKey(context: ExtensionContext | undefined, fallbackKey: symbol): PendingReviewConsentSessionKey {
|
|
3744
5360
|
try {
|
|
@@ -3797,6 +5413,61 @@ function reviewConsentDigest(consent: ReviewConsentEnvelope): string {
|
|
|
3797
5413
|
return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
|
|
3798
5414
|
}
|
|
3799
5415
|
|
|
5416
|
+
function reviewSessionManagerAndId(context: ExtensionContext): { manager: object; sessionId: string } | undefined {
|
|
5417
|
+
try {
|
|
5418
|
+
const manager = context.sessionManager as unknown as { getSessionId?: () => unknown };
|
|
5419
|
+
const sessionId = manager.getSessionId?.();
|
|
5420
|
+
if (typeof manager !== "object" || manager === null || typeof sessionId !== "string" || sessionId.length === 0) return undefined;
|
|
5421
|
+
return { manager, sessionId };
|
|
5422
|
+
} catch {
|
|
5423
|
+
return undefined;
|
|
5424
|
+
}
|
|
5425
|
+
}
|
|
5426
|
+
|
|
5427
|
+
function isDirectOrdinaryReviewStart(parametersValue: unknown): boolean {
|
|
5428
|
+
try {
|
|
5429
|
+
const parameters = parseReviewControllerParameters(parametersValue);
|
|
5430
|
+
if (parameters.operation !== REVIEW_CONTROLLER_OPERATION.START) return false;
|
|
5431
|
+
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
5432
|
+
return input.mode === REVIEW_MODE.ORDINARY;
|
|
5433
|
+
} catch {
|
|
5434
|
+
return false;
|
|
5435
|
+
}
|
|
5436
|
+
}
|
|
5437
|
+
|
|
5438
|
+
function isHostReviewConsentEligibleOperation(parametersValue: unknown): boolean {
|
|
5439
|
+
if (isDirectOrdinaryReviewStart(parametersValue)) return true;
|
|
5440
|
+
try {
|
|
5441
|
+
return parseReviewControllerParameters(parametersValue).operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED;
|
|
5442
|
+
} catch {
|
|
5443
|
+
return false;
|
|
5444
|
+
}
|
|
5445
|
+
}
|
|
5446
|
+
|
|
5447
|
+
function completedGrantedReviewConsent(outcome: Record<string, unknown>): boolean {
|
|
5448
|
+
if (
|
|
5449
|
+
outcome.operation !== REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT ||
|
|
5450
|
+
outcome.status !== undefined ||
|
|
5451
|
+
outcome.outcome !== undefined ||
|
|
5452
|
+
outcome.native_invocation_attempted === false ||
|
|
5453
|
+
outcome.mutation_performed === false ||
|
|
5454
|
+
outcome.lineage_created === false ||
|
|
5455
|
+
!isCanonicalProcessString(outcome.workspace_root)
|
|
5456
|
+
) return false;
|
|
5457
|
+
const result = outcome.result;
|
|
5458
|
+
if (!isRecord(result)) return false;
|
|
5459
|
+
const nonnegativeInteger = (value: unknown): boolean => Number.isInteger(value) && Number(value) >= 0;
|
|
5460
|
+
return isCanonicalProcessString(result.lineage_id) &&
|
|
5461
|
+
isCanonicalProcessString(result.state) &&
|
|
5462
|
+
isCanonicalProcessString(result.risk_tier) &&
|
|
5463
|
+
Array.isArray(result.selected_lenses) && result.selected_lenses.every(isCanonicalProcessString) &&
|
|
5464
|
+
nonnegativeInteger(result.changed_files) &&
|
|
5465
|
+
nonnegativeInteger(result.original_changed_lines) &&
|
|
5466
|
+
nonnegativeInteger(result.correction_budget) &&
|
|
5467
|
+
(result.action === "created" || result.action === "resumed" || result.action === "replayed" || result.action === "closed") &&
|
|
5468
|
+
typeof result.lenses_required === "boolean";
|
|
5469
|
+
}
|
|
5470
|
+
|
|
3800
5471
|
// gentle-pi#516: a binding this session does not hold (already answered,
|
|
3801
5472
|
// expired, or issued by another Pi session or process) used to fall through
|
|
3802
5473
|
// to the plain negotiated STATUS, which reads exactly like a healthy pre-start
|
|
@@ -3842,6 +5513,50 @@ function staleConsentBindingOutcome(operation: ReviewControllerOperation, bindin
|
|
|
3842
5513
|
};
|
|
3843
5514
|
}
|
|
3844
5515
|
|
|
5516
|
+
// gentle-pi#455 correction: cross-session resolution looks a binding up by
|
|
5517
|
+
// its opaque id alone, so it must independently confirm the answering
|
|
5518
|
+
// invocation addresses the same repository its owning START minted it for.
|
|
5519
|
+
// This is a typed, non-bearer refusal in the same shape family as the
|
|
5520
|
+
// stale-binding outcome -- it never runs the mode gate or native
|
|
5521
|
+
// answerConsent, and never consumes the binding, so it stays answerable from
|
|
5522
|
+
// the correct repository afterwards.
|
|
5523
|
+
function consentBindingRepositoryMismatchOutcome(operation: ReviewControllerOperation, binding: string, mintingRepositoryCwd: string, answeringRepositoryCwd: string): Record<string, unknown> {
|
|
5524
|
+
return {
|
|
5525
|
+
operation,
|
|
5526
|
+
status: "blocked",
|
|
5527
|
+
outcome: "consent-binding-repository-mismatch",
|
|
5528
|
+
consent_binding: binding,
|
|
5529
|
+
diagnostics: {
|
|
5530
|
+
code: "consent-binding-repository-mismatch",
|
|
5531
|
+
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.`,
|
|
5532
|
+
},
|
|
5533
|
+
native_invocation_attempted: false,
|
|
5534
|
+
...nativeStartPreAuthorityRejection(),
|
|
5535
|
+
next_action: "answer-from-minting-repository",
|
|
5536
|
+
};
|
|
5537
|
+
}
|
|
5538
|
+
|
|
5539
|
+
// gentle-pi#874: the committed-range selector a current STATUS owns. A
|
|
5540
|
+
// selectorless STATUS on a clean, fully committed worktree answers with a
|
|
5541
|
+
// review.start execute transition naming the exact merge-base the provider
|
|
5542
|
+
// wants, so a plain START can adopt the route the provider just rendered
|
|
5543
|
+
// instead of making the caller hand-copy `base-ref` into its input. Only the
|
|
5544
|
+
// provider's own offered argument is read -- never a guess, a persisted value,
|
|
5545
|
+
// or a stale transition -- and anything that is not a full commit id paired
|
|
5546
|
+
// with committed-only is refused, so every other STATUS keeps today's
|
|
5547
|
+
// behaviour byte-for-byte.
|
|
5548
|
+
const OFFERED_COMMITTED_RANGE_BASE_REF = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
5549
|
+
|
|
5550
|
+
function offeredCommittedRangeBaseRef(target: ReviewStatusV3): string | undefined {
|
|
5551
|
+
const execute = target.nextTransition?.kind === "execute" ? target.nextTransition.execute : undefined;
|
|
5552
|
+
if (execute?.operation !== "review.start") return undefined;
|
|
5553
|
+
if (execute.arguments.some((argument) => argument.name === "workspace-overlay")) return undefined;
|
|
5554
|
+
const baseRef = execute.arguments.find((argument) => argument.name === "base-ref")?.value;
|
|
5555
|
+
if (baseRef === undefined || !OFFERED_COMMITTED_RANGE_BASE_REF.test(baseRef)) return undefined;
|
|
5556
|
+
if (execute.arguments.find((argument) => argument.name === "committed-only")?.value !== "true") return undefined;
|
|
5557
|
+
return baseRef;
|
|
5558
|
+
}
|
|
5559
|
+
|
|
3845
5560
|
function assertNativeStartCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void {
|
|
3846
5561
|
candidateView.verify();
|
|
3847
5562
|
if (
|
|
@@ -3887,7 +5602,7 @@ function completeNativeStart(
|
|
|
3887
5602
|
}
|
|
3888
5603
|
|
|
3889
5604
|
function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_review_capture", error: unknown): Record<string, unknown> {
|
|
3890
|
-
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 } };
|
|
5605
|
+
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 } } };
|
|
3891
5606
|
if (isRecord(value.failureEnvelope) && isRecord(value.failureEnvelope.raw)) {
|
|
3892
5607
|
const mutationOutcome = value.failureEnvelope.mutationOutcome;
|
|
3893
5608
|
return {
|
|
@@ -3901,6 +5616,12 @@ function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_r
|
|
|
3901
5616
|
: { mutation_performed: false, mutation_outcome: "none" }),
|
|
3902
5617
|
...(typeof value.failureEnvelope.replayability === "string" ? { replayability: value.failureEnvelope.replayability } : {}),
|
|
3903
5618
|
...(typeof value.failureEnvelope.nextAction === "string" ? { next_action: value.failureEnvelope.nextAction } : {}),
|
|
5619
|
+
// gentle-pi#627: START's preflight failure envelope for a stale
|
|
5620
|
+
// managed-asset set carries a top-level continuation; render its
|
|
5621
|
+
// `gentle-ai sync` command as the one actionable next step.
|
|
5622
|
+
...(value.failureEnvelope.code === "managed_assets_outdated" && typeof value.failureEnvelope.continuation?.command === "string"
|
|
5623
|
+
? { hint: `run ${value.failureEnvelope.continuation.command}` }
|
|
5624
|
+
: {}),
|
|
3904
5625
|
};
|
|
3905
5626
|
}
|
|
3906
5627
|
// Every consent binding guard runs before the provider is launched, so this
|
|
@@ -3998,7 +5719,9 @@ async function reconcileNativeMutationFailure(
|
|
|
3998
5719
|
try {
|
|
3999
5720
|
const status = await nativeReviewCli.targetStatus(target);
|
|
4000
5721
|
syncRetainedNativeStatusSelections(retainedSelections, canonicalRetentionRoot, status, target.baseRef);
|
|
4001
|
-
const
|
|
5722
|
+
const projectedStatus = mapNativeTargetStatus(operation, status, target.lineageId);
|
|
5723
|
+
const { next_action: staleNextAction, required_status_action: staleStatusDirective, ...reconciledBase } = failure;
|
|
5724
|
+
void staleNextAction;
|
|
4002
5725
|
void staleStatusDirective;
|
|
4003
5726
|
// Field defect (fambig, 2026-08-16): an envelope-less mutating failure
|
|
4004
5727
|
// is stamped mutationOutcome "unknown", but a reconciled authority
|
|
@@ -4012,6 +5735,8 @@ async function reconcileNativeMutationFailure(
|
|
|
4012
5735
|
void staleReplayability;
|
|
4013
5736
|
return {
|
|
4014
5737
|
...provenBase,
|
|
5738
|
+
...projectedStatus,
|
|
5739
|
+
status: "blocked",
|
|
4015
5740
|
outcome: "native-mutation-status-reconciled",
|
|
4016
5741
|
reconciliation: status.raw,
|
|
4017
5742
|
authority_applicability: status.applicability,
|
|
@@ -4019,17 +5744,19 @@ async function reconcileNativeMutationFailure(
|
|
|
4019
5744
|
mutation_performed: false,
|
|
4020
5745
|
mutation_outcome: "none",
|
|
4021
5746
|
mutation_outcome_reason: `authority revision unchanged across reconciliation (${preOperationRevision}); the failed operation provably did not mutate`,
|
|
4022
|
-
next_action: status.action,
|
|
4023
5747
|
};
|
|
4024
5748
|
}
|
|
4025
5749
|
return {
|
|
4026
5750
|
...reconciledBase,
|
|
5751
|
+
...projectedStatus,
|
|
5752
|
+
status: "blocked",
|
|
4027
5753
|
outcome: "native-mutation-status-reconciled",
|
|
4028
5754
|
reconciliation: status.raw,
|
|
4029
5755
|
authority_applicability: status.applicability,
|
|
4030
5756
|
provider_action: status.action,
|
|
4031
5757
|
replayability: status.replayability,
|
|
4032
|
-
next_action:
|
|
5758
|
+
...(status.action === "start" && projectedStatus.next_action === undefined ? { next_action: "start" } : {}),
|
|
5759
|
+
required_status_action: projectedStatus.required_status_action ?? requiredStatusActionText(target.lineageId),
|
|
4033
5760
|
};
|
|
4034
5761
|
} catch (statusError) {
|
|
4035
5762
|
return {
|
|
@@ -4131,6 +5858,41 @@ function readRetainedNativeUntrackedSelection(selections: Map<string, RetainedNa
|
|
|
4131
5858
|
};
|
|
4132
5859
|
}
|
|
4133
5860
|
|
|
5861
|
+
// gentle-pi#706: inspect's untrackedScope round trip retains the resolved
|
|
5862
|
+
// selection under the pre-lineage empty-lineage key so the next plain START in
|
|
5863
|
+
// that worktree adopts it without re-deriving the selection.
|
|
5864
|
+
function nativePreLineageCandidateIdentity(
|
|
5865
|
+
status: ReviewStatusV3,
|
|
5866
|
+
): { targetIdentity: string; candidateTree: string } | undefined {
|
|
5867
|
+
const candidateTree = status.projection.currentCandidateTree;
|
|
5868
|
+
return isCanonicalProcessString(status.targetIdentity) && isCanonicalProcessString(candidateTree)
|
|
5869
|
+
? { targetIdentity: status.targetIdentity, candidateTree }
|
|
5870
|
+
: undefined;
|
|
5871
|
+
}
|
|
5872
|
+
|
|
5873
|
+
function readRetainedPreLineageNativeUntrackedSelection(
|
|
5874
|
+
selections: Map<string, RetainedNativeStatusSelection>,
|
|
5875
|
+
workspaceRoot: string,
|
|
5876
|
+
): RetainedPreLineageNativeUntrackedSelection | undefined {
|
|
5877
|
+
const selection = selections.get(reviewLifecycleStorageKey(workspaceRoot, ""));
|
|
5878
|
+
return selection !== undefined &&
|
|
5879
|
+
!("baseRef" in selection) &&
|
|
5880
|
+
typeof (selection as Partial<RetainedPreLineageNativeUntrackedSelection>).targetIdentity === "string" &&
|
|
5881
|
+
typeof (selection as Partial<RetainedPreLineageNativeUntrackedSelection>).candidateTree === "string"
|
|
5882
|
+
? selection as RetainedPreLineageNativeUntrackedSelection
|
|
5883
|
+
: undefined;
|
|
5884
|
+
}
|
|
5885
|
+
|
|
5886
|
+
function sameNativePreLineageCandidate(
|
|
5887
|
+
selection: RetainedPreLineageNativeUntrackedSelection,
|
|
5888
|
+
status: ReviewStatusV3,
|
|
5889
|
+
): boolean {
|
|
5890
|
+
const identity = nativePreLineageCandidateIdentity(status);
|
|
5891
|
+
return identity !== undefined &&
|
|
5892
|
+
identity.targetIdentity === selection.targetIdentity &&
|
|
5893
|
+
identity.candidateTree === selection.candidateTree;
|
|
5894
|
+
}
|
|
5895
|
+
|
|
4134
5896
|
function isRetainedNativeCaptureRoute(selection: RetainedNativeStatusSelection | undefined): selection is RetainedNativeCaptureRoute {
|
|
4135
5897
|
return selection !== undefined && "workspaceRoot" in selection;
|
|
4136
5898
|
}
|
|
@@ -4201,6 +5963,16 @@ const REVIEW_HOST_RELAY_REFUSED_ACTION =
|
|
|
4201
5963
|
"gentle-ai refused this submission at admission and did not consume the lens slot; the reason is in failure.stderr. "
|
|
4202
5964
|
+ "Call fresh STATUS and run only the exact slot it reoffers so the reviewer produces a new result that satisfies that refusal; never resubmit the refused bytes.";
|
|
4203
5965
|
|
|
5966
|
+
// gentle-pi#638: the declaration recorded, so fresh STATUS stops the review with one withdraw binding per declared slot instead of reoffering the reviewer. The withdraw command is the only way back to the same slot; everything else needs a smaller candidate and a new review.
|
|
5967
|
+
const REVIEW_HOST_RELAY_UNACHIEVABLE_ACTION =
|
|
5968
|
+
"A deterministic relay failure was declared unachievable for this bound slot, and fresh STATUS now stops this review instead of reoffering the reviewer. "
|
|
5969
|
+
+ "If the failure was transient, run the withdraw command in unachievable_lens_slots with the same binding so the review re-offers this exact reviewer; otherwise reduce the candidate scope and start a new review.";
|
|
5970
|
+
|
|
5971
|
+
// gentle-pi#638: the relay failure was deterministic but gentle-ai refused the declaration, so no slot state changed and the review still needs a provider-bound continuation.
|
|
5972
|
+
const REVIEW_HOST_RELAY_DECLARATION_FAILED_ACTION =
|
|
5973
|
+
"The relay failure was deterministic for this slot, but gentle-ai refused the unachievable declaration, so no slot state changed. "
|
|
5974
|
+
+ "Call fresh STATUS and follow only its declared action; never replay this capture from transcript inference.";
|
|
5975
|
+
|
|
4204
5976
|
function reviewHostRelayTimeoutNextAction(error: ReviewHostRelayError): string {
|
|
4205
5977
|
const measured = error.elapsedMs === null || error.timeoutMs === null
|
|
4206
5978
|
? ""
|
|
@@ -4290,11 +6062,13 @@ async function reconcileUnknownReviewCaptureFailure(
|
|
|
4290
6062
|
selections: Map<string, RetainedNativeStatusSelection>,
|
|
4291
6063
|
route: RetainedNativeCaptureRoute | undefined,
|
|
4292
6064
|
expectedReviewCaptureSuffix?: readonly string[],
|
|
6065
|
+
agent?: "pi",
|
|
4293
6066
|
): Promise<Record<string, unknown>> {
|
|
4294
6067
|
const failure = error === undefined ? undefined : nativeOperationFailure("gentle_review_capture", error);
|
|
4295
6068
|
if (error !== undefined && !nativeMutationRequiresStatus(error)) return failure;
|
|
4296
6069
|
try {
|
|
4297
|
-
const
|
|
6070
|
+
const selector = agent === undefined ? route : { ...route, agent };
|
|
6071
|
+
const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, selector);
|
|
4298
6072
|
syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef);
|
|
4299
6073
|
if (expectedReviewCaptureSuffix !== undefined && !hasExactReviewCaptureSuffix(status, expectedReviewCaptureSuffix)) return captureGroupAuthorityDrift(status);
|
|
4300
6074
|
return {
|
|
@@ -4355,10 +6129,10 @@ async function executeReviewHostRelayCapture(
|
|
|
4355
6129
|
},
|
|
4356
6130
|
};
|
|
4357
6131
|
} catch (error) {
|
|
4358
|
-
if (!(error instanceof ReviewHostRelayError)) return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route);
|
|
6132
|
+
if (!(error instanceof ReviewHostRelayError)) return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT);
|
|
4359
6133
|
if (error.mutationOutcome === "unknown") {
|
|
4360
6134
|
return {
|
|
4361
|
-
...(await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route)),
|
|
6135
|
+
...(await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT)),
|
|
4362
6136
|
failure: reviewHostRelayFailureReport(error),
|
|
4363
6137
|
reason: error.message,
|
|
4364
6138
|
};
|
|
@@ -4384,6 +6158,101 @@ async function executeReviewHostRelayCapture(
|
|
|
4384
6158
|
mutation_outcome: "none",
|
|
4385
6159
|
};
|
|
4386
6160
|
}
|
|
6161
|
+
// gentle-pi#638: the two deterministic relay failure classes end the slot, not the transport. Declaring the slot unachievable through the native verb records the provider-owned fact that this reviewer cannot complete under current conditions, then exactly one bound STATUS re-query renders the typed stop with its withdraw binding instead of reoffering the same slot. The declaration binding is re-derived from the slot's own provider-issued `--name=value` tokens, never from transcript state.
|
|
6162
|
+
const unachievableReason = reviewHostRelayUnachievableReason(error);
|
|
6163
|
+
const declarationBinding = unachievableSlotDeclarationBinding(slot);
|
|
6164
|
+
if (unachievableReason !== undefined && declarationBinding !== undefined && nativeReviewCli.captureUnachievableLens !== undefined) {
|
|
6165
|
+
let declared: NativeReviewUnachievableLensCaptureArtifact | undefined;
|
|
6166
|
+
try {
|
|
6167
|
+
declared = await nativeReviewCli.captureUnachievableLens({ cwd, ...declarationBinding, reason: unachievableReason, ...(reviewHostRelayUnachievableDetail(error) === undefined ? {} : { detail: reviewHostRelayUnachievableDetail(error)! }), ...(signal === undefined ? {} : { signal }) });
|
|
6168
|
+
} catch (declarationError) {
|
|
6169
|
+
// Fail open only on the unknown-verb capability refusal: an older binary without `capture-unachievable` keeps today's transport-failure behavior below. Every other declaration failure is surfaced, never hidden behind the relay failure it followed.
|
|
6170
|
+
if (!isNativeReviewUnachievableVerbRefused(declarationError)) {
|
|
6171
|
+
// gentle-pi#822 (outside-diff): the declaration failure's own envelope carries the mutation truth — the process may have recorded the declaration before failing — so the mutation fields are derived from it instead of hardcoded none, and an unknown outcome is proven or disproven by one bound STATUS re-query without ever changing the failure outcome.
|
|
6172
|
+
const declarationFailureReport = nativeOperationFailure("gentle_review_capture", declarationError);
|
|
6173
|
+
let declarationMutationPerformed = declarationFailureReport.mutation_performed === true;
|
|
6174
|
+
let declarationMutationOutcome: "none" | "unknown" | "committed" = declarationFailureReport.mutation_outcome === "committed" ? "committed" : declarationFailureReport.mutation_outcome === "unknown" ? "unknown" : "none";
|
|
6175
|
+
if (declarationMutationOutcome === "unknown") {
|
|
6176
|
+
try {
|
|
6177
|
+
const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, route === undefined ? { agent: REVIEW_HOST_AGENT } : { ...route, agent: REVIEW_HOST_AGENT });
|
|
6178
|
+
syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef);
|
|
6179
|
+
const stop = status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "unachievable_lens_slot" ? status.nextTransition : undefined;
|
|
6180
|
+
const declaredSlot = stop?.unachievableLensSlots?.find((candidate) => candidate.lens === slot.lens && String(candidate.selectedOrder) === slot.order && candidate.subjectHash === declarationBinding.requestHash && candidate.withdraw.binding.targetIdentity === declarationBinding.targetIdentity && candidate.withdraw.binding.lineageId === declarationBinding.lineageId && candidate.withdraw.binding.revision === declarationBinding.expectedRevision);
|
|
6181
|
+
if (stop !== undefined && declaredSlot !== undefined) {
|
|
6182
|
+
declarationMutationPerformed = true;
|
|
6183
|
+
declarationMutationOutcome = "committed";
|
|
6184
|
+
}
|
|
6185
|
+
} catch {
|
|
6186
|
+
// Without proof the mutation stays unknown; the declaration failure is already the reported outcome.
|
|
6187
|
+
}
|
|
6188
|
+
}
|
|
6189
|
+
return {
|
|
6190
|
+
tool: "gentle_review_capture",
|
|
6191
|
+
status: "blocked",
|
|
6192
|
+
outcome: "unachievable-lens-declaration-failed",
|
|
6193
|
+
reason: error.message,
|
|
6194
|
+
failure: reviewHostRelayFailureReport(error),
|
|
6195
|
+
declaration_failure: declarationFailureReport,
|
|
6196
|
+
mutation_performed: declarationMutationPerformed,
|
|
6197
|
+
mutation_outcome: declarationMutationOutcome,
|
|
6198
|
+
next_action: REVIEW_HOST_RELAY_DECLARATION_FAILED_ACTION,
|
|
6199
|
+
};
|
|
6200
|
+
}
|
|
6201
|
+
}
|
|
6202
|
+
if (declared !== undefined) {
|
|
6203
|
+
const declaration = { lens: declared.lens, selected_order: declared.selectedOrder, subject_hash: declarationBinding.requestHash, reason: declared.reason };
|
|
6204
|
+
try {
|
|
6205
|
+
const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, route === undefined ? { agent: REVIEW_HOST_AGENT } : { ...route, agent: REVIEW_HOST_AGENT });
|
|
6206
|
+
syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef);
|
|
6207
|
+
const stop = status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "unachievable_lens_slot" ? status.nextTransition : undefined;
|
|
6208
|
+
// gentle-pi#822: the stop may also carry slots declared by other runs, so expose only the entry matching the identity this session just declared. A stop with slots but no matching entry is a reconciliation failure, never a success rendering someone else's withdraw command.
|
|
6209
|
+
const declaredSlot = stop?.unachievableLensSlots?.find((slot) => slot.lens === declaration.lens && slot.selectedOrder === declaration.selected_order && slot.subjectHash === declaration.subject_hash && slot.withdraw.binding.targetIdentity === declarationBinding.targetIdentity && slot.withdraw.binding.lineageId === declarationBinding.lineageId && slot.withdraw.binding.revision === declarationBinding.expectedRevision);
|
|
6210
|
+
// gentle-pi#822: success is proven, never assumed — a STATUS with no unachievable_lens_slot stop at all (no transition, a different reason code, or a collect reoffer) is a reconciliation failure exactly like a stop whose entries do not match the declared identity.
|
|
6211
|
+
if (declaredSlot === undefined) {
|
|
6212
|
+
return {
|
|
6213
|
+
tool: "gentle_review_capture",
|
|
6214
|
+
status: "blocked",
|
|
6215
|
+
outcome: "unachievable-lens-declaration-reconciliation-failed",
|
|
6216
|
+
reason: error.message,
|
|
6217
|
+
failure: reviewHostRelayFailureReport(error),
|
|
6218
|
+
declaration,
|
|
6219
|
+
reconciliation_failure: { operation: "gentle_review_capture", status: "blocked", outcome: "unachievable-lens-slot-declaration-unmatched", reason: stop === undefined ? "the bound STATUS did not return the unachievable_lens_slot stop" : "no unachievable_lens_slots entry matches the declared slot identity", declared_slot: { lens: declaration.lens, selected_order: declaration.selected_order, subject_hash: declaration.subject_hash }, mutation_performed: true, mutation_outcome: "committed" },
|
|
6220
|
+
mutation_performed: true,
|
|
6221
|
+
mutation_outcome: "committed",
|
|
6222
|
+
next_action: REVIEW_HOST_RELAY_DECLARATION_FAILED_ACTION,
|
|
6223
|
+
};
|
|
6224
|
+
}
|
|
6225
|
+
return {
|
|
6226
|
+
tool: "gentle_review_capture",
|
|
6227
|
+
status: "blocked",
|
|
6228
|
+
outcome: "unachievable-lens-slot-declared",
|
|
6229
|
+
reason: error.message,
|
|
6230
|
+
failure: reviewHostRelayFailureReport(error),
|
|
6231
|
+
declaration,
|
|
6232
|
+
provider_action: status.action,
|
|
6233
|
+
...(status.nextTransition === undefined ? {} : { next_transition: status.nextTransition }),
|
|
6234
|
+
unachievable_lens_slots: [{ lens: declaredSlot.lens, selected_order: declaredSlot.selectedOrder, subject_hash: declaredSlot.subjectHash, reason: declaredSlot.reason, ...(declaredSlot.detail === undefined ? {} : { detail: declaredSlot.detail }), withdraw: declaredSlot.withdraw.command }],
|
|
6235
|
+
result: status.raw,
|
|
6236
|
+
next_action: REVIEW_HOST_RELAY_UNACHIEVABLE_ACTION,
|
|
6237
|
+
mutation_performed: true,
|
|
6238
|
+
mutation_outcome: "committed",
|
|
6239
|
+
};
|
|
6240
|
+
} catch (statusError) {
|
|
6241
|
+
return {
|
|
6242
|
+
tool: "gentle_review_capture",
|
|
6243
|
+
status: "blocked",
|
|
6244
|
+
outcome: "unachievable-lens-declaration-reconciliation-failed",
|
|
6245
|
+
reason: error.message,
|
|
6246
|
+
failure: reviewHostRelayFailureReport(error),
|
|
6247
|
+
declaration,
|
|
6248
|
+
reconciliation_failure: nativeOperationFailure("gentle_review_capture", statusError),
|
|
6249
|
+
mutation_performed: true,
|
|
6250
|
+
mutation_outcome: "committed",
|
|
6251
|
+
next_action: REVIEW_HOST_RELAY_DECLARATION_FAILED_ACTION,
|
|
6252
|
+
};
|
|
6253
|
+
}
|
|
6254
|
+
}
|
|
6255
|
+
}
|
|
4387
6256
|
return {
|
|
4388
6257
|
tool: "gentle_review_capture",
|
|
4389
6258
|
status: "blocked",
|
|
@@ -4404,6 +6273,21 @@ async function executeReviewHostRelayCapture(
|
|
|
4404
6273
|
const REVIEW_PROVIDER_ROLE_RETRY_ACTION =
|
|
4405
6274
|
"Call fresh STATUS and execute only the exact one-slot role vector it reoffers; never relaunch from transcript inference.";
|
|
4406
6275
|
|
|
6276
|
+
// gentle-pi#638: re-derives the capture-unachievable declaration binding from one materialize slot's own provider-issued tokens. The provider renders those tokens as `--name=value` pairs (review-host-relay.ts renderToken), and Go verifies every value against the frozen authority before recording, so a missing required value or subject hash means the slot cannot be declared and the caller keeps its fall-back behavior.
|
|
6277
|
+
function unachievableSlotDeclarationBinding(slot: ReviewHostRelaySlot): { lineageId: string; targetIdentity: string; expectedRevision: string; requestHash: string; repositoryContext?: string } | undefined {
|
|
6278
|
+
const tokenValue = (name: string): string | undefined => {
|
|
6279
|
+
const prefix = `--${name}=`;
|
|
6280
|
+
const token = slot.captureArgumentTokens.find((candidate) => candidate.startsWith(prefix));
|
|
6281
|
+
return token === undefined ? undefined : token.slice(prefix.length);
|
|
6282
|
+
};
|
|
6283
|
+
const lineageId = tokenValue("lineage");
|
|
6284
|
+
const targetIdentity = tokenValue("target");
|
|
6285
|
+
const expectedRevision = tokenValue("expected-revision");
|
|
6286
|
+
const repositoryContext = tokenValue("repository-context");
|
|
6287
|
+
if (lineageId === undefined || targetIdentity === undefined || expectedRevision === undefined || slot.subjectHash === undefined) return undefined;
|
|
6288
|
+
return { lineageId, targetIdentity, expectedRevision, requestHash: slot.subjectHash, ...(repositoryContext === undefined ? {} : { repositoryContext }) };
|
|
6289
|
+
}
|
|
6290
|
+
|
|
4407
6291
|
async function executeProviderRoleVectorCapture(
|
|
4408
6292
|
slot: ReviewProviderRoleVectorSlot,
|
|
4409
6293
|
nativeReviewCli: NativeReviewCli,
|
|
@@ -4573,9 +6457,9 @@ async function negotiatedStatusForHostTransport(
|
|
|
4573
6457
|
// under the exact guards `agent_end` uses to decide whether to nudge: a
|
|
4574
6458
|
// native review CLI with both `reviewMode` and `targetStatus`, a UI-bearing
|
|
4575
6459
|
// context, and RDD effectively on. Returns `undefined` on any missing guard,
|
|
4576
|
-
// an effective-off mode, or any STATUS error or transport refusal
|
|
4577
|
-
//
|
|
4578
|
-
//
|
|
6460
|
+
// an effective-off mode, or any STATUS error or transport refusal. Startup
|
|
6461
|
+
// negotiation and mutation-gated `agent_end` use the same native whole-target
|
|
6462
|
+
// path; neither derives candidate scope from local mutation receipts.
|
|
4579
6463
|
async function resolveNegotiatedReviewStatusForSession(
|
|
4580
6464
|
nativeReviewCli: NativeReviewCli | null,
|
|
4581
6465
|
ctx: ExtensionContext,
|
|
@@ -4600,12 +6484,12 @@ async function resolveNegotiatedReviewStatusForSession(
|
|
|
4600
6484
|
}
|
|
4601
6485
|
}
|
|
4602
6486
|
|
|
4603
|
-
// gentle-pi#556 / gentle-ai#4051: the
|
|
6487
|
+
// gentle-pi#556 / gentle-ai#4051: the mutation-gated reminder sent
|
|
4604
6488
|
// through `agent_end`. It never runs START itself, so it names the one
|
|
4605
6489
|
// supported continuation (gentle_review inspect) and defers the resulting
|
|
4606
6490
|
// consent envelope to the human.
|
|
4607
6491
|
function renderAgentEndReviewPreflightMessage(targetIdentity: string): string {
|
|
4608
|
-
return `Receipt-driven development is enabled, and this worktree holds an unreviewed candidate (target ${targetIdentity}).
|
|
6492
|
+
return `Receipt-driven development is enabled, and this worktree holds an unreviewed candidate (target ${targetIdentity}). First determine whether the user explicitly left this exact target unreviewed. If yes, do not invoke review; report that disposition and continue. Only otherwise, call 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 consumes only this session's observed mutation generation.`;
|
|
4609
6493
|
}
|
|
4610
6494
|
|
|
4611
6495
|
function canonicalReviewCaptureBinding(value: unknown): string {
|
|
@@ -4631,8 +6515,18 @@ function exactCollectArgument(input: ReviewCollectInputV3, name: string): string
|
|
|
4631
6515
|
return matches.length === 1 ? matches[0]!.value : undefined;
|
|
4632
6516
|
}
|
|
4633
6517
|
|
|
6518
|
+
// The intended-untracked collect input arrived with status/v6 and every later
|
|
6519
|
+
// status version keeps it; matching one exact version left every workspace
|
|
6520
|
+
// with untracked files unable to start a review once gentle-ai answered v7
|
|
6521
|
+
// (gentle-pi#610, gentle-ai#4187).
|
|
6522
|
+
const INTENDED_UNTRACKED_STATUS_SCHEMA = /^gentle-ai\.review-integration\.status\/v(\d+)$/;
|
|
6523
|
+
function statusCarriesIntendedUntrackedSelection(schema: unknown): boolean {
|
|
6524
|
+
const match = typeof schema === "string" ? INTENDED_UNTRACKED_STATUS_SCHEMA.exec(schema) : null;
|
|
6525
|
+
return match !== null && Number(match[1]) >= 6;
|
|
6526
|
+
}
|
|
6527
|
+
|
|
4634
6528
|
function reviewIntendedUntrackedInput(status: ReviewStatusV3): ReviewCollectInputV3 | undefined {
|
|
4635
|
-
if (status.raw.schema
|
|
6529
|
+
if (!statusCarriesIntendedUntrackedSelection(status.raw.schema) || status.nextTransition?.kind !== "collect") return undefined;
|
|
4636
6530
|
const matches = (status.nextTransition.collect?.inputs ?? []).filter((input) => {
|
|
4637
6531
|
const value = input.submission?.values[0];
|
|
4638
6532
|
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";
|
|
@@ -4640,6 +6534,13 @@ function reviewIntendedUntrackedInput(status: ReviewStatusV3): ReviewCollectInpu
|
|
|
4640
6534
|
return matches.length === 1 ? matches[0] : undefined;
|
|
4641
6535
|
}
|
|
4642
6536
|
|
|
6537
|
+
// gentle-pi#706: the inspect stop on the intended-untracked selection carries no
|
|
6538
|
+
// continuation, so the blocked result names it exactly: the inventory digest
|
|
6539
|
+
// covers path names only, and the round trip resolves either through the select
|
|
6540
|
+
// operation or through inspect's own top-level untrackedScope.
|
|
6541
|
+
const INSPECT_UNTRACKED_SELECTION_NEXT_STEP =
|
|
6542
|
+
'The intended-untracked selection is required before START. The expected_untracked_inventory digest covers untracked path names only (git ls-files --others --exclude-standard); nothing is read or hashed at inventory time, and file content is hashed only for selected paths at candidate freeze. Either call gentle_review with operation "select-intended-untracked" passing this selectionBinding and intendedUntracked ([] excludes every eligible path, a subset includes only those paths), or call inspect again with untrackedScope ("exclude", or "select" with intendedUntracked) to resolve the round trip in one call. To keep a path out of the inventory permanently, ignore it through .gitignore or .git/info/exclude.';
|
|
6543
|
+
|
|
4643
6544
|
interface PublicReviewCaptureBinding { collectBinding: string; }
|
|
4644
6545
|
function publicReviewCaptureBindings(status: ReviewStatusV3): readonly PublicReviewCaptureBinding[] {
|
|
4645
6546
|
if (status.nextTransition?.kind !== "collect") return [];
|
|
@@ -5012,11 +6913,11 @@ async function executeReviewCaptureGroupOperation(
|
|
|
5012
6913
|
}
|
|
5013
6914
|
} catch (error) {
|
|
5014
6915
|
if (error instanceof ReviewHostRelayError && error.mutationOutcome !== "unknown") return reviewHostRelayGroupFailure(error, group.slots, prepared, index);
|
|
5015
|
-
const reconciled = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, current.binding, retainedUntrackedSelections, route);
|
|
6916
|
+
const reconciled = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, current.binding, retainedUntrackedSelections, route, undefined, REVIEW_HOST_AGENT);
|
|
5016
6917
|
return { ...reconciled, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index, true), ...(error instanceof ReviewHostRelayError ? { failure: reviewHostRelayFailureReport(error), reason: error.message } : {}) };
|
|
5017
6918
|
}
|
|
5018
6919
|
}
|
|
5019
|
-
const reconciled = await reconcileUnknownReviewCaptureFailure(undefined, nativeReviewCli, cwd, group.binding, retainedUntrackedSelections, route, canonicalBindings.slice(prepared.length));
|
|
6920
|
+
const reconciled = await reconcileUnknownReviewCaptureFailure(undefined, nativeReviewCli, cwd, group.binding, retainedUntrackedSelections, route, canonicalBindings.slice(prepared.length), REVIEW_HOST_AGENT);
|
|
5020
6921
|
return { ...reconciled, tool: "gentle_review_capture_group", outcome: reconciled.outcome === "capture-group-authority-drift" ? reconciled.outcome : reconciled.status === "reconciled" ? "native-reviewer-group-status-reconciled" : "native-reviewer-group-status-reconciliation-failed", ...reviewHostRelayGroupProgress(group.slots, prepared, prepared.length) };
|
|
5021
6922
|
}
|
|
5022
6923
|
|
|
@@ -5057,7 +6958,6 @@ async function executeReviewControllerOperation(
|
|
|
5057
6958
|
retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
|
|
5058
6959
|
pendingReviewConsentRegistry: PendingReviewConsentRegistry = processPendingReviewConsentRegistry,
|
|
5059
6960
|
pendingReviewConsentFallbackKey: symbol = Symbol("pending-review-consent-fallback"),
|
|
5060
|
-
writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
|
|
5061
6961
|
reviewConsentNow: () => number = Date.now,
|
|
5062
6962
|
reviewConsentScheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void } = setTimeout,
|
|
5063
6963
|
intendedUntrackedSelection?: NativeIntendedUntrackedSelectionSubmission,
|
|
@@ -5065,7 +6965,7 @@ async function executeReviewControllerOperation(
|
|
|
5065
6965
|
const parameters = parseReviewControllerParameters(parametersValue);
|
|
5066
6966
|
const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
|
|
5067
6967
|
const pendingReviewConsentSession = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey);
|
|
5068
|
-
const
|
|
6968
|
+
const _useTargetLifecycleRoot = requiresExplicitTargetLifecycleRoot(parameters.workspaceRoot, sessionCwd, defaultCwd);
|
|
5069
6969
|
const includeWorkspaceRoot = parameters.workspaceRoot !== undefined || defaultCwd !== sessionCwd;
|
|
5070
6970
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.EXPORT || parameters.operation === REVIEW_CONTROLLER_OPERATION.IMPORT) {
|
|
5071
6971
|
// Legacy bundle transport rode on the retired pre-integration graph/compact
|
|
@@ -5082,6 +6982,15 @@ async function executeReviewControllerOperation(
|
|
|
5082
6982
|
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.",
|
|
5083
6983
|
};
|
|
5084
6984
|
}
|
|
6985
|
+
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ASSESS) {
|
|
6986
|
+
// Read-only native risk assessment (gentle-ai#4295, gentle-pi#662). Never
|
|
6987
|
+
// mutates, never requires a lineageId, and never routes through
|
|
6988
|
+
// authorizeDestructiveReviewOperation (it returns early for any
|
|
6989
|
+
// operation that is neither RESET nor a maintenance operation).
|
|
6990
|
+
const input = parseReviewAssessInput(parameters.operation, parameters.input);
|
|
6991
|
+
const details = await resolveReviewAssessmentPlan(nativeReviewCli, defaultCwd, input, signal);
|
|
6992
|
+
return { operation: parameters.operation, ...details, ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
|
|
6993
|
+
}
|
|
5085
6994
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS) {
|
|
5086
6995
|
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
5087
6996
|
return await executeNativeLegacyAliasRepair(input, defaultCwd, nativeReviewCli, signal, context);
|
|
@@ -5091,20 +7000,138 @@ async function executeReviewControllerOperation(
|
|
|
5091
7000
|
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
5092
7001
|
return await executeNativeAuthorityMaintenance(parameters.operation, maintenance, input, defaultCwd, nativeReviewCli, signal);
|
|
5093
7002
|
}
|
|
5094
|
-
if (
|
|
7003
|
+
if (
|
|
7004
|
+
parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT &&
|
|
7005
|
+
nativeReviewCli !== null
|
|
7006
|
+
) {
|
|
7007
|
+
// A new inspect supersedes every pre-lineage selection before its first
|
|
7008
|
+
// STATUS attempt. A failed or changed-candidate inspect cannot leave an
|
|
7009
|
+
// older selection available for a later START.
|
|
7010
|
+
clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, "");
|
|
5095
7011
|
try {
|
|
5096
7012
|
if (nativeReviewCli.targetStatus !== undefined) {
|
|
5097
|
-
const negotiated = await negotiatedStatusForHostTransport(
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
7013
|
+
const negotiated = await negotiatedStatusForHostTransport(
|
|
7014
|
+
nativeReviewCli,
|
|
7015
|
+
{
|
|
7016
|
+
cwd: defaultCwd,
|
|
7017
|
+
...(signal === undefined ? {} : { signal }),
|
|
7018
|
+
},
|
|
7019
|
+
retainedUntrackedSelections,
|
|
7020
|
+
defaultCwd,
|
|
7021
|
+
);
|
|
5101
7022
|
if (negotiated.transport !== undefined) {
|
|
5102
7023
|
return {
|
|
5103
7024
|
...hostTransportUnavailable(parameters.operation, negotiated.transport),
|
|
5104
7025
|
...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
|
|
5105
7026
|
};
|
|
5106
7027
|
}
|
|
5107
|
-
|
|
7028
|
+
const status = negotiated.status!;
|
|
7029
|
+
const plainMapped = mapNativeTargetStatus(
|
|
7030
|
+
parameters.operation,
|
|
7031
|
+
status,
|
|
7032
|
+
undefined,
|
|
7033
|
+
);
|
|
7034
|
+
if (parameters.untrackedScope === undefined) {
|
|
7035
|
+
// gentle-pi#706: the stop alone never tells the caller what to do next.
|
|
7036
|
+
return {
|
|
7037
|
+
...plainMapped,
|
|
7038
|
+
...("selectionBinding" in plainMapped
|
|
7039
|
+
? { nextStep: INSPECT_UNTRACKED_SELECTION_NEXT_STEP }
|
|
7040
|
+
: {}),
|
|
7041
|
+
...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
|
|
7042
|
+
};
|
|
7043
|
+
}
|
|
7044
|
+
const input = reviewIntendedUntrackedInput(status);
|
|
7045
|
+
if (input === undefined)
|
|
7046
|
+
return {
|
|
7047
|
+
...plainMapped,
|
|
7048
|
+
untracked_selection: "not-required",
|
|
7049
|
+
...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
|
|
7050
|
+
};
|
|
7051
|
+
const eligibleJson = exactCollectArgument(input, "eligible_paths_json"),
|
|
7052
|
+
inventory = exactCollectArgument(input, "expected_untracked_inventory");
|
|
7053
|
+
let eligible: unknown;
|
|
7054
|
+
try {
|
|
7055
|
+
eligible = JSON.parse(eligibleJson ?? "");
|
|
7056
|
+
} catch {
|
|
7057
|
+
eligible = undefined;
|
|
7058
|
+
}
|
|
7059
|
+
const selected = validateNativeStartUntrackedSelection({
|
|
7060
|
+
untrackedScope: parameters.untrackedScope,
|
|
7061
|
+
expectedUntrackedInventory: inventory,
|
|
7062
|
+
intendedUntracked: parameters.intendedUntracked,
|
|
7063
|
+
});
|
|
7064
|
+
if (
|
|
7065
|
+
!Array.isArray(eligible) ||
|
|
7066
|
+
selected.reason !== undefined ||
|
|
7067
|
+
selected.intendedUntracked!.some((path) => !eligible.includes(path))
|
|
7068
|
+
) {
|
|
7069
|
+
return {
|
|
7070
|
+
operation: parameters.operation,
|
|
7071
|
+
status: "blocked",
|
|
7072
|
+
outcome: "inspect-untracked-scope-invalid",
|
|
7073
|
+
reason:
|
|
7074
|
+
"The requested untrackedScope/intendedUntracked combination is invalid for the current intended-untracked stop: paths must be members of the eligible inventory, exclude selects none, and select selects at least one.",
|
|
7075
|
+
mutation_performed: false,
|
|
7076
|
+
mutation_outcome: "none",
|
|
7077
|
+
};
|
|
7078
|
+
}
|
|
7079
|
+
const submission: NativeIntendedUntrackedSelectionSubmission = {
|
|
7080
|
+
argumentTokens: input.submission!.argumentTokens,
|
|
7081
|
+
value: JSON.stringify({
|
|
7082
|
+
schema: "gentle-ai.review-intended-untracked-selection/v1",
|
|
7083
|
+
untracked_scope: parameters.untrackedScope,
|
|
7084
|
+
expected_untracked_inventory: inventory,
|
|
7085
|
+
intended_untracked: selected.intendedUntracked,
|
|
7086
|
+
}),
|
|
7087
|
+
};
|
|
7088
|
+
const resolved = await negotiatedStatusForHostTransport(
|
|
7089
|
+
nativeReviewCli,
|
|
7090
|
+
{
|
|
7091
|
+
cwd: defaultCwd,
|
|
7092
|
+
untrackedScope: parameters.untrackedScope,
|
|
7093
|
+
expectedUntrackedInventory: inventory,
|
|
7094
|
+
intendedUntracked: selected.intendedUntracked,
|
|
7095
|
+
intendedUntrackedSelection: submission,
|
|
7096
|
+
...(signal === undefined ? {} : { signal }),
|
|
7097
|
+
},
|
|
7098
|
+
retainedUntrackedSelections,
|
|
7099
|
+
defaultCwd,
|
|
7100
|
+
);
|
|
7101
|
+
if (resolved.transport !== undefined) {
|
|
7102
|
+
return {
|
|
7103
|
+
...hostTransportUnavailable(parameters.operation, resolved.transport),
|
|
7104
|
+
...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
|
|
7105
|
+
};
|
|
7106
|
+
}
|
|
7107
|
+
const resolvedStatus = resolved.status!;
|
|
7108
|
+
const candidateIdentity = nativePreLineageCandidateIdentity(resolvedStatus);
|
|
7109
|
+
if (candidateIdentity !== undefined) {
|
|
7110
|
+
retainNativeUntrackedSelection(
|
|
7111
|
+
retainedUntrackedSelections,
|
|
7112
|
+
defaultCwd,
|
|
7113
|
+
"",
|
|
7114
|
+
Object.freeze({
|
|
7115
|
+
untrackedScope: parameters.untrackedScope,
|
|
7116
|
+
expectedUntrackedInventory: inventory!,
|
|
7117
|
+
intendedUntracked: Object.freeze([...selected.intendedUntracked!]),
|
|
7118
|
+
submission,
|
|
7119
|
+
...candidateIdentity,
|
|
7120
|
+
}),
|
|
7121
|
+
);
|
|
7122
|
+
}
|
|
7123
|
+
const resolvedMapped = mapNativeTargetStatus(
|
|
7124
|
+
parameters.operation,
|
|
7125
|
+
resolvedStatus,
|
|
7126
|
+
undefined,
|
|
7127
|
+
);
|
|
7128
|
+
return {
|
|
7129
|
+
...resolvedMapped,
|
|
7130
|
+
...("selectionBinding" in resolvedMapped
|
|
7131
|
+
? { nextStep: INSPECT_UNTRACKED_SELECTION_NEXT_STEP }
|
|
7132
|
+
: {}),
|
|
7133
|
+
...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
|
|
7134
|
+
};
|
|
5108
7135
|
}
|
|
5109
7136
|
return nativeStatusUnsupported(parameters.operation);
|
|
5110
7137
|
} catch (error) {
|
|
@@ -5324,6 +7351,9 @@ async function executeReviewControllerOperation(
|
|
|
5324
7351
|
// The registry owns restoring writability of its 0555 views before
|
|
5325
7352
|
// removal; a terminal approved cleanup keeps the lineage projection.
|
|
5326
7353
|
candidateViews?.cleanupTerminal(parameters.lineageId, "approved", defaultCwd);
|
|
7354
|
+
// gentle-pi#668: `closed` is never auto-derived or recorded here --
|
|
7355
|
+
// a parent that wants the on-path passes nativeReviewOutcome:
|
|
7356
|
+
// "closed" explicitly on its next assess call for this candidate.
|
|
5327
7357
|
return {
|
|
5328
7358
|
operation: parameters.operation,
|
|
5329
7359
|
status: "closed",
|
|
@@ -5347,13 +7377,18 @@ async function executeReviewControllerOperation(
|
|
|
5347
7377
|
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");
|
|
5348
7378
|
if (typeof input.consentBinding !== "string" || input.consentBinding.length === 0) throw new Error("Review controller answer-consent requires an opaque consentBinding");
|
|
5349
7379
|
if (input.answer !== "granted" && input.answer !== "declined") throw new Error("Review controller answer-consent answer must be granted or declined");
|
|
5350
|
-
|
|
7380
|
+
// gentle-pi#455: resolve the binding by its opaque id alone, so a
|
|
7381
|
+
// binding one active Pi session's START created is answerable from any
|
|
7382
|
+
// active session presenting it -- not only the session that created it.
|
|
7383
|
+
const resolved = pendingReviewConsentRegistry.resolve(input.consentBinding);
|
|
7384
|
+
const pending = resolved?.pending;
|
|
7385
|
+
const owningSession = resolved?.sessionKey ?? pendingReviewConsentSession;
|
|
5351
7386
|
if (pending === undefined || pending.expiresAt <= reviewConsentNow()) {
|
|
5352
7387
|
const disposition = pending === undefined
|
|
5353
|
-
? pendingReviewConsentRegistry.staleDisposition(
|
|
7388
|
+
? pendingReviewConsentRegistry.staleDisposition(input.consentBinding)
|
|
5354
7389
|
: PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED;
|
|
5355
7390
|
const stale = staleConsentBindingDiagnostics(input.consentBinding, disposition);
|
|
5356
|
-
if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry,
|
|
7391
|
+
if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
|
|
5357
7392
|
if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
|
|
5358
7393
|
try {
|
|
5359
7394
|
const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
|
|
@@ -5366,14 +7401,15 @@ async function executeReviewControllerOperation(
|
|
|
5366
7401
|
return nativeStatusFailed(parameters.operation, error);
|
|
5367
7402
|
}
|
|
5368
7403
|
}
|
|
5369
|
-
|
|
7404
|
+
const answeringRepositoryCwd = realpathSync(defaultCwd);
|
|
7405
|
+
if (answeringRepositoryCwd !== pending.repositoryCwd) return consentBindingRepositoryMismatchOutcome(parameters.operation, input.consentBinding, pending.repositoryCwd, answeringRepositoryCwd);
|
|
5370
7406
|
if (reviewConsentDigest(pending.consent) !== pending.consentDigest) throw new Error("Review controller consent envelope binding changed");
|
|
5371
7407
|
pending.verifyCandidate();
|
|
5372
7408
|
if (nativeReviewCli?.answerConsent === undefined) throw new Error("Native review consent follow-up is unavailable");
|
|
5373
|
-
if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry,
|
|
7409
|
+
if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession)) {
|
|
5374
7410
|
const stale = staleConsentBindingDiagnostics(
|
|
5375
7411
|
input.consentBinding,
|
|
5376
|
-
pendingReviewConsentRegistry.staleDisposition(
|
|
7412
|
+
pendingReviewConsentRegistry.staleDisposition(input.consentBinding),
|
|
5377
7413
|
);
|
|
5378
7414
|
if (nativeReviewCli.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
|
|
5379
7415
|
try {
|
|
@@ -5390,11 +7426,14 @@ async function executeReviewControllerOperation(
|
|
|
5390
7426
|
try {
|
|
5391
7427
|
const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
|
|
5392
7428
|
if (gated !== undefined) {
|
|
5393
|
-
|
|
7429
|
+
// gentle-pi#668: mode disabled for this exact candidate -- keyed by
|
|
7430
|
+
// its targetIdentity, never by repository alone.
|
|
7431
|
+
recordNativeReviewOutcome(pending.authorityCwd, pending.consent.targetIdentity, NATIVE_REVIEW_OUTCOME.UNAVAILABLE);
|
|
7432
|
+
cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
|
|
5394
7433
|
return gated;
|
|
5395
7434
|
}
|
|
5396
7435
|
} catch (error) {
|
|
5397
|
-
cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry,
|
|
7436
|
+
cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
|
|
5398
7437
|
return nativeOperationFailure(parameters.operation, error);
|
|
5399
7438
|
}
|
|
5400
7439
|
// The one-shot binding is consumed before the first answer-path await. Any
|
|
@@ -5408,6 +7447,9 @@ async function executeReviewControllerOperation(
|
|
|
5408
7447
|
...(signal === undefined ? {} : { signal }),
|
|
5409
7448
|
});
|
|
5410
7449
|
if (answered.kind === "declined") {
|
|
7450
|
+
// gentle-pi#668: candidate-scoped decline, keyed by this exact
|
|
7451
|
+
// candidate's targetIdentity, never by repository alone.
|
|
7452
|
+
recordNativeReviewOutcome(pending.authorityCwd, pending.consent.targetIdentity, NATIVE_REVIEW_OUTCOME.DECLINED);
|
|
5411
7453
|
pending.cleanupCandidate();
|
|
5412
7454
|
return {
|
|
5413
7455
|
operation: parameters.operation,
|
|
@@ -5418,6 +7460,9 @@ async function executeReviewControllerOperation(
|
|
|
5418
7460
|
};
|
|
5419
7461
|
}
|
|
5420
7462
|
retainNativeUntrackedSelection(retainedUntrackedSelections, pending.authorityCwd, answered.start.lineageId, pending.untrackedSelection);
|
|
7463
|
+
// gentle-pi#706: a START completed through answer-consent consumed the
|
|
7464
|
+
// adopted pre-lineage selection too; clear it like the direct path.
|
|
7465
|
+
clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, pending.authorityCwd, "");
|
|
5421
7466
|
completed = completeNativeStart(parameters.operation, answered.start, pending.repositoryCwd, pending.candidateView, pending.candidateViews);
|
|
5422
7467
|
} catch (error) {
|
|
5423
7468
|
const value = error as { mutationOutcome?: unknown };
|
|
@@ -5428,15 +7473,6 @@ async function executeReviewControllerOperation(
|
|
|
5428
7473
|
projection: "workspace",
|
|
5429
7474
|
}, retainedUntrackedSelections);
|
|
5430
7475
|
}
|
|
5431
|
-
if (input.answer === "granted") {
|
|
5432
|
-
try {
|
|
5433
|
-
writeReviewConsentLatch(pending.repositoryCwd);
|
|
5434
|
-
} catch (error) {
|
|
5435
|
-
try {
|
|
5436
|
-
context?.ui.notify(`Native review start completed, but Pi could not record the local consent latch: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
5437
|
-
} catch { /* Reporting is best effort; native completion remains authoritative. */ }
|
|
5438
|
-
}
|
|
5439
|
-
}
|
|
5440
7476
|
return completed;
|
|
5441
7477
|
}
|
|
5442
7478
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED) {
|
|
@@ -5456,7 +7492,7 @@ async function executeReviewControllerOperation(
|
|
|
5456
7492
|
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));
|
|
5457
7493
|
if (rejected) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" };
|
|
5458
7494
|
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 }) };
|
|
5459
|
-
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,
|
|
7495
|
+
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);
|
|
5460
7496
|
return { ...result, operation: parameters.operation };
|
|
5461
7497
|
}
|
|
5462
7498
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
|
|
@@ -5478,9 +7514,35 @@ async function executeReviewControllerOperation(
|
|
|
5478
7514
|
if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) return nativeStartRejection("base-ref-invalid");
|
|
5479
7515
|
if (baseRef !== undefined && rawStart.committedOnly !== true) return nativeStartRejection("committed-only-required");
|
|
5480
7516
|
if (baseRef === undefined && "committedOnly" in rawStart) return nativeStartRejection("committed-only-invalid");
|
|
5481
|
-
const
|
|
5482
|
-
|
|
5483
|
-
|
|
7517
|
+
const explicitUntrackedSelection =
|
|
7518
|
+
validateNativeStartUntrackedSelection(rawStart);
|
|
7519
|
+
if (explicitUntrackedSelection.reason !== undefined)
|
|
7520
|
+
return nativeStartRejection(explicitUntrackedSelection.reason);
|
|
7521
|
+
// gentle-pi#706: a plain START adopts the selection an inspect
|
|
7522
|
+
// untrackedScope round trip retained pre-lineage; explicit input or a
|
|
7523
|
+
// carried submission always wins over the retained entry.
|
|
7524
|
+
const retainedPreLineageSelection =
|
|
7525
|
+
explicitUntrackedSelection.untrackedScope === undefined &&
|
|
7526
|
+
intendedUntrackedSelection === undefined
|
|
7527
|
+
? readRetainedPreLineageNativeUntrackedSelection(
|
|
7528
|
+
retainedUntrackedSelections,
|
|
7529
|
+
defaultCwd,
|
|
7530
|
+
)
|
|
7531
|
+
: undefined;
|
|
7532
|
+
const untrackedSelection: NativeStartUntrackedSelection =
|
|
7533
|
+
retainedPreLineageSelection === undefined
|
|
7534
|
+
? explicitUntrackedSelection
|
|
7535
|
+
: {
|
|
7536
|
+
untrackedScope: retainedPreLineageSelection.untrackedScope,
|
|
7537
|
+
expectedUntrackedInventory:
|
|
7538
|
+
retainedPreLineageSelection.expectedUntrackedInventory,
|
|
7539
|
+
intendedUntracked: [...retainedPreLineageSelection.intendedUntracked],
|
|
7540
|
+
};
|
|
7541
|
+
const untrackedSubmission =
|
|
7542
|
+
intendedUntrackedSelection ?? retainedPreLineageSelection?.submission;
|
|
7543
|
+
const retainedUntrackedSelection =
|
|
7544
|
+
retainedPreLineageSelection ??
|
|
7545
|
+
cloneRetainedNativeUntrackedSelection(explicitUntrackedSelection);
|
|
5484
7546
|
let canonicalBaseRef: string | undefined;
|
|
5485
7547
|
if (baseRef !== undefined) {
|
|
5486
7548
|
try {
|
|
@@ -5505,16 +7567,64 @@ async function executeReviewControllerOperation(
|
|
|
5505
7567
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5506
7568
|
...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }),
|
|
5507
7569
|
...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
|
|
5508
|
-
...(
|
|
7570
|
+
...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
|
|
5509
7571
|
...(signal === undefined ? {} : { signal }),
|
|
5510
7572
|
}, retainedUntrackedSelections, defaultCwd);
|
|
5511
7573
|
if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport);
|
|
5512
7574
|
target = negotiated.status!;
|
|
7575
|
+
// gentle-pi#874: the STATUS this START already fetched can itself
|
|
7576
|
+
// offer the committed-range START for an empty workspace candidate.
|
|
7577
|
+
// Adopt its own base commit *here*, before the candidate view and the
|
|
7578
|
+
// native START are resolved, and re-derive the target for that range,
|
|
7579
|
+
// so all three agree on one base-diff identity. Adopting the offer
|
|
7580
|
+
// later left the workspace target and the base-diff candidate view
|
|
7581
|
+
// disagreeing, and START failed with identity-mismatch. Both an
|
|
7582
|
+
// explicit caller baseRef and any START with an untracked selection in
|
|
7583
|
+
// play keep today's single-STATUS flow; only an adopted offer pays the
|
|
7584
|
+
// second read-only STATUS.
|
|
7585
|
+
if (canonicalBaseRef === undefined && untrackedSelection.untrackedScope === undefined && untrackedSubmission === undefined) {
|
|
7586
|
+
const offeredBaseRef = offeredCommittedRangeBaseRef(target);
|
|
7587
|
+
if (offeredBaseRef !== undefined) {
|
|
7588
|
+
const renegotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
|
|
7589
|
+
cwd: defaultCwd,
|
|
7590
|
+
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
7591
|
+
baseRef: offeredBaseRef,
|
|
7592
|
+
committedOnly: true,
|
|
7593
|
+
...(signal === undefined ? {} : { signal }),
|
|
7594
|
+
}, retainedUntrackedSelections, defaultCwd);
|
|
7595
|
+
if (renegotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, renegotiated.transport);
|
|
7596
|
+
canonicalBaseRef = offeredBaseRef;
|
|
7597
|
+
target = renegotiated.status!;
|
|
7598
|
+
}
|
|
7599
|
+
}
|
|
7600
|
+
if (
|
|
7601
|
+
retainedPreLineageSelection !== undefined &&
|
|
7602
|
+
!sameNativePreLineageCandidate(retainedPreLineageSelection, target)
|
|
7603
|
+
) {
|
|
7604
|
+
clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, "");
|
|
7605
|
+
return {
|
|
7606
|
+
operation: parameters.operation,
|
|
7607
|
+
status: "blocked",
|
|
7608
|
+
outcome: "native-start-retained-selection-candidate-mismatch",
|
|
7609
|
+
mutation_performed: false,
|
|
7610
|
+
mutation_outcome: "none",
|
|
7611
|
+
next_action: "inspect-and-resolve-the-current-intended-untracked-selection",
|
|
7612
|
+
};
|
|
7613
|
+
}
|
|
5513
7614
|
if (target.nextTransition?.kind === "collect" || target.applicability !== "unrelated" || target.action !== "start") return mapNativeTargetStatus(parameters.operation, target, parameters.lineageId);
|
|
5514
7615
|
} catch (error) {
|
|
5515
7616
|
return nativeOperationFailure(parameters.operation, error);
|
|
5516
7617
|
}
|
|
5517
|
-
|
|
7618
|
+
// gentle-pi#323: the replay key must fold in the current candidate
|
|
7619
|
+
// content identity. Without it, a second START with identical
|
|
7620
|
+
// {cwd, lineageId, input, inputPath} reuses a still-live (never
|
|
7621
|
+
// lineage-bound) frozen candidate view from within the consent TTL
|
|
7622
|
+
// window even after the live candidate content changed underneath
|
|
7623
|
+
// it, and dead-ends at candidate-target-projection-drift with no
|
|
7624
|
+
// recovery. Folding in currentCandidateTree makes a content change
|
|
7625
|
+
// mint a fresh replay key -- and therefore a fresh candidate view --
|
|
7626
|
+
// instead of reusing the stale one.
|
|
7627
|
+
const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null, candidateTree: target.projection.currentCandidateTree });
|
|
5518
7628
|
// Synchronously drop any binding whose TTL has already elapsed
|
|
5519
7629
|
// before reusing its retained candidate view, so a fresh-candidate
|
|
5520
7630
|
// retry cannot reuse a view tied to an expired binding and trip
|
|
@@ -5543,7 +7653,7 @@ async function executeReviewControllerOperation(
|
|
|
5543
7653
|
targetIdentity: target.targetIdentity,
|
|
5544
7654
|
projection: target.projection.projection,
|
|
5545
7655
|
...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
|
|
5546
|
-
...(
|
|
7656
|
+
...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
|
|
5547
7657
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5548
7658
|
...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
|
|
5549
7659
|
...(focus === undefined ? {} : { focus }),
|
|
@@ -5577,14 +7687,14 @@ async function executeReviewControllerOperation(
|
|
|
5577
7687
|
cleanupCandidate: () => {
|
|
5578
7688
|
if (candidateCleaned) return;
|
|
5579
7689
|
candidateCleaned = true;
|
|
5580
|
-
consentCandidateView.cleanup();
|
|
7690
|
+
try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent expiry/teardown still completes. */ }
|
|
5581
7691
|
},
|
|
5582
7692
|
...(retainedUntrackedSelection === undefined ? {} : { untrackedSelection: retainedUntrackedSelection }),
|
|
5583
7693
|
consent: error.consent,
|
|
5584
7694
|
consentDigest,
|
|
5585
7695
|
expiresAt: reviewConsentNow() + PENDING_REVIEW_CONSENT_TTL_MS,
|
|
5586
7696
|
};
|
|
5587
|
-
pendingReviewConsentRegistry.
|
|
7697
|
+
pendingReviewConsentRegistry.add(pendingReviewConsentSession, pending);
|
|
5588
7698
|
pending.expiry = reviewConsentScheduleTimer(
|
|
5589
7699
|
() => expirePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession),
|
|
5590
7700
|
PENDING_REVIEW_CONSENT_TTL_MS,
|
|
@@ -5601,22 +7711,30 @@ async function executeReviewControllerOperation(
|
|
|
5601
7711
|
};
|
|
5602
7712
|
}
|
|
5603
7713
|
retainNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, result.lineageId, retainedUntrackedSelection);
|
|
7714
|
+
// gentle-pi#706: the adopted pre-lineage selection dies with the START
|
|
7715
|
+
// that consumed it; it must never leak to the next candidate.
|
|
7716
|
+
clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, "");
|
|
5604
7717
|
return completeNativeStart(parameters.operation, result, defaultCwd, candidateView, candidateViews);
|
|
5605
7718
|
} catch (error) {
|
|
5606
|
-
if (error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true }));
|
|
7719
|
+
if (!nativeStartAttempted && error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true }));
|
|
5607
7720
|
if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason);
|
|
5608
7721
|
const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
|
|
5609
7722
|
const provenNoMutation = value.mutationOutcome === "none";
|
|
5610
|
-
const
|
|
5611
|
-
if (candidateView && candidateViews && (provenNoMutation ||
|
|
7723
|
+
const preNativeFailure = !nativeStartAttempted;
|
|
7724
|
+
if (candidateView && candidateViews && (provenNoMutation || preNativeFailure)) candidateViews.cleanup(candidateView.token);
|
|
7725
|
+
const nativeCliError = asNativeReviewCliError(error);
|
|
5612
7726
|
const failure = provenNoMutation
|
|
5613
7727
|
? error
|
|
5614
|
-
:
|
|
5615
|
-
? Object.assign(error, { candidateViewPreNative: true })
|
|
5616
|
-
: Object.assign(
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
7728
|
+
: preNativeFailure
|
|
7729
|
+
? error instanceof CandidateViewError ? Object.assign(error, { candidateViewPreNative: true }) : error
|
|
7730
|
+
: Object.assign(
|
|
7731
|
+
error instanceof Error
|
|
7732
|
+
? error
|
|
7733
|
+
: nativeCliError === undefined
|
|
7734
|
+
? new Error(String(error))
|
|
7735
|
+
: { name: "NativeReviewCliError", code: nativeCliError.code, diagnostics: nativeCliError.diagnostics },
|
|
7736
|
+
{ mutationOutcome: "unknown", nextAction: "review.status" },
|
|
7737
|
+
);
|
|
5620
7738
|
return reconcileNativeMutationFailure(parameters.operation, failure, nativeReviewCli, {
|
|
5621
7739
|
cwd: defaultCwd,
|
|
5622
7740
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
@@ -5725,12 +7843,16 @@ async function executeReviewControllerOperation(
|
|
|
5725
7843
|
const effectiveUntrackedSelection = rawStatus === undefined && parameters.lineageId !== undefined
|
|
5726
7844
|
? readRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId)
|
|
5727
7845
|
: untrackedSelection;
|
|
7846
|
+
const retainedCommittedTarget = rawStatus === undefined && parameters.lineageId !== undefined && candidateViews?.hasProjection(parameters.lineageId, defaultCwd)
|
|
7847
|
+
? candidateViews.resolveProjection(parameters.lineageId, defaultCwd)
|
|
7848
|
+
: undefined;
|
|
7849
|
+
const effectiveBaseRef = baseRef ?? (retainedCommittedTarget?.committedOnly === true ? retainedCommittedTarget.baseCommit : undefined);
|
|
5728
7850
|
if (nativeReviewCli?.targetStatus !== undefined) {
|
|
5729
7851
|
try {
|
|
5730
7852
|
const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
|
|
5731
7853
|
cwd: defaultCwd,
|
|
5732
7854
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5733
|
-
...(
|
|
7855
|
+
...(effectiveBaseRef === undefined ? {} : { baseRef: effectiveBaseRef, committedOnly: true }),
|
|
5734
7856
|
...(effectiveUntrackedSelection.untrackedScope === undefined ? {} : effectiveUntrackedSelection),
|
|
5735
7857
|
...(signal === undefined ? {} : { signal }),
|
|
5736
7858
|
}, retainedUntrackedSelections, defaultCwd);
|
|
@@ -5749,7 +7871,7 @@ async function executeReviewControllerOperation(
|
|
|
5749
7871
|
) retainNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId, retainedUntrackedSelection);
|
|
5750
7872
|
clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state);
|
|
5751
7873
|
hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, status);
|
|
5752
|
-
return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId
|
|
7874
|
+
return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
|
|
5753
7875
|
} catch (error) {
|
|
5754
7876
|
return nativeOperationFailure(parameters.operation, error);
|
|
5755
7877
|
}
|
|
@@ -5767,6 +7889,9 @@ export const __testing = {
|
|
|
5767
7889
|
listDiscoverableAgents,
|
|
5768
7890
|
orderDiscoverableAgents,
|
|
5769
7891
|
classifyGuardedCommand,
|
|
7892
|
+
evaluateGuardedCommand,
|
|
7893
|
+
guardedCommandPreview,
|
|
7894
|
+
guardedCommandTitle,
|
|
5770
7895
|
loadRuntimeGuardrailsConfig,
|
|
5771
7896
|
buildGentlePrompt,
|
|
5772
7897
|
nativeStatusUnsupported,
|
|
@@ -5789,8 +7914,22 @@ export const __testing = {
|
|
|
5789
7914
|
resolveBackgroundSubagentsCapability,
|
|
5790
7915
|
readActiveToolNames,
|
|
5791
7916
|
renderBackgroundSubagentsStatusLine,
|
|
7917
|
+
renderRddStatusLine,
|
|
7918
|
+
isValidRddModeStatus,
|
|
7919
|
+
resolveRddModeStatus,
|
|
7920
|
+
resolveRddStatusLine,
|
|
7921
|
+
RDD_STATUS_TIMEOUT_MS,
|
|
7922
|
+
RDD_STATUS_MEMO_TTL_MS,
|
|
7923
|
+
clearRddStatusMemoForTesting,
|
|
7924
|
+
readNativeReviewOutcome,
|
|
7925
|
+
recordNativeReviewOutcome,
|
|
7926
|
+
clearNativeReviewOutcomeMemoForTesting,
|
|
5792
7927
|
resolveControllerSddStatus,
|
|
5793
7928
|
resolveStartupControllerSddStatus,
|
|
7929
|
+
resolveSddChangeStartup,
|
|
7930
|
+
resolveSelectedNativeSddChangeStartup,
|
|
7931
|
+
readSddChangeFlag,
|
|
7932
|
+
resetTelemetryTriggerGuardForTesting,
|
|
5794
7933
|
createGentleAiExtension: createGentleAiExtensionForTesting,
|
|
5795
7934
|
};
|
|
5796
7935
|
|
|
@@ -5824,6 +7963,22 @@ export interface GentleAiRuntimeDependencies {
|
|
|
5824
7963
|
// sleep and without relying on the queued cleanup macrotask firing.
|
|
5825
7964
|
now?: () => number;
|
|
5826
7965
|
scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
|
|
7966
|
+
// The environment the session's child processes inherit; tests inject a
|
|
7967
|
+
// plain object so the handshake declaration is observable without
|
|
7968
|
+
// touching the test runner's own process.env.
|
|
7969
|
+
processEnv?: NodeJS.ProcessEnv;
|
|
7970
|
+
// Package-owned children use this parent-bound channel only to ask whether
|
|
7971
|
+
// their own pending ordinary START may replay a grant locally.
|
|
7972
|
+
childStandingReviewPermissionClient?: Pick<ChildStandingReviewPermissionClient, "requestAuthorization" | "close">;
|
|
7973
|
+
// gentle-pi#677: test-only seams for the telemetry trigger. Production
|
|
7974
|
+
// leaves both undefined: the real package-local resolveGentleAiBinary()
|
|
7975
|
+
// and the real detached child_process spawn run.
|
|
7976
|
+
resolveTelemetryTriggerBinary?: () => string;
|
|
7977
|
+
telemetryTriggerSpawn?: TelemetryTriggerSpawn;
|
|
7978
|
+
// Test-only seam for the foreground `/gentle:telemetry` slash command's
|
|
7979
|
+
// bounded exec; production leaves this undefined and uses the real node
|
|
7980
|
+
// exec-file adapter shared with the rest of the extension.
|
|
7981
|
+
telemetryExecFileAdapter?: ExecFileAdapter;
|
|
5827
7982
|
}
|
|
5828
7983
|
|
|
5829
7984
|
export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void {
|
|
@@ -5832,28 +7987,79 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
5832
7987
|
|
|
5833
7988
|
function createGentleAiExtensionForTesting(
|
|
5834
7989
|
dependencies: GentleAiRuntimeDependencies = {},
|
|
5835
|
-
writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
|
|
5836
7990
|
): (pi: ExtensionAPI) => void {
|
|
5837
7991
|
const nativeReviewCli = dependencies.nativeReviewCli === undefined ? createNativeReviewCli() : dependencies.nativeReviewCli;
|
|
7992
|
+
const childStandingReviewPermissionLease = dependencies.childStandingReviewPermissionClient === undefined
|
|
7993
|
+
? acquireChildStandingReviewPermissionClient(dependencies.processEnv ?? process.env)
|
|
7994
|
+
: undefined;
|
|
7995
|
+
const childStandingReviewPermission = dependencies.childStandingReviewPermissionClient ?? childStandingReviewPermissionLease?.client;
|
|
5838
7996
|
const reviewConsentNow = dependencies.now ?? (() => Date.now());
|
|
5839
7997
|
const reviewConsentScheduleTimer = dependencies.scheduleTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
5840
7998
|
const pendingReviewConsentRegistry = dependencies.pendingReviewConsentRegistry ?? processPendingReviewConsentRegistry;
|
|
7999
|
+
const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary;
|
|
8000
|
+
const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter();
|
|
5841
8001
|
return function gentleAi(pi: ExtensionAPI): void {
|
|
8002
|
+
const flags = pi as unknown as { registerFlag?: (name: string, definition: { description: string; type: "string"; default?: string }) => void };
|
|
8003
|
+
flags.registerFlag?.(SDD_CHANGE_FLAG, {
|
|
8004
|
+
description: "Internal launch-local selected SDD change identity for package-owned child agents.",
|
|
8005
|
+
type: "string",
|
|
8006
|
+
});
|
|
8007
|
+
declareReviewRelayHandshake(dependencies.processEnv ?? process.env);
|
|
5842
8008
|
const pendingReviewConsentFallbackKey = Symbol("pending-review-consent-fallback");
|
|
5843
8009
|
const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
|
|
5844
8010
|
const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events);
|
|
8011
|
+
const permissionEnvironment = dependencies.processEnv ?? process.env;
|
|
8012
|
+
|
|
8013
|
+
const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => {
|
|
8014
|
+
try {
|
|
8015
|
+
(context.ui as unknown as { setStatus?: (key: string, text?: string) => void }).setStatus?.(
|
|
8016
|
+
REVIEW_SESSION_PERMISSION_STATUS_KEY,
|
|
8017
|
+
active ? REVIEW_SESSION_PERMISSION_STATUS_TEXT : undefined,
|
|
8018
|
+
);
|
|
8019
|
+
} catch { /* Status is nonblocking and never permission authority. */ }
|
|
8020
|
+
};
|
|
8021
|
+
const capturePermissionIdentity = (context: ExtensionContext, cwd: string = context.cwd): Promise<ReviewSessionIdentity | undefined> =>
|
|
8022
|
+
captureReviewSessionIdentity({ ...context, cwd }, permissionEnvironment);
|
|
8023
|
+
const refreshReviewSessionPermissionStatus = async (context: ExtensionContext): Promise<ReviewSessionIdentity | undefined> => {
|
|
8024
|
+
const identity = await capturePermissionIdentity(context);
|
|
8025
|
+
setReviewSessionPermissionStatus(context, identity !== undefined && hasReviewSessionPermission(identity));
|
|
8026
|
+
return identity;
|
|
8027
|
+
};
|
|
8028
|
+
const revokeCurrentReviewSessionPermission = (context: ExtensionContext): boolean => {
|
|
8029
|
+
const coordinates = reviewSessionManagerAndId(context);
|
|
8030
|
+
const revoked = coordinates === undefined ? false : revokeReviewSessionPermissionsForSession(coordinates.manager, coordinates.sessionId);
|
|
8031
|
+
setReviewSessionPermissionStatus(context, false);
|
|
8032
|
+
return revoked;
|
|
8033
|
+
};
|
|
8034
|
+
const revokeCurrentRepositoryReviewSessionPermission = async (context: ExtensionContext): Promise<boolean> => {
|
|
8035
|
+
const identity = await capturePermissionIdentity(context);
|
|
8036
|
+
const revoked = identity === undefined ? false : revokeReviewSessionPermission(identity);
|
|
8037
|
+
setReviewSessionPermissionStatus(context, false);
|
|
8038
|
+
return revoked;
|
|
8039
|
+
};
|
|
5845
8040
|
|
|
5846
|
-
|
|
8041
|
+
let reminderSessionActive = true;
|
|
8042
|
+
let reminderEpoch = 0;
|
|
8043
|
+
pi.on("session_shutdown", (event, context) => {
|
|
8044
|
+
reminderSessionActive = false;
|
|
8045
|
+
reminderEpoch += 1;
|
|
8046
|
+
// Pi tears down this registry on reload as well as session replacement/quit.
|
|
8047
|
+
try { candidateViews?.cleanupAll(); } catch { /* Preserve failed owned views for later recovery. */ }
|
|
8048
|
+
const reason = (event as { reason?: unknown }).reason;
|
|
8049
|
+
if (reason !== "reload") {
|
|
8050
|
+
if (childStandingReviewPermissionLease !== undefined) childStandingReviewPermissionLease.closeIfCurrent();
|
|
8051
|
+
else childStandingReviewPermission?.close();
|
|
8052
|
+
revokeCurrentReviewSessionPermission(context);
|
|
8053
|
+
}
|
|
5847
8054
|
const sessionKey = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey);
|
|
5848
8055
|
cleanupAllPendingReviewConsents(pendingReviewConsentRegistry, sessionKey);
|
|
5849
8056
|
processRetainedNativeStatusSelections.delete(sessionKey);
|
|
5850
8057
|
processAgentEndSubagentDepth.delete(sessionKey);
|
|
5851
|
-
processAgentEndPreflightNudgedTargets.delete(sessionKey);
|
|
5852
|
-
processAgentEndSessionBaseline.delete(sessionKey);
|
|
5853
8058
|
});
|
|
5854
8059
|
|
|
5855
8060
|
pi.registerTool({
|
|
5856
8061
|
name: "gentle_review_scope",
|
|
8062
|
+
renderShell: "self",
|
|
5857
8063
|
label: "Gentle Review Scope",
|
|
5858
8064
|
description: "Read one bounded, integrity-checked page of the controller-owned frozen changed scope. This read-only tool never inspects the ambient or candidate tree.",
|
|
5859
8065
|
parameters: REVIEW_SCOPE_PARAMETERS,
|
|
@@ -5865,8 +8071,8 @@ function createGentleAiExtensionForTesting(
|
|
|
5865
8071
|
context as GentleAiRenderContext | undefined,
|
|
5866
8072
|
);
|
|
5867
8073
|
},
|
|
5868
|
-
renderResult(result, options) {
|
|
5869
|
-
return renderGentleAiResult(result, options);
|
|
8074
|
+
renderResult(result, options, theme, context) {
|
|
8075
|
+
return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
|
|
5870
8076
|
},
|
|
5871
8077
|
async execute(_toolCallId, parameters) {
|
|
5872
8078
|
const input = parameters as ReviewScopeParameters;
|
|
@@ -5875,8 +8081,28 @@ function createGentleAiExtensionForTesting(
|
|
|
5875
8081
|
},
|
|
5876
8082
|
});
|
|
5877
8083
|
|
|
8084
|
+
// The lens a reviewer capture runs is inside its collect binding, so the
|
|
8085
|
+
// card can say "review capture · risk" instead of a bare operation name.
|
|
8086
|
+
const lensLabel = (lens: unknown): string | undefined =>
|
|
8087
|
+
typeof lens === "string" && lens.length > 0 ? lens.replace(/^review-/, "") : undefined;
|
|
8088
|
+
const collectBindingLens = (binding: unknown): string | undefined => {
|
|
8089
|
+
if (typeof binding !== "string") return undefined;
|
|
8090
|
+
try {
|
|
8091
|
+
const parsed = JSON.parse(binding) as Record<string, unknown>;
|
|
8092
|
+
const subject = (parsed.artifactSubject ?? parsed.artifact_subject) as Record<string, unknown> | undefined;
|
|
8093
|
+
return lensLabel(subject?.lens);
|
|
8094
|
+
} catch {
|
|
8095
|
+
return undefined;
|
|
8096
|
+
}
|
|
8097
|
+
};
|
|
8098
|
+
const withLenses = (operation: string, lenses: readonly (string | undefined)[]): string => {
|
|
8099
|
+
const named = lenses.filter((lens): lens is string => lens !== undefined);
|
|
8100
|
+
return named.length === 0 ? operation : `${operation} · ${named.join(" · ")}`;
|
|
8101
|
+
};
|
|
8102
|
+
|
|
5878
8103
|
pi.registerTool({
|
|
5879
8104
|
name: "gentle_review_capture_group",
|
|
8105
|
+
renderShell: "self",
|
|
5880
8106
|
label: "Gentle Review Capture Group",
|
|
5881
8107
|
description: "Capture one complete provider-issued materialize reviewer group. It validates the exact ordered current collect set, forecasts its bounded model cost, runs reviewers concurrently, and admits outputs one at a time in provider order.",
|
|
5882
8108
|
promptSnippet: "Use one complete exact current STATUS materialize reviewer group; acknowledge its forecast before the grouped run.",
|
|
@@ -5886,11 +8112,13 @@ function createGentleAiExtensionForTesting(
|
|
|
5886
8112
|
],
|
|
5887
8113
|
parameters: REVIEW_CAPTURE_GROUP_PARAMETERS,
|
|
5888
8114
|
executionMode: "sequential",
|
|
5889
|
-
renderCall(
|
|
5890
|
-
|
|
8115
|
+
renderCall(args, theme, context) {
|
|
8116
|
+
const bindings = (args as { collectBindings?: unknown }).collectBindings;
|
|
8117
|
+
const lenses = Array.isArray(bindings) ? bindings.map(collectBindingLens) : [];
|
|
8118
|
+
return renderGentleAiLifecycleCall(withLenses("review capture group", lenses), theme, context as GentleAiRenderContext | undefined);
|
|
5891
8119
|
},
|
|
5892
|
-
renderResult(result, options) {
|
|
5893
|
-
return renderGentleAiResult(result, options);
|
|
8120
|
+
renderResult(result, options, theme, context) {
|
|
8121
|
+
return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
|
|
5894
8122
|
},
|
|
5895
8123
|
async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
|
|
5896
8124
|
if (signal?.aborted) throw new Error("Review capture group was cancelled");
|
|
@@ -5909,6 +8137,7 @@ function createGentleAiExtensionForTesting(
|
|
|
5909
8137
|
|
|
5910
8138
|
pi.registerTool({
|
|
5911
8139
|
name: "gentle_review_capture",
|
|
8140
|
+
renderShell: "self",
|
|
5912
8141
|
label: "Gentle Review Capture",
|
|
5913
8142
|
description: "Capture exactly one provider-issued ordinary native review collect slot. This is not a controller operation: it validates one opaque collect binding against current target-scoped STATUS, executes at most one capture, and never follows a transition.",
|
|
5914
8143
|
promptSnippet: "Use one exact current STATUS collectBinding for one ordinary native capture; call fresh STATUS before every additional capture.",
|
|
@@ -5919,15 +8148,15 @@ function createGentleAiExtensionForTesting(
|
|
|
5919
8148
|
],
|
|
5920
8149
|
parameters: REVIEW_CAPTURE_PARAMETERS,
|
|
5921
8150
|
executionMode: "sequential",
|
|
5922
|
-
renderCall(
|
|
8151
|
+
renderCall(args, theme, context) {
|
|
5923
8152
|
return renderGentleAiLifecycleCall(
|
|
5924
|
-
"review capture",
|
|
8153
|
+
withLenses("review capture", [collectBindingLens((args as { collectBinding?: unknown }).collectBinding)]),
|
|
5925
8154
|
theme,
|
|
5926
8155
|
context as GentleAiRenderContext | undefined,
|
|
5927
8156
|
);
|
|
5928
8157
|
},
|
|
5929
|
-
renderResult(result, options) {
|
|
5930
|
-
return renderGentleAiResult(result, options);
|
|
8158
|
+
renderResult(result, options, theme, context) {
|
|
8159
|
+
return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
|
|
5931
8160
|
},
|
|
5932
8161
|
async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
|
|
5933
8162
|
if (signal?.aborted) throw new Error("Review capture was cancelled");
|
|
@@ -5949,18 +8178,21 @@ function createGentleAiExtensionForTesting(
|
|
|
5949
8178
|
|
|
5950
8179
|
pi.registerTool({
|
|
5951
8180
|
name: "gentle_review",
|
|
8181
|
+
renderShell: "self",
|
|
5952
8182
|
label: "Gentle Review Controller",
|
|
5953
8183
|
description:
|
|
5954
8184
|
"Inspect and recover review authority and start native ordinary review. Ordinary capture is available only through the separate gentle_review_capture tool. Review outcomes never authorize delivery: commit, push, pull-request, and release commands follow ordinary repository policy. RESET/RECOVER remain destructive and are executed by the audited native CLI.",
|
|
5955
8185
|
promptSnippet: "Inspect authority, then start native ordinary review; use gentle_review_capture for one current collect slot",
|
|
5956
8186
|
promptGuidelines: [
|
|
5957
8187
|
'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).',
|
|
8188
|
+
'An inspect blocked on the intended-untracked selection returns nextStep naming the exact continuation: call select-intended-untracked with the returned selectionBinding, or call inspect again with top-level untrackedScope ("exclude", or "select" with intendedUntracked) to resolve the round trip in one call; the retained selection is adopted by the next plain START.',
|
|
5958
8189
|
"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.",
|
|
5959
|
-
"Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, capturedLensResults, findingsPresent,
|
|
8190
|
+
"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.",
|
|
5960
8191
|
"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.",
|
|
5961
8192
|
"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.",
|
|
5962
|
-
"A consent-required START
|
|
8193
|
+
"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.",
|
|
5963
8194
|
"Use gentle_review only for native review authority operations; delivery commands follow ordinary repository policy.",
|
|
8195
|
+
'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.',
|
|
5964
8196
|
],
|
|
5965
8197
|
parameters: REVIEW_CONTROLLER_PARAMETERS,
|
|
5966
8198
|
executionMode: "sequential",
|
|
@@ -5971,26 +8203,120 @@ function createGentleAiExtensionForTesting(
|
|
|
5971
8203
|
context as GentleAiRenderContext | undefined,
|
|
5972
8204
|
);
|
|
5973
8205
|
},
|
|
5974
|
-
renderResult(result, options) {
|
|
5975
|
-
return renderGentleAiResult(result, options);
|
|
8206
|
+
renderResult(result, options, theme, context) {
|
|
8207
|
+
return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined);
|
|
5976
8208
|
},
|
|
5977
8209
|
async execute(_toolCallId, parameters, signal, _onUpdate, ctx) {
|
|
5978
8210
|
if (signal?.aborted) throw new Error("Review controller operation was cancelled");
|
|
5979
8211
|
await authorizeDestructiveReviewOperation(parameters, ctx);
|
|
5980
|
-
const
|
|
8212
|
+
const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
8213
|
+
const retainedSelections = processRetainedNativeStatusSelections.get(sessionKey)
|
|
8214
|
+
?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!;
|
|
8215
|
+
// Snapshot before native awaits: a concurrent own write is a new generation.
|
|
8216
|
+
const acknowledgementEpoch = reminderEpoch;
|
|
8217
|
+
let acknowledgementRoot: string | undefined;
|
|
8218
|
+
let acknowledgementMutation: string | undefined;
|
|
8219
|
+
try {
|
|
8220
|
+
const parsed = parseReviewControllerParameters(parameters);
|
|
8221
|
+
if (parsed.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED) {
|
|
8222
|
+
acknowledgementRoot = resolveReviewControllerWorkspaceRoot(parsed.workspaceRoot, ctx.cwd, candidateViews, parsed.lineageId);
|
|
8223
|
+
acknowledgementMutation = pendingReviewMutation(ctx.sessionManager, acknowledgementRoot);
|
|
8224
|
+
}
|
|
8225
|
+
} catch { /* Controller validation owns invalid parameters and unavailable roots. */ }
|
|
8226
|
+
let details = await executeReviewControllerOperation(
|
|
5981
8227
|
parameters,
|
|
5982
8228
|
ctx.cwd,
|
|
5983
8229
|
nativeReviewCli,
|
|
5984
8230
|
signal,
|
|
5985
8231
|
candidateViews,
|
|
5986
8232
|
ctx,
|
|
5987
|
-
|
|
8233
|
+
retainedSelections,
|
|
5988
8234
|
pendingReviewConsentRegistry,
|
|
5989
8235
|
pendingReviewConsentFallbackKey,
|
|
5990
|
-
writeReviewConsentLatch,
|
|
5991
8236
|
reviewConsentNow,
|
|
5992
8237
|
reviewConsentScheduleTimer,
|
|
5993
8238
|
);
|
|
8239
|
+
if (details.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED &&
|
|
8240
|
+
details.outcome === "native-approved-acknowledgement-completed" &&
|
|
8241
|
+
details.status === "closed" && details.authority === "burned" &&
|
|
8242
|
+
typeof details.target_identity === "string") {
|
|
8243
|
+
try {
|
|
8244
|
+
if (reminderSessionActive && acknowledgementEpoch === reminderEpoch && acknowledgementRoot && pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) === sessionKey) {
|
|
8245
|
+
consumeReviewMutation(pi, ctx.sessionManager, acknowledgementRoot, acknowledgementMutation, "acknowledged", details.target_identity);
|
|
8246
|
+
}
|
|
8247
|
+
} catch { /* Bookkeeping cannot hide a confirmed native burn. */ }
|
|
8248
|
+
}
|
|
8249
|
+
if (
|
|
8250
|
+
isHostReviewConsentEligibleOperation(parameters) &&
|
|
8251
|
+
details.outcome === "native-review-consent-required" &&
|
|
8252
|
+
typeof details.consent_binding === "string"
|
|
8253
|
+
) {
|
|
8254
|
+
const resolved = pendingReviewConsentRegistry.resolve(details.consent_binding);
|
|
8255
|
+
const pending = resolved?.pending;
|
|
8256
|
+
const eligiblePending = pending !== undefined && isPiConsentV3(pending.consent)
|
|
8257
|
+
? pending
|
|
8258
|
+
: undefined;
|
|
8259
|
+
const answerPendingConsent = async (answer: "granted" | "declined") => executeReviewControllerOperation(
|
|
8260
|
+
{
|
|
8261
|
+
operation: REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT,
|
|
8262
|
+
input: JSON.stringify({ consentBinding: eligiblePending!.id, answer }),
|
|
8263
|
+
workspaceRoot: eligiblePending!.authorityCwd,
|
|
8264
|
+
},
|
|
8265
|
+
ctx.cwd,
|
|
8266
|
+
nativeReviewCli,
|
|
8267
|
+
signal,
|
|
8268
|
+
candidateViews,
|
|
8269
|
+
ctx,
|
|
8270
|
+
retainedSelections,
|
|
8271
|
+
pendingReviewConsentRegistry,
|
|
8272
|
+
pendingReviewConsentFallbackKey,
|
|
8273
|
+
reviewConsentNow,
|
|
8274
|
+
reviewConsentScheduleTimer,
|
|
8275
|
+
);
|
|
8276
|
+
let permissionWorkspaceRoot: string | undefined;
|
|
8277
|
+
try {
|
|
8278
|
+
const parsed = parseReviewControllerParameters(parameters);
|
|
8279
|
+
permissionWorkspaceRoot = resolveReviewControllerWorkspaceRoot(parsed.workspaceRoot, ctx.cwd, candidateViews, parsed.lineageId);
|
|
8280
|
+
} catch {
|
|
8281
|
+
// The successful native operation above remains authoritative; an
|
|
8282
|
+
// unresolvable local binding simply cannot consume host permission.
|
|
8283
|
+
}
|
|
8284
|
+
const initialIdentity = permissionWorkspaceRoot === undefined
|
|
8285
|
+
? undefined
|
|
8286
|
+
: await capturePermissionIdentity(ctx, permissionWorkspaceRoot);
|
|
8287
|
+
if (eligiblePending !== undefined && initialIdentity === undefined) {
|
|
8288
|
+
// A package child has no local standing grant. It may ask its
|
|
8289
|
+
// inherited parent only for the canonical repository identity of
|
|
8290
|
+
// this exact pending target, then replay this local binding once.
|
|
8291
|
+
const repositoryIdentity = permissionWorkspaceRoot === undefined
|
|
8292
|
+
? undefined
|
|
8293
|
+
: await resolveCanonicalGitRepositoryIdentity(permissionWorkspaceRoot);
|
|
8294
|
+
if (repositoryIdentity !== undefined && await childStandingReviewPermission?.requestAuthorization(repositoryIdentity) === true) details = await answerPendingConsent("granted");
|
|
8295
|
+
} else if (eligiblePending !== undefined && initialIdentity !== undefined) {
|
|
8296
|
+
const initialEpoch = reviewSessionPermissionEpoch(initialIdentity);
|
|
8297
|
+
const permissionAlreadyActive = hasReviewSessionPermission(initialIdentity);
|
|
8298
|
+
const selection = initialEpoch === undefined
|
|
8299
|
+
? undefined
|
|
8300
|
+
: permissionAlreadyActive
|
|
8301
|
+
? { kind: "host-session" as const }
|
|
8302
|
+
: await presentReviewConsentUi(ctx, eligiblePending.consent);
|
|
8303
|
+
if (selection !== undefined) {
|
|
8304
|
+
const confirmedIdentity = await capturePermissionIdentity(ctx, permissionWorkspaceRoot);
|
|
8305
|
+
if (initialEpoch !== undefined && confirmedIdentity !== undefined && sameReviewSessionIdentity(initialIdentity, confirmedIdentity) && reviewSessionPermissionEpoch(confirmedIdentity) === initialEpoch) {
|
|
8306
|
+
const answer = selection.kind === "provider" ? selection.answer : "granted";
|
|
8307
|
+
details = await answerPendingConsent(answer);
|
|
8308
|
+
if (!permissionAlreadyActive && selection.kind === "host-session" && completedGrantedReviewConsent(details)) {
|
|
8309
|
+
if (grantReviewSessionPermission(confirmedIdentity, initialEpoch)) {
|
|
8310
|
+
setReviewSessionPermissionStatus(ctx, true);
|
|
8311
|
+
try { ctx.ui.notify("Reviews are allowed for this Pi session and this Git repository.", "info"); } catch { /* Nonblocking indication only. */ }
|
|
8312
|
+
} else {
|
|
8313
|
+
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. */ }
|
|
8314
|
+
}
|
|
8315
|
+
}
|
|
8316
|
+
}
|
|
8317
|
+
}
|
|
8318
|
+
}
|
|
8319
|
+
}
|
|
5994
8320
|
return {
|
|
5995
8321
|
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
5996
8322
|
details,
|
|
@@ -5999,10 +8325,16 @@ function createGentleAiExtensionForTesting(
|
|
|
5999
8325
|
});
|
|
6000
8326
|
|
|
6001
8327
|
function runSddPreflight(ctx: ExtensionContext, promptFields: readonly SddPreflightField[] = []): Promise<SddPreflightPreferences> {
|
|
6002
|
-
return ensureSddPreflight(ctx, { pi, installAssets: (cwd) =>
|
|
8328
|
+
return ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installPackageAssets(cwd, true, ["sdd"]), applyModelConfig: async () => applySavedModelConfig(ctx) }, { promptFields });
|
|
6003
8329
|
}
|
|
6004
8330
|
|
|
6005
|
-
pi.on("session_start", async (
|
|
8331
|
+
pi.on("session_start", async (event, ctx) => {
|
|
8332
|
+
reminderSessionActive = true;
|
|
8333
|
+
reminderEpoch += 1;
|
|
8334
|
+
try { candidateViews?.sweepOrphans(ctx.cwd); } catch { /* Ownership sweeping must not block startup. */ }
|
|
8335
|
+
const reason = (event as { reason?: unknown }).reason;
|
|
8336
|
+
if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx);
|
|
8337
|
+
await refreshReviewSessionPermissionStatus(ctx);
|
|
6006
8338
|
// Loud, every session: an active dev-binary override means this session
|
|
6007
8339
|
// runs an unpinned gentle-ai. Announce which one before anything else.
|
|
6008
8340
|
try {
|
|
@@ -6013,7 +8345,7 @@ function createGentleAiExtensionForTesting(
|
|
|
6013
8345
|
if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
6014
8346
|
}
|
|
6015
8347
|
try {
|
|
6016
|
-
const installResult =
|
|
8348
|
+
const installResult = installPackageAssets(ctx.cwd, true, ["delegation", "review"]);
|
|
6017
8349
|
migrateLegacyProjectModelOverrides(ctx.cwd);
|
|
6018
8350
|
const modelResult = await applySavedModelConfig(ctx);
|
|
6019
8351
|
if (ctx.hasUI && modelResult.invalidPath) {
|
|
@@ -6025,7 +8357,7 @@ function createGentleAiExtensionForTesting(
|
|
|
6025
8357
|
}
|
|
6026
8358
|
if (ctx.hasUI && modelResult.updated > 0) {
|
|
6027
8359
|
ctx.ui.notify(
|
|
6028
|
-
`el Gentleman applied
|
|
8360
|
+
`el Gentleman applied saved model config to ${modelResult.updated} agent(s). Global delegation/review assets ready: ${installResult.agents} new agent(s), ${installResult.chains} new chain(s), ${installResult.support} new support file(s).`,
|
|
6029
8361
|
"info",
|
|
6030
8362
|
);
|
|
6031
8363
|
}
|
|
@@ -6039,18 +8371,13 @@ function createGentleAiExtensionForTesting(
|
|
|
6039
8371
|
);
|
|
6040
8372
|
}
|
|
6041
8373
|
}
|
|
6042
|
-
//
|
|
6043
|
-
//
|
|
6044
|
-
// later. Best-effort and silent: it never notifies and never lets a
|
|
6045
|
-
// STATUS failure fail session start.
|
|
8374
|
+
// Keep the startup transport negotiation, but do not treat its target as
|
|
8375
|
+
// an ownership baseline: reload may have outstanding durable receipts.
|
|
6046
8376
|
try {
|
|
6047
8377
|
const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
6048
|
-
|
|
6049
|
-
if (status?.targetIdentity !== undefined) {
|
|
6050
|
-
processAgentEndSessionBaseline.set(sessionKey, status.targetIdentity);
|
|
6051
|
-
}
|
|
8378
|
+
await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
|
|
6052
8379
|
} catch {
|
|
6053
|
-
//
|
|
8380
|
+
// Startup negotiation is best-effort only; never surface or throw.
|
|
6054
8381
|
}
|
|
6055
8382
|
});
|
|
6056
8383
|
|
|
@@ -6058,11 +8385,17 @@ function createGentleAiExtensionForTesting(
|
|
|
6058
8385
|
if (typeof event.text !== "string" || !isSddPreflightTrigger(event.text)) {
|
|
6059
8386
|
return { action: "continue" };
|
|
6060
8387
|
}
|
|
6061
|
-
await runSddPreflight(ctx);
|
|
8388
|
+
try { await runSddPreflight(ctx); }
|
|
8389
|
+
catch (error) {
|
|
8390
|
+
if (ctx.hasUI) ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
8391
|
+
return { action: "handled" };
|
|
8392
|
+
}
|
|
6062
8393
|
return { action: "continue" };
|
|
6063
8394
|
});
|
|
6064
8395
|
|
|
8396
|
+
let nativeSddStartupBlock: string | undefined;
|
|
6065
8397
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
8398
|
+
nativeSddStartupBlock = undefined;
|
|
6066
8399
|
const isSddAgent = isSddAgentStartEvent(event);
|
|
6067
8400
|
const isNamedAgent = isNamedAgentStartEvent(event);
|
|
6068
8401
|
const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
@@ -6071,26 +8404,92 @@ function createGentleAiExtensionForTesting(
|
|
|
6071
8404
|
} else {
|
|
6072
8405
|
processAgentEndSubagentDepth.set(subagentDepthKey, 0);
|
|
6073
8406
|
}
|
|
6074
|
-
|
|
6075
|
-
|
|
8407
|
+
// gentle-pi#677: nudge gentle-ai's own telemetry trigger for a primary
|
|
8408
|
+
// session only, reusing the exact isNamedAgent/isSddAgent predicate that
|
|
8409
|
+
// decides the orchestrator prompt below. At most one attempt per
|
|
8410
|
+
// process regardless of how many primary-session before_agent_start
|
|
8411
|
+
// events this process observes; a missing/old binary or a spawn error
|
|
8412
|
+
// must never affect activation, so every failure is swallowed silently.
|
|
8413
|
+
if (!isNamedAgent && !isSddAgent && !processTelemetryTriggerAttempted) {
|
|
8414
|
+
processTelemetryTriggerAttempted = true;
|
|
8415
|
+
try {
|
|
8416
|
+
const executable = resolveTelemetryTriggerBinary();
|
|
8417
|
+
spawnTelemetryTrigger({
|
|
8418
|
+
executable,
|
|
8419
|
+
cwd: ctx.cwd,
|
|
8420
|
+
env: dependencies.processEnv ?? process.env,
|
|
8421
|
+
spawn: dependencies.telemetryTriggerSpawn,
|
|
8422
|
+
});
|
|
8423
|
+
} catch {
|
|
8424
|
+
// Best-effort only; never surfaced and never affects activation.
|
|
8425
|
+
}
|
|
8426
|
+
}
|
|
8427
|
+
try {
|
|
8428
|
+
if (isSddAgent && !getSddPreflightPreferences(ctx) && ctx.mode !== "rpc") {
|
|
8429
|
+
await runSddPreflight(ctx);
|
|
8430
|
+
}
|
|
8431
|
+
} catch (error) {
|
|
8432
|
+
// Pi logs thrown before_agent_start errors and continues. Return an
|
|
8433
|
+
// unresolved gate instead of silently losing the preflight instructions.
|
|
8434
|
+
return { systemPrompt: `${event.systemPrompt}\n\nSDD preflight unresolved: ${error instanceof Error ? error.message : String(error)}\nSTOP: Do not initialize the project, launch phases, write artifacts, or infer consent. Request session preflight confirmation before continuing.` };
|
|
6076
8435
|
}
|
|
6077
8436
|
const prefs = getSddPreflightPreferences(ctx);
|
|
8437
|
+
// RPC children never resolve or persist defaults. The parent dispatch gate
|
|
8438
|
+
// transports the rendered block in the existing task context, and Gentle
|
|
8439
|
+
// Agents refuses a missing/malformed payload before spawning the child.
|
|
6078
8440
|
const sddPrompt =
|
|
6079
8441
|
prefs && (!isNamedAgent || isSddAgent)
|
|
6080
8442
|
? `\n\n${renderSddPreflightPrompt(prefs)}`
|
|
6081
8443
|
: "";
|
|
6082
8444
|
const phase = isSddAgent ? sddPhaseFromAgentStartEvent(event) : undefined;
|
|
8445
|
+
const launchSddChange = readSddChangeFlag(pi);
|
|
8446
|
+
if (launchSddChange !== undefined && !phase) nativeSddStartupBlock = "Receiving agent has no recognized SDD phase";
|
|
6083
8447
|
const nativeStatusPrompt = phase
|
|
6084
|
-
?
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
|
|
6090
|
-
|
|
8448
|
+
? await (async () => {
|
|
8449
|
+
try {
|
|
8450
|
+
if (launchSddChange === undefined) {
|
|
8451
|
+
if (phase === "sync") return `\n\n${renderNativeSddPhasePrompt(resolveStartupControllerSddStatus(ctx.cwd, undefined, true, prefs?.artifactStore), phase)}`;
|
|
8452
|
+
const { status } = await readCommandSddStatus("", ctx);
|
|
8453
|
+
if (status.changeName === null || !status.phaseInstructions || status.nextRecommended !== phase) throw new Error(`Native SDD discovery cannot run ${phase}.`);
|
|
8454
|
+
return `\n\n${renderNativeSddPhasePrompt(status, phase)}`;
|
|
8455
|
+
}
|
|
8456
|
+
const agentName = `sdd-${phase}`;
|
|
8457
|
+
const startup = await resolveSelectedNativeSddChangeStartup(
|
|
8458
|
+
launchSddChange,
|
|
8459
|
+
ctx.cwd,
|
|
8460
|
+
agentName,
|
|
8461
|
+
nativeReviewCli,
|
|
8462
|
+
(options) => resolveControllerSddStatus(
|
|
8463
|
+
options.cwd,
|
|
8464
|
+
options.changeName,
|
|
8465
|
+
true,
|
|
8466
|
+
prefs?.artifactStore,
|
|
8467
|
+
),
|
|
8468
|
+
);
|
|
8469
|
+
return `\n\n${renderNativeSddPhasePrompt(startup.status, phase)}`;
|
|
8470
|
+
} catch (error) {
|
|
8471
|
+
nativeSddStartupBlock = error instanceof Error ? error.message : String(error);
|
|
8472
|
+
return `\n\n## Native SDD Status Engine\nSDD selection blocked: ${nativeSddStartupBlock}\nDo not run phase work; return this blocker to the parent.`;
|
|
8473
|
+
}
|
|
8474
|
+
})()
|
|
8475
|
+
: launchSddChange === undefined
|
|
8476
|
+
? ""
|
|
8477
|
+
: "\n\n## Native SDD Status Engine\nSDD selection blocked: the receiving agent has no recognized SDD phase.\nDo not run phase work; return this blocker to the parent.";
|
|
8478
|
+
// gentle-pi#661: the RDD status line (and the rest of the gentle prompt)
|
|
8479
|
+
// is built only for the primary session, mirrored on the
|
|
8480
|
+
// reviewContractPrompt condition below -- named/SDD agents never reach
|
|
8481
|
+
// this branch, so no line is resolved or computed for them.
|
|
8482
|
+
// resolveRddStatusLine never throws and never hangs past
|
|
8483
|
+
// RDD_STATUS_TIMEOUT_MS: an absent/timed-out/aborted/failing native
|
|
8484
|
+
// binary renders the fail-closed "unknown" line instead.
|
|
6091
8485
|
const gentlePrompt = isNamedAgent || isSddAgent
|
|
6092
8486
|
? ""
|
|
6093
|
-
: `\n\n${buildGentlePrompt(
|
|
8487
|
+
: `\n\n${buildGentlePrompt(
|
|
8488
|
+
readPersonaMode(ctx.cwd),
|
|
8489
|
+
ctx.cwd,
|
|
8490
|
+
readActiveToolNames(pi),
|
|
8491
|
+
await resolveRddStatusLine(nativeReviewCli, ctx.cwd, AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS), undefined, ctx),
|
|
8492
|
+
)}`;
|
|
6094
8493
|
// gentle-pi#560 / gentle-ai#4056, #4057: inject the mirrored provider
|
|
6095
8494
|
// contract bundle's review execution contract for the primary session
|
|
6096
8495
|
// only, and only when a native review CLI is actually present.
|
|
@@ -6102,7 +8501,7 @@ function createGentleAiExtensionForTesting(
|
|
|
6102
8501
|
})()
|
|
6103
8502
|
: "";
|
|
6104
8503
|
return {
|
|
6105
|
-
systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}`,
|
|
8504
|
+
systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}${!isNamedAgent && !isSddAgent ? `\n\n${renderResearchCapabilities(resolveResearchCapabilities(pi))}` : ""}`,
|
|
6106
8505
|
};
|
|
6107
8506
|
});
|
|
6108
8507
|
|
|
@@ -6110,33 +8509,30 @@ function createGentleAiExtensionForTesting(
|
|
|
6110
8509
|
// an authorized implementation and report completion without ever running
|
|
6111
8510
|
// the review STATUS preflight or offering the consent question. This
|
|
6112
8511
|
// handler is read-only and idempotent: it never runs START, never answers
|
|
6113
|
-
// consent,
|
|
6114
|
-
//
|
|
6115
|
-
// gentle-pi#568: a candidate matching the baseline `session_start`
|
|
6116
|
-
// recorded predates this session's own work and is skipped rather than
|
|
6117
|
-
// nudged, so a worktree already dirty from the user's own edits does not
|
|
6118
|
-
// draw a reminder about work this session never produced.
|
|
8512
|
+
// consent, or chooses a partial candidate. Durable own-mutation receipts
|
|
8513
|
+
// gate STATUS and consume only the generation captured before that await.
|
|
6119
8514
|
pi.on("agent_end", async (_event, ctx) => {
|
|
6120
8515
|
if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return;
|
|
6121
|
-
if (ctx.hasUI !== true) return;
|
|
8516
|
+
if (ctx.hasUI !== true || !reminderSessionActive) return;
|
|
6122
8517
|
const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
6123
8518
|
const subagentDepth = processAgentEndSubagentDepth.get(sessionKey) ?? 0;
|
|
6124
8519
|
if (subagentDepth > 0) {
|
|
6125
8520
|
processAgentEndSubagentDepth.set(sessionKey, subagentDepth - 1);
|
|
6126
8521
|
return;
|
|
6127
8522
|
}
|
|
8523
|
+
const root = resolveSessionWorktree(ctx.cwd, ctx.cwd)?.root;
|
|
8524
|
+
if (!root) return;
|
|
8525
|
+
let mutation: string | undefined;
|
|
8526
|
+
try { mutation = pendingReviewMutation(ctx.sessionManager, root); }
|
|
8527
|
+
catch { return; }
|
|
8528
|
+
if (!mutation) return;
|
|
8529
|
+
const epoch = reminderEpoch;
|
|
6128
8530
|
const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
|
|
6129
|
-
if (status === undefined) return;
|
|
8531
|
+
if (status === undefined || !reminderSessionActive || epoch !== reminderEpoch || pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) !== sessionKey) return;
|
|
8532
|
+
// Another concurrent end or ACK may already have consumed this prefix.
|
|
8533
|
+
if (!pendingReviewMutation(ctx.sessionManager, root, mutation)) return;
|
|
6130
8534
|
if (status.nextTransition?.kind !== "execute" || status.nextTransition.execute.operation !== "review.start") return;
|
|
6131
8535
|
const targetIdentity = status.targetIdentity;
|
|
6132
|
-
if (processAgentEndSessionBaseline.get(sessionKey) === targetIdentity) return;
|
|
6133
|
-
let nudged = processAgentEndPreflightNudgedTargets.get(sessionKey);
|
|
6134
|
-
if (nudged === undefined) {
|
|
6135
|
-
nudged = new Set<string>();
|
|
6136
|
-
processAgentEndPreflightNudgedTargets.set(sessionKey, nudged);
|
|
6137
|
-
}
|
|
6138
|
-
if (nudged.has(targetIdentity)) return;
|
|
6139
|
-
nudged.add(targetIdentity);
|
|
6140
8536
|
pi.sendMessage(
|
|
6141
8537
|
{
|
|
6142
8538
|
customType: "gentle-pi.review-preflight",
|
|
@@ -6145,15 +8541,70 @@ function createGentleAiExtensionForTesting(
|
|
|
6145
8541
|
},
|
|
6146
8542
|
{ triggerTurn: true, deliverAs: "followUp" },
|
|
6147
8543
|
);
|
|
8544
|
+
consumeReviewMutation(pi, ctx.sessionManager, root, mutation, "nudged", targetIdentity);
|
|
8545
|
+
});
|
|
8546
|
+
|
|
8547
|
+
pi.on("tool_result", (event, ctx) => {
|
|
8548
|
+
if (!reminderSessionActive || event.isError !== false || (event.toolName !== "write" && event.toolName !== "edit")) return;
|
|
8549
|
+
if (!isRecord(event.input) || typeof event.input.path !== "string" || !event.input.path.trim()) return;
|
|
8550
|
+
try {
|
|
8551
|
+
const root = resolveSessionWorktree(event.input.path, ctx.cwd)?.root;
|
|
8552
|
+
if (root) recordReviewMutation(pi, ctx.sessionManager, root, { source: "direct", toolName: event.toolName, toolCallId: event.toolCallId });
|
|
8553
|
+
} catch { /* Receipt persistence must not change a successful tool result. */ }
|
|
6148
8554
|
});
|
|
6149
8555
|
|
|
6150
8556
|
pi.on("tool_call", async (event, ctx) => {
|
|
8557
|
+
if (nativeSddStartupBlock && event.toolName !== "subagent_parent_message") return { block: true, reason: `SDD selection blocked: ${nativeSddStartupBlock}` };
|
|
6151
8558
|
const sensitivePathDenied = evaluateSensitivePathTool(
|
|
6152
8559
|
event.toolName,
|
|
6153
8560
|
event.input,
|
|
6154
8561
|
);
|
|
6155
8562
|
if (sensitivePathDenied) return sensitivePathDenied;
|
|
6156
8563
|
if (event.toolName === "subagent_run") {
|
|
8564
|
+
const sddAgent = sddDispatchAgentName(event.input);
|
|
8565
|
+
if (sddAgent === "invalid") {
|
|
8566
|
+
return { block: true, reason: "SDD dispatch requires exactly one shipped SDD agent name." };
|
|
8567
|
+
}
|
|
8568
|
+
if (sddAgent !== undefined) {
|
|
8569
|
+
// An RPC child is a delegated actor, never an authority originator. It
|
|
8570
|
+
// must receive the already-confirmed block from its interactive parent.
|
|
8571
|
+
if (ctx.mode === "rpc") {
|
|
8572
|
+
return { block: true, reason: "SDD dispatch refused: an RPC child cannot originate or persist SDD preflight defaults." };
|
|
8573
|
+
}
|
|
8574
|
+
try {
|
|
8575
|
+
const prefs = getSddPreflightPreferences(ctx) ?? await runSddPreflight(ctx);
|
|
8576
|
+
const rendered = renderSddPreflightPrompt(prefs);
|
|
8577
|
+
if (!prefs.prompted && ctx.hasUI) {
|
|
8578
|
+
return { block: true, reason: "SDD dispatch refused: interactive parent preflight lacks current-session confirmation." };
|
|
8579
|
+
}
|
|
8580
|
+
if (!isRecord(event.input)) {
|
|
8581
|
+
return { block: true, reason: "SDD dispatch refused: child input is malformed." };
|
|
8582
|
+
}
|
|
8583
|
+
if (event.input.context !== undefined && typeof event.input.context !== "string") {
|
|
8584
|
+
return { block: true, reason: "SDD dispatch refused: child context must be text." };
|
|
8585
|
+
}
|
|
8586
|
+
// The existing context payload is the sole parent-to-child transport.
|
|
8587
|
+
// Refuse a caller-authored lookalike so the child receives one exact,
|
|
8588
|
+
// parent-rendered authority block rather than an ambiguous mixture.
|
|
8589
|
+
const context = typeof event.input.context === "string" ? event.input.context.trim() : "";
|
|
8590
|
+
if (/^## SDD Session Preflight[ \t]*$/m.test(context)) {
|
|
8591
|
+
return { block: true, reason: "SDD dispatch refused: child context already contains an untrusted preflight block." };
|
|
8592
|
+
}
|
|
8593
|
+
event.input.context = context.length === 0
|
|
8594
|
+
? rendered
|
|
8595
|
+
: `${rendered}\n\n${context}`;
|
|
8596
|
+
if (!isParentConfirmedSddPreflightContext(event.input.context)) {
|
|
8597
|
+
return { block: true, reason: "SDD dispatch refused: rendered preflight transport is malformed." };
|
|
8598
|
+
}
|
|
8599
|
+
} catch (error) {
|
|
8600
|
+
return {
|
|
8601
|
+
block: true,
|
|
8602
|
+
reason: `SDD dispatch refused before child launch: ${error instanceof Error ? error.message : String(error)}`,
|
|
8603
|
+
};
|
|
8604
|
+
}
|
|
8605
|
+
}
|
|
8606
|
+
const judgmentDayFixDenied = rejectInvalidJudgmentDayFixDispatch(event.input);
|
|
8607
|
+
if (judgmentDayFixDenied) return judgmentDayFixDenied;
|
|
6157
8608
|
const writerScopeDenied = rejectUnscopedBoundedWriterDispatch(event.input);
|
|
6158
8609
|
if (writerScopeDenied) return writerScopeDenied;
|
|
6159
8610
|
try {
|
|
@@ -6173,39 +8624,50 @@ function createGentleAiExtensionForTesting(
|
|
|
6173
8624
|
return await confirmCommand(event.input.command, ctx, pi.events, herdrLifecycle);
|
|
6174
8625
|
});
|
|
6175
8626
|
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
8627
|
+
for (const owner of ["delegation", "review", "sdd"] as const) {
|
|
8628
|
+
const label = owner === "sdd" ? "SDD" : owner;
|
|
8629
|
+
pi.registerCommand(`gentle:install-${owner}`, {
|
|
8630
|
+
description: `Repair or refresh only global Gentle AI ${label} assets.`,
|
|
8631
|
+
handler: async (args, ctx) => {
|
|
8632
|
+
const force = args.includes("--force");
|
|
8633
|
+
const result = installPackageAssets(ctx.cwd, force, [owner]);
|
|
8634
|
+
ctx.ui.notify(
|
|
8635
|
+
`Global Gentle AI ${label} assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`,
|
|
8636
|
+
"info",
|
|
8637
|
+
);
|
|
8638
|
+
},
|
|
8639
|
+
});
|
|
8640
|
+
}
|
|
6188
8641
|
|
|
6189
8642
|
pi.registerCommand("gentle:sdd-preflight", {
|
|
6190
8643
|
description:
|
|
6191
|
-
"Run or reuse
|
|
6192
|
-
handler: async (
|
|
6193
|
-
|
|
8644
|
+
"Run or reuse session SDD preflight; use --edit to change preferences.",
|
|
8645
|
+
handler: async (args, ctx) => {
|
|
8646
|
+
if (args.trim() !== "" && args.trim() !== "--edit") {
|
|
8647
|
+
ctx.ui.notify("Usage: /gentle:sdd-preflight [--edit]", "warning");
|
|
8648
|
+
return;
|
|
8649
|
+
}
|
|
8650
|
+
try {
|
|
8651
|
+
await runSddPreflight(ctx, args.trim() === "--edit" ? SDD_PREFLIGHT_FIELDS : []);
|
|
8652
|
+
} catch (error) {
|
|
8653
|
+
ctx.ui?.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
8654
|
+
}
|
|
6194
8655
|
},
|
|
6195
8656
|
});
|
|
6196
8657
|
|
|
6197
|
-
const
|
|
8658
|
+
const readCommandSddStatus = async (args: string, ctx: ExtensionContext) => {
|
|
6198
8659
|
const parsed = parseSddStatusCommandArgs(args);
|
|
6199
|
-
const
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
ctx.ui
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
);
|
|
8660
|
+
const request = { changeName: parsed.changeName, workspaceRoot: realpathSync(ctx.cwd) };
|
|
8661
|
+
if (!nativeReviewCli?.sddStatus) throw new Error("Native SDD status capability unavailable; no local fallback.");
|
|
8662
|
+
const status = decodeNativeSddStatusV2(await nativeReviewCli.sddStatus(request), request);
|
|
8663
|
+
return { parsed, request, status };
|
|
8664
|
+
};
|
|
8665
|
+
const showCommandSddStatus = (status: NativeSddStatusV2, json: boolean, ctx: ExtensionContext) => {
|
|
8666
|
+
ctx.ui?.notify(json ? JSON.stringify(status, null, 2) : renderNativeSddPhasePrompt(status), "info");
|
|
8667
|
+
};
|
|
8668
|
+
const handleSddStatusCommand = async (args: string, ctx: ExtensionContext) => {
|
|
8669
|
+
const { parsed, status } = await readCommandSddStatus(args, ctx);
|
|
8670
|
+
showCommandSddStatus(status, parsed.json, ctx);
|
|
6209
8671
|
};
|
|
6210
8672
|
|
|
6211
8673
|
pi.registerCommand("gentle-sdd-status", {
|
|
@@ -6216,17 +8678,34 @@ function createGentleAiExtensionForTesting(
|
|
|
6216
8678
|
});
|
|
6217
8679
|
|
|
6218
8680
|
const handleSddContinueCommand = async (args: string, ctx: ExtensionContext) => {
|
|
6219
|
-
const parsed =
|
|
6220
|
-
const
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
);
|
|
8681
|
+
const { parsed, request, status } = await readCommandSddStatus(args, ctx);
|
|
8682
|
+
const planning = status.planningHome;
|
|
8683
|
+
const changeRoot = status.changeRoot;
|
|
8684
|
+
// Native context is an upper bound, never the human's per-call grant.
|
|
8685
|
+
if (status.changeName === null || !ctx.hasUI || typeof ctx.ui?.confirm !== "function" || !nativeReviewCli?.sddContinue) {
|
|
8686
|
+
showCommandSddStatus(status, parsed.json, ctx);
|
|
8687
|
+
return;
|
|
8688
|
+
}
|
|
8689
|
+
if (typeof planning !== "object" || planning === null || !("path" in planning) || typeof planning.path !== "string" || typeof changeRoot !== "string") throw new Error("Native SDD continuation lacks an exact planning path.");
|
|
8690
|
+
if (!["openspec", "both"].includes(String(status.artifactStore)) || !["repo-local", "workspace-planning"].includes(String(status.actionContext.mode))) throw new Error("Native SDD continuation has unsupported planning context.");
|
|
8691
|
+
const marker = join(changeRoot, ".gentle-ai-instance");
|
|
8692
|
+
const checkMarker = () => {
|
|
8693
|
+
try {
|
|
8694
|
+
if (!lstatSync(marker).isFile() || realpathSync(marker) !== marker) throw new Error("Native SDD marker is not a canonical regular file.");
|
|
8695
|
+
} catch (error) {
|
|
8696
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
8697
|
+
}
|
|
8698
|
+
};
|
|
8699
|
+
checkMarker();
|
|
8700
|
+
if (realpathSync(changeRoot) !== changeRoot || realpathSync(planning.path) !== planning.path || changeRoot !== join(planning.path, "changes", status.changeName) || !isStrictDescendantPath(request.workspaceRoot, marker)) throw new Error("Native SDD continuation workspace or planning path mismatch.");
|
|
8701
|
+
if (await ctx.ui.confirm("Prepare SDD marker?", `Authorize only continuation-time preparation of ${marker}? This grants no source roots and no persistent authority.`) !== true) {
|
|
8702
|
+
showCommandSddStatus(status, parsed.json, ctx);
|
|
8703
|
+
return;
|
|
8704
|
+
}
|
|
8705
|
+
if (realpathSync(ctx.cwd) !== request.workspaceRoot || realpathSync(changeRoot) !== changeRoot) throw new Error("Native SDD continuation workspace changed during confirmation.");
|
|
8706
|
+
checkMarker();
|
|
8707
|
+
const selected = { ...request, changeName: status.changeName };
|
|
8708
|
+
showCommandSddStatus(decodeNativeSddStatusV2(await nativeReviewCli.sddContinue(selected), selected), parsed.json, ctx);
|
|
6230
8709
|
};
|
|
6231
8710
|
|
|
6232
8711
|
pi.registerCommand("gentle-sdd-continue", {
|
|
@@ -6243,6 +8722,13 @@ function createGentleAiExtensionForTesting(
|
|
|
6243
8722
|
},
|
|
6244
8723
|
});
|
|
6245
8724
|
|
|
8725
|
+
pi.registerCommand("gentle:profiles", {
|
|
8726
|
+
description: "Create, switch, and manage global agent-model profiles for el Gentleman.",
|
|
8727
|
+
handler: async (_args, ctx) => {
|
|
8728
|
+
await handleProfilesCommand(ctx);
|
|
8729
|
+
},
|
|
8730
|
+
});
|
|
8731
|
+
|
|
6246
8732
|
pi.registerCommand("gentle:persona", {
|
|
6247
8733
|
description: "Switch el Gentleman persona between gentleman and neutral.",
|
|
6248
8734
|
handler: async (_args, ctx) => {
|
|
@@ -6313,29 +8799,19 @@ function createGentleAiExtensionForTesting(
|
|
|
6313
8799
|
pi.registerCommand("gentle:doctor", {
|
|
6314
8800
|
description: "Run read-only Gentle AI diagnostics for this Pi workspace.",
|
|
6315
8801
|
handler: async (_args, ctx) => {
|
|
6316
|
-
const
|
|
6317
|
-
join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
|
|
6318
|
-
);
|
|
6319
|
-
const chainsInstalled = existsSync(
|
|
6320
|
-
join(gentlePiAgentHome(), "chains", "sdd-full.chain.md"),
|
|
6321
|
-
);
|
|
8802
|
+
const assetLines = packageAssetDiagnosticLines(ctx.cwd);
|
|
6322
8803
|
const openspecConfigured = existsSync(
|
|
6323
8804
|
join(ctx.cwd, "openspec", "config.yaml"),
|
|
6324
8805
|
);
|
|
6325
8806
|
const skillRegistryPresent = existsSync(
|
|
6326
8807
|
join(ctx.cwd, ".atl", "skill-registry.md"),
|
|
6327
8808
|
);
|
|
6328
|
-
const staleSddAssets = sddGlobalAssetDriftCount();
|
|
6329
|
-
const localSddAgentOverrides = sddLocalAgentOverrideCount(ctx.cwd);
|
|
6330
8809
|
const modelConfig = await readSavedModelConfigAsync(ctx.cwd);
|
|
6331
8810
|
const engramActive = hasWritableEngramTool(pi);
|
|
6332
8811
|
const devBinary = await describeDevBinaryOverride();
|
|
6333
8812
|
const lines = [
|
|
6334
8813
|
"el Gentleman doctor",
|
|
6335
|
-
|
|
6336
|
-
`${chainsInstalled ? "pass" : "fail"}: Global SDD chains ${chainsInstalled ? "installed" : "missing"}`,
|
|
6337
|
-
`${staleSddAssets === 0 ? "pass" : "warn"}: Global SDD asset drift ${staleSddAssets} file(s)`,
|
|
6338
|
-
`${localSddAgentOverrides === 0 ? "pass" : "warn"}: Project-local SDD agent overrides ${localSddAgentOverrides} file(s)`,
|
|
8814
|
+
...assetLines,
|
|
6339
8815
|
`${openspecConfigured ? "pass" : "warn"}: OpenSpec config ${openspecConfigured ? "present" : "missing"}`,
|
|
6340
8816
|
`${skillRegistryPresent ? "pass" : "warn"}: Skill registry ${skillRegistryPresent ? "present" : "missing"}`,
|
|
6341
8817
|
`${modelConfig.status === "invalid" ? "fail" : "pass"}: Global model config ${modelConfig.status}`,
|
|
@@ -6344,24 +8820,42 @@ function createGentleAiExtensionForTesting(
|
|
|
6344
8820
|
...(devBinary.state === "active" ? [`warn: ${devBinary.line}`] : []),
|
|
6345
8821
|
...(devBinary.state === "invalid" ? [`fail: ${devBinary.line}`, "remedy: fix the dev binary override or clear it with /gentle:dev-binary off (or unset GENTLE_PI_GENTLE_AI_DEV_BINARY)"] : []),
|
|
6346
8822
|
];
|
|
6347
|
-
if (!agentsInstalled || !chainsInstalled) {
|
|
6348
|
-
lines.push("remedy: run /gentle:install-sdd --force to refresh global SDD assets intentionally");
|
|
6349
|
-
}
|
|
6350
8823
|
if (modelConfig.status === "invalid") {
|
|
6351
8824
|
lines.push(`remedy: fix or remove ${modelConfig.path}`);
|
|
6352
8825
|
}
|
|
6353
|
-
if (localSddAgentOverrides > 0) {
|
|
6354
|
-
lines.push("remedy: remove project-local SDD agent overrides unless intentionally debugging package assets");
|
|
6355
|
-
}
|
|
6356
8826
|
ctx.ui.notify(
|
|
6357
8827
|
lines.join("\n"),
|
|
6358
|
-
lines.some((line) => line.startsWith("fail:")) ? "warning" : "info",
|
|
8828
|
+
lines.some((line) => line.startsWith("fail:")) || assetLines.some((line) => line.startsWith("warn:")) ? "warning" : "info",
|
|
6359
8829
|
);
|
|
6360
8830
|
},
|
|
6361
8831
|
});
|
|
6362
8832
|
|
|
8833
|
+
pi.registerCommand("gentle:review-session-permission", {
|
|
8834
|
+
description: "Show or revoke the process-memory review permission for this exact Pi session and Git repository (status|revoke).",
|
|
8835
|
+
handler: async (args, ctx) => {
|
|
8836
|
+
const subAction = args.trim().length === 0 ? "status" : args.trim();
|
|
8837
|
+
if (subAction !== "status" && subAction !== "revoke") {
|
|
8838
|
+
ctx.ui.notify(`Unknown /gentle:review-session-permission sub-action "${subAction}". Use status or revoke.`, "warning");
|
|
8839
|
+
return;
|
|
8840
|
+
}
|
|
8841
|
+
if (subAction === "revoke") {
|
|
8842
|
+
const revoked = await revokeCurrentRepositoryReviewSessionPermission(ctx);
|
|
8843
|
+
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");
|
|
8844
|
+
return;
|
|
8845
|
+
}
|
|
8846
|
+
const identity = await refreshReviewSessionPermissionStatus(ctx);
|
|
8847
|
+
if (identity === undefined) {
|
|
8848
|
+
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");
|
|
8849
|
+
return;
|
|
8850
|
+
}
|
|
8851
|
+
ctx.ui.notify(hasReviewSessionPermission(identity)
|
|
8852
|
+
? "Reviews are allowed for this Pi session and Git repository. Use /gentle:review-session-permission revoke to ask again."
|
|
8853
|
+
: "Reviews are not pre-authorized for this Pi session; each medium- or high-risk candidate asks normally.", "info");
|
|
8854
|
+
},
|
|
8855
|
+
});
|
|
8856
|
+
|
|
6363
8857
|
pi.registerCommand("gentle:review-mode", {
|
|
6364
|
-
description: "Show or set the Gentle AI
|
|
8858
|
+
description: "Show or set the Gentle AI receipt-driven development kill switch (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.",
|
|
6365
8859
|
handler: async (args, ctx) => {
|
|
6366
8860
|
const subAction = args.trim().length === 0 ? NATIVE_REVIEW_MODE_OPERATION.STATUS : args.trim();
|
|
6367
8861
|
if (subAction !== NATIVE_REVIEW_MODE_OPERATION.STATUS && subAction !== NATIVE_REVIEW_MODE_OPERATION.ENABLE && subAction !== NATIVE_REVIEW_MODE_OPERATION.DISABLE) {
|
|
@@ -6405,6 +8899,56 @@ function createGentleAiExtensionForTesting(
|
|
|
6405
8899
|
},
|
|
6406
8900
|
});
|
|
6407
8901
|
|
|
8902
|
+
// gentle-pi#677: gentle-ai owns telemetry end to end (status, the opt-out
|
|
8903
|
+
// switches, and rate limiting); this command only runs the corresponding
|
|
8904
|
+
// `gentle-ai telemetry <op> --json` in the foreground and relays its
|
|
8905
|
+
// output, so a Pi user never has to leave Pi to check or change it.
|
|
8906
|
+
pi.registerCommand("gentle:telemetry", {
|
|
8907
|
+
description: "Show or change the local Gentle AI telemetry trigger (status|enable|disable|preview); gentle-ai owns the data and the opt-out.",
|
|
8908
|
+
handler: async (args, ctx) => {
|
|
8909
|
+
const subAction = args.trim().length === 0 ? "status" : args.trim();
|
|
8910
|
+
if (subAction !== "status" && subAction !== "enable" && subAction !== "disable" && subAction !== "preview") {
|
|
8911
|
+
ctx.ui.notify(`Unknown /gentle:telemetry sub-action "${subAction}". Use status, enable, disable, or preview.`, "warning");
|
|
8912
|
+
return;
|
|
8913
|
+
}
|
|
8914
|
+
let executable: string;
|
|
8915
|
+
try {
|
|
8916
|
+
executable = resolveTelemetryTriggerBinary();
|
|
8917
|
+
} catch (error) {
|
|
8918
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
8919
|
+
return;
|
|
8920
|
+
}
|
|
8921
|
+
let result: ExecFileResult;
|
|
8922
|
+
try {
|
|
8923
|
+
result = await telemetryExecFileAdapter({
|
|
8924
|
+
file: executable,
|
|
8925
|
+
arguments: ["telemetry", subAction, "--json"],
|
|
8926
|
+
cwd: ctx.cwd,
|
|
8927
|
+
timeoutMs: 5_000,
|
|
8928
|
+
maxBufferBytes: 1024 * 1024,
|
|
8929
|
+
});
|
|
8930
|
+
} catch (error) {
|
|
8931
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
8932
|
+
return;
|
|
8933
|
+
}
|
|
8934
|
+
if (result.exitCode !== 0) {
|
|
8935
|
+
ctx.ui.notify(`gentle-ai telemetry ${subAction} failed (exit ${result.exitCode}): ${(result.stderr || result.stdout || "no output").trim()}`, "error");
|
|
8936
|
+
return;
|
|
8937
|
+
}
|
|
8938
|
+
let relayed: string;
|
|
8939
|
+
try {
|
|
8940
|
+
relayed = JSON.stringify(JSON.parse(result.stdout), null, 2);
|
|
8941
|
+
} catch {
|
|
8942
|
+
relayed = result.stdout.trim();
|
|
8943
|
+
}
|
|
8944
|
+
if (subAction === "disable") {
|
|
8945
|
+
ctx.ui.notify("Gentle AI telemetry disabled.", "info");
|
|
8946
|
+
return;
|
|
8947
|
+
}
|
|
8948
|
+
ctx.ui.notify(relayed, "info");
|
|
8949
|
+
},
|
|
8950
|
+
});
|
|
8951
|
+
|
|
6408
8952
|
// Mirrors gentle:review-mode: a user-owned switch, never an automated one.
|
|
6409
8953
|
// It matters more here than there, because this policy governs whether
|
|
6410
8954
|
// background subagents may be launched at all, so nothing in Pi may write
|
|
@@ -6433,41 +8977,27 @@ function createGentleAiExtensionForTesting(
|
|
|
6433
8977
|
pi.registerCommand("gentle:status", {
|
|
6434
8978
|
description: "Show Gentle AI package status for this project.",
|
|
6435
8979
|
handler: async (_args, ctx) => {
|
|
6436
|
-
const
|
|
6437
|
-
join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
|
|
6438
|
-
);
|
|
6439
|
-
const chainsInstalled = existsSync(
|
|
6440
|
-
join(gentlePiAgentHome(), "chains", "sdd-full.chain.md"),
|
|
6441
|
-
);
|
|
8980
|
+
const assetLines = packageAssetDiagnosticLines(ctx.cwd);
|
|
6442
8981
|
const openspecConfigured = existsSync(
|
|
6443
8982
|
join(ctx.cwd, "openspec", "config.yaml"),
|
|
6444
8983
|
);
|
|
6445
|
-
const
|
|
6446
|
-
|
|
6447
|
-
|
|
8984
|
+
const savedConfig = await readModelRoutingAuthorityAsync(
|
|
8985
|
+
modelConfigPath(ctx.cwd),
|
|
8986
|
+
legacyProjectModelConfigPath(ctx.cwd),
|
|
8987
|
+
);
|
|
6448
8988
|
const devBinary = await describeDevBinaryOverride();
|
|
6449
8989
|
ctx.ui.notify(
|
|
6450
8990
|
[
|
|
6451
8991
|
"el Gentleman package is active.",
|
|
6452
8992
|
...(devBinary.state === "inactive" ? [] : [devBinary.line]),
|
|
6453
8993
|
`Persona: ${readPersonaMode(ctx.cwd)}`,
|
|
6454
|
-
|
|
6455
|
-
`Global SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
|
|
6456
|
-
`Global SDD assets stale: ${staleSddAssets} file(s)${
|
|
6457
|
-
staleSddAssets > 0
|
|
6458
|
-
? " — run /gentle:install-sdd --force to refresh intentionally"
|
|
6459
|
-
: ""
|
|
6460
|
-
}`,
|
|
6461
|
-
`Project-local SDD agent overrides: ${localSddAgentOverrides} file(s)${
|
|
6462
|
-
localSddAgentOverrides > 0
|
|
6463
|
-
? " — local SDD agents shadow package assets; remove them unless intentionally debugging"
|
|
6464
|
-
: ""
|
|
6465
|
-
}`,
|
|
8994
|
+
...assetLines,
|
|
6466
8995
|
`OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
|
|
6467
8996
|
`Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
|
|
6468
|
-
|
|
8997
|
+
`Saved model routing: ${savedConfig.status}${savedConfig.status === "invalid" ? ` (${savedConfig.path})` : ""}`,
|
|
8998
|
+
...(savedConfig.status === "invalid" ? [] : describeModelConfig(ctx.cwd, savedConfig.status === "valid" ? savedConfig.config : {})),
|
|
6469
8999
|
].join("\n"),
|
|
6470
|
-
|
|
9000
|
+
savedConfig.status === "invalid" || assetLines.some((line) => line.startsWith("warn:")) || devBinary.state !== "inactive" ? "warning" : "info",
|
|
6471
9001
|
);
|
|
6472
9002
|
},
|
|
6473
9003
|
});
|