gentle-pi 2.5.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 +139 -30
- package/assets/agents/gentle-ai-worker.md +4 -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 +21 -3
- 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/telemetry.md +57 -1
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +143 -15
- package/extensions/codegraph-tools.ts +1 -0
- package/extensions/gentle-agents.ts +799 -50
- package/extensions/gentle-ai.ts +2033 -322
- package/extensions/gentle-shell.ts +145 -42
- package/extensions/gentle-todo.ts +47 -12
- package/extensions/quiet-tools.ts +1 -0
- 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 +7 -10
- package/lib/agents-history.ts +9 -1
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +77 -5
- package/lib/agents-runner.ts +548 -26
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +548 -191
- package/lib/agents-widget.ts +33 -14
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +8 -6
- package/lib/native-review-cli.ts +283 -1
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +296 -46
- package/lib/review-candidate-view.ts +30 -20
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +53 -8
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +187 -5
- package/lib/review-last-event-controller.ts +7 -4
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-session-standing-permission.ts +27 -6
- 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 +47 -1
- package/lib/shell-card.ts +1 -4
- package/lib/shell-changes-view.ts +362 -37
- package/lib/shell-changes.ts +81 -1
- package/lib/shell-prompt.ts +11 -15
- 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 +28 -11
- package/lib/telemetry-trigger.ts +2 -0
- package/package.json +6 -3
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +283 -1
- package/runtime/review-integration-v2.mjs +187 -5
- package/runtime/telemetry-trigger.mjs +2 -0
- package/scripts/build-runtime-modules.mjs +9 -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 +16 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +4 -2
- package/skills/_shared/review-ledger-contract.md +17 -1
- 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 +62 -0
- package/tests/agents-fake-child.ts +15 -1
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +45 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner.test.ts +571 -14
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-view.test.ts +476 -65
- package/tests/agents-widget.test.ts +31 -1
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +169 -3
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +2 -1
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +2 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +1460 -33
- 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 +38 -0
- package/tests/gentle-ai.test.ts +944 -4
- package/tests/gentle-shell.test.ts +303 -12
- package/tests/gentle-todo.test.ts +54 -10
- 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-review-capability-contract.test.ts +12 -1
- package/tests/native-review-cli.test.ts +277 -3
- package/tests/native-review-parity.test.ts +14 -7
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/package-manifest.test.ts +232 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +1 -0
- package/tests/rdd-aware-verification-contract.test.ts +10 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +304 -6
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +14 -0
- package/tests/review-controller-native-routing.test.ts +563 -3
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +364 -4
- 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 +164 -0
- package/tests/review-last-event-closure.test.ts +105 -1
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-session-standing-permission-controller.test.ts +52 -4
- package/tests/review-session-standing-permission.test.ts +30 -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-card.test.ts +24 -3
- package/tests/shell-changes-view.test.ts +471 -8
- package/tests/shell-changes.test.ts +168 -0
- package/tests/shell-prompt.test.ts +28 -6
- 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 +100 -11
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +3 -1
package/extensions/gentle-ai.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
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";
|
|
1
4
|
import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
|
|
2
5
|
import { execFileSync } from "node:child_process";
|
|
3
6
|
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -29,15 +32,20 @@ import type {
|
|
|
29
32
|
ThemeColor,
|
|
30
33
|
ToolCallEventResult,
|
|
31
34
|
} from "@earendil-works/pi-coding-agent";
|
|
32
|
-
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";
|
|
33
36
|
import { resolveGentlePiAgentHome } from "../lib/agent-home.ts";
|
|
34
37
|
import {
|
|
35
38
|
ensureSddPreflight,
|
|
36
39
|
getSddPreflightPreferences,
|
|
37
|
-
|
|
40
|
+
installPackageAssets,
|
|
41
|
+
getPackageAssetOwner,
|
|
42
|
+
hasPackageAssetOwnerInstallation,
|
|
43
|
+
type PackageAssetOwner,
|
|
38
44
|
isPackageManagedSddAsset,
|
|
39
45
|
isSddPreflightTrigger,
|
|
40
46
|
renderSddPreflightPrompt,
|
|
47
|
+
isParentConfirmedSddPreflightContext,
|
|
48
|
+
SHIPPED_SDD_AGENT_NAMES,
|
|
41
49
|
SDD_PREFLIGHT_FIELDS,
|
|
42
50
|
type SddPreflightField,
|
|
43
51
|
type SddPreflightPreferences,
|
|
@@ -55,13 +63,46 @@ import {
|
|
|
55
63
|
type ModelConfigFileResult,
|
|
56
64
|
type ThinkingLevel,
|
|
57
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";
|
|
58
102
|
import {
|
|
59
103
|
parseSddStatusCommandArgs,
|
|
60
104
|
renderNativeSddPhasePrompt,
|
|
61
|
-
renderSddDispatcherMarkdown,
|
|
62
|
-
renderSddStatusMarkdown,
|
|
63
105
|
resolveSddStatus,
|
|
64
|
-
sddStatusSeverity,
|
|
65
106
|
type SddPhase,
|
|
66
107
|
} from "../lib/sdd-status.ts";
|
|
67
108
|
import {
|
|
@@ -72,6 +113,8 @@ import {
|
|
|
72
113
|
REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
|
|
73
114
|
ReviewHostRelayError,
|
|
74
115
|
reviewHostRelaySlots,
|
|
116
|
+
reviewHostRelayUnachievableDetail,
|
|
117
|
+
reviewHostRelayUnachievableReason,
|
|
75
118
|
reviewProviderRoleVectorSlots,
|
|
76
119
|
resolveReviewHostRelaySubmission,
|
|
77
120
|
runReviewHostRelayReviewerGroup,
|
|
@@ -107,6 +150,8 @@ import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
|
|
|
107
150
|
import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, type CandidateView } from "../lib/review-candidate-view.ts";
|
|
108
151
|
import {
|
|
109
152
|
GentleAiDevBinaryOverrideError,
|
|
153
|
+
GENTLE_AI_INSTALL_RECOVERY_COMMAND,
|
|
154
|
+
GENTLE_AI_INSTALL_RECOVERY_INSTRUCTIONS,
|
|
110
155
|
registerGentleAiDevBinary,
|
|
111
156
|
resolveGentleAiBinary,
|
|
112
157
|
resolveGentleAiDevBinaryOverride,
|
|
@@ -120,7 +165,9 @@ import {
|
|
|
120
165
|
import {
|
|
121
166
|
createNativeReviewCli,
|
|
122
167
|
createNodeExecFileAdapter,
|
|
168
|
+
decodeNativeSddStatusV2,
|
|
123
169
|
isCanonicalProcessString,
|
|
170
|
+
isNativeReviewUnachievableVerbRefused,
|
|
124
171
|
nativeReviewAbandonAuthorization,
|
|
125
172
|
nativeReviewLegacyAliasRepairAuthorization,
|
|
126
173
|
nativeReviewLegacyQuarantineAuthorization,
|
|
@@ -140,13 +187,16 @@ import {
|
|
|
140
187
|
NATIVE_REVIEW_RECONCILE_ANOMALIES,
|
|
141
188
|
sanitizeForeignNativeReviewDiagnostics,
|
|
142
189
|
type NativeReviewCli,
|
|
190
|
+
type NativeSddStatusV2,
|
|
143
191
|
type NativeIntendedUntrackedSelectionSubmission,
|
|
144
192
|
type NativeReviewAcknowledgeApprovedOutcome,
|
|
145
193
|
type NativeReviewAcknowledgeApprovedRequest,
|
|
146
194
|
type NativeTargetStatusRequest,
|
|
147
195
|
type NativeReviewModeOperation,
|
|
148
196
|
type NativeReviewModeSource,
|
|
197
|
+
type NativeReviewModeStatus,
|
|
149
198
|
type NativeReviewProcessDiagnostics,
|
|
199
|
+
type NativeReviewUnachievableLensCaptureArtifact,
|
|
150
200
|
type NativeStartResult,
|
|
151
201
|
type NativeReviewAssessRequest,
|
|
152
202
|
type ExecFileAdapter,
|
|
@@ -195,8 +245,9 @@ function gentlePiAgentHome(): string {
|
|
|
195
245
|
return resolveGentlePiAgentHome();
|
|
196
246
|
}
|
|
197
247
|
|
|
198
|
-
function
|
|
248
|
+
function packageAssetAudit(owner: PackageAssetOwner): { stale: number; overrides: number } {
|
|
199
249
|
let stale = 0;
|
|
250
|
+
let overrides = 0;
|
|
200
251
|
for (const [assetSubdir, installedSubdir, ownershipPrefix] of [
|
|
201
252
|
["agents", "agents", "agents"],
|
|
202
253
|
["chains", "chains", "chains"],
|
|
@@ -205,7 +256,7 @@ function sddGlobalAssetDriftCount(): number {
|
|
|
205
256
|
const assetDir = join(ASSETS_DIR, assetSubdir);
|
|
206
257
|
if (!existsSync(assetDir)) continue;
|
|
207
258
|
for (const entry of readdirSync(assetDir, { withFileTypes: true })) {
|
|
208
|
-
if (!entry.isFile()) continue;
|
|
259
|
+
if (!entry.isFile() || getPackageAssetOwner(`${ownershipPrefix}/${entry.name}`) !== owner) continue;
|
|
209
260
|
const installedPath = join(gentlePiAgentHome(), installedSubdir, entry.name);
|
|
210
261
|
try {
|
|
211
262
|
if (!existsSync(installedPath)) {
|
|
@@ -218,6 +269,7 @@ function sddGlobalAssetDriftCount(): number {
|
|
|
218
269
|
`${ownershipPrefix}/${entry.name}`,
|
|
219
270
|
)
|
|
220
271
|
) {
|
|
272
|
+
overrides += 1;
|
|
221
273
|
continue;
|
|
222
274
|
}
|
|
223
275
|
const packaged = readFileSync(join(assetDir, entry.name), "utf8");
|
|
@@ -238,26 +290,47 @@ function sddGlobalAssetDriftCount(): number {
|
|
|
238
290
|
}
|
|
239
291
|
}
|
|
240
292
|
}
|
|
241
|
-
return stale;
|
|
293
|
+
return { stale, overrides };
|
|
242
294
|
}
|
|
243
295
|
|
|
244
|
-
function
|
|
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
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function localAgentOverrideCount(cwd: string, owner: PackageAssetOwner): number {
|
|
245
319
|
const packageSddAgentsDir = join(ASSETS_DIR, "agents");
|
|
246
|
-
const packageSddAgentNames =
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
.
|
|
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,
|
|
251
326
|
)
|
|
252
|
-
|
|
327
|
+
.map((agent) => agent.name),
|
|
328
|
+
);
|
|
253
329
|
let count = 0;
|
|
254
|
-
for (const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (!existsSync(installedDir)) continue;
|
|
259
|
-
for (const entry of readdirSync(installedDir, { withFileTypes: true })) {
|
|
260
|
-
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;
|
|
261
334
|
}
|
|
262
335
|
}
|
|
263
336
|
return count;
|
|
@@ -497,7 +570,13 @@ function renderBackgroundSubagentsReport(
|
|
|
497
570
|
|
|
498
571
|
const SUBAGENTS_PACKAGE_NAMES = ["pi-subagents-j0k3r", "pi-subagents"] as const;
|
|
499
572
|
const SUBAGENT_RUN_TOOL = "subagent_run";
|
|
500
|
-
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";
|
|
501
580
|
const ALLOWED_EDIT_SURFACES_HEADING = /^## Allowed edit surfaces[ \t]*$/gim;
|
|
502
581
|
const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6} /;
|
|
503
582
|
const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)]) +/;
|
|
@@ -601,6 +680,15 @@ function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean {
|
|
|
601
680
|
return hasSection;
|
|
602
681
|
}
|
|
603
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
|
+
|
|
604
692
|
function rejectUnscopedBoundedWriterDispatch(input: unknown): { block: true; reason: string } | undefined {
|
|
605
693
|
if (
|
|
606
694
|
!isRecord(input) ||
|
|
@@ -615,6 +703,165 @@ function rejectUnscopedBoundedWriterDispatch(input: unknown): { block: true; rea
|
|
|
615
703
|
return { block: true, reason: WRITER_EDIT_SURFACE_REJECTION };
|
|
616
704
|
}
|
|
617
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
|
+
|
|
618
865
|
/**
|
|
619
866
|
* Roots where an installed subagents package may live. These are the same
|
|
620
867
|
* roots builtinAgentDirs() walks, minus its `/agents` suffix.
|
|
@@ -1140,7 +1387,7 @@ ${getOrchestratorPrompt(cwd, activeTools, rddStatusLine)}`;
|
|
|
1140
1387
|
// Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
|
|
1141
1388
|
// between `git` and the subcommand. Short flags may be followed by a separate value token.
|
|
1142
1389
|
const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)* `;
|
|
1143
|
-
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`);
|
|
1144
1391
|
|
|
1145
1392
|
const DENIED_BASH_PATTERNS: RegExp[] = [
|
|
1146
1393
|
// Block rm -rf targeting /, ~ or ~/subdir, $HOME or $HOME/subdir, .. or .
|
|
@@ -1167,6 +1414,19 @@ const GUARD_ACTION = {
|
|
|
1167
1414
|
type GuardAction = (typeof GUARD_ACTION)[keyof typeof GUARD_ACTION];
|
|
1168
1415
|
type GuardClassification = GuardAction | "not-guarded";
|
|
1169
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
|
+
|
|
1170
1430
|
const GUARDED_COMMAND_KEY = {
|
|
1171
1431
|
GIT_PUSH: "gitPush",
|
|
1172
1432
|
GIT_REBASE: "gitRebase",
|
|
@@ -1191,10 +1451,10 @@ interface LoadGuardrailsOptions {
|
|
|
1191
1451
|
|
|
1192
1452
|
const GUARDED_KEY_PATTERNS: Record<GuardedCommandKey, RegExp> = {
|
|
1193
1453
|
gitPush: GIT_PUSH_RE,
|
|
1194
|
-
gitRebase: /\bgit\s+rebase\b/,
|
|
1195
|
-
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)/,
|
|
1196
|
-
npmPublish: /\bnpm\s+publish\b/,
|
|
1197
|
-
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/,
|
|
1198
1458
|
};
|
|
1199
1459
|
|
|
1200
1460
|
const AUTONOMOUS_DEFAULT_ACTIONS: Record<GuardedCommandKey, GuardAction> = {
|
|
@@ -1205,6 +1465,14 @@ const AUTONOMOUS_DEFAULT_ACTIONS: Record<GuardedCommandKey, GuardAction> = {
|
|
|
1205
1465
|
piRemove: "confirm",
|
|
1206
1466
|
};
|
|
1207
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
|
+
|
|
1208
1476
|
const SAFE_GUARDRAILS_CONFIG: RuntimeGuardrailsConfig = {
|
|
1209
1477
|
autonomousMode: false,
|
|
1210
1478
|
guardedCommands: {},
|
|
@@ -1220,31 +1488,83 @@ const SAFE_GUARDRAILS_CONFIG: RuntimeGuardrailsConfig = {
|
|
|
1220
1488
|
* (applying AUTONOMOUS_DEFAULT_ACTIONS for any key not set in guardedCommands)
|
|
1221
1489
|
* 4. No match → "not-guarded"
|
|
1222
1490
|
*/
|
|
1223
|
-
function
|
|
1491
|
+
function collectGuardedMatches(
|
|
1224
1492
|
command: string,
|
|
1225
1493
|
config: RuntimeGuardrailsConfig,
|
|
1226
|
-
):
|
|
1227
|
-
|
|
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.
|
|
1228
1522
|
for (const pattern of DENIED_BASH_PATTERNS) {
|
|
1229
|
-
|
|
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
|
+
};
|
|
1230
1534
|
}
|
|
1231
1535
|
|
|
1232
|
-
//
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
+
}
|
|
1235
1543
|
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1544
|
+
function classifyGuardedCommand(
|
|
1545
|
+
command: string,
|
|
1546
|
+
config: RuntimeGuardrailsConfig,
|
|
1547
|
+
): GuardClassification {
|
|
1548
|
+
return evaluateGuardedCommand(command, config).action;
|
|
1549
|
+
}
|
|
1241
1550
|
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
}
|
|
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
|
+
}
|
|
1246
1556
|
|
|
1247
|
-
|
|
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]}?`;
|
|
1248
1568
|
}
|
|
1249
1569
|
|
|
1250
1570
|
function parseGuardrailsConfigFile(
|
|
@@ -1352,22 +1672,9 @@ const SENSITIVE_PATH_PATTERNS: RegExp[] = [
|
|
|
1352
1672
|
/\.(?:pem|key|p12|pfx)$/,
|
|
1353
1673
|
];
|
|
1354
1674
|
|
|
1355
|
-
const
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
"sdd-explore",
|
|
1359
|
-
"sdd-research",
|
|
1360
|
-
"sdd-proposal",
|
|
1361
|
-
"sdd-spec",
|
|
1362
|
-
"sdd-design",
|
|
1363
|
-
"sdd-tasks",
|
|
1364
|
-
"sdd-status",
|
|
1365
|
-
"sdd-apply",
|
|
1366
|
-
"sdd-verify",
|
|
1367
|
-
"sdd-sync",
|
|
1368
|
-
"sdd-archive",
|
|
1369
|
-
] as const;
|
|
1370
|
-
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;
|
|
1371
1678
|
|
|
1372
1679
|
const JUDGMENT_DAY_AGENT_NAMES = [
|
|
1373
1680
|
"jd-judge-a",
|
|
@@ -1376,7 +1683,7 @@ const JUDGMENT_DAY_AGENT_NAMES = [
|
|
|
1376
1683
|
] as const;
|
|
1377
1684
|
|
|
1378
1685
|
const CORE_MODEL_AGENT_NAMES = [
|
|
1379
|
-
...
|
|
1686
|
+
...SHIPPED_SDD_AGENT_NAMES,
|
|
1380
1687
|
...JUDGMENT_DAY_AGENT_NAMES,
|
|
1381
1688
|
] as const;
|
|
1382
1689
|
const CORE_MODEL_AGENT_NAME_SET = new Set<string>(CORE_MODEL_AGENT_NAMES);
|
|
@@ -1418,10 +1725,10 @@ function readStringPath(value: unknown, path: string[]): string | undefined {
|
|
|
1418
1725
|
|
|
1419
1726
|
function isSddAgentStartEvent(event: unknown): boolean {
|
|
1420
1727
|
const candidates = readAgentStartNames(event);
|
|
1421
|
-
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;
|
|
1422
1729
|
|
|
1423
1730
|
const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
|
|
1424
|
-
return
|
|
1731
|
+
return SHIPPED_SDD_AGENT_NAMES.some((name) => {
|
|
1425
1732
|
const phase = name.replace(/^sdd-/, "");
|
|
1426
1733
|
return new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt);
|
|
1427
1734
|
});
|
|
@@ -1444,19 +1751,107 @@ function isNamedAgentStartEvent(event: unknown): boolean {
|
|
|
1444
1751
|
return readAgentStartNames(event).length > 0;
|
|
1445
1752
|
}
|
|
1446
1753
|
|
|
1447
|
-
function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | undefined {
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
if (name === "sdd-verify") return "verify";
|
|
1451
|
-
if (name === "sdd-sync") return "sync";
|
|
1452
|
-
if (name === "sdd-archive") return "archive";
|
|
1453
|
-
}
|
|
1754
|
+
function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | "remediate" | undefined {
|
|
1755
|
+
const phases = ["apply", "verify", "sync", "archive", "remediate"] as const;
|
|
1756
|
+
const names = readAgentStartNames(event);
|
|
1454
1757
|
const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
|
|
1455
|
-
|
|
1456
|
-
if (
|
|
1457
|
-
if (
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
+
}
|
|
1460
1855
|
}
|
|
1461
1856
|
|
|
1462
1857
|
function normalizePolicyPath(value: string): string {
|
|
@@ -1576,7 +1971,8 @@ async function confirmCommand(
|
|
|
1576
1971
|
herdrLifecycle: HerdrConfirmationLifecycle,
|
|
1577
1972
|
): Promise<ToolCallEventResult | undefined> {
|
|
1578
1973
|
const guardrailsConfig = loadRuntimeGuardrailsConfig(ctx.cwd);
|
|
1579
|
-
const
|
|
1974
|
+
const evaluation = evaluateGuardedCommand(command, guardrailsConfig);
|
|
1975
|
+
const { action: classification } = evaluation;
|
|
1580
1976
|
|
|
1581
1977
|
if (classification === "block") {
|
|
1582
1978
|
return {
|
|
@@ -1599,11 +1995,8 @@ async function confirmCommand(
|
|
|
1599
1995
|
"Gentle AI safety policy requires interactive confirmation before this command.",
|
|
1600
1996
|
};
|
|
1601
1997
|
}
|
|
1602
|
-
const
|
|
1603
|
-
|
|
1604
|
-
180,
|
|
1605
|
-
"…",
|
|
1606
|
-
);
|
|
1998
|
+
const title = guardedCommandTitle(evaluation.key, evaluation.matches);
|
|
1999
|
+
const preview = guardedCommandPreview(command, evaluation.triggerIndex);
|
|
1607
2000
|
const requestId = randomUUID();
|
|
1608
2001
|
const emitPermissionRequest = (
|
|
1609
2002
|
state: "waiting" | "approved" | "denied",
|
|
@@ -1622,7 +2015,7 @@ async function confirmCommand(
|
|
|
1622
2015
|
emitPermissionRequest("waiting");
|
|
1623
2016
|
herdrLifecycle.begin();
|
|
1624
2017
|
try {
|
|
1625
|
-
approved = await ctx.ui.confirm(
|
|
2018
|
+
approved = await ctx.ui.confirm(title, preview);
|
|
1626
2019
|
} catch (error) {
|
|
1627
2020
|
confirmationFailed = true;
|
|
1628
2021
|
confirmationError = error;
|
|
@@ -1754,7 +2147,10 @@ function parseModelExport(value: unknown): AgentModelConfig | undefined {
|
|
|
1754
2147
|
}
|
|
1755
2148
|
|
|
1756
2149
|
async function exportSavedModelConfig(ctx: ExtensionContext): Promise<number> {
|
|
1757
|
-
const saved = await
|
|
2150
|
+
const saved = await readModelRoutingAuthorityAsync(
|
|
2151
|
+
modelConfigPath(ctx.cwd),
|
|
2152
|
+
legacyProjectModelConfigPath(ctx.cwd),
|
|
2153
|
+
);
|
|
1758
2154
|
if (saved.status === "invalid") throw new Error(`Invalid model config: ${saved.path}`);
|
|
1759
2155
|
const agents = saved.status === "valid" ? saved.config : {};
|
|
1760
2156
|
const path = modelExportPath(ctx.cwd);
|
|
@@ -1905,6 +2301,49 @@ async function listAgentsFromDirAsync(
|
|
|
1905
2301
|
return entries;
|
|
1906
2302
|
}
|
|
1907
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
|
+
|
|
1908
2347
|
function builtinAgentDirs(cwd: string): string[] {
|
|
1909
2348
|
return [
|
|
1910
2349
|
join(PACKAGE_ROOT, "..", "pi-subagents-j0k3r", "agents"),
|
|
@@ -1935,16 +2374,12 @@ async function listBuiltinAgentNamesAsync(cwd: string): Promise<Set<string>> {
|
|
|
1935
2374
|
}
|
|
1936
2375
|
|
|
1937
2376
|
function listDiscoverableAgents(cwd: string): AgentEntry[] {
|
|
1938
|
-
const globalAgentHome = gentlePiAgentHome();
|
|
1939
2377
|
const builtinDirs = builtinAgentDirs(cwd);
|
|
1940
2378
|
const agents = [
|
|
1941
2379
|
...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")),
|
|
1942
|
-
...
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
...listAgentsFromDir(join(cwd, ".agents"), "project"),
|
|
1946
|
-
...listAgentsFromDir(join(cwd, ".pi", "agents"), "project"),
|
|
1947
|
-
...listAgentsFromDir(join(cwd, ".pi", "subagents"), "project"),
|
|
2380
|
+
...discoverableNonBuiltinAgentRoots(cwd).flatMap(({ dir, source }) =>
|
|
2381
|
+
listAgentsFromDir(dir, source),
|
|
2382
|
+
),
|
|
1948
2383
|
];
|
|
1949
2384
|
const byName = new Map<string, AgentEntry>();
|
|
1950
2385
|
for (const agent of agents) byName.set(agent.name, agent);
|
|
@@ -1952,21 +2387,12 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
|
|
|
1952
2387
|
}
|
|
1953
2388
|
|
|
1954
2389
|
async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
|
|
1955
|
-
const globalAgentHome = gentlePiAgentHome();
|
|
1956
2390
|
const builtinDirs = builtinAgentDirs(cwd);
|
|
1957
2391
|
const agents: AgentEntry[] = [];
|
|
1958
2392
|
for (const dir of builtinDirs) {
|
|
1959
2393
|
agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
|
|
1960
2394
|
}
|
|
1961
|
-
const
|
|
1962
|
-
[join(globalAgentHome, "agents"), "user"],
|
|
1963
|
-
[join(globalAgentHome, "subagents"), "user"],
|
|
1964
|
-
[join(homedir(), ".agents"), "user"],
|
|
1965
|
-
[join(cwd, ".agents"), "project"],
|
|
1966
|
-
[join(cwd, ".pi", "agents"), "project"],
|
|
1967
|
-
[join(cwd, ".pi", "subagents"), "project"],
|
|
1968
|
-
];
|
|
1969
|
-
for (const [dir, source] of otherDirs) {
|
|
2395
|
+
for (const { dir, source } of discoverableNonBuiltinAgentRoots(cwd)) {
|
|
1970
2396
|
agents.push(...(await listAgentsFromDirAsync(dir, source)));
|
|
1971
2397
|
}
|
|
1972
2398
|
const byName = new Map<string, AgentEntry>();
|
|
@@ -2083,6 +2509,15 @@ function projectSettingsPath(cwd: string): string {
|
|
|
2083
2509
|
return join(cwd, ".pi", "settings.json");
|
|
2084
2510
|
}
|
|
2085
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
|
+
|
|
2086
2521
|
function removeLegacyAgentOverridesFromSettings(
|
|
2087
2522
|
settingsPath: string,
|
|
2088
2523
|
settings: Record<string, unknown>,
|
|
@@ -2107,6 +2542,30 @@ function isValidJsonObjectFileOrMissing(path: string): boolean {
|
|
|
2107
2542
|
}
|
|
2108
2543
|
}
|
|
2109
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
|
+
|
|
2110
2569
|
function migrateLegacyProjectModelOverrides(cwd: string): number {
|
|
2111
2570
|
const settingsPath = projectSettingsPath(cwd);
|
|
2112
2571
|
if (!existsSync(settingsPath)) return 0;
|
|
@@ -2125,6 +2584,7 @@ function migrateLegacyProjectModelOverrides(cwd: string): number {
|
|
|
2125
2584
|
if (!agentOverrides) return 0;
|
|
2126
2585
|
const agentsByName = new Map(listDiscoverableAgents(cwd).map((agent) => [agent.name, agent]));
|
|
2127
2586
|
const migratableEntries = Object.entries(agentOverrides)
|
|
2587
|
+
.filter(([name]) => !isProviderReviewRole(name))
|
|
2128
2588
|
.map(([name, value]) => ({ name, entry: normalizeRoutingEntry(value) }))
|
|
2129
2589
|
.filter((item): item is { name: string; entry: AgentRoutingEntry } =>
|
|
2130
2590
|
item.entry !== undefined && !isClearRoutingEntry(item.entry),
|
|
@@ -2167,30 +2627,40 @@ export function applyModelConfig(
|
|
|
2167
2627
|
let skipped = 0;
|
|
2168
2628
|
const seenAgents = new Set<string>();
|
|
2169
2629
|
for (const agent of listDiscoverableAgents(cwd)) {
|
|
2630
|
+
if (isProviderReviewRole(agent.name)) continue;
|
|
2170
2631
|
seenAgents.add(agent.name);
|
|
2171
2632
|
const entry = config[agent.name];
|
|
2172
2633
|
if (entry === undefined) {
|
|
2173
2634
|
skipped += 1;
|
|
2174
2635
|
continue;
|
|
2175
2636
|
}
|
|
2176
|
-
if (
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
if (!agent.filePath || !existsSync(agent.filePath)) {
|
|
2180
|
-
skipped += 1;
|
|
2637
|
+
if (agent.source === "builtin") {
|
|
2638
|
+
if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1;
|
|
2639
|
+
else skipped += 1;
|
|
2181
2640
|
continue;
|
|
2182
2641
|
}
|
|
2183
|
-
|
|
2184
|
-
const next = updateFrontmatterRouting(original, entry);
|
|
2185
|
-
if (next === original) {
|
|
2642
|
+
if (!agent.filePath || !existsSync(agent.filePath)) {
|
|
2186
2643
|
skipped += 1;
|
|
2187
|
-
|
|
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
|
+
}
|
|
2188
2655
|
}
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
updated += 1;
|
|
2656
|
+
if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1;
|
|
2657
|
+
else skipped += 1;
|
|
2192
2658
|
}
|
|
2193
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;
|
|
2194
2664
|
if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
|
|
2195
2665
|
if (updateSubagentModelProfile(cwd, "user", name, entry)) updated += 1;
|
|
2196
2666
|
else skipped += 1;
|
|
@@ -2207,31 +2677,40 @@ export async function applyModelConfigAsync(
|
|
|
2207
2677
|
let skipped = 0;
|
|
2208
2678
|
const seenAgents = new Set<string>();
|
|
2209
2679
|
for (const agent of await listDiscoverableAgentsAsync(cwd)) {
|
|
2680
|
+
if (isProviderReviewRole(agent.name)) continue;
|
|
2210
2681
|
seenAgents.add(agent.name);
|
|
2211
2682
|
const entry = config[agent.name];
|
|
2212
2683
|
if (entry === undefined) {
|
|
2213
2684
|
skipped += 1;
|
|
2214
2685
|
continue;
|
|
2215
2686
|
}
|
|
2216
|
-
if (
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
if (!agent.filePath || !(await pathExists(agent.filePath))) {
|
|
2221
|
-
skipped += 1;
|
|
2687
|
+
if (agent.source === "builtin") {
|
|
2688
|
+
if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry))
|
|
2689
|
+
updated += 1;
|
|
2690
|
+
else skipped += 1;
|
|
2222
2691
|
continue;
|
|
2223
2692
|
}
|
|
2224
|
-
|
|
2225
|
-
const next = updateFrontmatterRouting(original, entry);
|
|
2226
|
-
if (next === original) {
|
|
2693
|
+
if (!agent.filePath || !(await pathExists(agent.filePath))) {
|
|
2227
2694
|
skipped += 1;
|
|
2228
|
-
|
|
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
|
+
}
|
|
2229
2706
|
}
|
|
2230
|
-
await
|
|
2231
|
-
|
|
2232
|
-
|
|
2707
|
+
if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry))
|
|
2708
|
+
updated += 1;
|
|
2709
|
+
else skipped += 1;
|
|
2233
2710
|
}
|
|
2234
2711
|
for (const [name, entry] of Object.entries(config)) {
|
|
2712
|
+
if (isProviderReviewRole(name)) continue;
|
|
2713
|
+
if (isProfileOrchestratorKey(name)) continue;
|
|
2235
2714
|
if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
|
|
2236
2715
|
if (await updateSubagentModelProfileAsync(cwd, "user", name, entry))
|
|
2237
2716
|
updated += 1;
|
|
@@ -2259,11 +2738,11 @@ export async function applySavedModelConfig(
|
|
|
2259
2738
|
}
|
|
2260
2739
|
|
|
2261
2740
|
function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
|
|
2262
|
-
return
|
|
2263
|
-
const entry = config[
|
|
2264
|
-
const model = entry?.model ?? "
|
|
2265
|
-
const thinking = entry?.thinking ?? "
|
|
2266
|
-
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)}`;
|
|
2267
2746
|
});
|
|
2268
2747
|
}
|
|
2269
2748
|
|
|
@@ -2631,7 +3110,7 @@ class SddModelPanel implements OverlayComponent {
|
|
|
2631
3110
|
lines.push("");
|
|
2632
3111
|
lines.push(
|
|
2633
3112
|
line(
|
|
2634
|
-
|
|
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`,
|
|
2635
3114
|
"muted",
|
|
2636
3115
|
),
|
|
2637
3116
|
);
|
|
@@ -2663,7 +3142,9 @@ class SddModelPanel implements OverlayComponent {
|
|
|
2663
3142
|
const focused = i === this.modelCursor;
|
|
2664
3143
|
lines.push(
|
|
2665
3144
|
`${this.renderCursor(focused)} ${this.renderText(
|
|
2666
|
-
options[i]
|
|
3145
|
+
options[i] === INHERIT_MODEL && isProviderReviewRole(this.selectedRow)
|
|
3146
|
+
? routingDefaultLabel(this.selectedRow, "model")
|
|
3147
|
+
: (options[i] ?? ""),
|
|
2667
3148
|
focused ? "status" : "text",
|
|
2668
3149
|
)}`,
|
|
2669
3150
|
);
|
|
@@ -2715,7 +3196,9 @@ class SddModelPanel implements OverlayComponent {
|
|
|
2715
3196
|
const focused = i === this.effortCursor;
|
|
2716
3197
|
lines.push(
|
|
2717
3198
|
`${this.renderCursor(focused)} ${this.renderText(
|
|
2718
|
-
THINKING_OPTIONS[i]
|
|
3199
|
+
THINKING_OPTIONS[i] === INHERIT_THINKING && isProviderReviewRole(this.selectedRow)
|
|
3200
|
+
? routingDefaultLabel(this.selectedRow, "effort")
|
|
3201
|
+
: (THINKING_OPTIONS[i] ?? ""),
|
|
2719
3202
|
focused ? "status" : "text",
|
|
2720
3203
|
)}`,
|
|
2721
3204
|
);
|
|
@@ -2728,10 +3211,10 @@ class SddModelPanel implements OverlayComponent {
|
|
|
2728
3211
|
private renderSetAllLabel(row: string): string {
|
|
2729
3212
|
const models = this.rows
|
|
2730
3213
|
.slice(1)
|
|
2731
|
-
.map((name) => this.draft[name]?.model ?? "
|
|
3214
|
+
.map((name) => this.draft[name]?.model ?? routingDefaultLabel(name, "model"));
|
|
2732
3215
|
const efforts = this.rows
|
|
2733
3216
|
.slice(1)
|
|
2734
|
-
.map((name) => this.draft[name]?.thinking ?? "
|
|
3217
|
+
.map((name) => this.draft[name]?.thinking ?? routingDefaultLabel(name, "effort"));
|
|
2735
3218
|
const firstModel = models[0] ?? "inherit";
|
|
2736
3219
|
const firstEffort = efforts[0] ?? "inherit";
|
|
2737
3220
|
const modelLabel = models.every((value) => value === firstModel)
|
|
@@ -2747,8 +3230,8 @@ class SddModelPanel implements OverlayComponent {
|
|
|
2747
3230
|
}
|
|
2748
3231
|
|
|
2749
3232
|
private renderAgentLabel(row: string): string {
|
|
2750
|
-
const model = this.draft[row]?.model ?? "
|
|
2751
|
-
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");
|
|
2752
3235
|
return `${this.renderText(sanitizeTerminalText(row).padEnd(20), "text")} ${this.renderText("model=", "muted")}${this.renderText(model, "status")}${this.renderText(
|
|
2753
3236
|
", effort=",
|
|
2754
3237
|
"muted",
|
|
@@ -2773,7 +3256,7 @@ async function showSddModelPanel(
|
|
|
2773
3256
|
config: AgentModelConfig,
|
|
2774
3257
|
): Promise<ModelPanelResult> {
|
|
2775
3258
|
const modelOptions = await getPiModelOptions(ctx);
|
|
2776
|
-
const agents =
|
|
3259
|
+
const agents = modelAssignmentNames(ctx.cwd);
|
|
2777
3260
|
return ctx.ui.custom<ModelPanelResult>(
|
|
2778
3261
|
(_tui, theme, _keybindings, done) =>
|
|
2779
3262
|
new SddModelPanel(config, modelOptions, agents, done, theme),
|
|
@@ -2791,7 +3274,10 @@ async function showSddModelPanel(
|
|
|
2791
3274
|
|
|
2792
3275
|
async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
2793
3276
|
migrateLegacyProjectModelOverrides(ctx.cwd);
|
|
2794
|
-
const savedConfig = await
|
|
3277
|
+
const savedConfig = await readModelRoutingAuthorityAsync(
|
|
3278
|
+
modelConfigPath(ctx.cwd),
|
|
3279
|
+
legacyProjectModelConfigPath(ctx.cwd),
|
|
3280
|
+
);
|
|
2795
3281
|
if (savedConfig.status === "invalid") {
|
|
2796
3282
|
ctx.ui.notify(
|
|
2797
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.`,
|
|
@@ -2871,9 +3357,9 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
2871
3357
|
}
|
|
2872
3358
|
if (result.agent === "all") {
|
|
2873
3359
|
const next: AgentModelConfig = { ...config };
|
|
2874
|
-
for (const
|
|
2875
|
-
next[
|
|
2876
|
-
...(next[
|
|
3360
|
+
for (const name of modelAssignmentNames(ctx.cwd)) {
|
|
3361
|
+
next[name] = {
|
|
3362
|
+
...(next[name] ?? {}),
|
|
2877
3363
|
model,
|
|
2878
3364
|
};
|
|
2879
3365
|
}
|
|
@@ -2888,20 +3374,681 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
2888
3374
|
};
|
|
2889
3375
|
}
|
|
2890
3376
|
}
|
|
2891
|
-
result = await showSddModelPanel(ctx, config);
|
|
3377
|
+
result = await showSddModelPanel(ctx, config);
|
|
3378
|
+
}
|
|
3379
|
+
if (result.type !== "save") return;
|
|
3380
|
+
writeModelConfig(ctx.cwd, result.config);
|
|
3381
|
+
const applyResult = await applyModelConfigAsync(ctx.cwd, result.config);
|
|
3382
|
+
ctx.ui.notify(
|
|
3383
|
+
[
|
|
3384
|
+
"el Gentleman global model config saved.",
|
|
3385
|
+
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
3386
|
+
`Agents updated: ${applyResult.updated}`,
|
|
3387
|
+
...describeModelConfig(ctx.cwd, result.config),
|
|
3388
|
+
].join("\n"),
|
|
3389
|
+
"info",
|
|
3390
|
+
);
|
|
3391
|
+
}
|
|
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);
|
|
2892
4051
|
}
|
|
2893
|
-
if (result.type !== "save") return;
|
|
2894
|
-
writeModelConfig(ctx.cwd, result.config);
|
|
2895
|
-
const applyResult = await applyModelConfigAsync(ctx.cwd, result.config);
|
|
2896
|
-
ctx.ui.notify(
|
|
2897
|
-
[
|
|
2898
|
-
"el Gentleman global model config saved.",
|
|
2899
|
-
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
2900
|
-
`Agents updated: ${applyResult.updated}`,
|
|
2901
|
-
...describeModelConfig(ctx.cwd, result.config),
|
|
2902
|
-
].join("\n"),
|
|
2903
|
-
"info",
|
|
2904
|
-
);
|
|
2905
4052
|
}
|
|
2906
4053
|
|
|
2907
4054
|
async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
|
|
@@ -2981,7 +4128,8 @@ const REVIEW_CONTROLLER_PARAMETERS = {
|
|
|
2981
4128
|
description: "Bounded review lineage identifier. A failed start creates no lineage; do not use it with status or advance.",
|
|
2982
4129
|
},
|
|
2983
4130
|
selectionBinding: { type: "string", description: "Opaque provider-issued pre-lineage intended-untracked selection binding." },
|
|
2984
|
-
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.' },
|
|
2985
4133
|
changeName: {
|
|
2986
4134
|
type: "string",
|
|
2987
4135
|
description: "Canonical OpenSpec change name required to resolve a recovered authority during lifecycle validate.",
|
|
@@ -3134,6 +4282,7 @@ interface ReviewControllerParameters {
|
|
|
3134
4282
|
lineageId?: string;
|
|
3135
4283
|
selectionBinding?: string;
|
|
3136
4284
|
intendedUntracked?: readonly string[];
|
|
4285
|
+
untrackedScope?: NativeStartUntrackedScope;
|
|
3137
4286
|
changeName?: string;
|
|
3138
4287
|
idempotencyKey?: string;
|
|
3139
4288
|
transition?: string;
|
|
@@ -3173,6 +4322,41 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
|
|
|
3173
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");
|
|
3174
4323
|
return { operation: value.operation, selectionBinding: value.selectionBinding, intendedUntracked: value.intendedUntracked, ...(typeof value.workspaceRoot === "string" ? { workspaceRoot: value.workspaceRoot } : {}) };
|
|
3175
4324
|
}
|
|
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
|
+
|
|
3176
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);
|
|
3177
4361
|
if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
|
|
3178
4362
|
throw new Error("Review controller requires a lineageId");
|
|
@@ -3180,6 +4364,8 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
|
|
|
3180
4364
|
const parameters: ReviewControllerParameters = {
|
|
3181
4365
|
operation: value.operation,
|
|
3182
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] } : {}),
|
|
3183
4369
|
};
|
|
3184
4370
|
for (const key of ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource", "workspaceRoot"] as const) {
|
|
3185
4371
|
const optional = value[key];
|
|
@@ -3486,8 +4672,8 @@ function nativeStatusUnsupported(operation: ReviewControllerOperation): Record<s
|
|
|
3486
4672
|
// Bundled and source module instances can coexist, making instanceof insufficient.
|
|
3487
4673
|
function asNativeReviewCliError(error: unknown): { code: string; diagnostics: NativeReviewProcessDiagnostics } | undefined {
|
|
3488
4674
|
if (error instanceof NativeReviewCliError) return error;
|
|
3489
|
-
if (!(error
|
|
3490
|
-
const value = error as
|
|
4675
|
+
if (!isRecord(error) || error.name !== "NativeReviewCliError") return undefined;
|
|
4676
|
+
const value = error as { code?: unknown; diagnostics?: unknown };
|
|
3491
4677
|
if (typeof value.code !== "string") return undefined;
|
|
3492
4678
|
const diagnostics = sanitizeForeignNativeReviewDiagnostics(value.diagnostics);
|
|
3493
4679
|
return diagnostics === undefined || value.code !== diagnostics.error_code ? undefined : { code: value.code, diagnostics };
|
|
@@ -3509,7 +4695,9 @@ function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation,
|
|
|
3509
4695
|
...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { lineage_created: false, mutation_performed: false, mutation_outcome: "none" }),
|
|
3510
4696
|
inventory_complete: false,
|
|
3511
4697
|
diagnostics,
|
|
3512
|
-
|
|
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,
|
|
3513
4701
|
};
|
|
3514
4702
|
}
|
|
3515
4703
|
|
|
@@ -3843,6 +5031,18 @@ function mapNativeTargetStatus(operation: ReviewControllerOperation, status: Rev
|
|
|
3843
5031
|
hint: `run ${status.nextTransition.continuation.command}`,
|
|
3844
5032
|
};
|
|
3845
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
|
+
}
|
|
3846
5046
|
return {
|
|
3847
5047
|
operation,
|
|
3848
5048
|
status: status.action === "start" ? "ready" : "blocked",
|
|
@@ -3936,6 +5136,12 @@ interface RetainedNativeUntrackedSelection {
|
|
|
3936
5136
|
readonly untrackedScope: NativeStartUntrackedScope;
|
|
3937
5137
|
readonly expectedUntrackedInventory: string;
|
|
3938
5138
|
readonly intendedUntracked: readonly string[];
|
|
5139
|
+
readonly submission?: NativeIntendedUntrackedSelectionSubmission;
|
|
5140
|
+
}
|
|
5141
|
+
|
|
5142
|
+
interface RetainedPreLineageNativeUntrackedSelection extends RetainedNativeUntrackedSelection {
|
|
5143
|
+
readonly targetIdentity: string;
|
|
5144
|
+
readonly candidateTree: string;
|
|
3939
5145
|
}
|
|
3940
5146
|
|
|
3941
5147
|
interface RetainedNativeCaptureRoute { readonly workspaceRoot: string; readonly lineageId: string; readonly baseRef?: string; readonly committedOnly?: true; }
|
|
@@ -4138,17 +5344,6 @@ const processRetainedNativeStatusSelections = new Map<PendingReviewConsentSessio
|
|
|
4138
5344
|
// and a fresh primary-loop start resets it to 0.
|
|
4139
5345
|
const processAgentEndSubagentDepth = new Map<PendingReviewConsentSessionKey, number>();
|
|
4140
5346
|
|
|
4141
|
-
// Target identities already nudged once per session, so the read-only
|
|
4142
|
-
// `agent_end` preflight reminder fires at most once per unreviewed candidate.
|
|
4143
|
-
const processAgentEndPreflightNudgedTargets = new Map<PendingReviewConsentSessionKey, Set<string>>();
|
|
4144
|
-
|
|
4145
|
-
// gentle-pi#568: the target identity negotiated STATUS reported at
|
|
4146
|
-
// `session_start`, before this session touched the worktree. A candidate
|
|
4147
|
-
// already present at that point is the user's own pre-session work, not
|
|
4148
|
-
// something this session produced, so `agent_end` must not treat it as an
|
|
4149
|
-
// unreviewed candidate this session should be reminded about.
|
|
4150
|
-
const processAgentEndSessionBaseline = new Map<PendingReviewConsentSessionKey, string>();
|
|
4151
|
-
|
|
4152
5347
|
// gentle-pi#677: gentle-ai#4309 owns anonymous usage telemetry end to end;
|
|
4153
5348
|
// Pi only nudges it once per process. This is a plain process-lifetime
|
|
4154
5349
|
// guard, not a session-keyed map, because the nudge is meant to fire at most
|
|
@@ -4341,6 +5536,27 @@ function consentBindingRepositoryMismatchOutcome(operation: ReviewControllerOper
|
|
|
4341
5536
|
};
|
|
4342
5537
|
}
|
|
4343
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
|
+
|
|
4344
5560
|
function assertNativeStartCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void {
|
|
4345
5561
|
candidateView.verify();
|
|
4346
5562
|
if (
|
|
@@ -4503,7 +5719,9 @@ async function reconcileNativeMutationFailure(
|
|
|
4503
5719
|
try {
|
|
4504
5720
|
const status = await nativeReviewCli.targetStatus(target);
|
|
4505
5721
|
syncRetainedNativeStatusSelections(retainedSelections, canonicalRetentionRoot, status, target.baseRef);
|
|
4506
|
-
const
|
|
5722
|
+
const projectedStatus = mapNativeTargetStatus(operation, status, target.lineageId);
|
|
5723
|
+
const { next_action: staleNextAction, required_status_action: staleStatusDirective, ...reconciledBase } = failure;
|
|
5724
|
+
void staleNextAction;
|
|
4507
5725
|
void staleStatusDirective;
|
|
4508
5726
|
// Field defect (fambig, 2026-08-16): an envelope-less mutating failure
|
|
4509
5727
|
// is stamped mutationOutcome "unknown", but a reconciled authority
|
|
@@ -4517,6 +5735,8 @@ async function reconcileNativeMutationFailure(
|
|
|
4517
5735
|
void staleReplayability;
|
|
4518
5736
|
return {
|
|
4519
5737
|
...provenBase,
|
|
5738
|
+
...projectedStatus,
|
|
5739
|
+
status: "blocked",
|
|
4520
5740
|
outcome: "native-mutation-status-reconciled",
|
|
4521
5741
|
reconciliation: status.raw,
|
|
4522
5742
|
authority_applicability: status.applicability,
|
|
@@ -4524,17 +5744,19 @@ async function reconcileNativeMutationFailure(
|
|
|
4524
5744
|
mutation_performed: false,
|
|
4525
5745
|
mutation_outcome: "none",
|
|
4526
5746
|
mutation_outcome_reason: `authority revision unchanged across reconciliation (${preOperationRevision}); the failed operation provably did not mutate`,
|
|
4527
|
-
next_action: status.action,
|
|
4528
5747
|
};
|
|
4529
5748
|
}
|
|
4530
5749
|
return {
|
|
4531
5750
|
...reconciledBase,
|
|
5751
|
+
...projectedStatus,
|
|
5752
|
+
status: "blocked",
|
|
4532
5753
|
outcome: "native-mutation-status-reconciled",
|
|
4533
5754
|
reconciliation: status.raw,
|
|
4534
5755
|
authority_applicability: status.applicability,
|
|
4535
5756
|
provider_action: status.action,
|
|
4536
5757
|
replayability: status.replayability,
|
|
4537
|
-
next_action:
|
|
5758
|
+
...(status.action === "start" && projectedStatus.next_action === undefined ? { next_action: "start" } : {}),
|
|
5759
|
+
required_status_action: projectedStatus.required_status_action ?? requiredStatusActionText(target.lineageId),
|
|
4538
5760
|
};
|
|
4539
5761
|
} catch (statusError) {
|
|
4540
5762
|
return {
|
|
@@ -4636,6 +5858,41 @@ function readRetainedNativeUntrackedSelection(selections: Map<string, RetainedNa
|
|
|
4636
5858
|
};
|
|
4637
5859
|
}
|
|
4638
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
|
+
|
|
4639
5896
|
function isRetainedNativeCaptureRoute(selection: RetainedNativeStatusSelection | undefined): selection is RetainedNativeCaptureRoute {
|
|
4640
5897
|
return selection !== undefined && "workspaceRoot" in selection;
|
|
4641
5898
|
}
|
|
@@ -4706,6 +5963,16 @@ const REVIEW_HOST_RELAY_REFUSED_ACTION =
|
|
|
4706
5963
|
"gentle-ai refused this submission at admission and did not consume the lens slot; the reason is in failure.stderr. "
|
|
4707
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.";
|
|
4708
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
|
+
|
|
4709
5976
|
function reviewHostRelayTimeoutNextAction(error: ReviewHostRelayError): string {
|
|
4710
5977
|
const measured = error.elapsedMs === null || error.timeoutMs === null
|
|
4711
5978
|
? ""
|
|
@@ -4891,6 +6158,101 @@ async function executeReviewHostRelayCapture(
|
|
|
4891
6158
|
mutation_outcome: "none",
|
|
4892
6159
|
};
|
|
4893
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
|
+
}
|
|
4894
6256
|
return {
|
|
4895
6257
|
tool: "gentle_review_capture",
|
|
4896
6258
|
status: "blocked",
|
|
@@ -4911,6 +6273,21 @@ async function executeReviewHostRelayCapture(
|
|
|
4911
6273
|
const REVIEW_PROVIDER_ROLE_RETRY_ACTION =
|
|
4912
6274
|
"Call fresh STATUS and execute only the exact one-slot role vector it reoffers; never relaunch from transcript inference.";
|
|
4913
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
|
+
|
|
4914
6291
|
async function executeProviderRoleVectorCapture(
|
|
4915
6292
|
slot: ReviewProviderRoleVectorSlot,
|
|
4916
6293
|
nativeReviewCli: NativeReviewCli,
|
|
@@ -5080,9 +6457,9 @@ async function negotiatedStatusForHostTransport(
|
|
|
5080
6457
|
// under the exact guards `agent_end` uses to decide whether to nudge: a
|
|
5081
6458
|
// native review CLI with both `reviewMode` and `targetStatus`, a UI-bearing
|
|
5082
6459
|
// context, and RDD effectively on. Returns `undefined` on any missing guard,
|
|
5083
|
-
// an effective-off mode, or any STATUS error or transport refusal
|
|
5084
|
-
//
|
|
5085
|
-
//
|
|
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.
|
|
5086
6463
|
async function resolveNegotiatedReviewStatusForSession(
|
|
5087
6464
|
nativeReviewCli: NativeReviewCli | null,
|
|
5088
6465
|
ctx: ExtensionContext,
|
|
@@ -5107,12 +6484,12 @@ async function resolveNegotiatedReviewStatusForSession(
|
|
|
5107
6484
|
}
|
|
5108
6485
|
}
|
|
5109
6486
|
|
|
5110
|
-
// gentle-pi#556 / gentle-ai#4051: the
|
|
6487
|
+
// gentle-pi#556 / gentle-ai#4051: the mutation-gated reminder sent
|
|
5111
6488
|
// through `agent_end`. It never runs START itself, so it names the one
|
|
5112
6489
|
// supported continuation (gentle_review inspect) and defers the resulting
|
|
5113
6490
|
// consent envelope to the human.
|
|
5114
6491
|
function renderAgentEndReviewPreflightMessage(targetIdentity: string): string {
|
|
5115
|
-
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.`;
|
|
5116
6493
|
}
|
|
5117
6494
|
|
|
5118
6495
|
function canonicalReviewCaptureBinding(value: unknown): string {
|
|
@@ -5157,6 +6534,13 @@ function reviewIntendedUntrackedInput(status: ReviewStatusV3): ReviewCollectInpu
|
|
|
5157
6534
|
return matches.length === 1 ? matches[0] : undefined;
|
|
5158
6535
|
}
|
|
5159
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
|
+
|
|
5160
6544
|
interface PublicReviewCaptureBinding { collectBinding: string; }
|
|
5161
6545
|
function publicReviewCaptureBindings(status: ReviewStatusV3): readonly PublicReviewCaptureBinding[] {
|
|
5162
6546
|
if (status.nextTransition?.kind !== "collect") return [];
|
|
@@ -5581,7 +6965,7 @@ async function executeReviewControllerOperation(
|
|
|
5581
6965
|
const parameters = parseReviewControllerParameters(parametersValue);
|
|
5582
6966
|
const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
|
|
5583
6967
|
const pendingReviewConsentSession = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey);
|
|
5584
|
-
const
|
|
6968
|
+
const _useTargetLifecycleRoot = requiresExplicitTargetLifecycleRoot(parameters.workspaceRoot, sessionCwd, defaultCwd);
|
|
5585
6969
|
const includeWorkspaceRoot = parameters.workspaceRoot !== undefined || defaultCwd !== sessionCwd;
|
|
5586
6970
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.EXPORT || parameters.operation === REVIEW_CONTROLLER_OPERATION.IMPORT) {
|
|
5587
6971
|
// Legacy bundle transport rode on the retired pre-integration graph/compact
|
|
@@ -5616,20 +7000,138 @@ async function executeReviewControllerOperation(
|
|
|
5616
7000
|
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
5617
7001
|
return await executeNativeAuthorityMaintenance(parameters.operation, maintenance, input, defaultCwd, nativeReviewCli, signal);
|
|
5618
7002
|
}
|
|
5619
|
-
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, "");
|
|
5620
7011
|
try {
|
|
5621
7012
|
if (nativeReviewCli.targetStatus !== undefined) {
|
|
5622
|
-
const negotiated = await negotiatedStatusForHostTransport(
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
7013
|
+
const negotiated = await negotiatedStatusForHostTransport(
|
|
7014
|
+
nativeReviewCli,
|
|
7015
|
+
{
|
|
7016
|
+
cwd: defaultCwd,
|
|
7017
|
+
...(signal === undefined ? {} : { signal }),
|
|
7018
|
+
},
|
|
7019
|
+
retainedUntrackedSelections,
|
|
7020
|
+
defaultCwd,
|
|
7021
|
+
);
|
|
5626
7022
|
if (negotiated.transport !== undefined) {
|
|
5627
7023
|
return {
|
|
5628
7024
|
...hostTransportUnavailable(parameters.operation, negotiated.transport),
|
|
5629
7025
|
...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
|
|
5630
7026
|
};
|
|
5631
7027
|
}
|
|
5632
|
-
|
|
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
|
+
};
|
|
5633
7135
|
}
|
|
5634
7136
|
return nativeStatusUnsupported(parameters.operation);
|
|
5635
7137
|
} catch (error) {
|
|
@@ -5958,6 +7460,9 @@ async function executeReviewControllerOperation(
|
|
|
5958
7460
|
};
|
|
5959
7461
|
}
|
|
5960
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, "");
|
|
5961
7466
|
completed = completeNativeStart(parameters.operation, answered.start, pending.repositoryCwd, pending.candidateView, pending.candidateViews);
|
|
5962
7467
|
} catch (error) {
|
|
5963
7468
|
const value = error as { mutationOutcome?: unknown };
|
|
@@ -6009,9 +7514,35 @@ async function executeReviewControllerOperation(
|
|
|
6009
7514
|
if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) return nativeStartRejection("base-ref-invalid");
|
|
6010
7515
|
if (baseRef !== undefined && rawStart.committedOnly !== true) return nativeStartRejection("committed-only-required");
|
|
6011
7516
|
if (baseRef === undefined && "committedOnly" in rawStart) return nativeStartRejection("committed-only-invalid");
|
|
6012
|
-
const
|
|
6013
|
-
|
|
6014
|
-
|
|
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);
|
|
6015
7546
|
let canonicalBaseRef: string | undefined;
|
|
6016
7547
|
if (baseRef !== undefined) {
|
|
6017
7548
|
try {
|
|
@@ -6036,11 +7567,50 @@ async function executeReviewControllerOperation(
|
|
|
6036
7567
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
6037
7568
|
...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }),
|
|
6038
7569
|
...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
|
|
6039
|
-
...(
|
|
7570
|
+
...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
|
|
6040
7571
|
...(signal === undefined ? {} : { signal }),
|
|
6041
7572
|
}, retainedUntrackedSelections, defaultCwd);
|
|
6042
7573
|
if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport);
|
|
6043
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
|
+
}
|
|
6044
7614
|
if (target.nextTransition?.kind === "collect" || target.applicability !== "unrelated" || target.action !== "start") return mapNativeTargetStatus(parameters.operation, target, parameters.lineageId);
|
|
6045
7615
|
} catch (error) {
|
|
6046
7616
|
return nativeOperationFailure(parameters.operation, error);
|
|
@@ -6083,7 +7653,7 @@ async function executeReviewControllerOperation(
|
|
|
6083
7653
|
targetIdentity: target.targetIdentity,
|
|
6084
7654
|
projection: target.projection.projection,
|
|
6085
7655
|
...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
|
|
6086
|
-
...(
|
|
7656
|
+
...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
|
|
6087
7657
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
6088
7658
|
...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
|
|
6089
7659
|
...(focus === undefined ? {} : { focus }),
|
|
@@ -6141,22 +7711,30 @@ async function executeReviewControllerOperation(
|
|
|
6141
7711
|
};
|
|
6142
7712
|
}
|
|
6143
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, "");
|
|
6144
7717
|
return completeNativeStart(parameters.operation, result, defaultCwd, candidateView, candidateViews);
|
|
6145
7718
|
} catch (error) {
|
|
6146
|
-
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 }));
|
|
6147
7720
|
if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason);
|
|
6148
7721
|
const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
|
|
6149
7722
|
const provenNoMutation = value.mutationOutcome === "none";
|
|
6150
|
-
const
|
|
6151
|
-
if (candidateView && candidateViews && (provenNoMutation ||
|
|
7723
|
+
const preNativeFailure = !nativeStartAttempted;
|
|
7724
|
+
if (candidateView && candidateViews && (provenNoMutation || preNativeFailure)) candidateViews.cleanup(candidateView.token);
|
|
7725
|
+
const nativeCliError = asNativeReviewCliError(error);
|
|
6152
7726
|
const failure = provenNoMutation
|
|
6153
7727
|
? error
|
|
6154
|
-
:
|
|
6155
|
-
? Object.assign(error, { candidateViewPreNative: true })
|
|
6156
|
-
: Object.assign(
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
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
|
+
);
|
|
6160
7738
|
return reconcileNativeMutationFailure(parameters.operation, failure, nativeReviewCli, {
|
|
6161
7739
|
cwd: defaultCwd,
|
|
6162
7740
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
@@ -6293,7 +7871,7 @@ async function executeReviewControllerOperation(
|
|
|
6293
7871
|
) retainNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId, retainedUntrackedSelection);
|
|
6294
7872
|
clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state);
|
|
6295
7873
|
hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, status);
|
|
6296
|
-
return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId
|
|
7874
|
+
return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
|
|
6297
7875
|
} catch (error) {
|
|
6298
7876
|
return nativeOperationFailure(parameters.operation, error);
|
|
6299
7877
|
}
|
|
@@ -6311,6 +7889,9 @@ export const __testing = {
|
|
|
6311
7889
|
listDiscoverableAgents,
|
|
6312
7890
|
orderDiscoverableAgents,
|
|
6313
7891
|
classifyGuardedCommand,
|
|
7892
|
+
evaluateGuardedCommand,
|
|
7893
|
+
guardedCommandPreview,
|
|
7894
|
+
guardedCommandTitle,
|
|
6314
7895
|
loadRuntimeGuardrailsConfig,
|
|
6315
7896
|
buildGentlePrompt,
|
|
6316
7897
|
nativeStatusUnsupported,
|
|
@@ -6345,6 +7926,9 @@ export const __testing = {
|
|
|
6345
7926
|
clearNativeReviewOutcomeMemoForTesting,
|
|
6346
7927
|
resolveControllerSddStatus,
|
|
6347
7928
|
resolveStartupControllerSddStatus,
|
|
7929
|
+
resolveSddChangeStartup,
|
|
7930
|
+
resolveSelectedNativeSddChangeStartup,
|
|
7931
|
+
readSddChangeFlag,
|
|
6348
7932
|
resetTelemetryTriggerGuardForTesting,
|
|
6349
7933
|
createGentleAiExtension: createGentleAiExtensionForTesting,
|
|
6350
7934
|
};
|
|
@@ -6415,6 +7999,11 @@ function createGentleAiExtensionForTesting(
|
|
|
6415
7999
|
const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary;
|
|
6416
8000
|
const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter();
|
|
6417
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
|
+
});
|
|
6418
8007
|
declareReviewRelayHandshake(dependencies.processEnv ?? process.env);
|
|
6419
8008
|
const pendingReviewConsentFallbackKey = Symbol("pending-review-consent-fallback");
|
|
6420
8009
|
const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
|
|
@@ -6449,7 +8038,11 @@ function createGentleAiExtensionForTesting(
|
|
|
6449
8038
|
return revoked;
|
|
6450
8039
|
};
|
|
6451
8040
|
|
|
8041
|
+
let reminderSessionActive = true;
|
|
8042
|
+
let reminderEpoch = 0;
|
|
6452
8043
|
pi.on("session_shutdown", (event, context) => {
|
|
8044
|
+
reminderSessionActive = false;
|
|
8045
|
+
reminderEpoch += 1;
|
|
6453
8046
|
// Pi tears down this registry on reload as well as session replacement/quit.
|
|
6454
8047
|
try { candidateViews?.cleanupAll(); } catch { /* Preserve failed owned views for later recovery. */ }
|
|
6455
8048
|
const reason = (event as { reason?: unknown }).reason;
|
|
@@ -6462,12 +8055,11 @@ function createGentleAiExtensionForTesting(
|
|
|
6462
8055
|
cleanupAllPendingReviewConsents(pendingReviewConsentRegistry, sessionKey);
|
|
6463
8056
|
processRetainedNativeStatusSelections.delete(sessionKey);
|
|
6464
8057
|
processAgentEndSubagentDepth.delete(sessionKey);
|
|
6465
|
-
processAgentEndPreflightNudgedTargets.delete(sessionKey);
|
|
6466
|
-
processAgentEndSessionBaseline.delete(sessionKey);
|
|
6467
8058
|
});
|
|
6468
8059
|
|
|
6469
8060
|
pi.registerTool({
|
|
6470
8061
|
name: "gentle_review_scope",
|
|
8062
|
+
renderShell: "self",
|
|
6471
8063
|
label: "Gentle Review Scope",
|
|
6472
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.",
|
|
6473
8065
|
parameters: REVIEW_SCOPE_PARAMETERS,
|
|
@@ -6510,6 +8102,7 @@ function createGentleAiExtensionForTesting(
|
|
|
6510
8102
|
|
|
6511
8103
|
pi.registerTool({
|
|
6512
8104
|
name: "gentle_review_capture_group",
|
|
8105
|
+
renderShell: "self",
|
|
6513
8106
|
label: "Gentle Review Capture Group",
|
|
6514
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.",
|
|
6515
8108
|
promptSnippet: "Use one complete exact current STATUS materialize reviewer group; acknowledge its forecast before the grouped run.",
|
|
@@ -6544,6 +8137,7 @@ function createGentleAiExtensionForTesting(
|
|
|
6544
8137
|
|
|
6545
8138
|
pi.registerTool({
|
|
6546
8139
|
name: "gentle_review_capture",
|
|
8140
|
+
renderShell: "self",
|
|
6547
8141
|
label: "Gentle Review Capture",
|
|
6548
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.",
|
|
6549
8143
|
promptSnippet: "Use one exact current STATUS collectBinding for one ordinary native capture; call fresh STATUS before every additional capture.",
|
|
@@ -6584,12 +8178,14 @@ function createGentleAiExtensionForTesting(
|
|
|
6584
8178
|
|
|
6585
8179
|
pi.registerTool({
|
|
6586
8180
|
name: "gentle_review",
|
|
8181
|
+
renderShell: "self",
|
|
6587
8182
|
label: "Gentle Review Controller",
|
|
6588
8183
|
description:
|
|
6589
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.",
|
|
6590
8185
|
promptSnippet: "Inspect authority, then start native ordinary review; use gentle_review_capture for one current collect slot",
|
|
6591
8186
|
promptGuidelines: [
|
|
6592
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.',
|
|
6593
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.",
|
|
6594
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.",
|
|
6595
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.",
|
|
@@ -6616,6 +8212,17 @@ function createGentleAiExtensionForTesting(
|
|
|
6616
8212
|
const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
6617
8213
|
const retainedSelections = processRetainedNativeStatusSelections.get(sessionKey)
|
|
6618
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. */ }
|
|
6619
8226
|
let details = await executeReviewControllerOperation(
|
|
6620
8227
|
parameters,
|
|
6621
8228
|
ctx.cwd,
|
|
@@ -6629,6 +8236,16 @@ function createGentleAiExtensionForTesting(
|
|
|
6629
8236
|
reviewConsentNow,
|
|
6630
8237
|
reviewConsentScheduleTimer,
|
|
6631
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
|
+
}
|
|
6632
8249
|
if (
|
|
6633
8250
|
isHostReviewConsentEligibleOperation(parameters) &&
|
|
6634
8251
|
details.outcome === "native-review-consent-required" &&
|
|
@@ -6708,10 +8325,12 @@ function createGentleAiExtensionForTesting(
|
|
|
6708
8325
|
});
|
|
6709
8326
|
|
|
6710
8327
|
function runSddPreflight(ctx: ExtensionContext, promptFields: readonly SddPreflightField[] = []): Promise<SddPreflightPreferences> {
|
|
6711
|
-
return ensureSddPreflight(ctx, { pi, installAssets: (cwd) =>
|
|
8328
|
+
return ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installPackageAssets(cwd, true, ["sdd"]), applyModelConfig: async () => applySavedModelConfig(ctx) }, { promptFields });
|
|
6712
8329
|
}
|
|
6713
8330
|
|
|
6714
8331
|
pi.on("session_start", async (event, ctx) => {
|
|
8332
|
+
reminderSessionActive = true;
|
|
8333
|
+
reminderEpoch += 1;
|
|
6715
8334
|
try { candidateViews?.sweepOrphans(ctx.cwd); } catch { /* Ownership sweeping must not block startup. */ }
|
|
6716
8335
|
const reason = (event as { reason?: unknown }).reason;
|
|
6717
8336
|
if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx);
|
|
@@ -6726,7 +8345,7 @@ function createGentleAiExtensionForTesting(
|
|
|
6726
8345
|
if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
6727
8346
|
}
|
|
6728
8347
|
try {
|
|
6729
|
-
const installResult =
|
|
8348
|
+
const installResult = installPackageAssets(ctx.cwd, true, ["delegation", "review"]);
|
|
6730
8349
|
migrateLegacyProjectModelOverrides(ctx.cwd);
|
|
6731
8350
|
const modelResult = await applySavedModelConfig(ctx);
|
|
6732
8351
|
if (ctx.hasUI && modelResult.invalidPath) {
|
|
@@ -6738,7 +8357,7 @@ function createGentleAiExtensionForTesting(
|
|
|
6738
8357
|
}
|
|
6739
8358
|
if (ctx.hasUI && modelResult.updated > 0) {
|
|
6740
8359
|
ctx.ui.notify(
|
|
6741
|
-
`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).`,
|
|
6742
8361
|
"info",
|
|
6743
8362
|
);
|
|
6744
8363
|
}
|
|
@@ -6752,18 +8371,13 @@ function createGentleAiExtensionForTesting(
|
|
|
6752
8371
|
);
|
|
6753
8372
|
}
|
|
6754
8373
|
}
|
|
6755
|
-
//
|
|
6756
|
-
//
|
|
6757
|
-
// later. Best-effort and silent: it never notifies and never lets a
|
|
6758
|
-
// 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.
|
|
6759
8376
|
try {
|
|
6760
8377
|
const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
6761
|
-
|
|
6762
|
-
if (status?.targetIdentity !== undefined) {
|
|
6763
|
-
processAgentEndSessionBaseline.set(sessionKey, status.targetIdentity);
|
|
6764
|
-
}
|
|
8378
|
+
await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
|
|
6765
8379
|
} catch {
|
|
6766
|
-
//
|
|
8380
|
+
// Startup negotiation is best-effort only; never surface or throw.
|
|
6767
8381
|
}
|
|
6768
8382
|
});
|
|
6769
8383
|
|
|
@@ -6771,11 +8385,17 @@ function createGentleAiExtensionForTesting(
|
|
|
6771
8385
|
if (typeof event.text !== "string" || !isSddPreflightTrigger(event.text)) {
|
|
6772
8386
|
return { action: "continue" };
|
|
6773
8387
|
}
|
|
6774
|
-
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
|
+
}
|
|
6775
8393
|
return { action: "continue" };
|
|
6776
8394
|
});
|
|
6777
8395
|
|
|
8396
|
+
let nativeSddStartupBlock: string | undefined;
|
|
6778
8397
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
8398
|
+
nativeSddStartupBlock = undefined;
|
|
6779
8399
|
const isSddAgent = isSddAgentStartEvent(event);
|
|
6780
8400
|
const isNamedAgent = isNamedAgentStartEvent(event);
|
|
6781
8401
|
const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
@@ -6804,23 +8424,57 @@ function createGentleAiExtensionForTesting(
|
|
|
6804
8424
|
// Best-effort only; never surfaced and never affects activation.
|
|
6805
8425
|
}
|
|
6806
8426
|
}
|
|
6807
|
-
|
|
6808
|
-
|
|
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.` };
|
|
6809
8435
|
}
|
|
6810
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.
|
|
6811
8440
|
const sddPrompt =
|
|
6812
8441
|
prefs && (!isNamedAgent || isSddAgent)
|
|
6813
8442
|
? `\n\n${renderSddPreflightPrompt(prefs)}`
|
|
6814
8443
|
: "";
|
|
6815
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";
|
|
6816
8447
|
const nativeStatusPrompt = phase
|
|
6817
|
-
?
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
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.";
|
|
6824
8478
|
// gentle-pi#661: the RDD status line (and the rest of the gentle prompt)
|
|
6825
8479
|
// is built only for the primary session, mirrored on the
|
|
6826
8480
|
// reviewContractPrompt condition below -- named/SDD agents never reach
|
|
@@ -6847,7 +8501,7 @@ function createGentleAiExtensionForTesting(
|
|
|
6847
8501
|
})()
|
|
6848
8502
|
: "";
|
|
6849
8503
|
return {
|
|
6850
|
-
systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}`,
|
|
8504
|
+
systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}${!isNamedAgent && !isSddAgent ? `\n\n${renderResearchCapabilities(resolveResearchCapabilities(pi))}` : ""}`,
|
|
6851
8505
|
};
|
|
6852
8506
|
});
|
|
6853
8507
|
|
|
@@ -6855,33 +8509,30 @@ function createGentleAiExtensionForTesting(
|
|
|
6855
8509
|
// an authorized implementation and report completion without ever running
|
|
6856
8510
|
// the review STATUS preflight or offering the consent question. This
|
|
6857
8511
|
// handler is read-only and idempotent: it never runs START, never answers
|
|
6858
|
-
// consent,
|
|
6859
|
-
//
|
|
6860
|
-
// gentle-pi#568: a candidate matching the baseline `session_start`
|
|
6861
|
-
// recorded predates this session's own work and is skipped rather than
|
|
6862
|
-
// nudged, so a worktree already dirty from the user's own edits does not
|
|
6863
|
-
// draw a reminder about work this session never produced.
|
|
8512
|
+
// consent, or chooses a partial candidate. Durable own-mutation receipts
|
|
8513
|
+
// gate STATUS and consume only the generation captured before that await.
|
|
6864
8514
|
pi.on("agent_end", async (_event, ctx) => {
|
|
6865
8515
|
if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return;
|
|
6866
|
-
if (ctx.hasUI !== true) return;
|
|
8516
|
+
if (ctx.hasUI !== true || !reminderSessionActive) return;
|
|
6867
8517
|
const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
6868
8518
|
const subagentDepth = processAgentEndSubagentDepth.get(sessionKey) ?? 0;
|
|
6869
8519
|
if (subagentDepth > 0) {
|
|
6870
8520
|
processAgentEndSubagentDepth.set(sessionKey, subagentDepth - 1);
|
|
6871
8521
|
return;
|
|
6872
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;
|
|
6873
8530
|
const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey);
|
|
6874
|
-
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;
|
|
6875
8534
|
if (status.nextTransition?.kind !== "execute" || status.nextTransition.execute.operation !== "review.start") return;
|
|
6876
8535
|
const targetIdentity = status.targetIdentity;
|
|
6877
|
-
if (processAgentEndSessionBaseline.get(sessionKey) === targetIdentity) return;
|
|
6878
|
-
let nudged = processAgentEndPreflightNudgedTargets.get(sessionKey);
|
|
6879
|
-
if (nudged === undefined) {
|
|
6880
|
-
nudged = new Set<string>();
|
|
6881
|
-
processAgentEndPreflightNudgedTargets.set(sessionKey, nudged);
|
|
6882
|
-
}
|
|
6883
|
-
if (nudged.has(targetIdentity)) return;
|
|
6884
|
-
nudged.add(targetIdentity);
|
|
6885
8536
|
pi.sendMessage(
|
|
6886
8537
|
{
|
|
6887
8538
|
customType: "gentle-pi.review-preflight",
|
|
@@ -6890,15 +8541,70 @@ function createGentleAiExtensionForTesting(
|
|
|
6890
8541
|
},
|
|
6891
8542
|
{ triggerTurn: true, deliverAs: "followUp" },
|
|
6892
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. */ }
|
|
6893
8554
|
});
|
|
6894
8555
|
|
|
6895
8556
|
pi.on("tool_call", async (event, ctx) => {
|
|
8557
|
+
if (nativeSddStartupBlock && event.toolName !== "subagent_parent_message") return { block: true, reason: `SDD selection blocked: ${nativeSddStartupBlock}` };
|
|
6896
8558
|
const sensitivePathDenied = evaluateSensitivePathTool(
|
|
6897
8559
|
event.toolName,
|
|
6898
8560
|
event.input,
|
|
6899
8561
|
);
|
|
6900
8562
|
if (sensitivePathDenied) return sensitivePathDenied;
|
|
6901
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;
|
|
6902
8608
|
const writerScopeDenied = rejectUnscopedBoundedWriterDispatch(event.input);
|
|
6903
8609
|
if (writerScopeDenied) return writerScopeDenied;
|
|
6904
8610
|
try {
|
|
@@ -6918,39 +8624,50 @@ function createGentleAiExtensionForTesting(
|
|
|
6918
8624
|
return await confirmCommand(event.input.command, ctx, pi.events, herdrLifecycle);
|
|
6919
8625
|
});
|
|
6920
8626
|
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
|
|
6926
|
-
|
|
6927
|
-
|
|
6928
|
-
|
|
6929
|
-
|
|
6930
|
-
|
|
6931
|
-
|
|
6932
|
-
|
|
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
|
+
}
|
|
6933
8641
|
|
|
6934
8642
|
pi.registerCommand("gentle:sdd-preflight", {
|
|
6935
8643
|
description:
|
|
6936
|
-
"Run or reuse
|
|
6937
|
-
handler: async (
|
|
6938
|
-
|
|
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
|
+
}
|
|
6939
8655
|
},
|
|
6940
8656
|
});
|
|
6941
8657
|
|
|
6942
|
-
const
|
|
8658
|
+
const readCommandSddStatus = async (args: string, ctx: ExtensionContext) => {
|
|
6943
8659
|
const parsed = parseSddStatusCommandArgs(args);
|
|
6944
|
-
const
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
6950
|
-
ctx.ui
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
);
|
|
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);
|
|
6954
8671
|
};
|
|
6955
8672
|
|
|
6956
8673
|
pi.registerCommand("gentle-sdd-status", {
|
|
@@ -6961,17 +8678,34 @@ function createGentleAiExtensionForTesting(
|
|
|
6961
8678
|
});
|
|
6962
8679
|
|
|
6963
8680
|
const handleSddContinueCommand = async (args: string, ctx: ExtensionContext) => {
|
|
6964
|
-
const parsed =
|
|
6965
|
-
const
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
6974
|
-
);
|
|
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);
|
|
6975
8709
|
};
|
|
6976
8710
|
|
|
6977
8711
|
pi.registerCommand("gentle-sdd-continue", {
|
|
@@ -6988,6 +8722,13 @@ function createGentleAiExtensionForTesting(
|
|
|
6988
8722
|
},
|
|
6989
8723
|
});
|
|
6990
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
|
+
|
|
6991
8732
|
pi.registerCommand("gentle:persona", {
|
|
6992
8733
|
description: "Switch el Gentleman persona between gentleman and neutral.",
|
|
6993
8734
|
handler: async (_args, ctx) => {
|
|
@@ -7058,29 +8799,19 @@ function createGentleAiExtensionForTesting(
|
|
|
7058
8799
|
pi.registerCommand("gentle:doctor", {
|
|
7059
8800
|
description: "Run read-only Gentle AI diagnostics for this Pi workspace.",
|
|
7060
8801
|
handler: async (_args, ctx) => {
|
|
7061
|
-
const
|
|
7062
|
-
join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
|
|
7063
|
-
);
|
|
7064
|
-
const chainsInstalled = existsSync(
|
|
7065
|
-
join(gentlePiAgentHome(), "chains", "sdd-full.chain.md"),
|
|
7066
|
-
);
|
|
8802
|
+
const assetLines = packageAssetDiagnosticLines(ctx.cwd);
|
|
7067
8803
|
const openspecConfigured = existsSync(
|
|
7068
8804
|
join(ctx.cwd, "openspec", "config.yaml"),
|
|
7069
8805
|
);
|
|
7070
8806
|
const skillRegistryPresent = existsSync(
|
|
7071
8807
|
join(ctx.cwd, ".atl", "skill-registry.md"),
|
|
7072
8808
|
);
|
|
7073
|
-
const staleSddAssets = sddGlobalAssetDriftCount();
|
|
7074
|
-
const localSddAgentOverrides = sddLocalAgentOverrideCount(ctx.cwd);
|
|
7075
8809
|
const modelConfig = await readSavedModelConfigAsync(ctx.cwd);
|
|
7076
8810
|
const engramActive = hasWritableEngramTool(pi);
|
|
7077
8811
|
const devBinary = await describeDevBinaryOverride();
|
|
7078
8812
|
const lines = [
|
|
7079
8813
|
"el Gentleman doctor",
|
|
7080
|
-
|
|
7081
|
-
`${chainsInstalled ? "pass" : "fail"}: Global SDD chains ${chainsInstalled ? "installed" : "missing"}`,
|
|
7082
|
-
`${staleSddAssets === 0 ? "pass" : "warn"}: Global SDD asset drift ${staleSddAssets} file(s)`,
|
|
7083
|
-
`${localSddAgentOverrides === 0 ? "pass" : "warn"}: Project-local SDD agent overrides ${localSddAgentOverrides} file(s)`,
|
|
8814
|
+
...assetLines,
|
|
7084
8815
|
`${openspecConfigured ? "pass" : "warn"}: OpenSpec config ${openspecConfigured ? "present" : "missing"}`,
|
|
7085
8816
|
`${skillRegistryPresent ? "pass" : "warn"}: Skill registry ${skillRegistryPresent ? "present" : "missing"}`,
|
|
7086
8817
|
`${modelConfig.status === "invalid" ? "fail" : "pass"}: Global model config ${modelConfig.status}`,
|
|
@@ -7089,18 +8820,12 @@ function createGentleAiExtensionForTesting(
|
|
|
7089
8820
|
...(devBinary.state === "active" ? [`warn: ${devBinary.line}`] : []),
|
|
7090
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)"] : []),
|
|
7091
8822
|
];
|
|
7092
|
-
if (!agentsInstalled || !chainsInstalled) {
|
|
7093
|
-
lines.push("remedy: run /gentle:install-sdd --force to refresh global SDD assets intentionally");
|
|
7094
|
-
}
|
|
7095
8823
|
if (modelConfig.status === "invalid") {
|
|
7096
8824
|
lines.push(`remedy: fix or remove ${modelConfig.path}`);
|
|
7097
8825
|
}
|
|
7098
|
-
if (localSddAgentOverrides > 0) {
|
|
7099
|
-
lines.push("remedy: remove project-local SDD agent overrides unless intentionally debugging package assets");
|
|
7100
|
-
}
|
|
7101
8826
|
ctx.ui.notify(
|
|
7102
8827
|
lines.join("\n"),
|
|
7103
|
-
lines.some((line) => line.startsWith("fail:")) ? "warning" : "info",
|
|
8828
|
+
lines.some((line) => line.startsWith("fail:")) || assetLines.some((line) => line.startsWith("warn:")) ? "warning" : "info",
|
|
7104
8829
|
);
|
|
7105
8830
|
},
|
|
7106
8831
|
});
|
|
@@ -7130,7 +8855,7 @@ function createGentleAiExtensionForTesting(
|
|
|
7130
8855
|
});
|
|
7131
8856
|
|
|
7132
8857
|
pi.registerCommand("gentle:review-mode", {
|
|
7133
|
-
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.",
|
|
7134
8859
|
handler: async (args, ctx) => {
|
|
7135
8860
|
const subAction = args.trim().length === 0 ? NATIVE_REVIEW_MODE_OPERATION.STATUS : args.trim();
|
|
7136
8861
|
if (subAction !== NATIVE_REVIEW_MODE_OPERATION.STATUS && subAction !== NATIVE_REVIEW_MODE_OPERATION.ENABLE && subAction !== NATIVE_REVIEW_MODE_OPERATION.DISABLE) {
|
|
@@ -7252,41 +8977,27 @@ function createGentleAiExtensionForTesting(
|
|
|
7252
8977
|
pi.registerCommand("gentle:status", {
|
|
7253
8978
|
description: "Show Gentle AI package status for this project.",
|
|
7254
8979
|
handler: async (_args, ctx) => {
|
|
7255
|
-
const
|
|
7256
|
-
join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
|
|
7257
|
-
);
|
|
7258
|
-
const chainsInstalled = existsSync(
|
|
7259
|
-
join(gentlePiAgentHome(), "chains", "sdd-full.chain.md"),
|
|
7260
|
-
);
|
|
8980
|
+
const assetLines = packageAssetDiagnosticLines(ctx.cwd);
|
|
7261
8981
|
const openspecConfigured = existsSync(
|
|
7262
8982
|
join(ctx.cwd, "openspec", "config.yaml"),
|
|
7263
8983
|
);
|
|
7264
|
-
const
|
|
7265
|
-
|
|
7266
|
-
|
|
8984
|
+
const savedConfig = await readModelRoutingAuthorityAsync(
|
|
8985
|
+
modelConfigPath(ctx.cwd),
|
|
8986
|
+
legacyProjectModelConfigPath(ctx.cwd),
|
|
8987
|
+
);
|
|
7267
8988
|
const devBinary = await describeDevBinaryOverride();
|
|
7268
8989
|
ctx.ui.notify(
|
|
7269
8990
|
[
|
|
7270
8991
|
"el Gentleman package is active.",
|
|
7271
8992
|
...(devBinary.state === "inactive" ? [] : [devBinary.line]),
|
|
7272
8993
|
`Persona: ${readPersonaMode(ctx.cwd)}`,
|
|
7273
|
-
|
|
7274
|
-
`Global SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
|
|
7275
|
-
`Global SDD assets stale: ${staleSddAssets} file(s)${
|
|
7276
|
-
staleSddAssets > 0
|
|
7277
|
-
? " — run /gentle:install-sdd --force to refresh intentionally"
|
|
7278
|
-
: ""
|
|
7279
|
-
}`,
|
|
7280
|
-
`Project-local SDD agent overrides: ${localSddAgentOverrides} file(s)${
|
|
7281
|
-
localSddAgentOverrides > 0
|
|
7282
|
-
? " — local SDD agents shadow package assets; remove them unless intentionally debugging"
|
|
7283
|
-
: ""
|
|
7284
|
-
}`,
|
|
8994
|
+
...assetLines,
|
|
7285
8995
|
`OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
|
|
7286
8996
|
`Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
|
|
7287
|
-
|
|
8997
|
+
`Saved model routing: ${savedConfig.status}${savedConfig.status === "invalid" ? ` (${savedConfig.path})` : ""}`,
|
|
8998
|
+
...(savedConfig.status === "invalid" ? [] : describeModelConfig(ctx.cwd, savedConfig.status === "valid" ? savedConfig.config : {})),
|
|
7288
8999
|
].join("\n"),
|
|
7289
|
-
|
|
9000
|
+
savedConfig.status === "invalid" || assetLines.some((line) => line.startsWith("warn:")) || devBinary.state !== "inactive" ? "warning" : "info",
|
|
7290
9001
|
);
|
|
7291
9002
|
},
|
|
7292
9003
|
});
|