gentle-pi 4.0.0 → 4.2.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 +157 -18
- package/assets/agents/gentle-ai-explore.md +11 -4
- package/assets/agents/gentle-ai-verify.md +23 -4
- package/assets/agents/gentle-ai-worker.md +6 -6
- package/assets/install-wizard/index.html +47 -0
- package/assets/install-wizard/wizard.css +1279 -0
- package/assets/install-wizard/wizard.js +951 -0
- package/assets/orchestrator-delegation.md +70 -163
- package/assets/orchestrator-memory.md +11 -4
- package/assets/orchestrator-prompts.md +48 -0
- package/assets/orchestrator-tracking.md +53 -0
- package/assets/orchestrator-verification.md +28 -0
- package/assets/orchestrator-writer.md +38 -0
- package/assets/orchestrator.md +29 -25
- package/assets/sounds/LICENSE.md +52 -0
- package/assets/sounds/attention.wav +0 -0
- package/assets/sounds/error.wav +0 -0
- package/assets/sounds/success.wav +0 -0
- package/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +708 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +399 -0
- package/docs/delegated-verification.md +3 -5
- package/docs/gentle-agents-activity.md +513 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +90 -33
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +1 -1
- package/docs/permission-modes.md +127 -0
- package/docs/prompt-history.md +1 -1
- package/docs/readme-reference.md +310 -47
- package/docs/render-cache-performance.md +28 -0
- package/docs/session-profile-format.md +39 -0
- package/docs/sound-notifications-proposal.md +191 -0
- package/docs/sound-notifications.md +114 -0
- package/docs/yolo-mode.md +11 -10
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/child-context.ts +11 -2
- package/extensions/child-safety.ts +4 -2
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +718 -87
- package/extensions/gentle-ai.ts +991 -263
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +271 -80
- package/extensions/gentle-todo.ts +123 -18
- package/extensions/history/index.ts +11 -6
- package/extensions/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +91 -21
- package/extensions/skill-registry.ts +175 -46
- package/extensions/startup-banner.ts +11 -2
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +40 -1
- package/lib/agents-runner.ts +90 -15
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +459 -0
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +484 -0
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/background-jobs-tools.ts +202 -0
- package/lib/background-jobs.ts +253 -0
- package/lib/background-monitor.ts +154 -0
- package/lib/bounded-writer-admission.ts +83 -10
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +14 -4
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/context-threshold.ts +26 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-shell-bundled.ts +535 -0
- package/lib/gentle-shell-launcher.ts +75 -24
- package/lib/gentle-shell-startup-flow.ts +54 -0
- package/lib/gentle-shell-startup-update.ts +92 -0
- package/lib/gentle-shell-uninstall.ts +840 -0
- package/lib/gentle-shell-update-panel.ts +231 -0
- package/lib/herdr-activity.ts +183 -0
- package/lib/inprocess-reviewer.ts +3 -0
- package/lib/jobs-sidebar-state.ts +20 -0
- package/lib/jobs-view.ts +161 -0
- package/lib/model-price-ratio.ts +83 -0
- package/lib/model-routing-authority.ts +6 -1
- package/lib/nan-provider.ts +33 -11
- package/lib/native-review-cli.ts +194 -73
- package/lib/notification-audio-native.ts +162 -0
- package/lib/notification-audio-windows.ts +399 -0
- package/lib/notification-audio.ts +332 -0
- package/lib/notification-customize.ts +241 -0
- package/lib/notification-events.ts +113 -0
- package/lib/notification-policy.ts +148 -0
- package/lib/notification-pulse-client.ts +367 -0
- package/lib/notification-pulse-protocol.ts +305 -0
- package/lib/notification-pulse-stream.ts +213 -0
- package/lib/notification-pulse-worker.ts +66 -0
- package/lib/notification-scheduler.ts +173 -0
- package/lib/notification-service.ts +202 -0
- package/lib/notification-ui.ts +87 -0
- package/lib/odd-phase.ts +1 -1
- package/lib/orchestrator-alias.ts +20 -0
- package/lib/orchestrator-catalog.ts +79 -0
- package/lib/orchestrator-consultation.ts +61 -0
- package/lib/orchestrator-discovery.ts +49 -0
- package/lib/orchestrator-helper-consent.ts +122 -0
- package/lib/orchestrator-helper.ts +167 -0
- package/lib/orchestrator-message-card.ts +105 -0
- package/lib/orchestrator-presence.ts +115 -14
- package/lib/orchestrator-scope.ts +80 -0
- package/lib/orchestrator-state.ts +107 -0
- package/lib/orchestrator-tool-card.ts +280 -0
- package/lib/orchestrator-work-search.ts +218 -0
- package/lib/orchestrator-work.ts +79 -0
- package/lib/permission-mode-policy.ts +59 -0
- package/lib/review-candidate-view-owner.ts +8 -6
- package/lib/review-candidate-view.ts +3 -2
- package/lib/review-compact-contract.ts +2 -2
- package/lib/review-host-relay.ts +7 -2
- package/lib/review-integration-v2.ts +100 -22
- package/lib/review-publication-gate.ts +1 -1
- package/lib/review-risk-assessment.ts +77 -51
- package/lib/review-sidebar-state.ts +2 -1
- package/lib/review-transaction.ts +72 -3
- package/lib/runtime-metrics-children.ts +1 -1
- package/lib/runtime-metrics-native.ts +3 -2
- package/lib/selection-engine.ts +22 -7
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +261 -0
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/session-worktree-registry.ts +74 -8
- package/lib/shell-bar.ts +41 -11
- package/lib/shell-changes-view.ts +40 -13
- package/lib/shell-changes.ts +88 -71
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-sidebar-layout.ts +5 -1
- package/lib/shell-todo.ts +81 -26
- package/lib/shell-usage.ts +4 -3
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/vim-editor-adapter.ts +27 -5
- package/lib/visual-customization-policy.ts +20 -9
- package/lib/visual-customize-view.ts +171 -20
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +198 -121
- package/package.json +5 -5
- package/runtime/child-package-injection.mjs +63 -0
- package/runtime/gentle-ai-binary.mjs +109 -4
- package/runtime/gentle-shell-bundled.mjs +536 -0
- package/runtime/gentle-shell-launcher.mjs +74 -23
- package/runtime/gentle-shell-startup-flow.mjs +55 -0
- package/runtime/gentle-shell-startup-update.mjs +93 -0
- package/runtime/gentle-shell-uninstall.mjs +841 -0
- package/runtime/gentle-shell-update-panel.mjs +232 -0
- package/runtime/native-review-cli.mjs +191 -70
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +77 -51
- package/runtime/status-timing-diagnostics.mjs +202 -0
- package/runtime/telemetry-trigger.mjs +3 -1
- package/scripts/bootstrap.cmd +255 -0
- package/scripts/bootstrap.sh +168 -0
- package/scripts/build-distribution.mjs +157 -0
- package/scripts/build-installer-bundles.mjs +136 -0
- package/scripts/build-runtime-modules.mjs +7 -0
- package/scripts/bundled-install.mjs +1308 -0
- package/scripts/bundled-wizard.mjs +399 -0
- package/scripts/check-types.mjs +4 -6
- package/scripts/gentle-ai-installer.mjs +79 -32
- package/scripts/install-gentle-ai.mjs +48 -1
- package/scripts/install-tui-mode-setting.mjs +10 -1
- package/scripts/install-wizard-preview.mjs +154 -0
- package/scripts/installer-downloads.mjs +648 -0
- package/scripts/installer-preflight.mjs +387 -0
- package/scripts/installer-probes.mjs +564 -0
- package/scripts/installer-runner.mjs +1102 -0
- package/scripts/installer-server.mjs +881 -0
- package/scripts/installer-windows-artifacts.json +15 -0
- package/scripts/installer-windows.mjs +752 -0
- package/scripts/main-channel.mjs +452 -0
- package/scripts/npm-pack-result.mjs +31 -0
- package/scripts/pi-update.mjs +223 -0
- package/scripts/render-cache-performance-worker.mjs +205 -0
- package/scripts/render-cache-performance.mjs +115 -0
- package/scripts/run-test-suite.mjs +22 -8
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +23 -5
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +82 -18
- package/skills/gentle-ai/SKILL.md +12 -11
- package/tests/agent-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +12 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-protocol.test.ts +57 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +89 -4
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +276 -0
- package/tests/auto-mode-sdk.test.ts +84 -0
- package/tests/auto-mode.test.ts +49 -0
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +634 -0
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +454 -0
- package/tests/autonomous-guard.test.ts +86 -16
- package/tests/background-cache-warming.test.ts +2 -1
- package/tests/background-jobs.test.ts +227 -0
- package/tests/background-monitor.test.ts +195 -0
- package/tests/background-subagents-default-mode.test.ts +28 -5
- package/tests/bounded-stop-contract.test.ts +85 -0
- package/tests/bounded-writer-admission.test.ts +52 -1
- package/tests/bridge-wake-lifecycle.test.ts +129 -0
- package/tests/bridge-wake-rendering.test.ts +56 -0
- package/tests/build-distribution.test.ts +269 -0
- package/tests/builtin-codemode-optout.test.ts +242 -0
- package/tests/bundled-install.test.ts +1319 -0
- package/tests/bundled-wizard.test.ts +505 -0
- package/tests/card-content-policy.test.ts +68 -0
- package/tests/card-style-policy.test.ts +34 -2
- package/tests/child-codegraph-bootstrap.test.ts +74 -0
- package/tests/child-package-entrypoints.test.ts +65 -0
- package/tests/child-package-injection.test.ts +74 -0
- package/tests/child-safety.test.ts +12 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +107 -2
- package/tests/command-palette.test.ts +5 -5
- package/tests/context-threshold.test.ts +31 -0
- package/tests/delegate-for-reason-contract.test.ts +327 -0
- package/tests/delegated-key-learnings-contract.test.ts +3 -2
- package/tests/devbinary/native-review-parity.devtest.ts +1 -1
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +316 -0
- package/tests/generic-agent-tools.test.ts +16 -1
- package/tests/gentle-agents.test.ts +1895 -76
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +19 -2
- package/tests/gentle-ai-child-guards.test.ts +202 -0
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
- package/tests/gentle-ai-installer.test.ts +548 -58
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +6 -6
- package/tests/gentle-ai.test.ts +999 -31
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +263 -19
- package/tests/gentle-shell-bundled.test.ts +1036 -0
- package/tests/gentle-shell-launcher.test.ts +251 -25
- package/tests/gentle-shell-startup-flow.test.ts +75 -0
- package/tests/gentle-shell-startup-update.test.ts +85 -0
- package/tests/gentle-shell-uninstall.test.ts +978 -0
- package/tests/gentle-shell-update-panel.test.ts +158 -0
- package/tests/gentle-shell.test.ts +611 -100
- package/tests/gentle-todo.test.ts +317 -14
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-child-guard.test.ts +70 -0
- package/tests/inprocess-reviewer.test.ts +20 -0
- package/tests/install-gentle-ai-failure.test.ts +69 -0
- package/tests/install-tui-mode-setting.test.ts +15 -0
- package/tests/install-wizard.test.ts +1223 -0
- package/tests/installer-bundles.test.ts +122 -0
- package/tests/installer-posix-bootstrap.test.ts +1201 -0
- package/tests/installer-preflight.test.ts +750 -0
- package/tests/installer-probes.test.ts +1106 -0
- package/tests/installer-runner.test.ts +2159 -0
- package/tests/installer-server.test.ts +1567 -0
- package/tests/installer-windows-bootstrap.test.ts +2410 -0
- package/tests/jobs-view.test.ts +160 -0
- package/tests/main-channel-upgrade.test.ts +395 -0
- package/tests/main-channel.test.ts +344 -0
- package/tests/maintainer/provider-relay.maintest.ts +13 -12
- package/tests/model-price-ratio.test.ts +116 -0
- package/tests/model-profiles-preservation.test.ts +153 -0
- package/tests/model-routing-package.test.ts +10 -0
- package/tests/nan-provider-runtime.test.ts +156 -0
- package/tests/nan-provider.test.ts +28 -18
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +236 -1
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +1 -1
- package/tests/native-review-parity.test.ts +12 -48
- package/tests/native-risk-evidence-parity.test.ts +21 -0
- package/tests/no-self-pause-contract.test.ts +87 -0
- package/tests/notification-audio-native.test.ts +281 -0
- package/tests/notification-audio-windows.test.ts +581 -0
- package/tests/notification-audio.test.ts +548 -0
- package/tests/notification-customize.test.ts +655 -0
- package/tests/notification-events.test.ts +115 -0
- package/tests/notification-policy.test.ts +201 -0
- package/tests/notification-pulse-client.test.ts +356 -0
- package/tests/notification-pulse-protocol.test.ts +213 -0
- package/tests/notification-pulse-stream.test.ts +320 -0
- package/tests/notification-scheduler.test.ts +258 -0
- package/tests/notification-ui.test.ts +157 -0
- package/tests/npm-pack-result.test.ts +41 -0
- package/tests/odd-integration.test.ts +14 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
- package/tests/odd-routing-contract.test.ts +80 -23
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/orchestrator-budget.test.ts +29 -6
- package/tests/orchestrator-catalog.test.ts +146 -0
- package/tests/orchestrator-consultation-sdk.test.ts +614 -0
- package/tests/orchestrator-consultation.test.ts +111 -0
- package/tests/orchestrator-discovery.test.ts +241 -0
- package/tests/orchestrator-helper-consent.test.ts +171 -0
- package/tests/orchestrator-helper.test.ts +297 -0
- package/tests/orchestrator-message-card.test.ts +190 -0
- package/tests/orchestrator-presence.test.ts +23 -0
- package/tests/orchestrator-rdd-ownership.test.ts +6 -5
- package/tests/orchestrator-scope.test.ts +107 -0
- package/tests/orchestrator-state.test.ts +114 -0
- package/tests/orchestrator-status-publication-contract.test.ts +34 -0
- package/tests/orchestrator-tool-card.test.ts +398 -0
- package/tests/orchestrator-work-search.test.ts +318 -0
- package/tests/orchestrator-work.test.ts +147 -0
- package/tests/package-manifest.test.ts +105 -21
- package/tests/packed-runner-owned-path.test.ts +35 -1
- package/tests/parallel-reason-contract.test.ts +21 -0
- package/tests/parallel-writer-admission.test.ts +210 -0
- package/tests/permission-mode-policy.test.ts +194 -0
- package/tests/persona-single-channel.test.ts +4 -12
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/provider-defect-handoff.test.ts +4 -3
- package/tests/quiet-tool-rendering.test.ts +160 -3
- package/tests/rdd-aware-verification-contract.test.ts +29 -41
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/review-agent-end-preflight.test.ts +62 -10
- package/tests/review-candidate-view.test.ts +101 -12
- package/tests/review-contract-prompt.test.ts +33 -4
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +255 -1
- package/tests/review-controller-workspace-root.test.ts +43 -2
- package/tests/review-controller.test.ts +2 -2
- package/tests/review-correction-lifecycle.test.ts +2 -1
- package/tests/review-gate.test.ts +7 -4
- package/tests/review-graph-schema.test.ts +2 -2
- package/tests/review-host-relay-routing.test.ts +5 -3
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +21 -1
- package/tests/review-json-arguments.test.ts +167 -0
- package/tests/review-last-event-closure.test.ts +157 -8
- package/tests/review-ledger-contract.test.ts +5 -4
- package/tests/review-policy-judgment-day.test.ts +1 -1
- package/tests/review-policy-ordinary.test.ts +2 -2
- package/tests/review-relay-transport-agent.test.ts +66 -1
- package/tests/review-risk-assessment.test.ts +109 -33
- package/tests/review-session-standing-permission-controller.test.ts +16 -17
- package/tests/review-session-standing-permission-ipc.test.ts +6 -5
- package/tests/review-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +83 -5
- package/tests/runtime-harness.mjs +42 -15
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +65 -5
- package/tests/session-change-capture.test.ts +10 -10
- package/tests/session-changes-shell.test.ts +242 -48
- package/tests/session-changes.test.ts +17 -24
- package/tests/session-profile-binding.test.ts +163 -0
- package/tests/session-profile-freeze.test.ts +394 -0
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/session-subject-routing-contract.test.ts +34 -0
- package/tests/session-worktree-registry.test.ts +57 -2
- package/tests/shell-bar.test.ts +40 -0
- package/tests/shell-changes-view.test.ts +85 -34
- package/tests/shell-changes.test.ts +79 -62
- package/tests/shell-render-cache-runtime.test.ts +69 -0
- package/tests/shell-render-cache.test.ts +129 -0
- package/tests/shell-sidebar-layout.test.ts +27 -2
- package/tests/shell-todo.test.ts +132 -11
- package/tests/shell-usage-view.test.ts +1 -1
- package/tests/shell-usage.test.ts +35 -0
- package/tests/short-check-routing-contract.test.ts +49 -0
- package/tests/skill-registry-watcher.test.ts +119 -0
- package/tests/skill-registry.test.ts +311 -5
- package/tests/startup-banner.test.ts +117 -3
- package/tests/startup-home-repo.test.ts +170 -0
- package/tests/status-timing-diagnostics.test.ts +283 -0
- package/tests/support/orchestrator-modules.ts +19 -0
- package/tests/task-size-routing-contract.test.ts +241 -0
- package/tests/telemetry-trigger.test.ts +27 -1
- package/tests/test-strength-contract.test.ts +124 -0
- package/tests/verify-agent-contract.test.ts +90 -0
- package/tests/verify-first-launch-contract.test.ts +139 -0
- package/tests/verify-package-files.test.ts +56 -0
- package/tests/vim-editor-adapter.test.ts +47 -21
- package/tests/visual-customization-policy.test.ts +32 -3
- package/tests/visual-customize-view.test.ts +288 -10
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +18 -3
- package/tests/yolo-mode-runtime.test.ts +37 -10
- package/tests/yolo-mode.test.ts +38 -5
- package/tests/yolo-session-policy.test.ts +1 -1
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
package/extensions/gentle-ai.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
|
|
2
|
-
import { recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts";
|
|
2
|
+
import { dataArgumentRanges, recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts";
|
|
3
|
+
import { SHELL_COMMAND_TOOLS } from "../lib/background-jobs.ts";
|
|
3
4
|
import { blockChildDestructiveCommand } from "./child-safety.ts";
|
|
4
|
-
import { allowedEditSurfaces as hasTaskScopedAllowedEditSurfaces, bindSessionRepositoryPreparation, captureBoundSessionRepositoryAuthority, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sourcePathWithinProject } from "../lib/bounded-writer-admission.ts";
|
|
5
|
+
import { allowedEditSurfaces as hasTaskScopedAllowedEditSurfaces, bindSessionRepositoryPreparation, captureBoundSessionRepositoryAuthority, isHomeOrBroadUserDirectory, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sourcePathWithinProject } from "../lib/bounded-writer-admission.ts";
|
|
5
6
|
import { consumeReviewMutation, pendingReviewMutation, pendingReviewMutationProfiles, recordReviewMutation, type ReceiptSession } from "../lib/review-reminder-receipt.ts";
|
|
6
7
|
import { createReviewSidebarPublisher } from "../lib/review-sidebar-state.ts";
|
|
8
|
+
import { StatusTimingDiagnostics } from "../lib/status-timing-diagnostics.ts";
|
|
7
9
|
import { isOddPhase, oddPhaseRegistry, ODD_PHASES } from "../lib/odd-phase.ts";
|
|
10
|
+
import { shellEnabled } from "../lib/shell-bar.ts";
|
|
8
11
|
import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
|
|
9
12
|
import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
|
|
10
13
|
import { execFileSync } from "node:child_process";
|
|
11
14
|
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { isProxy } from "node:util/types";
|
|
12
16
|
import {
|
|
13
17
|
existsSync,
|
|
14
18
|
lstatSync,
|
|
@@ -50,6 +54,7 @@ import { installPackageAssets, getPackageAssetOwner, hasPackageAssetOwnerInstall
|
|
|
50
54
|
import {
|
|
51
55
|
THINKING_LEVELS,
|
|
52
56
|
normalizeModelConfig,
|
|
57
|
+
isThinkingLevel,
|
|
53
58
|
normalizeModelId,
|
|
54
59
|
normalizeRoutingEntry,
|
|
55
60
|
readSavedModelConfig as readModelRoutingAuthority,
|
|
@@ -98,6 +103,7 @@ import {
|
|
|
98
103
|
type ProfilePinSource,
|
|
99
104
|
type ProfilePinStatus,
|
|
100
105
|
} from "../lib/agent-profile-pin.ts";
|
|
106
|
+
import { bindSessionProfile, readSessionProfileBinding } from "../lib/session-profile-binding.ts";
|
|
101
107
|
import {
|
|
102
108
|
applyOrchestratorSettings,
|
|
103
109
|
readOrchestratorSettings,
|
|
@@ -172,12 +178,16 @@ import {
|
|
|
172
178
|
createNativeReviewCli,
|
|
173
179
|
createNodeExecFileAdapter,
|
|
174
180
|
isCanonicalProcessString,
|
|
181
|
+
isNativeReviewAbandonReason,
|
|
182
|
+
NATIVE_REVIEW_ABANDON_REASONS,
|
|
175
183
|
isNativeReviewUnachievableVerbRefused,
|
|
176
184
|
nativeReviewAbandonAuthorization,
|
|
177
185
|
nativeReviewLegacyAliasRepairAuthorization,
|
|
178
186
|
nativeReviewLegacyQuarantineAuthorization,
|
|
179
187
|
nativeReviewReconcileAuthorization,
|
|
180
188
|
nativeReviewRecoverAuthorization,
|
|
189
|
+
NATIVE_REVIEW_AUTHORITY_STATUS,
|
|
190
|
+
type NativeReviewAuthorityEntry,
|
|
181
191
|
|
|
182
192
|
NativeReviewCliError,
|
|
183
193
|
nativeUntrackedSelection,
|
|
@@ -211,6 +221,9 @@ import {
|
|
|
211
221
|
verificationPlan,
|
|
212
222
|
isSmallWriterProfile,
|
|
213
223
|
resolveWriterProfile,
|
|
224
|
+
decodeAgentRiskEscalation,
|
|
225
|
+
escalatedRisk,
|
|
226
|
+
HIGH_RISK_ITEMS,
|
|
214
227
|
RDD_LINE,
|
|
215
228
|
WRITER_PROFILE,
|
|
216
229
|
VERIFICATION_TIER,
|
|
@@ -223,6 +236,7 @@ import {
|
|
|
223
236
|
type ReviewDueReason,
|
|
224
237
|
type NativeReviewOutcome,
|
|
225
238
|
type WriterProfile,
|
|
239
|
+
type AgentRiskEscalation,
|
|
226
240
|
} from "../lib/review-risk-assessment.ts";
|
|
227
241
|
import {
|
|
228
242
|
assertReviewApprovedAcknowledgementExecuteV1,
|
|
@@ -235,6 +249,8 @@ import {
|
|
|
235
249
|
} from "../lib/review-integration-v2.ts";
|
|
236
250
|
import { reconcileUnknownReviewLastEventCapture } from "../lib/review-last-event-controller.ts";
|
|
237
251
|
import { registerYoloSessionPolicy, updateYoloPrompt } from "../lib/yolo-session-policy.ts";
|
|
252
|
+
import { AutoPermissionRuntime, updateAutoPrompt, type AutoRuntimeClassifier } from "../lib/auto-permission-runtime.ts";
|
|
253
|
+
import { AutoPermissionClassifier } from "../lib/auto-permission-classifier.ts";
|
|
238
254
|
import { acquireChildStandingReviewPermissionClient, type ChildStandingReviewPermissionClient } from "../lib/review-session-standing-permission-ipc.ts";
|
|
239
255
|
import { isPiConsentV3, presentReviewConsentUi } from "../lib/review-consent-ui.ts";
|
|
240
256
|
import {
|
|
@@ -362,6 +378,7 @@ type BackgroundSubagentsCapability = "ready" | "absent";
|
|
|
362
378
|
interface BackgroundSubagentsRendering {
|
|
363
379
|
policy: BackgroundSubagentsPolicy;
|
|
364
380
|
capability: BackgroundSubagentsCapability;
|
|
381
|
+
singleShot?: boolean;
|
|
365
382
|
}
|
|
366
383
|
|
|
367
384
|
const DEFAULT_BACKGROUND_SUBAGENTS_RENDERING: BackgroundSubagentsRendering = {
|
|
@@ -705,9 +722,17 @@ function resolveBackgroundSubagentsCapability(
|
|
|
705
722
|
function renderBackgroundSubagentsStatusLine(
|
|
706
723
|
background: BackgroundSubagentsRendering,
|
|
707
724
|
): string {
|
|
725
|
+
if (background.singleShot) return `Background subagent policy: off (single-shot mode)`;
|
|
708
726
|
return `Background subagent policy: ${background.policy} (capability: ${background.capability})`;
|
|
709
727
|
}
|
|
710
728
|
|
|
729
|
+
// gentle-shell#1731 T27: `pi -p` and `pi --mode json` run one prompt and then
|
|
730
|
+
// dispose the runtime, so background results can never arrive. Mirrors
|
|
731
|
+
// isSingleShotMode in extensions/gentle-agents.ts, which rejects the launch.
|
|
732
|
+
function isSingleShotHostMode(mode: string | undefined): boolean {
|
|
733
|
+
return mode === "print" || mode === "json";
|
|
734
|
+
}
|
|
735
|
+
|
|
711
736
|
/**
|
|
712
737
|
* A `status` object is only trusted when `effective` is exactly `on`/`off`
|
|
713
738
|
* and `source` is one of the exported `NATIVE_REVIEW_MODE_SOURCE` values.
|
|
@@ -846,6 +871,11 @@ async function readRddModeStatusOnce(
|
|
|
846
871
|
interface ReviewAssessmentPlanDetails {
|
|
847
872
|
schema: "gentle-pi.review-assessment-plan/v1";
|
|
848
873
|
risk: VerificationTier;
|
|
874
|
+
// gentle-shell#1494: the native tier before any agent escalation. `risk`
|
|
875
|
+
// differs from it only when the agent raised the candidate to high.
|
|
876
|
+
nativeRisk: VerificationTier;
|
|
877
|
+
// `applied` is false when the native tier was already high or unassessable.
|
|
878
|
+
agentEscalation?: { item: number; label: string; reason: string; applied: boolean };
|
|
849
879
|
reasons: readonly ReviewAssessmentReason[];
|
|
850
880
|
changedPaths: number;
|
|
851
881
|
changedLines: number;
|
|
@@ -1000,7 +1030,10 @@ async function resolveReviewAssessmentPlan(
|
|
|
1000
1030
|
}
|
|
1001
1031
|
}
|
|
1002
1032
|
|
|
1003
|
-
const
|
|
1033
|
+
const nativeRisk: VerificationTier = assessment?.risk ?? VERIFICATION_TIER.UNASSESSABLE;
|
|
1034
|
+
// gentle-shell#1494: an agent escalation raises passive/medium to high and
|
|
1035
|
+
// never lowers; the native tier stays visible as `nativeRisk`.
|
|
1036
|
+
const risk = escalatedRisk(nativeRisk, input.escalate);
|
|
1004
1037
|
// gentle-pi#668: the memo is read only for THIS candidate's own target
|
|
1005
1038
|
// identity, never repository-only. It is still needed when the caller
|
|
1006
1039
|
// claims `closed`, because a recorded decline beats closure (gentle-pi#1175).
|
|
@@ -1012,6 +1045,10 @@ async function resolveReviewAssessmentPlan(
|
|
|
1012
1045
|
return {
|
|
1013
1046
|
schema: "gentle-pi.review-assessment-plan/v1",
|
|
1014
1047
|
risk,
|
|
1048
|
+
nativeRisk,
|
|
1049
|
+
...(input.escalate === undefined
|
|
1050
|
+
? {}
|
|
1051
|
+
: { agentEscalation: { item: input.escalate.item, label: HIGH_RISK_ITEMS[input.escalate.item], reason: input.escalate.reason, applied: risk !== nativeRisk } }),
|
|
1015
1052
|
// No path is known for an unassessable candidate: omit it rather than
|
|
1016
1053
|
// emitting an empty string the native reason shape forbids.
|
|
1017
1054
|
reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: unassessableCode, detail: unassessableDetail }]),
|
|
@@ -1038,6 +1075,22 @@ async function resolveReviewAssessmentPlan(
|
|
|
1038
1075
|
|
|
1039
1076
|
let rddStatusUnavailableWarned = false;
|
|
1040
1077
|
|
|
1078
|
+
// S2 (startup-home-repo-and-changes): a repository rooted at HOME, an ancestor
|
|
1079
|
+
// of it or a broad user folder makes every Git call scan the user's whole
|
|
1080
|
+
// tree. Warn once per process and repository, never per session restart.
|
|
1081
|
+
const HOME_REPOSITORY_WARNING = "Gentle Shell is running inside a Git repository rooted at your home folder (or a broad user folder). Every Git operation scans that whole tree, which makes startup and reviews very slow. Open Gentle Shell inside a project folder, or remove the stray .git if you did not create it on purpose.";
|
|
1082
|
+
const homeRepositoryWarnings = new Set<string>();
|
|
1083
|
+
|
|
1084
|
+
// An unresolvable HOME or a resolver failure is unknown, not a match: the
|
|
1085
|
+
// bootstrap guard fails closed there, but startup must not warn or skip work.
|
|
1086
|
+
function homeRootedRepository(cwd: string): string | undefined {
|
|
1087
|
+
try {
|
|
1088
|
+
const home = realpathSync.native(homedir());
|
|
1089
|
+
const root = resolveSessionWorktree(cwd, cwd)?.root;
|
|
1090
|
+
return root !== undefined && isHomeOrBroadUserDirectory(root, { home }) ? root : undefined;
|
|
1091
|
+
} catch { return undefined; }
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1041
1094
|
/**
|
|
1042
1095
|
* Best-effort native RDD mode status read for prompt rendering, memoized per
|
|
1043
1096
|
* cwd for `RDD_STATUS_MEMO_TTL_MS`. Reuses the `reviewMode` STATUS reader
|
|
@@ -1045,9 +1098,11 @@ let rddStatusUnavailableWarned = false;
|
|
|
1045
1098
|
* `NativeReviewModeStatus`) that the `/gentle:review-mode` command also
|
|
1046
1099
|
* calls. Never throws and never hangs past `signal`'s deadline when one is
|
|
1047
1100
|
* given: an absent binary, a timed-out/aborted process, or a native CLI
|
|
1048
|
-
* failure all resolve to `undefined
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1101
|
+
* failure all resolve to `undefined` unless an earlier read for the same cwd
|
|
1102
|
+
* succeeded, in which case that last known status is kept (gentle-shell#1029:
|
|
1103
|
+
* the rendered line sits in the cached prompt prefix). `ctx` is optional and
|
|
1104
|
+
* used only for a one-shot (per process) UI notice when the read is
|
|
1105
|
+
* swallowed, so a sustained native outage is observable beyond the prompt.
|
|
1051
1106
|
*/
|
|
1052
1107
|
async function resolveRddModeStatus(
|
|
1053
1108
|
nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
|
|
@@ -1059,13 +1114,19 @@ async function resolveRddModeStatus(
|
|
|
1059
1114
|
const nowMs = now();
|
|
1060
1115
|
const cached = rddStatusMemo.get(cwd);
|
|
1061
1116
|
if (cached !== undefined && cached.expiresAt > nowMs) return cached.status;
|
|
1062
|
-
|
|
1117
|
+
// gentle-shell#1029: the rendered line is part of the cached system-prompt
|
|
1118
|
+
// prefix. A re-read that fails or times out keeps the last successfully
|
|
1119
|
+
// read status instead of flipping the prompt to "unknown" and back, which
|
|
1120
|
+
// would invalidate the provider's prefix cache for the whole conversation.
|
|
1121
|
+
// Only a status the native reader actually reports replaces a known one.
|
|
1122
|
+
const read = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
|
|
1123
|
+
const status = read ?? cached?.status;
|
|
1063
1124
|
rddStatusMemo.set(cwd, { status, expiresAt: nowMs + RDD_STATUS_MEMO_TTL_MS });
|
|
1064
|
-
if (
|
|
1125
|
+
if (read === undefined && !rddStatusUnavailableWarned) {
|
|
1065
1126
|
rddStatusUnavailableWarned = true;
|
|
1066
1127
|
if (ctx?.hasUI) {
|
|
1067
1128
|
ctx.ui.notify(
|
|
1068
|
-
"Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt renders \"unknown\"
|
|
1129
|
+
"Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt keeps the last known status, or renders \"unknown\" when none was read; this notice will not repeat this session.",
|
|
1069
1130
|
"warning",
|
|
1070
1131
|
);
|
|
1071
1132
|
}
|
|
@@ -1073,6 +1134,11 @@ async function resolveRddModeStatus(
|
|
|
1073
1134
|
return status;
|
|
1074
1135
|
}
|
|
1075
1136
|
|
|
1137
|
+
/** True only for a validated `off` line; `unknown` and `on` are never off. */
|
|
1138
|
+
function isRddStatusLineOff(line: string | undefined): boolean {
|
|
1139
|
+
return line !== undefined && line.startsWith("Receipt-driven development: off ");
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1076
1142
|
/** Resolves and renders the RDD status line for a production call site in one call. */
|
|
1077
1143
|
async function resolveRddStatusLine(
|
|
1078
1144
|
nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
|
|
@@ -1097,12 +1163,14 @@ function getOrchestratorPrompt(
|
|
|
1097
1163
|
cwd: string = process.cwd(),
|
|
1098
1164
|
activeTools?: readonly string[],
|
|
1099
1165
|
rddStatusLine: string = renderRddStatusLine(undefined),
|
|
1166
|
+
hostMode?: string,
|
|
1100
1167
|
): string {
|
|
1101
1168
|
const background: BackgroundSubagentsRendering = {
|
|
1102
1169
|
policy: loadBackgroundSubagentsPolicy(cwd),
|
|
1103
1170
|
capability: resolveBackgroundSubagentsCapability(cwd, activeTools),
|
|
1171
|
+
singleShot: isSingleShotHostMode(hostMode),
|
|
1104
1172
|
};
|
|
1105
|
-
const cacheKey = `${background.policy}:${background.capability}:${rddStatusLine}`;
|
|
1173
|
+
const cacheKey = `${background.policy}:${background.capability}:${background.singleShot}:${rddStatusLine}`;
|
|
1106
1174
|
let prompt = orchestratorPromptCache.get(cacheKey);
|
|
1107
1175
|
if (prompt === undefined) {
|
|
1108
1176
|
prompt = renderOrchestratorPrompt(ASSETS_DIR, background, rddStatusLine);
|
|
@@ -1221,6 +1289,7 @@ function buildGentlePrompt(
|
|
|
1221
1289
|
cwd: string = process.cwd(),
|
|
1222
1290
|
activeTools?: readonly string[],
|
|
1223
1291
|
rddStatusLine: string = renderRddStatusLine(undefined),
|
|
1292
|
+
hostMode?: string,
|
|
1224
1293
|
): string {
|
|
1225
1294
|
const personaPrompt =
|
|
1226
1295
|
persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT;
|
|
@@ -1245,34 +1314,44 @@ ${personaPrompt}
|
|
|
1245
1314
|
|
|
1246
1315
|
${languageBoundary}
|
|
1247
1316
|
|
|
1317
|
+
## Authorized work continuity
|
|
1318
|
+
|
|
1319
|
+
An authorized outcome includes its necessary in-scope implementation, documentation and verification steps. Continue those steps without asking the human to approve each transition. Never end a turn merely announcing an immediately executable authorized next step; execute it instead. A progress update is not a request for permission.
|
|
1320
|
+
|
|
1321
|
+
Stop only for an unresolved consequential scope/product decision, required explicit consent, a denied action, unavailable required proof, or the human's explicit stop. Do not infer permission for publishing, destructive actions, unrelated scope or native consent from this continuity rule. Report a concrete blocker and the exact decision needed rather than requesting a generic "continue".
|
|
1322
|
+
|
|
1323
|
+
For background work, launch the bounded action and end the turn when no independent in-scope work remains. Retain its identity and resume the already-authorized continuation automatically on its completion event; do not ask the human to say "continue", poll, or claim completion while checks remain pending.
|
|
1324
|
+
|
|
1248
1325
|
Default workflow: Organic Driven Development (MANDATORY)
|
|
1249
1326
|
Organic Driven Development (ODD) is the predefined workflow of this orchestrator. Every request enters it, without the user asking for a workflow, a plan, or task tracking. Never describe this workflow only when asked about it: run it. Run these steps in this order on every request:
|
|
1250
|
-
1. **Authorize.** Investigation, explanation, review, comparison, and proposal-only requests stay read-only: no writer, apply, or implementation artifacts. Ambiguous or conditional change intent gets one clarification; stop and wait.
|
|
1251
|
-
2. **Explore.** Explore existing code and requirements first, proportionately to the request, before proposing or writing anything.
|
|
1327
|
+
1. **Authorize.** Investigation, explanation, review, comparison, and proposal-only requests stay read-only: no writer, apply, or implementation artifacts. Ambiguous or conditional change intent (unclear whether a change is authorized at all) gets one clarification; stop and wait. A user saying they may stop you or resume later asks for notes and separate commits, not a stop.
|
|
1328
|
+
2. **Explore.** Explore existing code and requirements first, proportionately to the request, before proposing or writing anything. Do not delegate exploration of files you will read anyway to work inline; explore only for a map you need to decide or route.
|
|
1252
1329
|
3. **Resolve uncertainty.** Recommend optional research only for a named uncertainty; ask one focused user question only for a real unresolved product decision, then stop and wait; use at most one scoped read-only assumption challenge for a high-consequence unproven premise.
|
|
1253
|
-
4. **Classify.**
|
|
1254
|
-
5. **Track before the first write.** For
|
|
1255
|
-
6. **Implement task by task.** Route each task through the orchestrator's
|
|
1256
|
-
7. **Close.** Report the verified outcome, every failed, skipped, or pending check, and the next step. The native review candidate is a work-unit commit or a PR slice, never a TODO checkbox and never the accumulated feature branch; native review runs only under the user-owned RDD switch.
|
|
1330
|
+
4. **Classify.** Size the task by the orchestrator's Task Size section: small when understood, risk is contained, and the work could be resumed from the original request and \`git diff\` alone; large only when that resume test fails. Never classify by counting files, commands, tests, fixes, or a requested todo list. Small work stays inline and creates no durable task artifacts.
|
|
1331
|
+
5. **Track before the first write.** For large authorized implementation, create \`odd/tasks/<feature-name>.md\` and its Engram mirror \`odd/<feature-name>/tasks\` automatically, then create or rebuild the visible \`todo\` list from the reconciled feature tasks, all before the first source write and without asking permission for tasks or storage. Tell the user in one line which feature document was created and how many tasks it holds. The document is the specification subagents read, in this order: a two- or three-line header; \`## Specs\` with numbered \`S#\` that quote the user's exact strings, error messages, and examples verbatim, never summarized and never adding unrequested requirements; \`## Tasks\` with one line per task (ID, linked \`S#\`, route, commit); \`## Log\` last, where \`L1\` is the user's original request verbatim and later user corrections, evidence, and decisions are appended. A requirement change appends its verbatim Log entry, rewrites only the affected \`S#\`, and reopens only its task.
|
|
1332
|
+
6. **Implement task by task.** Hand off by reference, never by paraphrase: name the document, task, and specs (for example \`Spec: odd/tasks/<feature>.md, T2, S3-S4\`), tell workers to read until \`## Log\`, and ask which \`S#\` were covered. Without a feature document, include the user's request verbatim. Verify reads the whole document, runs the spec's examples the parent authorized, against isolated state when they mutate data, and returns a verdict per \`S#\`. When the user reports a failure, reproduce it before deciding it already works. Each test asserts every observable effect of the rule it covers (exit code, exact stdout and stderr, and that rejected input leaves stored data and counters unchanged), covers the cases the rule itself names (its examples, boundaries, and errors), and checks through the public interface, never internal storage. When you add or change a command, option, or message, update the help text and docs that describe it. Route each task through the orchestrator's Mechanisms, honoring its mandatory delegation triggers, with applicable test-first development and checks. These triggers are mandatory, not advisory: executing past a fired trigger inline is a routing defect even if the work succeeds. Check an item off only after its outcome and checks were observed; update the file, mirror, and visible \`todo\` projection after every task transition and material plan change. Every tracked task closes with at least one work-unit commit on the feature branch, branch first when on the default branch, with tests and docs alongside the behavior, using a Conventional Commit message; record the commit identity in the feature document as evidence. Work-unit commits on the feature branch are part of authorized large ODD implementation; push, pull request creation, and merge remain the user's decisions.
|
|
1333
|
+
7. **Close.** Report the verified outcome, every failed, skipped, or pending check, and the next step. Before writing \`Risk: none\`, check whether your diff changes code that existing behavior the request did not mention also uses (shared options, parsers, helpers); if it does, that is item 3. Never end with a tracked task pending unless you quote the user's explicit stop. An applicable quick check runs once; an unavailable verifier or subagent is reported as unavailable, never retried or escalated into extra ceremony. Partial, blocked, unavailable, or exhausted proof becomes one **Needs your decision** result naming the open blockers or missing proof, never more verification; that result is a valid stop, hedged wording is not. The native review candidate is a work-unit commit or a PR slice, never a TODO checkbox and never the accumulated feature branch; native review runs only under the user-owned RDD switch.
|
|
1257
1334
|
Phase reporting: the Gentle Shell prompt label is inferred automatically from the primary session's tool activity (reads show \`exploring\`, edits \`implementing\`, test runs \`checking\`, user questions \`deciding\`). When the \`gentle_odd_phase\` tool is available, use it to refine that label with phases tools cannot show (\`authorizing\`, \`researching\`, \`deciding\`, \`closing\`): call \`gentle_odd_phase\` only when the primary session's ODD phase actually changes, never per tool call or on a fixed cadence, and never from a subagent. It drives the Gentle Shell prompt label only.
|
|
1258
1335
|
Resume an interrupted feature with \`mem_context\`, then project- and feature-scoped \`mem_search\`, then \`mem_get_observation\` for the full document, then the task file itself; reconcile before continuing the next unfinished task. Detail for steps 3–7: \`orchestrator-delegation.md\` and \`orchestrator-memory.md\`.
|
|
1259
1336
|
|
|
1260
1337
|
Harness principles:
|
|
1261
1338
|
- el Gentleman is not prompt engineering. It is runtime discipline around powerful agents.
|
|
1262
|
-
- Organic Driven Development (ODD) is the predefined workflow for every request: authorize, explore, resolve uncertainty, classify, track
|
|
1339
|
+
- Organic Driven Development (ODD) is the predefined workflow for every request: authorize, explore, resolve uncertainty, classify, track large work before the first write, implement task by task with proportionate checks, close each tracked task with a work-unit commit, and close.
|
|
1263
1340
|
- Clarify scope, constraints, acceptance criteria, and non-goals before implementation.
|
|
1264
1341
|
- Use subagents when available for exploration, planning, implementation, and review, while keeping one parent session responsible for orchestration.
|
|
1265
|
-
-
|
|
1266
|
-
- For behavior changes with applicable runnable deterministic tests and a clear expected outcome, use test-first by default: observe RED, GREEN, then refactor with focused checks. Test presence alone does not establish applicability; no chat or TUI toggle activates it. For passive documentation, non-testable changes, an unavailable runner, or no meaningful RED, explain why and run proportionate ordinary functional or structural verification. Never invent lifecycle evidence or skip checks. Follow orchestrator-delegation.md for ODD forwarding and evidence.
|
|
1342
|
+
- Parallel writers only with disjoint Allowed edit surfaces (runtime-enforced) or isolated worktrees.
|
|
1343
|
+
- For behavior changes with applicable runnable deterministic tests and a clear expected outcome, use test-first by default: observe RED, GREEN, then refactor with focused checks. Write one RED test per requested rule. For every existing command or option the change touches, add one test proving its previous behavior still holds; add no other cases. An existing behavior counts as touched when it shares the code you changed (options, parsers, helpers, validation). Test presence alone does not establish applicability; no chat or TUI toggle activates it. For passive documentation, non-testable changes, an unavailable runner, or no meaningful RED, explain why and run proportionate ordinary functional or structural verification. Never invent lifecycle evidence or skip checks. Follow orchestrator-delegation.md for ODD forwarding and evidence.
|
|
1267
1344
|
- Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
|
|
1268
1345
|
- Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
|
|
1269
1346
|
|
|
1270
|
-
${getOrchestratorPrompt(cwd, activeTools, rddStatusLine)}`;
|
|
1347
|
+
${getOrchestratorPrompt(cwd, activeTools, rddStatusLine, hostMode)}`;
|
|
1271
1348
|
}
|
|
1272
1349
|
|
|
1273
1350
|
// Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
|
|
1274
1351
|
// between `git` and the subcommand. Short flags may be followed by a separate value token.
|
|
1275
|
-
|
|
1352
|
+
// gentle-shell#2172: `\s+` rather than a literal space, so a tab-separated
|
|
1353
|
+
// `git\tpush` is guarded like any other spelling.
|
|
1354
|
+
const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)*\s+`;
|
|
1276
1355
|
const GIT_PUSH_RE = new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}(push)\b`);
|
|
1277
1356
|
|
|
1278
1357
|
const DENIED_BASH_PATTERNS: RegExp[] = [
|
|
@@ -1338,7 +1417,7 @@ interface LoadGuardrailsOptions {
|
|
|
1338
1417
|
|
|
1339
1418
|
const GUARDED_KEY_PATTERNS: Record<GuardedCommandKey, RegExp> = {
|
|
1340
1419
|
gitPush: GIT_PUSH_RE,
|
|
1341
|
-
gitRebase:
|
|
1420
|
+
gitRebase: new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}(rebase)\b`),
|
|
1342
1421
|
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)/,
|
|
1343
1422
|
npmPublish: /\bnpm\s+(publish)\b/,
|
|
1344
1423
|
piRemove: /\bpi\s+(remove)\b/,
|
|
@@ -1380,12 +1459,15 @@ function collectGuardedMatches(
|
|
|
1380
1459
|
config: RuntimeGuardrailsConfig,
|
|
1381
1460
|
): GuardMatch[] {
|
|
1382
1461
|
const matches: GuardMatch[] = [];
|
|
1462
|
+
const dataRanges = dataArgumentRanges(command);
|
|
1383
1463
|
for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [
|
|
1384
1464
|
GuardedCommandKey,
|
|
1385
1465
|
RegExp,
|
|
1386
1466
|
][]) {
|
|
1387
1467
|
const globalPattern = new RegExp(pattern.source, `${pattern.flags}g`);
|
|
1388
1468
|
for (const match of command.matchAll(globalPattern)) {
|
|
1469
|
+
// gentle-shell#2174: text inside a data command's argument is not an operation.
|
|
1470
|
+
if (inDataArgument(dataRanges, match.index)) continue;
|
|
1389
1471
|
const action = config.autonomousMode
|
|
1390
1472
|
? (config.guardedCommands[key] ?? AUTONOMOUS_DEFAULT_ACTIONS[key])
|
|
1391
1473
|
: "confirm";
|
|
@@ -1399,6 +1481,10 @@ function collectGuardedMatches(
|
|
|
1399
1481
|
return matches.sort((left, right) => left.triggerIndex - right.triggerIndex);
|
|
1400
1482
|
}
|
|
1401
1483
|
|
|
1484
|
+
function inDataArgument(ranges: ReadonlyArray<readonly [number, number]>, index: number): boolean {
|
|
1485
|
+
return ranges.some(([start, end]) => index >= start && index < end);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1402
1488
|
function evaluateGuardedCommand(
|
|
1403
1489
|
command: string,
|
|
1404
1490
|
config: RuntimeGuardrailsConfig,
|
|
@@ -1409,8 +1495,10 @@ function evaluateGuardedCommand(
|
|
|
1409
1495
|
if (hardDeny) return { action: "block", triggerIndex: hardDeny.triggerIndex, matches };
|
|
1410
1496
|
|
|
1411
1497
|
// Hard denies override every configured action across the complete command.
|
|
1498
|
+
const dataRanges = dataArgumentRanges(command);
|
|
1412
1499
|
for (const pattern of DENIED_BASH_PATTERNS) {
|
|
1413
|
-
const denied = pattern.
|
|
1500
|
+
const denied = [...command.matchAll(new RegExp(pattern.source, `${pattern.flags.replace("g", "")}g`))]
|
|
1501
|
+
.find((candidate) => !inDataArgument(dataRanges, candidate.index));
|
|
1414
1502
|
if (!denied) continue;
|
|
1415
1503
|
const matchedAction = matches.find((match) =>
|
|
1416
1504
|
match.triggerIndex >= denied.index && match.triggerIndex < denied.index + denied[0].length,
|
|
@@ -1756,14 +1844,60 @@ function createHerdrConfirmationLifecycle(events: ExtensionAPI["events"]): Herdr
|
|
|
1756
1844
|
};
|
|
1757
1845
|
}
|
|
1758
1846
|
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1847
|
+
// gentle-shell#2172: YOLO waives reversible delivery, not only one bare push.
|
|
1848
|
+
// A guarded segment is judged from its trigger word (`push`/`rebase`) to the
|
|
1849
|
+
// next command separator; a plain token or allow-listed option list is the
|
|
1850
|
+
// whole eligible grammar, so quotes, wrappers and unknown options fall back
|
|
1851
|
+
// to the ordinary confirmation. Everything that deletes or rewrites a remote
|
|
1852
|
+
// ref, publishes, or runs arbitrary commands is deliberately absent:
|
|
1853
|
+
// `--delete`/`-d`, `:dst` and `+src` refspecs, `--mirror`, `--prune`,
|
|
1854
|
+
// `--all`, every force spelling, `--receive-pack`/`--exec`, `--repo`, `-o`.
|
|
1855
|
+
const YOLO_WAIVABLE_KEYS: ReadonlySet<GuardedCommandKey> = new Set(["gitPush", "gitRebase"]);
|
|
1856
|
+
const YOLO_SEGMENT_END_RE = /&&|\|\||;|\||\n|\r/;
|
|
1857
|
+
// Text between the segment start and the `push`/`rebase` word: optional
|
|
1858
|
+
// variable assignments, `env`/`command` prefixes and the `git` word with at
|
|
1859
|
+
// most a `-C <path>`. Other global options (`-c key=value`, `--exec-path`,
|
|
1860
|
+
// `--git-dir`) can run or redirect code, so they keep the confirmation.
|
|
1861
|
+
const YOLO_GIT_PREFIX_RE = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=[^\s'"`$]*\s+|env\s+(?:-i\s+)?|command\s+)*git(?:\s+-C\s+[^\s'"`$;&|]+)?\s+$/;
|
|
1862
|
+
const YOLO_PUSH_SEGMENT_RE = /^push(?:\s+(?:-u|--set-upstream|-q|--quiet|-v|--verbose|--tags|--follow-tags|--no-verify|--porcelain|[A-Za-z0-9_][A-Za-z0-9_./-]*(?::[A-Za-z0-9_][A-Za-z0-9_./-]*)?))*\s*$/;
|
|
1863
|
+
const YOLO_REBASE_SEGMENT_RE = /^rebase(?:\s+(?:--onto\s+[A-Za-z0-9_][A-Za-z0-9_./~^-]*|--continue|--abort|--skip|--autostash|--no-autostash|[A-Za-z0-9_][A-Za-z0-9_./~^-]*))*\s*$/;
|
|
1864
|
+
|
|
1865
|
+
function yoloGuardedSegment(command: string, triggerIndex: number): string {
|
|
1866
|
+
const rest = command.slice(triggerIndex);
|
|
1867
|
+
const end = rest.search(YOLO_SEGMENT_END_RE);
|
|
1868
|
+
return end < 0 ? rest : rest.slice(0, end);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function yoloSegmentPrefix(command: string, triggerIndex: number): string {
|
|
1872
|
+
const head = command.slice(0, triggerIndex);
|
|
1873
|
+
const separators = [...head.matchAll(new RegExp(YOLO_SEGMENT_END_RE.source, "g"))];
|
|
1874
|
+
const last = separators.at(-1);
|
|
1875
|
+
return last === undefined ? head : head.slice(last.index + last[0].length);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/** Every guarded match and every recognized Git side effect must be a reversible push or non-interactive rebase. */
|
|
1879
|
+
function isReversibleYoloCommand(command: string, evaluation: GuardEvaluation): boolean {
|
|
1880
|
+
if (evaluation.action !== "confirm" || evaluation.dataLoss || evaluation.matches.length === 0) return false;
|
|
1881
|
+
for (const match of evaluation.matches) {
|
|
1882
|
+
if (!YOLO_WAIVABLE_KEYS.has(match.key)) return false;
|
|
1883
|
+
if (!YOLO_GIT_PREFIX_RE.test(yoloSegmentPrefix(command, match.triggerIndex))) return false;
|
|
1884
|
+
const segment = yoloGuardedSegment(command, match.triggerIndex);
|
|
1885
|
+
if (!(match.key === "gitPush" ? YOLO_PUSH_SEGMENT_RE : YOLO_REBASE_SEGMENT_RE).test(segment)) return false;
|
|
1886
|
+
}
|
|
1887
|
+
// The data-loss recognizer leaves non-hard Git effects (reset, stash drop,
|
|
1888
|
+
// restore, checkout --) to the primary policy; under YOLO only a rebase that
|
|
1889
|
+
// a guarded match already vetted may remain.
|
|
1890
|
+
const rebaseTriggers = new Set(evaluation.matches.filter((match) => match.key === "gitRebase").map((match) => match.triggerIndex));
|
|
1891
|
+
for (const effect of recognizeDestructiveCommands(command)) {
|
|
1892
|
+
if (effect.hardDeny || effect.kind !== "git") return false;
|
|
1893
|
+
const rebase = /^git(?:\s+(?:-C|-c|--git-dir|--work-tree|--namespace)\s+\S+|\s+-\S+)*\s+(rebase)\b/.exec(command.slice(effect.triggerIndex));
|
|
1894
|
+
if (rebase === null || !rebaseTriggers.has(effect.triggerIndex + rebase.index + rebase[0].lastIndexOf("rebase"))) return false;
|
|
1895
|
+
}
|
|
1896
|
+
return true;
|
|
1763
1897
|
}
|
|
1764
1898
|
|
|
1765
|
-
/** Preserve explicitly configured confirmations even when legacy env/autonomy resolution ignores them. */
|
|
1766
|
-
function
|
|
1899
|
+
/** Preserve explicitly configured confirmations for the waivable keys present, even when legacy env/autonomy resolution ignores them. */
|
|
1900
|
+
function yoloConfiguredRestriction(cwd: string, keys: readonly GuardedCommandKey[], options: LoadGuardrailsOptions = {}): GuardAction | undefined {
|
|
1767
1901
|
let restriction: GuardAction | undefined;
|
|
1768
1902
|
for (const path of [join(options.gentlePiConfigHome ?? gentleAiConfigHome(), "runtime-guardrails.json"), join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) {
|
|
1769
1903
|
try {
|
|
@@ -1773,9 +1907,11 @@ function yoloPushConfiguredRestriction(cwd: string, options: LoadGuardrailsOptio
|
|
|
1773
1907
|
restriction ??= "confirm";
|
|
1774
1908
|
continue;
|
|
1775
1909
|
}
|
|
1776
|
-
const
|
|
1777
|
-
|
|
1778
|
-
|
|
1910
|
+
for (const key of keys) {
|
|
1911
|
+
const action = isRecord(parsed.guardedCommands) ? parsed.guardedCommands[key] : undefined;
|
|
1912
|
+
if (action === "block") return "block";
|
|
1913
|
+
if (action !== undefined && action !== "allow") restriction = "confirm";
|
|
1914
|
+
}
|
|
1779
1915
|
} catch { restriction ??= "confirm"; }
|
|
1780
1916
|
}
|
|
1781
1917
|
return restriction;
|
|
@@ -1787,14 +1923,16 @@ async function confirmCommand(
|
|
|
1787
1923
|
events: ExtensionAPI["events"],
|
|
1788
1924
|
herdrLifecycle: HerdrConfirmationLifecycle,
|
|
1789
1925
|
yoloActive = false,
|
|
1926
|
+
autoGate?: () => Promise<ToolCallEventResult | undefined>,
|
|
1790
1927
|
): Promise<ToolCallEventResult | undefined> {
|
|
1791
1928
|
const guardrailsConfig = loadRuntimeGuardrailsConfig(ctx.cwd);
|
|
1792
1929
|
const evaluation = evaluateGuardedCommand(command, guardrailsConfig);
|
|
1793
1930
|
// Configuration provenance is distinct from legacy autonomy resolution, which
|
|
1794
1931
|
// can ignore confirmations when an environment override is present. Activation
|
|
1795
1932
|
// cannot waive a restriction from either user or project configuration layer.
|
|
1796
|
-
const
|
|
1797
|
-
|
|
1933
|
+
const waivableKeys = evaluation.matches.map((match) => match.key).filter((key) => YOLO_WAIVABLE_KEYS.has(key));
|
|
1934
|
+
const configuredRestriction = yoloActive && waivableKeys.length > 0
|
|
1935
|
+
? yoloConfiguredRestriction(ctx.cwd, waivableKeys) : undefined;
|
|
1798
1936
|
const classification = evaluation.action === "block" || configuredRestriction === "block"
|
|
1799
1937
|
? "block" : configuredRestriction ?? evaluation.action;
|
|
1800
1938
|
|
|
@@ -1806,13 +1944,31 @@ async function confirmCommand(
|
|
|
1806
1944
|
};
|
|
1807
1945
|
}
|
|
1808
1946
|
|
|
1809
|
-
if (classification === "not-guarded") return
|
|
1947
|
+
if (classification === "not-guarded") return autoGate?.();
|
|
1810
1948
|
|
|
1811
1949
|
// classification is "allow" or "confirm" from this point on
|
|
1812
|
-
if (classification === "allow") return
|
|
1950
|
+
if (classification === "allow") return autoGate?.();
|
|
1951
|
+
|
|
1952
|
+
// Auto may replace only a default confirmation, never an explicit user/project
|
|
1953
|
+
// ask/block rule or the independent data-loss confirmation.
|
|
1954
|
+
if (autoGate && !evaluation.dataLoss) {
|
|
1955
|
+
let explicit = false;
|
|
1956
|
+
for (const path of [join(gentleAiConfigHome(), "runtime-guardrails.json"), join(ctx.cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) {
|
|
1957
|
+
try {
|
|
1958
|
+
if (!existsSync(path)) continue;
|
|
1959
|
+
const settings: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
1960
|
+
if (!isRecord(settings) || (settings.guardedCommands !== undefined && !isRecord(settings.guardedCommands))) { explicit = true; continue; }
|
|
1961
|
+
if (isRecord(settings.guardedCommands) && evaluation.matches.some(match => {
|
|
1962
|
+
const action = (settings.guardedCommands as Record<string, unknown>)[match.key];
|
|
1963
|
+
return action !== undefined && action !== "allow";
|
|
1964
|
+
})) explicit = true;
|
|
1965
|
+
} catch { explicit = true; }
|
|
1966
|
+
}
|
|
1967
|
+
if (!explicit) return autoGate();
|
|
1968
|
+
}
|
|
1813
1969
|
|
|
1814
1970
|
// Full T1 evaluation has already run. YOLO never changes persistent autonomy.
|
|
1815
|
-
if (yoloActive && configuredRestriction === undefined &&
|
|
1971
|
+
if (yoloActive && configuredRestriction === undefined && isReversibleYoloCommand(command, evaluation)) return undefined;
|
|
1816
1972
|
|
|
1817
1973
|
// classification === "confirm"
|
|
1818
1974
|
if (!ctx.hasUI) {
|
|
@@ -2157,6 +2313,11 @@ function readEffectiveModelConfig(cwd: string): AgentModelConfig {
|
|
|
2157
2313
|
return pinnedEffectiveModelConfig(cwd) ?? readGlobalEffectiveModelConfig(cwd);
|
|
2158
2314
|
}
|
|
2159
2315
|
|
|
2316
|
+
/** Resolve one complete reviewer routing snapshot without per-role fallback. */
|
|
2317
|
+
function readReviewerModelConfig(cwd: string, sessionId: string | undefined): AgentModelConfig {
|
|
2318
|
+
return readSessionProfileBinding(sessionId)?.modelProfiles ?? pinnedEffectiveModelConfig(cwd) ?? readModelConfig(cwd);
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2160
2321
|
/** The routing in effect once the pin is set aside: what a global save materializes. */
|
|
2161
2322
|
function readGlobalEffectiveModelConfig(cwd: string): AgentModelConfig {
|
|
2162
2323
|
const effective = cloneModelConfig(readModelConfig(cwd));
|
|
@@ -2172,7 +2333,21 @@ function readGlobalEffectiveModelConfig(cwd: string): AgentModelConfig {
|
|
|
2172
2333
|
async function readEffectiveModelConfigAsync(cwd: string): Promise<AgentModelConfig> {
|
|
2173
2334
|
const pinned = pinnedEffectiveModelConfig(cwd);
|
|
2174
2335
|
if (pinned) return pinned;
|
|
2175
|
-
|
|
2336
|
+
return readGlobalEffectiveModelConfigFromAsync(cwd, await readModelConfigAsync(cwd));
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* The saved global routing merged with the materialized stores of every
|
|
2341
|
+
* discoverable agent it is silent about — the same effective view
|
|
2342
|
+
* `readEffectiveModelConfigAsync` builds, but starting from an already-read
|
|
2343
|
+
* saved routing so callers that must distinguish an unreadable authority can
|
|
2344
|
+
* keep that distinction while still seeing materialized routes.
|
|
2345
|
+
*/
|
|
2346
|
+
async function readGlobalEffectiveModelConfigFromAsync(
|
|
2347
|
+
cwd: string,
|
|
2348
|
+
base: AgentModelConfig,
|
|
2349
|
+
): Promise<AgentModelConfig> {
|
|
2350
|
+
const effective = cloneModelConfig(base);
|
|
2176
2351
|
const profilesByPath = new Map<string, Record<string, unknown>>();
|
|
2177
2352
|
for (const agent of await listDiscoverableAgentsAsync(cwd)) {
|
|
2178
2353
|
if (isProviderReviewRole(agent.name) || agent.name in effective) continue;
|
|
@@ -2716,10 +2891,14 @@ export async function applySavedModelConfig(
|
|
|
2716
2891
|
if (result.status === "invalid") {
|
|
2717
2892
|
return { updated: 0, skipped: 0, invalidPath: result.path };
|
|
2718
2893
|
}
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2894
|
+
// Startup/reload has no deletion approval. Persisted inherit entries are
|
|
2895
|
+
// not instructions to erase routing, even if an agent is no longer present.
|
|
2896
|
+
// Explicit panel saves and confirmed profile replacement still apply clears.
|
|
2897
|
+
const config = result.status === "valid" ? result.config : {};
|
|
2898
|
+
const activationConfig = Object.fromEntries(
|
|
2899
|
+
Object.entries(config).filter(([, entry]) => !isClearRoutingEntry(entry)),
|
|
2722
2900
|
);
|
|
2901
|
+
return applyConfig(ctx.cwd, activationConfig);
|
|
2723
2902
|
}
|
|
2724
2903
|
|
|
2725
2904
|
function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
|
|
@@ -3546,6 +3725,7 @@ function updateCurrentProfileFromSavedRouting(ctx: ExtensionContext, pi: Extensi
|
|
|
3546
3725
|
|
|
3547
3726
|
type ProfilesPanelResult =
|
|
3548
3727
|
| { type: "apply"; name: string }
|
|
3728
|
+
| { type: "apply-global"; name: string }
|
|
3549
3729
|
| { type: "create" }
|
|
3550
3730
|
| { type: "update"; name: string }
|
|
3551
3731
|
| { type: "duplicate"; name: string }
|
|
@@ -3733,6 +3913,8 @@ class ProfilesPanel implements OverlayComponent {
|
|
|
3733
3913
|
private readonly orchestratorSettings: OrchestratorSettingsReadResult;
|
|
3734
3914
|
// Actions reopen the panel, refreshing this snapshot without disk reads during rendering.
|
|
3735
3915
|
private readonly pinStatus: ProfilePinStatus | undefined;
|
|
3916
|
+
private readonly sessionBoundName: string | undefined;
|
|
3917
|
+
private readonly liveOrchestrator: (() => AgentRoutingEntry | undefined) | undefined;
|
|
3736
3918
|
|
|
3737
3919
|
constructor(
|
|
3738
3920
|
file: AgentProfilesFile,
|
|
@@ -3746,6 +3928,8 @@ class ProfilesPanel implements OverlayComponent {
|
|
|
3746
3928
|
saveSnapshot: ProfilesSnapshotHandler,
|
|
3747
3929
|
requestRender: () => void,
|
|
3748
3930
|
pinStatus: () => ProfilePinStatus | undefined,
|
|
3931
|
+
sessionBound: () => string | undefined,
|
|
3932
|
+
liveOrchestrator?: () => AgentRoutingEntry | undefined,
|
|
3749
3933
|
feedback?: string,
|
|
3750
3934
|
) {
|
|
3751
3935
|
this.file = file;
|
|
@@ -3758,7 +3942,9 @@ class ProfilesPanel implements OverlayComponent {
|
|
|
3758
3942
|
this.rows = rows;
|
|
3759
3943
|
this.orchestratorSettings = orchestratorSettings;
|
|
3760
3944
|
this.pinStatus = pinStatus();
|
|
3761
|
-
|
|
3945
|
+
this.sessionBoundName = sessionBound();
|
|
3946
|
+
this.liveOrchestrator = liveOrchestrator;
|
|
3947
|
+
const items = buildProfileListItems(file, evaluateProfilePin(this.pinStatus, file.profiles).winner?.profile, this.sessionBoundName);
|
|
3762
3948
|
this.listItems = items;
|
|
3763
3949
|
this.list = new NativeChoiceList<ProfileListItem>(
|
|
3764
3950
|
items,
|
|
@@ -3821,6 +4007,7 @@ class ProfilesPanel implements OverlayComponent {
|
|
|
3821
4007
|
this.requestRender();
|
|
3822
4008
|
return;
|
|
3823
4009
|
}
|
|
4010
|
+
if (data === "a") return this.finish({ type: "apply-global", name });
|
|
3824
4011
|
if (data === "d") return this.finish({ type: "duplicate", name });
|
|
3825
4012
|
if (data === "r") return this.finish({ type: "rename", name });
|
|
3826
4013
|
if (data === "x") return this.finish({ type: "delete", name });
|
|
@@ -3899,7 +4086,7 @@ class ProfilesPanel implements OverlayComponent {
|
|
|
3899
4086
|
private refreshListItems(): void {
|
|
3900
4087
|
// Keep the list instance (and its pointer observer) alive while refreshing the
|
|
3901
4088
|
// mutable item records that NativeChoiceList already holds by reference.
|
|
3902
|
-
for (const item of buildProfileListItems(this.file, evaluateProfilePin(this.pinStatus, this.file.profiles).winner?.profile)) {
|
|
4089
|
+
for (const item of buildProfileListItems(this.file, evaluateProfilePin(this.pinStatus, this.file.profiles).winner?.profile, this.sessionBoundName)) {
|
|
3903
4090
|
const current = this.listItems.find((candidate) => candidate.id === item.id);
|
|
3904
4091
|
if (current) Object.assign(current, item);
|
|
3905
4092
|
}
|
|
@@ -3946,7 +4133,7 @@ class ProfilesPanel implements OverlayComponent {
|
|
|
3946
4133
|
|
|
3947
4134
|
private renderFooterRow(width: number): string {
|
|
3948
4135
|
const hints =
|
|
3949
|
-
"enter
|
|
4136
|
+
"enter use in this session · a set as global default · c create · s snapshot · d duplicate · r rename · x delete · e export · i import · p pin · P share · j/k line · ctrl+j/k page · esc close";
|
|
3950
4137
|
const text = this.feedback ?? hints;
|
|
3951
4138
|
return [
|
|
3952
4139
|
this.renderText("│", "border"),
|
|
@@ -3982,6 +4169,13 @@ class ProfilesPanel implements OverlayComponent {
|
|
|
3982
4169
|
// any invalid or stale layer, and the scope sentence all come from the shared
|
|
3983
4170
|
// precedence rule the launch resolver uses.
|
|
3984
4171
|
...profilePinDetailLines(this.pinStatus, this.file.profiles).map((line) => this.renderLine(line, width, "muted")),
|
|
4172
|
+
// gentle-shell#1064: the binding is stored for this session and outranks
|
|
4173
|
+
// the pin in the panel list, so it is named right after the pin layers.
|
|
4174
|
+
// Since #1558 launches resolve it ahead of pins and the global default,
|
|
4175
|
+
// and nothing was written to make that true.
|
|
4176
|
+
...(this.sessionBoundName === undefined
|
|
4177
|
+
? []
|
|
4178
|
+
: [this.renderLine(`session ${sanitizeTerminalText(this.sessionBoundName)} (session) — launches resolve it ahead of pins and the global default; nothing was written`, width, "muted")]),
|
|
3985
4179
|
"",
|
|
3986
4180
|
this.renderLine("Profile routing", width, "accent"),
|
|
3987
4181
|
...this.indentLines(this.routingLines(profileRows, widths), width),
|
|
@@ -3992,6 +4186,13 @@ class ProfilesPanel implements OverlayComponent {
|
|
|
3992
4186
|
}
|
|
3993
4187
|
|
|
3994
4188
|
private effectiveOrchestratorLabel(): string {
|
|
4189
|
+
// The now line answers what this session runs: the live orchestrator wins
|
|
4190
|
+
// when one exists, and the settings read only stands in for sessions
|
|
4191
|
+
// without a live model (and still surfaces an unreadable settings file).
|
|
4192
|
+
const live = this.liveOrchestrator?.();
|
|
4193
|
+
if (live !== undefined) {
|
|
4194
|
+
return formatOrchestratorSelection(live);
|
|
4195
|
+
}
|
|
3995
4196
|
const settings = this.orchestratorSettings;
|
|
3996
4197
|
if (settings.status === "invalid") {
|
|
3997
4198
|
return `unreadable (${sanitizeTerminalText(settings.reason)})`;
|
|
@@ -4038,13 +4239,16 @@ class ProfilesPanel implements OverlayComponent {
|
|
|
4038
4239
|
}
|
|
4039
4240
|
}
|
|
4040
4241
|
|
|
4242
|
+
/** The full-screen profile picker: one visit per action, so every reopen reads fresh store, pin, orchestrator, and session-binding state. */
|
|
4041
4243
|
async function showProfilesPanel(
|
|
4042
4244
|
ctx: ExtensionContext,
|
|
4043
4245
|
file: AgentProfilesFile,
|
|
4044
4246
|
currentConfig: AgentModelConfig,
|
|
4045
4247
|
selectedName: string | undefined,
|
|
4046
4248
|
saveSnapshot: ProfilesSnapshotHandler,
|
|
4249
|
+
live: LiveSession,
|
|
4047
4250
|
status?: string,
|
|
4251
|
+
sessionBoundName?: string,
|
|
4048
4252
|
): Promise<ProfilesPanelResult> {
|
|
4049
4253
|
// Both orchestrator and pin state are snapshots for this panel visit.
|
|
4050
4254
|
// Actions (including p/P) reopen the panel and read fresh state.
|
|
@@ -4063,6 +4267,8 @@ async function showProfilesPanel(
|
|
|
4063
4267
|
saveSnapshot,
|
|
4064
4268
|
() => tui.requestRender(),
|
|
4065
4269
|
() => readProfilePinStatus(ctx.cwd),
|
|
4270
|
+
() => sessionBoundName,
|
|
4271
|
+
() => liveOrchestratorSnapshot(ctx, live)?.entry,
|
|
4066
4272
|
status,
|
|
4067
4273
|
);
|
|
4068
4274
|
const container = createNativeFullscreenInteraction({
|
|
@@ -4110,15 +4316,15 @@ function reportProfilesDrops(ctx: ExtensionContext, path: string, drops: Profile
|
|
|
4110
4316
|
}
|
|
4111
4317
|
|
|
4112
4318
|
/** Pi's own live-session controls: the ExtensionAPI's setModel/setThinkingLevel. */
|
|
4113
|
-
type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel">;
|
|
4319
|
+
type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel" | "getThinkingLevel">;
|
|
4114
4320
|
|
|
4115
4321
|
/**
|
|
4116
4322
|
* Switch the running session to the profile's orchestrator. `settings.json`
|
|
4117
4323
|
* is the default for new sessions only; Pi's `setModel`/`setThinkingLevel`
|
|
4118
|
-
* are what move the live one
|
|
4119
|
-
*
|
|
4120
|
-
*
|
|
4121
|
-
*
|
|
4324
|
+
* are what move the live one without persisting global defaults. This helper
|
|
4325
|
+
* also serves explicit global apply, but never writes settings itself. Failures
|
|
4326
|
+
* keep the current live model and report what happened; callers retain their
|
|
4327
|
+
* routing snapshot or persisted default independently.
|
|
4122
4328
|
*/
|
|
4123
4329
|
async function switchLiveOrchestrator(ctx: ExtensionContext, live: LiveSession, entry: AgentRoutingEntry): Promise<string> {
|
|
4124
4330
|
const reference = parseOrchestratorModelRef(entry.model);
|
|
@@ -4149,17 +4355,37 @@ async function switchLiveOrchestrator(ctx: ExtensionContext, live: LiveSession,
|
|
|
4149
4355
|
return `\nThis session now runs on ${label} · ${entry.thinking}.`;
|
|
4150
4356
|
}
|
|
4151
4357
|
|
|
4358
|
+
/**
|
|
4359
|
+
* The live session's orchestrator entry, when a model is running. `s` captures
|
|
4360
|
+
* what the session actually runs rather than the default new sessions would
|
|
4361
|
+
* get from settings.json; an unreadable thinking level still snapshots the
|
|
4362
|
+
* model, just without a thinking claim.
|
|
4363
|
+
*/
|
|
4364
|
+
/** The live session's orchestrator: a snapshot result shaped for `s`, not a settings-file read. */
|
|
4365
|
+
interface LiveOrchestratorSnapshot {
|
|
4366
|
+
readonly status: "valid";
|
|
4367
|
+
readonly entry: AgentRoutingEntry;
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
function liveOrchestratorSnapshot(ctx: ExtensionContext, live: LiveSession): LiveOrchestratorSnapshot | undefined {
|
|
4371
|
+
if (ctx.model === undefined || typeof ctx.model.provider !== "string" || typeof ctx.model.id !== "string") return undefined;
|
|
4372
|
+
let thinking: unknown;
|
|
4373
|
+
try { thinking = live.getThinkingLevel(); } catch { thinking = undefined; }
|
|
4374
|
+
return { status: "valid", entry: { model: `${ctx.model.provider}/${ctx.model.id}`, thinking: isThinkingLevel(thinking) ? thinking : undefined } };
|
|
4375
|
+
}
|
|
4376
|
+
|
|
4152
4377
|
function profileSnapshotFrom(
|
|
4153
4378
|
current: AgentModelConfig,
|
|
4154
|
-
|
|
4379
|
+
orchestrator: LiveOrchestratorSnapshot | OrchestratorSettingsReadResult,
|
|
4155
4380
|
): AgentModelConfig {
|
|
4156
4381
|
const snapshot = cloneModelConfig(current);
|
|
4157
|
-
if (
|
|
4158
|
-
snapshot[PROFILE_ORCHESTRATOR_KEY] = { ...
|
|
4382
|
+
if (orchestrator.status === "valid" && orchestrator.entry !== undefined) {
|
|
4383
|
+
snapshot[PROFILE_ORCHESTRATOR_KEY] = { ...orchestrator.entry };
|
|
4159
4384
|
}
|
|
4160
4385
|
return snapshot;
|
|
4161
4386
|
}
|
|
4162
4387
|
|
|
4388
|
+
/** Runs one finished panel action against the store and the live session, returning the file the reopened panel should show. */
|
|
4163
4389
|
async function runProfilesPanelAction(
|
|
4164
4390
|
ctx: ExtensionContext,
|
|
4165
4391
|
live: LiveSession,
|
|
@@ -4170,6 +4396,29 @@ async function runProfilesPanelAction(
|
|
|
4170
4396
|
): Promise<AgentProfilesFile> {
|
|
4171
4397
|
switch (result.type) {
|
|
4172
4398
|
case "apply": {
|
|
4399
|
+
if (!hasOwnProfile(file.profiles, result.name)) return file;
|
|
4400
|
+
// Enter applies a complete routing snapshot to this parent session and
|
|
4401
|
+
// switches only its live orchestrator. No shared defaults, materialized
|
|
4402
|
+
// stores, agent frontmatter, pins, or declarations are written.
|
|
4403
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
4404
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
4405
|
+
ctx.ui.notify(
|
|
4406
|
+
`el Gentleman cannot bind profile "${result.name}" to this session: no parent session id is available here. Set it as the global default with a instead.`,
|
|
4407
|
+
"warning",
|
|
4408
|
+
);
|
|
4409
|
+
return file;
|
|
4410
|
+
}
|
|
4411
|
+
const snapshot = normalizeModelConfig(file.profiles[result.name]) ?? {};
|
|
4412
|
+
bindSessionProfile(sessionId, result.name, snapshot);
|
|
4413
|
+
const orchestrator = readProfileOrchestrator(snapshot);
|
|
4414
|
+
const liveNote = orchestrator === undefined ? "" : await switchLiveOrchestrator(ctx, live, orchestrator);
|
|
4415
|
+
ctx.ui.notify(
|
|
4416
|
+
`el Gentleman bound profile "${result.name}" to this session — shown as "${result.name} (session)". Subagents and reviewers use this session's routing snapshot. Shared defaults were not written: the global routing, pins, and materialized stores are untouched. Set as global default with a.${liveNote}`,
|
|
4417
|
+
"info",
|
|
4418
|
+
);
|
|
4419
|
+
return file;
|
|
4420
|
+
}
|
|
4421
|
+
case "apply-global": {
|
|
4173
4422
|
if (!hasOwnProfile(file.profiles, result.name)) return file;
|
|
4174
4423
|
// A pinned repository resolves its subagent routing from the profile at launch,
|
|
4175
4424
|
// so a global apply would move global state this repository never reads. When a
|
|
@@ -4198,6 +4447,128 @@ async function runProfilesPanelAction(
|
|
|
4198
4447
|
}
|
|
4199
4448
|
const normalized = normalizeModelConfig(file.profiles[result.name]) ?? {};
|
|
4200
4449
|
const orchestratorEntry = readProfileOrchestrator(normalized);
|
|
4450
|
+
const hasAgentRoutes = Object.keys(normalized).some((name) => !isProfileOrchestratorKey(name));
|
|
4451
|
+
// Every global apply confirms before anything is written: applying replaces
|
|
4452
|
+
// the whole routing map in models.json and clears materialized routes the
|
|
4453
|
+
// profile omits, so the dialog must name the concrete diff. The current
|
|
4454
|
+
// routing is read from the same authority every other consumer uses; an
|
|
4455
|
+
// unreadable config is tolerated as empty, exactly like readModelConfigAsync.
|
|
4456
|
+
const savedRouting = await readModelRoutingAuthorityAsync(
|
|
4457
|
+
modelConfigPath(ctx.cwd),
|
|
4458
|
+
legacyProjectModelConfigPath(ctx.cwd),
|
|
4459
|
+
);
|
|
4460
|
+
// The apply pads omitted discoverable agents with clear entries, so the
|
|
4461
|
+
// diff must run against the effective current routing: the saved global
|
|
4462
|
+
// routing plus the materialized routes (frontmatter, subagents.json) of
|
|
4463
|
+
// agents the saved routing is silent about. models.json alone would hide
|
|
4464
|
+
// materialized-only routes the approval actually clears.
|
|
4465
|
+
const currentRouting = savedRouting.status === "valid"
|
|
4466
|
+
? await readGlobalEffectiveModelConfigFromAsync(ctx.cwd, savedRouting.config)
|
|
4467
|
+
: {};
|
|
4468
|
+
const agentNames = [
|
|
4469
|
+
...new Set([
|
|
4470
|
+
...Object.keys(currentRouting).filter((name) => !isProfileOrchestratorKey(name)),
|
|
4471
|
+
...Object.keys(normalized).filter((name) => !isProfileOrchestratorKey(name)),
|
|
4472
|
+
]),
|
|
4473
|
+
].sort();
|
|
4474
|
+
const replacedRoutes: string[] = [];
|
|
4475
|
+
const clearedRoutes: string[] = [];
|
|
4476
|
+
const addedRoutes: string[] = [];
|
|
4477
|
+
for (const name of agentNames) {
|
|
4478
|
+
const from = currentRouting[name];
|
|
4479
|
+
const to = normalized[name];
|
|
4480
|
+
if (to === undefined) {
|
|
4481
|
+
clearedRoutes.push(`${name}: ${formatOrchestratorSelection(from)} → inherit (cleared)`);
|
|
4482
|
+
} else if (from === undefined) {
|
|
4483
|
+
addedRoutes.push(`${name}: ${formatOrchestratorSelection(to)} (added)`);
|
|
4484
|
+
} else if (from.model !== to.model || from.thinking !== to.thinking) {
|
|
4485
|
+
replacedRoutes.push(`${name}: ${formatOrchestratorSelection(from)} → ${formatOrchestratorSelection(to)}`);
|
|
4486
|
+
}
|
|
4487
|
+
}
|
|
4488
|
+
if (!hasAgentRoutes) {
|
|
4489
|
+
// A genuinely empty profile and an orchestrator-only profile both wipe
|
|
4490
|
+
// every materialized agent route, but they read very differently to the
|
|
4491
|
+
// user: the orchestrator entry survives the apply and reconfigures the
|
|
4492
|
+
// orchestrator, so the dialog must not promise an entirely empty config.
|
|
4493
|
+
const [confirmTitle, confirmMessage] = orchestratorEntry !== undefined
|
|
4494
|
+
? [
|
|
4495
|
+
"Apply orchestrator-only profile?",
|
|
4496
|
+
`Profile "${result.name}" has an orchestrator entry but no agent routing entries. Applying it will replace global routing in ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} with the orchestrator entry alone, clear every materialized agent route so every agent returns to inherit its default model, set the configured orchestrator entry in settings.json, and attempt to switch this session to that orchestrator model. Continue?`,
|
|
4497
|
+
]
|
|
4498
|
+
: [
|
|
4499
|
+
"Apply empty profile?",
|
|
4500
|
+
`Profile "${result.name}" has no routing entries. Applying it will replace global routing in ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} with an empty configuration and return every agent to inherit its default model. Continue?`,
|
|
4501
|
+
];
|
|
4502
|
+
const approved = await ctx.ui.confirm(confirmTitle, confirmMessage);
|
|
4503
|
+
if (!approved) return file;
|
|
4504
|
+
} else {
|
|
4505
|
+
// A populated profile keeps the same abort semantics as the empty and
|
|
4506
|
+
// orchestrator-only dialogs: declining leaves every surface untouched.
|
|
4507
|
+
// When the routing authority is unreadable the diff above was computed
|
|
4508
|
+
// against an empty map, so the dialog must disclose the unreadable
|
|
4509
|
+
// routing and the replace/clear-to-inherit effect instead of presenting
|
|
4510
|
+
// existing routes as merely "(added)" (the #1349 wipe-bug class).
|
|
4511
|
+
// When the profile carries an orchestrator entry, approval also writes
|
|
4512
|
+
// settings.json and tries to move the live session, so both variants
|
|
4513
|
+
// must disclose those effects in the same words the orchestrator-only
|
|
4514
|
+
// dialog uses. An unconditional "will switch" is never claimed: the
|
|
4515
|
+
// registry/auth can refuse the live move.
|
|
4516
|
+
const orchestratorEffects = orchestratorEntry !== undefined
|
|
4517
|
+
? ", set the configured orchestrator entry in settings.json, and attempt to switch this session to that orchestrator model"
|
|
4518
|
+
: "";
|
|
4519
|
+
// A profile whose agent routes already match the effective current routing
|
|
4520
|
+
// and that moves no orchestrator changes nothing: re-selecting the active
|
|
4521
|
+
// profile or verifying state would otherwise train users to approve a
|
|
4522
|
+
// dialog without reading it, weakening the guard on the destructive cases
|
|
4523
|
+
// (issue #1683). Any routing change, any orchestrator change, or an
|
|
4524
|
+
// unreadable routing authority (the diff above cannot prove a no-op)
|
|
4525
|
+
// keeps the confirmation exactly as #1349/#1384 defined it.
|
|
4526
|
+
// `applyOrchestratorSettings` treats an entry without a model as "leave
|
|
4527
|
+
// settings.json alone", so such an entry is a no-op too, not a change.
|
|
4528
|
+
// The live session is a fourth surface: applying re-asserts the profile's
|
|
4529
|
+
// orchestrator on it, so a session already moved to another model or
|
|
4530
|
+
// thinking level mid-session is a real change the user must approve,
|
|
4531
|
+
// even when settings.json and the profile agree.
|
|
4532
|
+
const liveOrchestrator = (() => {
|
|
4533
|
+
if (ctx.model === undefined || typeof ctx.model.provider !== "string" || typeof ctx.model.id !== "string") return undefined;
|
|
4534
|
+
let thinking: unknown;
|
|
4535
|
+
try { thinking = live.getThinkingLevel(); } catch { return undefined; }
|
|
4536
|
+
return { model: `${ctx.model.provider}/${ctx.model.id}`, thinking: isThinkingLevel(thinking) ? thinking : undefined };
|
|
4537
|
+
})();
|
|
4538
|
+
const orchestratorUnchanged = (orchestratorEntry === undefined || orchestratorEntry.model === undefined) || (() => {
|
|
4539
|
+
const current = readOrchestratorSettings(orchestratorSettingsPath());
|
|
4540
|
+
// An invalid stored defaultThinkingLevel is dropped from the entry but
|
|
4541
|
+
// applyOrchestratorSettings would delete the key, so the file would
|
|
4542
|
+
// change: a no-op cannot be proven and the dialog must stay.
|
|
4543
|
+
if (current.status === "valid" && "defaultThinkingLevel" in current.value && current.entry?.thinking === undefined) return false;
|
|
4544
|
+
return current.status === "valid" && current.entry !== undefined
|
|
4545
|
+
&& current.entry.model === orchestratorEntry.model
|
|
4546
|
+
&& current.entry.thinking === orchestratorEntry.thinking
|
|
4547
|
+
&& liveOrchestrator !== undefined
|
|
4548
|
+
&& liveOrchestrator.model === orchestratorEntry.model
|
|
4549
|
+
&& liveOrchestrator.thinking === orchestratorEntry.thinking;
|
|
4550
|
+
})();
|
|
4551
|
+
if (savedRouting.status === "valid" && replacedRoutes.length === 0 && clearedRoutes.length === 0 && addedRoutes.length === 0 && orchestratorUnchanged) {
|
|
4552
|
+
ctx.ui.notify(
|
|
4553
|
+
`Profile "${result.name}" already matches the current global routing${orchestratorEntry !== undefined ? " and orchestrator" : ""}; applying it changed nothing.`,
|
|
4554
|
+
"info",
|
|
4555
|
+
);
|
|
4556
|
+
} else {
|
|
4557
|
+
let confirmMessage: string;
|
|
4558
|
+
if (savedRouting.status !== "valid") {
|
|
4559
|
+
const modelsPath = sanitizeTerminalText(modelConfigPath(ctx.cwd));
|
|
4560
|
+
confirmMessage = `Profile "${result.name}" has agent routing entries, but the current global routing in ${modelsPath} could not be read, so existing routes are not listed. Applying it will replace global routing in ${modelsPath} with this profile's routes, so every existing agent route may be replaced or cleared back to inherit${orchestratorEffects}. Continue?`;
|
|
4561
|
+
} else {
|
|
4562
|
+
const changes = [...replacedRoutes, ...clearedRoutes, ...addedRoutes];
|
|
4563
|
+
const changeSummary = changes.length > 0
|
|
4564
|
+
? changes.join("; ")
|
|
4565
|
+
: "its agent routes already match the current global routing";
|
|
4566
|
+
confirmMessage = `Profile "${result.name}" has agent routing entries. Applying it will replace global routing in ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} with this profile's routes: ${changeSummary}${orchestratorEffects}. Continue?`;
|
|
4567
|
+
}
|
|
4568
|
+
const approved = await ctx.ui.confirm(`Apply profile "${result.name}"?`, confirmMessage);
|
|
4569
|
+
if (!approved) return file;
|
|
4570
|
+
}
|
|
4571
|
+
}
|
|
4201
4572
|
// Applying spans three files — the store, models.json, and Pi's global
|
|
4202
4573
|
// settings.json — and there is no cross-file rename, so order the writes to
|
|
4203
4574
|
// keep the store truthful and compensate on failure: claim the profile in
|
|
@@ -4587,6 +4958,7 @@ async function runProfilesPanelAction(
|
|
|
4587
4958
|
}
|
|
4588
4959
|
}
|
|
4589
4960
|
|
|
4961
|
+
/** `/gentle:profiles`: seed or open the store, then loop the panel over one action at a time until it closes. */
|
|
4590
4962
|
async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): Promise<void> {
|
|
4591
4963
|
const path = profilesFilePath(gentleAiConfigHome());
|
|
4592
4964
|
const read = readProfilesFileResult(path);
|
|
@@ -4619,8 +4991,15 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession):
|
|
|
4619
4991
|
file,
|
|
4620
4992
|
name,
|
|
4621
4993
|
profileSnapshotFrom(
|
|
4622
|
-
|
|
4623
|
-
|
|
4994
|
+
// The snapshot captures the same routing the panel shows as current,
|
|
4995
|
+
// so a session binding outranks the shared layers here too.
|
|
4996
|
+
readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles
|
|
4997
|
+
?? readEffectiveModelConfig(ctx.cwd),
|
|
4998
|
+
// The orchestrator follows the same honesty rule: capture what the
|
|
4999
|
+
// session actually runs (ctx.model plus the live thinking level)
|
|
5000
|
+
// when a live model exists; settings.json defaults stand in only
|
|
5001
|
+
// when it does not (LCubero's finding on #1064).
|
|
5002
|
+
liveOrchestratorSnapshot(ctx, live) ?? readOrchestratorSettings(orchestratorSettingsPath()),
|
|
4624
5003
|
),
|
|
4625
5004
|
);
|
|
4626
5005
|
writeProfilesFileSync(path, next);
|
|
@@ -4628,12 +5007,23 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession):
|
|
|
4628
5007
|
return next;
|
|
4629
5008
|
};
|
|
4630
5009
|
let selectedName: string | undefined;
|
|
5010
|
+
const sessionBoundName = () => readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.name;
|
|
5011
|
+
// The panel's "Current routing (effective)" table shows what this session's
|
|
5012
|
+
// launches resolve right now, and a session binding outranks every shared
|
|
5013
|
+
// layer, so a bound session reads its snapshot as the current routing while an
|
|
5014
|
+
// unbound session keeps reading the effective config exactly as before.
|
|
5015
|
+
const currentRoutingForPanel = async () =>
|
|
5016
|
+
readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles
|
|
5017
|
+
?? await readEffectiveModelConfigAsync(ctx.cwd);
|
|
4631
5018
|
let result = await showProfilesPanel(
|
|
4632
5019
|
ctx,
|
|
4633
5020
|
file,
|
|
4634
|
-
await
|
|
5021
|
+
await currentRoutingForPanel(),
|
|
4635
5022
|
selectedName,
|
|
4636
5023
|
saveSnapshot,
|
|
5024
|
+
live,
|
|
5025
|
+
undefined,
|
|
5026
|
+
sessionBoundName(),
|
|
4637
5027
|
);
|
|
4638
5028
|
while (result.type !== "close") {
|
|
4639
5029
|
const report: ProfilesPanelReport = {};
|
|
@@ -4642,10 +5032,12 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession):
|
|
|
4642
5032
|
result = await showProfilesPanel(
|
|
4643
5033
|
ctx,
|
|
4644
5034
|
file,
|
|
4645
|
-
await
|
|
5035
|
+
await currentRoutingForPanel(),
|
|
4646
5036
|
selectedName,
|
|
4647
5037
|
saveSnapshot,
|
|
5038
|
+
live,
|
|
4648
5039
|
report.status,
|
|
5040
|
+
sessionBoundName(),
|
|
4649
5041
|
);
|
|
4650
5042
|
}
|
|
4651
5043
|
}
|
|
@@ -4712,7 +5104,12 @@ function reviewToolOperationPath(args: unknown): string {
|
|
|
4712
5104
|
return `review ${operation.replaceAll("-", " ")}`;
|
|
4713
5105
|
}
|
|
4714
5106
|
|
|
4715
|
-
|
|
5107
|
+
// A JSON-object string pattern prevents Pi primitive coercion from admitting
|
|
5108
|
+
// numbers/booleans as strings. Facade parsers still own JSON and authority checks.
|
|
5109
|
+
const REVIEW_JSON_STRING = { type: "string", pattern: "^\\s*\\{" } as const;
|
|
5110
|
+
const REVIEW_JSON_ARGUMENT = { anyOf: [REVIEW_JSON_STRING, { type: "object" }] } as const;
|
|
5111
|
+
|
|
5112
|
+
const REVIEW_CONTROLLER_PARAMETER_FIELDS = {
|
|
4716
5113
|
type: "object",
|
|
4717
5114
|
additionalProperties: false,
|
|
4718
5115
|
required: ["operation"],
|
|
@@ -4743,7 +5140,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
|
|
|
4743
5140
|
},
|
|
4744
5141
|
input: {
|
|
4745
5142
|
type: "string",
|
|
4746
|
-
description: "A JSON
|
|
5143
|
+
description: "A JSON object or serialized object string for START/ASSESS only; every other operation requires a serialized object string. New native ordinary START uses {\"mode\":\"ordinary\"}, plus the agent lens selection {\"lenses\":[\"risk\",...],\"lensesReason\":\"<what you touched and how>\"} (both or neither, 1-4 of risk, resilience, readability, reliability; never with focus; providers that predate it keep the tier default); answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. baseRef must be HEAD, a full 40- or 64-character commit id, or a ref name; abbreviated commit ids are rejected as base-ref-unresolvable. ASSESS accepts an optional object with baseRef, committedOnly, writerModelId, writerEffort, and nativeReviewOutcome (gentle-pi#662/#668/#1175). The writer profile comes from the runtime-recorded model and effort of the pending mutations for this root (writerProfileSource runtime); writerModelId and writerEffort are only a fallback when no runtime evidence exists (caller), and with neither the profile fails closed to small (fallback). A missing model, a mini model token (gemini is not mini), or low effort keeps the conservative small-model bias. ASSESS derives `closed` only from the native candidate.consumed fact for this exact candidate, which native records only when the approved review for that candidate is acknowledged; a caller-supplied closed is not authority and, without that fact, resolves to unknown. Every outcome yields the exact risk-gated plan ASSESS returns when RDD is off: a closed review never removes the separate verifier the tier requires (verify-always-rdd-high S1), unknown is never treated as closed, and a decline is candidate-scoped and never lowers the bar below the RDD-off path. A declined or unavailable outcome, stated explicitly or recorded by this process for this exact candidate (never a different one, and never from repository state alone), wins over closure. The returned outcome_source (explicit|derived|unknown) says which of these produced the value. Legacy controller input remains separate.",
|
|
4747
5144
|
},
|
|
4748
5145
|
outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
|
|
4749
5146
|
inputPath: { type: "string", description: "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired." },
|
|
@@ -4756,6 +5153,22 @@ const REVIEW_CONTROLLER_PARAMETERS = {
|
|
|
4756
5153
|
},
|
|
4757
5154
|
} as const;
|
|
4758
5155
|
|
|
5156
|
+
// Root-level union combinators (anyOf/oneOf/allOf) are forbidden by the Anthropic
|
|
5157
|
+
// tool definition spec and cause Claude Code/Agent SDK to drop the tool (#1698).
|
|
5158
|
+
// The root schema is a plain object; the nullable root shell prevents Pi from
|
|
5159
|
+
// deleting an optional supplied null, while runtime branches in the facade
|
|
5160
|
+
// enforce that null and non-START/ASSESS objects are rejected fail-closed.
|
|
5161
|
+
const REVIEW_CONTROLLER_PARAMETERS = {
|
|
5162
|
+
...REVIEW_CONTROLLER_PARAMETER_FIELDS,
|
|
5163
|
+
properties: {
|
|
5164
|
+
...REVIEW_CONTROLLER_PARAMETER_FIELDS.properties,
|
|
5165
|
+
input: {
|
|
5166
|
+
anyOf: [...REVIEW_JSON_ARGUMENT.anyOf, { type: "null" }],
|
|
5167
|
+
description: `${REVIEW_CONTROLLER_PARAMETER_FIELDS.properties.input.description} Null is invalid; omit input when optional.`,
|
|
5168
|
+
},
|
|
5169
|
+
},
|
|
5170
|
+
} as const;
|
|
5171
|
+
|
|
4759
5172
|
const REVIEW_CAPTURE_PARAMETERS = {
|
|
4760
5173
|
type: "object",
|
|
4761
5174
|
additionalProperties: false,
|
|
@@ -4767,9 +5180,8 @@ const REVIEW_CAPTURE_PARAMETERS = {
|
|
|
4767
5180
|
description: "Exact lineage from the current provider-issued collect transition.",
|
|
4768
5181
|
},
|
|
4769
5182
|
collectBinding: {
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
description: "JSON-serialized exact copy of one decoded provider-owned next_transition.collect input from current STATUS.",
|
|
5183
|
+
...REVIEW_JSON_ARGUMENT,
|
|
5184
|
+
description: "JSON object or serialized exact copy of one complete provider-owned collect input from current STATUS; never compose or alter it.",
|
|
4773
5185
|
},
|
|
4774
5186
|
reviewerRunAcknowledged: {
|
|
4775
5187
|
type: "boolean",
|
|
@@ -4801,7 +5213,7 @@ const REVIEW_CAPTURE_GROUP_PARAMETERS = {
|
|
|
4801
5213
|
required: ["lineageId", "collectBindings"],
|
|
4802
5214
|
properties: {
|
|
4803
5215
|
lineageId: { type: "string", minLength: 1, description: "Exact lineage from the current provider-issued collect transition." },
|
|
4804
|
-
collectBindings: { type: "array", minItems: 1, items:
|
|
5216
|
+
collectBindings: { type: "array", minItems: 1, items: REVIEW_JSON_ARGUMENT, description: "Ordered JSON objects or serialized exact copies of the complete current STATUS materialize reviewer collect set; never mix, reorder, or alter bindings." },
|
|
4805
5217
|
reviewerRunAcknowledged: { type: "boolean", description: "Required after the one group forecast; authorizes exactly the forecast reviewer runs." },
|
|
4806
5218
|
workspaceRoot: { type: "string", description: "Optional explicit existing Git worktree root, resolved with the controller's worktree confinement semantics." },
|
|
4807
5219
|
},
|
|
@@ -4847,6 +5259,9 @@ interface ReviewAssessInput extends Pick<NativeReviewAssessRequest, "untrackedSc
|
|
|
4847
5259
|
// candidate's own target identity (never a different one); `closed` is
|
|
4848
5260
|
// never auto-derived -- pass it explicitly.
|
|
4849
5261
|
nativeReviewOutcome?: NativeReviewOutcome;
|
|
5262
|
+
// gentle-shell#1494: the agent raises this candidate to high by citing a
|
|
5263
|
+
// high-risk item; it can never lower the native tier.
|
|
5264
|
+
escalate?: AgentRiskEscalation;
|
|
4850
5265
|
}
|
|
4851
5266
|
|
|
4852
5267
|
function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome {
|
|
@@ -4856,10 +5271,10 @@ function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome {
|
|
|
4856
5271
|
function parseReviewAssessInput(operation: ReviewControllerOperation, raw: string | undefined): ReviewAssessInput {
|
|
4857
5272
|
if (raw === undefined) return {};
|
|
4858
5273
|
const value = parseControllerJson(raw, operation);
|
|
4859
|
-
const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"]);
|
|
5274
|
+
const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome", "escalate", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"]);
|
|
4860
5275
|
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
|
|
4861
5276
|
if (unexpected !== undefined) throw new Error(`Review controller ${operation} input does not accept ${unexpected}`);
|
|
4862
|
-
const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome } = value;
|
|
5277
|
+
const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome, escalate } = value;
|
|
4863
5278
|
if (baseRef !== undefined && typeof baseRef !== "string") throw new Error(`Review controller ${operation} input baseRef must be a string`);
|
|
4864
5279
|
if (committedOnly !== undefined && typeof committedOnly !== "boolean") throw new Error(`Review controller ${operation} input committedOnly must be a boolean`);
|
|
4865
5280
|
if (writerModelId !== undefined && typeof writerModelId !== "string") throw new Error(`Review controller ${operation} input writerModelId must be a string`);
|
|
@@ -4874,6 +5289,7 @@ function parseReviewAssessInput(operation: ReviewControllerOperation, raw: strin
|
|
|
4874
5289
|
...(writerModelId === undefined ? {} : { writerModelId: writerModelId as string }),
|
|
4875
5290
|
...(writerEffort === undefined ? {} : { writerEffort: writerEffort as string }),
|
|
4876
5291
|
...(nativeReviewOutcome === undefined ? {} : { nativeReviewOutcome: nativeReviewOutcome as NativeReviewOutcome }),
|
|
5292
|
+
...(escalate === undefined ? {} : { escalate: decodeAgentRiskEscalation(escalate) }),
|
|
4877
5293
|
};
|
|
4878
5294
|
}
|
|
4879
5295
|
|
|
@@ -4912,6 +5328,32 @@ function isReviewControllerOperation(value: string): value is ReviewControllerOp
|
|
|
4912
5328
|
return Object.values(REVIEW_CONTROLLER_OPERATION).some((operation) => operation === value);
|
|
4913
5329
|
}
|
|
4914
5330
|
|
|
5331
|
+
function serializeReviewJsonArgument(value: unknown): string {
|
|
5332
|
+
if (typeof value === "string") return value;
|
|
5333
|
+
if (isProxy(value) || !isRecord(value)) throw new Error("Review JSON argument must be a serialized object string or JSON object");
|
|
5334
|
+
const ancestors = new Set<object>();
|
|
5335
|
+
const check = (item: unknown): void => {
|
|
5336
|
+
if (item === null || typeof item === "string" || typeof item === "boolean") return;
|
|
5337
|
+
if (typeof item === "number" && Number.isFinite(item) && !Object.is(item, -0)) return;
|
|
5338
|
+
if (typeof item !== "object" || item === null || isProxy(item) || ancestors.has(item)) throw new Error("Review JSON argument contains a non-JSON or cyclic value");
|
|
5339
|
+
const array = Array.isArray(item);
|
|
5340
|
+
if (array && Object.getPrototypeOf(item) !== Array.prototype) throw new Error("Review JSON argument must contain only plain JSON arrays");
|
|
5341
|
+
if (!array && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) throw new Error("Review JSON argument must contain only plain JSON objects");
|
|
5342
|
+
ancestors.add(item);
|
|
5343
|
+
const keys = Reflect.ownKeys(item);
|
|
5344
|
+
if (array && keys.length !== item.length + 1) throw new Error("Review JSON argument contains a sparse or extended array");
|
|
5345
|
+
for (const key of keys) {
|
|
5346
|
+
if (array && key === "length") continue;
|
|
5347
|
+
const descriptor = Object.getOwnPropertyDescriptor(item, key)!;
|
|
5348
|
+
if (typeof key !== "string" || !descriptor.enumerable || !("value" in descriptor) || (array && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= item.length))) throw new Error("Review JSON argument contains an ambiguous property");
|
|
5349
|
+
check(descriptor.value);
|
|
5350
|
+
}
|
|
5351
|
+
ancestors.delete(item);
|
|
5352
|
+
};
|
|
5353
|
+
check(value);
|
|
5354
|
+
return JSON.stringify(value);
|
|
5355
|
+
}
|
|
5356
|
+
|
|
4915
5357
|
function parseReviewControllerParameters(value: unknown): ReviewControllerParameters {
|
|
4916
5358
|
if (!isRecord(value)) throw new Error("Review controller parameters must be an object");
|
|
4917
5359
|
if (typeof value.operation !== "string" || !isReviewControllerOperation(value.operation)) {
|
|
@@ -4957,22 +5399,23 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
|
|
|
4957
5399
|
}
|
|
4958
5400
|
}
|
|
4959
5401
|
|
|
4960
|
-
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
|
|
5402
|
+
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] as readonly ReviewControllerOperation[]).includes(value.operation);
|
|
4961
5403
|
if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
|
|
4962
5404
|
throw new Error("Review controller requires a lineageId");
|
|
4963
5405
|
}
|
|
4964
5406
|
const parameters: ReviewControllerParameters = {
|
|
4965
5407
|
operation: value.operation,
|
|
4966
5408
|
...(typeof value.lineageId === "string" ? { lineageId: value.lineageId } : {}),
|
|
4967
|
-
...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && value.untrackedScope
|
|
4968
|
-
...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && value.intendedUntracked
|
|
5409
|
+
...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && (value.untrackedScope === NATIVE_START_UNTRACKED_SCOPE.EXCLUDE || value.untrackedScope === NATIVE_START_UNTRACKED_SCOPE.SELECT) ? { untrackedScope: value.untrackedScope } : {}),
|
|
5410
|
+
...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && Array.isArray(value.intendedUntracked) ? { intendedUntracked: [...value.intendedUntracked] } : {}),
|
|
4969
5411
|
};
|
|
4970
5412
|
for (const key of ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource", "workspaceRoot"] as const) {
|
|
4971
5413
|
const optional = value[key];
|
|
5414
|
+
if (key === "input" && key in value && (value.operation === REVIEW_CONTROLLER_OPERATION.START || value.operation === REVIEW_CONTROLLER_OPERATION.ASSESS)) {
|
|
5415
|
+
parameters.input = serializeReviewJsonArgument(optional);
|
|
5416
|
+
continue;
|
|
5417
|
+
}
|
|
4972
5418
|
if (optional !== undefined && typeof optional !== "string") {
|
|
4973
|
-
if (value.operation === REVIEW_CONTROLLER_OPERATION.START && key === "input") {
|
|
4974
|
-
throw new Error("Review controller START input must be a JSON string encoding an object, not a nested object. No lineage was created; do not call STATUS or ADVANCE for this attempted lineage.");
|
|
4975
|
-
}
|
|
4976
5419
|
throw new Error(`Review controller ${key} must be a string`);
|
|
4977
5420
|
}
|
|
4978
5421
|
if (typeof optional === "string") parameters[key] = optional;
|
|
@@ -4986,16 +5429,18 @@ function parseReviewCaptureParameters(value: unknown): ReviewCaptureParameters {
|
|
|
4986
5429
|
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
|
|
4987
5430
|
if (unexpected !== undefined) throw new Error(`Review capture does not accept ${unexpected}`);
|
|
4988
5431
|
if (!isCanonicalProcessString(value.lineageId)) throw new Error("Review capture requires an exact non-empty lineageId");
|
|
4989
|
-
|
|
5432
|
+
const { reviewerRunAcknowledged, correctionLines, workspaceRoot } = value;
|
|
5433
|
+
const collectBinding = serializeReviewJsonArgument(value.collectBinding);
|
|
5434
|
+
if (collectBinding.length === 0) throw new Error("Review capture requires a non-empty collectBinding");
|
|
4990
5435
|
if (value.reviewerRunAcknowledged !== undefined && typeof value.reviewerRunAcknowledged !== "boolean") throw new Error("Review capture reviewerRunAcknowledged must be boolean");
|
|
4991
|
-
if (value.correctionLines !== undefined && (!Number.isSafeInteger(value.correctionLines) || value.correctionLines < 1)) throw new Error("Review capture correctionLines must be a positive integer");
|
|
5436
|
+
if (value.correctionLines !== undefined && (typeof value.correctionLines !== "number" || !Number.isSafeInteger(value.correctionLines) || value.correctionLines < 1)) throw new Error("Review capture correctionLines must be a positive integer");
|
|
4992
5437
|
if (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string") throw new Error("Review capture workspaceRoot must be a string");
|
|
4993
5438
|
return {
|
|
4994
5439
|
lineageId: value.lineageId,
|
|
4995
|
-
collectBinding
|
|
4996
|
-
...(
|
|
4997
|
-
...(
|
|
4998
|
-
...(
|
|
5440
|
+
collectBinding,
|
|
5441
|
+
...(typeof reviewerRunAcknowledged === "boolean" ? { reviewerRunAcknowledged } : {}),
|
|
5442
|
+
...(typeof correctionLines === "number" ? { correctionLines } : {}),
|
|
5443
|
+
...(typeof workspaceRoot === "string" ? { workspaceRoot } : {}),
|
|
4999
5444
|
};
|
|
5000
5445
|
}
|
|
5001
5446
|
|
|
@@ -5005,14 +5450,17 @@ function parseReviewCaptureGroupParameters(value: unknown): ReviewCaptureGroupPa
|
|
|
5005
5450
|
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
|
|
5006
5451
|
if (unexpected !== undefined) throw new Error(`Review capture group does not accept ${unexpected}`);
|
|
5007
5452
|
if (!isCanonicalProcessString(value.lineageId)) throw new Error("Review capture group requires an exact non-empty lineageId");
|
|
5008
|
-
if (!Array.isArray(value.collectBindings) || value.collectBindings.length === 0
|
|
5453
|
+
if (!Array.isArray(value.collectBindings) || value.collectBindings.length === 0) throw new Error("Review capture group requires one or more collectBindings");
|
|
5454
|
+
const { reviewerRunAcknowledged, workspaceRoot } = value;
|
|
5455
|
+
const collectBindings = value.collectBindings.map(serializeReviewJsonArgument);
|
|
5456
|
+
if (collectBindings.some((binding) => binding.length === 0)) throw new Error("Review capture group requires non-empty collectBindings");
|
|
5009
5457
|
if (value.reviewerRunAcknowledged !== undefined && typeof value.reviewerRunAcknowledged !== "boolean") throw new Error("Review capture group reviewerRunAcknowledged must be boolean");
|
|
5010
5458
|
if (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string") throw new Error("Review capture group workspaceRoot must be a string");
|
|
5011
5459
|
return {
|
|
5012
5460
|
lineageId: value.lineageId,
|
|
5013
|
-
collectBindings
|
|
5014
|
-
...(
|
|
5015
|
-
...(
|
|
5461
|
+
collectBindings,
|
|
5462
|
+
...(typeof reviewerRunAcknowledged === "boolean" ? { reviewerRunAcknowledged } : {}),
|
|
5463
|
+
...(typeof workspaceRoot === "string" ? { workspaceRoot } : {}),
|
|
5016
5464
|
};
|
|
5017
5465
|
}
|
|
5018
5466
|
|
|
@@ -5078,7 +5526,10 @@ async function authorizeDestructiveReviewOperation(
|
|
|
5078
5526
|
// (issue #212).
|
|
5079
5527
|
// RECOVER authorizes itself in `executeReviewControllerOperation`, the way
|
|
5080
5528
|
// REPAIR_LEGACY_ALIAS does, because its binding can only be derived from a
|
|
5081
|
-
// fresh native target-status read.
|
|
5529
|
+
// fresh native target-status read. ABANDON joins that self-authorizing
|
|
5530
|
+
// family for the same reason (issue #1159): its binding is derived from a
|
|
5531
|
+
// fresh native authority inventory read.
|
|
5532
|
+
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ABANDON) return;
|
|
5082
5533
|
const isReset = parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET;
|
|
5083
5534
|
const maintenance = nativeMaintenanceOperation(parameters.operation);
|
|
5084
5535
|
if (!isReset && maintenance === undefined) return;
|
|
@@ -5097,7 +5548,7 @@ async function authorizeDestructiveReviewOperation(
|
|
|
5097
5548
|
const approved = await ctx.ui.confirm(
|
|
5098
5549
|
maintenance !== undefined ? `Authorize review authority ${parameters.operation.toUpperCase()}?` : `Authorize destructive review authority ${parameters.operation.toUpperCase()}?`,
|
|
5099
5550
|
maintenance !== undefined
|
|
5100
|
-
|
|
5551
|
+
? ["Operation: " + parameters.operation.toUpperCase(), "Exact published authorization binding:", maintenanceAuthorization!, maintenance === "quarantineLegacy" ? "The native command may quarantine only the published malformed freeze-findings legacy diagnostic." : "The native command may quarantine only the bound invalid recovery successor; the predecessor stays untouched."].join("\n")
|
|
5101
5552
|
: [`Operation: ${parameters.operation.toUpperCase()}`, `Repository: ${input.repositoryId}`, `Exact challenge: ${input.confirmation}`, "This invalidates all prior review authority for this repository."].join("\n"),
|
|
5102
5553
|
);
|
|
5103
5554
|
if (!approved) throw new Error(`Review controller ${parameters.operation.toUpperCase()} was not explicitly authorized`);
|
|
@@ -5226,11 +5677,15 @@ function nativeReviewModeSkipped(operation: ReviewControllerOperation, source: N
|
|
|
5226
5677
|
|
|
5227
5678
|
// The legacy START compatibility gate is deliberately permissive when mode is
|
|
5228
5679
|
// unavailable. It is not authorization to enter bootstrap-capable native code.
|
|
5229
|
-
|
|
5680
|
+
// `rememberForPrompt` lets a decoded read serve the next prompt's RDD status
|
|
5681
|
+
// line from the memo instead of spawning the same STATUS again.
|
|
5682
|
+
async function nativeRepositoryPreparationEnabled(nativeReviewCli: NativeReviewCli | null, cwd: string, signal?: AbortSignal, rememberForPrompt = false): Promise<boolean> {
|
|
5230
5683
|
if (nativeReviewCli?.reviewMode === undefined) return false;
|
|
5231
5684
|
try {
|
|
5232
5685
|
const mode = await nativeReviewCli.reviewMode({ cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, ...(signal === undefined ? {} : { signal }) });
|
|
5233
|
-
|
|
5686
|
+
const decoded = mode.operation === NATIVE_REVIEW_MODE_OPERATION.STATUS && isValidRddModeStatus(mode.status);
|
|
5687
|
+
if (decoded && rememberForPrompt) rddStatusMemo.set(cwd, { status: mode.status, expiresAt: Date.now() + RDD_STATUS_MEMO_TTL_MS });
|
|
5688
|
+
return decoded && mode.status.effective === "on";
|
|
5234
5689
|
} catch {
|
|
5235
5690
|
return false;
|
|
5236
5691
|
}
|
|
@@ -5304,7 +5759,7 @@ function asNativeReviewConsentBindingError(error: unknown): { reason: string; me
|
|
|
5304
5759
|
return typeof reason !== "string" || reason.length === 0 ? undefined : { reason, message: error.message };
|
|
5305
5760
|
}
|
|
5306
5761
|
|
|
5307
|
-
function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation, diagnostics: NativeReviewProcessDiagnostics): Record<string, unknown> {
|
|
5762
|
+
function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation | "gentle_review_capture", diagnostics: NativeReviewProcessDiagnostics): Record<string, unknown> {
|
|
5308
5763
|
return {
|
|
5309
5764
|
operation,
|
|
5310
5765
|
status: "blocked",
|
|
@@ -5365,27 +5820,24 @@ const NATIVE_RECOVERY_INPUT = {
|
|
|
5365
5820
|
} as const;
|
|
5366
5821
|
|
|
5367
5822
|
const NATIVE_MAINTENANCE_INPUT = {
|
|
5368
|
-
abandon: ["lineage", "expectedRevision", "snapshotIdentity", "actor", "reason"],
|
|
5369
5823
|
quarantineLegacy: ["repository", "lineage", "expectedRevision", "diagnostic", "disposition", "actor", "reason"],
|
|
5370
5824
|
reconcileAuthority: ["predecessorLineage", "expectedPredecessorRevision", "successorLineage", "expectedSuccessorRevision", "actor", "reason"],
|
|
5371
5825
|
} as const;
|
|
5372
5826
|
type NativeMaintenanceOperation = keyof typeof NATIVE_MAINTENANCE_INPUT;
|
|
5373
5827
|
|
|
5828
|
+
// ABANDON left this caller-supplied family: its binding can only be derived
|
|
5829
|
+
// from a fresh native authority inventory read (issue #1159), so it takes the
|
|
5830
|
+
// self-authorizing route RECOVER and REPAIR_LEGACY_ALIAS use.
|
|
5831
|
+
const NATIVE_ABANDON_INPUT = ["lineage", "actor", "reason"] as const;
|
|
5832
|
+
|
|
5374
5833
|
function nativeMaintenanceOperation(operation: ReviewControllerOperation): NativeMaintenanceOperation | undefined {
|
|
5375
|
-
if (operation === REVIEW_CONTROLLER_OPERATION.ABANDON) return "abandon";
|
|
5376
5834
|
if (operation === REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY) return "quarantineLegacy";
|
|
5377
5835
|
if (operation === REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY) return "reconcileAuthority";
|
|
5378
5836
|
return undefined;
|
|
5379
5837
|
}
|
|
5380
5838
|
|
|
5381
5839
|
function missingNativeMaintenanceInputs(operation: NativeMaintenanceOperation, input: Record<string, unknown>): readonly string[] {
|
|
5382
|
-
|
|
5383
|
-
if (operation !== "abandon") return missing;
|
|
5384
|
-
return [
|
|
5385
|
-
...missing,
|
|
5386
|
-
...(Array.isArray(input.capturedLensResults) && input.capturedLensResults.every((entry) => isCanonicalProcessString(entry)) ? [] : ["capturedLensResults"]),
|
|
5387
|
-
...(typeof input.findingsPresent === "boolean" ? [] : ["findingsPresent"]),
|
|
5388
|
-
];
|
|
5840
|
+
return NATIVE_MAINTENANCE_INPUT[operation].filter((key) => !isCanonicalProcessString(input[key]));
|
|
5389
5841
|
}
|
|
5390
5842
|
|
|
5391
5843
|
function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, input: Record<string, unknown>): boolean {
|
|
@@ -5394,7 +5846,6 @@ function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, in
|
|
|
5394
5846
|
}
|
|
5395
5847
|
|
|
5396
5848
|
function nativeMaintenanceAuthorization(operation: NativeMaintenanceOperation, input: Record<string, unknown>): string {
|
|
5397
|
-
if (operation === "abandon") return nativeReviewAbandonAuthorization({ lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, actor: String(input.actor), reason: String(input.reason) });
|
|
5398
5849
|
if (operation === "quarantineLegacy") return nativeReviewLegacyQuarantineAuthorization({ repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason) });
|
|
5399
5850
|
return nativeReviewReconcileAuthorization({ predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }) });
|
|
5400
5851
|
}
|
|
@@ -5407,8 +5858,8 @@ async function executeNativeAuthorityMaintenance(
|
|
|
5407
5858
|
nativeReviewCli: NativeReviewCli | null,
|
|
5408
5859
|
signal: AbortSignal | undefined,
|
|
5409
5860
|
): Promise<Record<string, unknown>> {
|
|
5410
|
-
const method = nativeOperation === "
|
|
5411
|
-
const nativeCommand = nativeOperation === "quarantineLegacy" ? "review quarantine-legacy" :
|
|
5861
|
+
const method = nativeOperation === "quarantineLegacy" ? nativeReviewCli?.quarantineLegacy : nativeReviewCli?.reconcileAuthority;
|
|
5862
|
+
const nativeCommand = nativeOperation === "quarantineLegacy" ? "review quarantine-legacy" : "review reconcile-authority";
|
|
5412
5863
|
if (method === undefined) {
|
|
5413
5864
|
return { operation, status: "blocked", outcome: "native-maintenance-unavailable", native_operation: nativeCommand, mutation_performed: false, mutation_outcome: "none", next_action: "install-package-local-gentle-ai-or-run-native-review-cli-directly" };
|
|
5414
5865
|
}
|
|
@@ -5420,17 +5871,145 @@ async function executeNativeAuthorityMaintenance(
|
|
|
5420
5871
|
return { operation, status: "blocked", outcome: "native-input-invalid", native_operation: nativeCommand, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-the-exact-published-native-maintenance-binding" };
|
|
5421
5872
|
}
|
|
5422
5873
|
try {
|
|
5423
|
-
const result = nativeOperation === "
|
|
5424
|
-
? await nativeReviewCli.
|
|
5425
|
-
: nativeOperation ===
|
|
5426
|
-
? await nativeReviewCli.quarantineLegacy!({ cwd, repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
|
|
5427
|
-
: await nativeReviewCli.reconcileAuthority!({ cwd, predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) });
|
|
5874
|
+
const result = nativeOperation === "quarantineLegacy"
|
|
5875
|
+
? await nativeReviewCli.quarantineLegacy!({ cwd, repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
|
|
5876
|
+
: await nativeReviewCli.reconcileAuthority!({ cwd, predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) });
|
|
5428
5877
|
return { operation, native_operation: nativeCommand, result: result.record, mutation_performed: true, mutation_outcome: "committed", next_action: "inspect" };
|
|
5429
5878
|
} catch (error) {
|
|
5430
5879
|
return nativeOperationFailure(operation, error);
|
|
5431
5880
|
}
|
|
5432
5881
|
}
|
|
5433
5882
|
|
|
5883
|
+
// Terminal authority states observed in the native lifecycle (closure,
|
|
5884
|
+
// retirement, quarantine, abandonment records). Native re-derives the real
|
|
5885
|
+
// eligibility; this pre-check only fails fast on clearly dead lineages so a
|
|
5886
|
+
// terminal entry never reaches the approval path.
|
|
5887
|
+
const NATIVE_ABANDON_TERMINAL_STATES = new Set(["approved", "closed", "superseded", "quarantined", "abandoned"]);
|
|
5888
|
+
|
|
5889
|
+
/**
|
|
5890
|
+
* The single eligible ABANDON candidate for a lineage, or undefined. Requires
|
|
5891
|
+
* an authoritative inventory, unique lineage identity across ALL entries
|
|
5892
|
+
* before any eligibility filter (an incomplete duplicate must not be hidden by
|
|
5893
|
+
* the selection), and a live compact-v2 entry (active status, non-terminal
|
|
5894
|
+
* state) carrying the discarded-work projection (reviewer input from dnlrsls
|
|
5895
|
+
* and CodeRabbit on #1668: complete:true authoritative:false, hidden duplicates,
|
|
5896
|
+
* and terminal lineages must not reach the approval path).
|
|
5897
|
+
*/
|
|
5898
|
+
function nativeAbandonCandidate(inventory: { authoritative: boolean; entries: readonly NativeReviewAuthorityEntry[] }, lineage: string): NativeReviewAuthorityEntry | undefined {
|
|
5899
|
+
if (!inventory.authoritative) return undefined;
|
|
5900
|
+
const matches = inventory.entries.filter((entry) => entry.lineageId === lineage);
|
|
5901
|
+
if (matches.length !== 1) return undefined;
|
|
5902
|
+
const entry = matches[0]!;
|
|
5903
|
+
if (entry.version !== "compact-v2"
|
|
5904
|
+
|| entry.status !== NATIVE_REVIEW_AUTHORITY_STATUS.ACTIVE
|
|
5905
|
+
|| (entry.state !== undefined && NATIVE_ABANDON_TERMINAL_STATES.has(entry.state))
|
|
5906
|
+
|| entry.discardedWork === undefined
|
|
5907
|
+
|| !isCanonicalProcessString(entry.revision)
|
|
5908
|
+
|| entry.snapshotIdentity === undefined) return undefined;
|
|
5909
|
+
return entry;
|
|
5910
|
+
}
|
|
5911
|
+
|
|
5912
|
+
function sameOrderedLensList(actual: readonly string[], expected: readonly string[]): boolean {
|
|
5913
|
+
return actual.length === expected.length && actual.every((lens, index) => lens === expected[index]);
|
|
5914
|
+
}
|
|
5915
|
+
|
|
5916
|
+
/**
|
|
5917
|
+
* ABANDON freshly reads the native authority inventory, locates the single
|
|
5918
|
+
* eligible compact-v2 entry for the caller-specified lineage, derives its
|
|
5919
|
+
* revision, snapshot identity, and discarded-work summary, and renders the
|
|
5920
|
+
* exact eight-line `gentle-ai.review-abandon-authorization/v2` binding for
|
|
5921
|
+
* interactive approval before any mutation (issue #1159). The caller supplies
|
|
5922
|
+
* only lineage, actor, and reason; every inventory-derived field is derived,
|
|
5923
|
+
* never requested, because no negotiated facade operation publishes the
|
|
5924
|
+
* discarded-work projection (the encoding the native gate recomputes carries
|
|
5925
|
+
* the ordinal-prefixed lens order, which a caller cannot reconstruct).
|
|
5926
|
+
*/
|
|
5927
|
+
async function executeNativeAbandon(
|
|
5928
|
+
input: Record<string, unknown>,
|
|
5929
|
+
lineageParameter: string | undefined,
|
|
5930
|
+
cwd: string,
|
|
5931
|
+
nativeReviewCli: NativeReviewCli | null,
|
|
5932
|
+
signal: AbortSignal | undefined,
|
|
5933
|
+
context: ExtensionContext | undefined,
|
|
5934
|
+
): Promise<Record<string, unknown>> {
|
|
5935
|
+
const operation = REVIEW_CONTROLLER_OPERATION.ABANDON;
|
|
5936
|
+
const nativeOperation = "review abandon";
|
|
5937
|
+
if (nativeReviewCli?.abandon === undefined || nativeReviewCli.reviewStatus === undefined) {
|
|
5938
|
+
return { operation, status: "blocked", outcome: "native-maintenance-unavailable", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "install-package-local-gentle-ai-or-run-native-review-cli-directly" };
|
|
5939
|
+
}
|
|
5940
|
+
const unknown = Object.keys(input).filter((key) => !(NATIVE_ABANDON_INPUT as readonly string[]).includes(key));
|
|
5941
|
+
if (unknown.length > 0 || (input.lineage !== undefined && lineageParameter !== undefined && String(input.lineage) !== lineageParameter)) {
|
|
5942
|
+
return { operation, status: "blocked", outcome: "native-input-invalid", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-lineage-actor-and-reason-only-pi-derives-the-discarded-work-summary-from-fresh-native-inventory" };
|
|
5943
|
+
}
|
|
5944
|
+
const lineage = input.lineage !== undefined ? String(input.lineage) : lineageParameter;
|
|
5945
|
+
const missing = [
|
|
5946
|
+
...(isCanonicalProcessString(lineage) ? [] : ["lineage"]),
|
|
5947
|
+
...(isCanonicalProcessString(input.actor) ? [] : ["actor"]),
|
|
5948
|
+
...(isCanonicalProcessString(input.reason) ? [] : ["reason"]),
|
|
5949
|
+
];
|
|
5950
|
+
if (missing.length > 0) {
|
|
5951
|
+
return { operation, status: "blocked", outcome: "native-input-required", native_operation: nativeOperation, missing_input: missing, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-lineage-actor-and-reason" };
|
|
5952
|
+
}
|
|
5953
|
+
if (!isNativeReviewAbandonReason(input.reason)) {
|
|
5954
|
+
return { operation, status: "blocked", outcome: "native-input-invalid", native_operation: nativeOperation, allowed_reasons: NATIVE_REVIEW_ABANDON_REASONS, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-reason-operator_disposition-or-retired_schema" };
|
|
5955
|
+
}
|
|
5956
|
+
let inventory;
|
|
5957
|
+
try {
|
|
5958
|
+
inventory = await nativeReviewCli.reviewStatus({ cwd, ...(signal === undefined ? {} : { signal }) });
|
|
5959
|
+
} catch (error) {
|
|
5960
|
+
return nativeOperationFailure(operation, error);
|
|
5961
|
+
}
|
|
5962
|
+
if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled");
|
|
5963
|
+
const entry = nativeAbandonCandidate(inventory, lineage);
|
|
5964
|
+
if (entry === undefined) {
|
|
5965
|
+
return { operation, status: "blocked", outcome: "native-abandon-ineligible", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "inspect-complete-native-authority-inventory" };
|
|
5966
|
+
}
|
|
5967
|
+
const request = {
|
|
5968
|
+
cwd,
|
|
5969
|
+
lineage: entry.lineageId!,
|
|
5970
|
+
expectedRevision: entry.revision!,
|
|
5971
|
+
snapshotIdentity: entry.snapshotIdentity!,
|
|
5972
|
+
capturedLensResults: entry.discardedWork!.capturedLensResults,
|
|
5973
|
+
findingsPresent: entry.discardedWork!.findingsPresent,
|
|
5974
|
+
actor: String(input.actor),
|
|
5975
|
+
reason: input.reason,
|
|
5976
|
+
};
|
|
5977
|
+
const authorization = nativeReviewAbandonAuthorization(request);
|
|
5978
|
+
if (context?.hasUI !== true) throw new Error("Review controller ABANDON requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed");
|
|
5979
|
+
const approved = await context.ui.confirm(
|
|
5980
|
+
"Authorize review authority ABANDON?",
|
|
5981
|
+
["Operation: ABANDON", "Exact published authorization binding:", authorization, "The native command may quarantine only an eligible pristine compact-v2 lineage."].join("\n"),
|
|
5982
|
+
);
|
|
5983
|
+
if (!approved) throw new Error("Review controller ABANDON was not explicitly authorized");
|
|
5984
|
+
// The approval binds the exact derived authority. Re-read the inventory and
|
|
5985
|
+
// fail closed on any drift before mutating, the way RECOVER rechecks its
|
|
5986
|
+
// provider-bound authorization (same TOCTOU window: another actor may have
|
|
5987
|
+
// advanced the lineage between the deriving read and the approval).
|
|
5988
|
+
if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled");
|
|
5989
|
+
let recheck;
|
|
5990
|
+
try {
|
|
5991
|
+
recheck = await nativeReviewCli.reviewStatus({ cwd, ...(signal === undefined ? {} : { signal }) });
|
|
5992
|
+
} catch (error) {
|
|
5993
|
+
return nativeOperationFailure(operation, error);
|
|
5994
|
+
}
|
|
5995
|
+
if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled");
|
|
5996
|
+
const reconfirmed = nativeAbandonCandidate(recheck, request.lineage);
|
|
5997
|
+
if (reconfirmed === undefined
|
|
5998
|
+
|| reconfirmed.revision !== request.expectedRevision
|
|
5999
|
+
|| reconfirmed.snapshotIdentity !== request.snapshotIdentity
|
|
6000
|
+
|| reconfirmed.discardedWork!.findingsPresent !== request.findingsPresent
|
|
6001
|
+
|| !sameOrderedLensList(reconfirmed.discardedWork!.capturedLensResults, request.capturedLensResults)) {
|
|
6002
|
+
return { operation, status: "blocked", outcome: "native-abandon-authority-changed", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "inspect-and-restart-abandon-from-fresh-inventory" };
|
|
6003
|
+
}
|
|
6004
|
+
if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled");
|
|
6005
|
+
try {
|
|
6006
|
+
const result = await nativeReviewCli.abandon({ ...request, maintainerAuthorization: authorization, ...(signal === undefined ? {} : { signal }) });
|
|
6007
|
+
return { operation, native_operation: nativeOperation, result: result.record, mutation_performed: true, mutation_outcome: "committed", next_action: "inspect" };
|
|
6008
|
+
} catch (error) {
|
|
6009
|
+
return nativeOperationFailure(operation, error);
|
|
6010
|
+
}
|
|
6011
|
+
}
|
|
6012
|
+
|
|
5434
6013
|
const NATIVE_LEGACY_ALIAS_REPAIR_INPUT = ["lineage", "actor", "reason"] as const;
|
|
5435
6014
|
|
|
5436
6015
|
async function executeNativeLegacyAliasRepair(
|
|
@@ -5513,6 +6092,7 @@ async function executeNativeRecoveryRoute(
|
|
|
5513
6092
|
cwd: string,
|
|
5514
6093
|
nativeReviewCli: NativeReviewCli | null,
|
|
5515
6094
|
signal: AbortSignal | undefined,
|
|
6095
|
+
targetSelectors: { baseRef?: string; committedOnly?: boolean } = {},
|
|
5516
6096
|
): Promise<Record<string, unknown>> {
|
|
5517
6097
|
const nativeCommand = `review ${nativeOperation}`;
|
|
5518
6098
|
const method = nativeOperation === "reclaim" ? nativeReviewCli?.reclaim : nativeReviewCli?.recover;
|
|
@@ -5549,6 +6129,7 @@ async function executeNativeRecoveryRoute(
|
|
|
5549
6129
|
? await nativeReviewCli.reclaim!({ cwd, lineage: String(input.lineage), actor: String(input.actor), reason: String(input.reason), ...(signal === undefined ? {} : { signal }) })
|
|
5550
6130
|
: await nativeReviewCli.recover!({
|
|
5551
6131
|
cwd,
|
|
6132
|
+
...targetSelectors,
|
|
5552
6133
|
predecessorLineage: String(input.predecessorLineage),
|
|
5553
6134
|
expectedPredecessorRevision: String(input.expectedPredecessorRevision),
|
|
5554
6135
|
successorLineage: String(input.successorLineage),
|
|
@@ -5609,7 +6190,7 @@ function withoutRawCollectInputs(raw: Record<string, unknown>): Record<string, u
|
|
|
5609
6190
|
return { ...raw, next_transition: transition };
|
|
5610
6191
|
}
|
|
5611
6192
|
|
|
5612
|
-
function mapNativeTargetStatus(operation: ReviewControllerOperation, status: ReviewStatusV3, requestedLineageId?: string): Record<string, unknown> {
|
|
6193
|
+
function mapNativeTargetStatus(operation: ReviewControllerOperation, status: ReviewStatusV3, requestedLineageId?: string, workspaceRoot?: string): Record<string, unknown> {
|
|
5613
6194
|
if (
|
|
5614
6195
|
status.nextTransition?.kind === "collect" &&
|
|
5615
6196
|
(operation === REVIEW_CONTROLLER_OPERATION.START || operation === REVIEW_CONTROLLER_OPERATION.INSPECT || operation === REVIEW_CONTROLLER_OPERATION.STATUS)
|
|
@@ -5660,6 +6241,22 @@ function mapNativeTargetStatus(operation: ReviewControllerOperation, status: Rev
|
|
|
5660
6241
|
...(withdrawSlot === undefined ? {} : { hint: `run ${withdrawSlot.withdraw.command}` }),
|
|
5661
6242
|
};
|
|
5662
6243
|
}
|
|
6244
|
+
if (
|
|
6245
|
+
status.nextTransition?.kind === "execute" &&
|
|
6246
|
+
status.nextTransition.execute.operation === "review.acknowledge-approved"
|
|
6247
|
+
) {
|
|
6248
|
+
const lineageId = status.authority?.lineageId ?? requestedLineageId;
|
|
6249
|
+
const nextAction = lineageId === undefined
|
|
6250
|
+
? undefined
|
|
6251
|
+
: `gentle_review {"operation":"acknowledge-approved","lineageId":"${lineageId}"${workspaceRoot ? `,"workspaceRoot":${JSON.stringify(workspaceRoot)}` : ""}}`;
|
|
6252
|
+
return {
|
|
6253
|
+
operation,
|
|
6254
|
+
status: "blocked",
|
|
6255
|
+
result: status.raw,
|
|
6256
|
+
...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }),
|
|
6257
|
+
...(nextAction === undefined ? {} : { next_action: nextAction }),
|
|
6258
|
+
};
|
|
6259
|
+
}
|
|
5663
6260
|
return {
|
|
5664
6261
|
operation,
|
|
5665
6262
|
status: status.action === "start" ? "ready" : "blocked",
|
|
@@ -5732,6 +6329,31 @@ const NATIVE_START_FOCUS = {
|
|
|
5732
6329
|
} as const;
|
|
5733
6330
|
type NativeStartFocus = (typeof NATIVE_START_FOCUS)[keyof typeof NATIVE_START_FOCUS];
|
|
5734
6331
|
|
|
6332
|
+
const NATIVE_START_LENSES = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
|
|
6333
|
+
const NATIVE_START_LENS_REASON_MAX = 500;
|
|
6334
|
+
|
|
6335
|
+
/**
|
|
6336
|
+
* Validates the optional agent lens selection of an ordinary START input
|
|
6337
|
+
* (gentle-ai START --lenses): both fields or neither, one to four known lens
|
|
6338
|
+
* names (short or review- form) without repeats, a non-empty single-line
|
|
6339
|
+
* reason of at most 500 bytes, and never together with focus. Returns the
|
|
6340
|
+
* canonical request fields, {} without a selection, or undefined when invalid.
|
|
6341
|
+
*/
|
|
6342
|
+
function validateNativeStartLensSelection(rawStart: Record<string, unknown>): { lenses?: readonly string[]; lensesReason?: string } | undefined {
|
|
6343
|
+
const { lenses, lensesReason } = rawStart;
|
|
6344
|
+
if (lenses === undefined && lensesReason === undefined) return {};
|
|
6345
|
+
if (!Array.isArray(lenses) || lenses.length === 0 || typeof lensesReason !== "string" || rawStart.focus !== undefined) return undefined;
|
|
6346
|
+
if (lensesReason.trim() === "" || /[\r\n]/.test(lensesReason) || Buffer.byteLength(lensesReason, "utf8") > NATIVE_START_LENS_REASON_MAX) return undefined;
|
|
6347
|
+
const chosen = new Set<string>();
|
|
6348
|
+
for (const lens of lenses) {
|
|
6349
|
+
if (typeof lens !== "string") return undefined;
|
|
6350
|
+
const canonical = lens.startsWith("review-") ? lens : `review-${lens}`;
|
|
6351
|
+
if (!(NATIVE_START_LENSES as readonly string[]).includes(canonical) || chosen.has(canonical)) return undefined;
|
|
6352
|
+
chosen.add(canonical);
|
|
6353
|
+
}
|
|
6354
|
+
return { lenses: NATIVE_START_LENSES.filter((lens) => chosen.has(lens)), lensesReason };
|
|
6355
|
+
}
|
|
6356
|
+
|
|
5735
6357
|
function isNativeStartFocus(value: unknown): value is NativeStartFocus {
|
|
5736
6358
|
return typeof value === "string" && (Object.values(NATIVE_START_FOCUS) as readonly string[]).includes(value);
|
|
5737
6359
|
}
|
|
@@ -5804,12 +6426,13 @@ function validateNativeStartUntrackedSelection(value: Record<string, unknown>):
|
|
|
5804
6426
|
!isCanonicalProcessString(expectedUntrackedInventory) ||
|
|
5805
6427
|
(intendedUntracked !== undefined && (!Array.isArray(intendedUntracked) || intendedUntracked.some((path) => !isNativeStartUntrackedPath(path) || intendedUntracked.indexOf(path) !== intendedUntracked.lastIndexOf(path))))
|
|
5806
6428
|
) return { reason: "untracked-selection-invalid" };
|
|
5807
|
-
|
|
5808
|
-
if (scope === NATIVE_START_UNTRACKED_SCOPE.
|
|
6429
|
+
const paths = Array.isArray(intendedUntracked) ? intendedUntracked : [];
|
|
6430
|
+
if (scope === NATIVE_START_UNTRACKED_SCOPE.EXCLUDE && paths.length > 0) return { reason: "untracked-selection-invalid" };
|
|
6431
|
+
if (scope === NATIVE_START_UNTRACKED_SCOPE.SELECT && paths.length === 0) return { reason: "untracked-selection-invalid" };
|
|
5809
6432
|
return {
|
|
5810
6433
|
untrackedScope: scope,
|
|
5811
6434
|
expectedUntrackedInventory,
|
|
5812
|
-
intendedUntracked:
|
|
6435
|
+
intendedUntracked: [...paths],
|
|
5813
6436
|
};
|
|
5814
6437
|
}
|
|
5815
6438
|
|
|
@@ -5835,7 +6458,7 @@ function nativeStartRejection(reason: string, field?: string): Record<string, un
|
|
|
5835
6458
|
? "native-start-committed-only-required"
|
|
5836
6459
|
: reason === "committed-only-invalid"
|
|
5837
6460
|
? "native-start-committed-only-invalid"
|
|
5838
|
-
: reason === "unknown-field" || reason === "focus-invalid" || reason === "untracked-selection-invalid" || reason === "provider-base-offer-invalid" || reason === "provider-base-tree-mismatch"
|
|
6461
|
+
: reason === "unknown-field" || reason === "focus-invalid" || reason === "lenses-invalid" || reason === "untracked-selection-invalid" || reason === "provider-base-offer-invalid" || reason === "provider-base-tree-mismatch"
|
|
5839
6462
|
? "native-start-input-invalid"
|
|
5840
6463
|
: "native-start-policy-path-invalid",
|
|
5841
6464
|
reason,
|
|
@@ -5871,7 +6494,6 @@ function nativeInspectInputRejection(reason: string, field?: string): Record<str
|
|
|
5871
6494
|
};
|
|
5872
6495
|
}
|
|
5873
6496
|
|
|
5874
|
-
const PENDING_REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
|
|
5875
6497
|
const REVIEW_SESSION_PERMISSION_STATUS_KEY = "gentle-review-session-permission";
|
|
5876
6498
|
const REVIEW_SESSION_PERMISSION_STATUS_TEXT = "reviews allowed for this session";
|
|
5877
6499
|
|
|
@@ -5888,12 +6510,9 @@ interface PendingReviewConsent {
|
|
|
5888
6510
|
untrackedSelection?: RetainedNativeUntrackedSelection;
|
|
5889
6511
|
consent: ReviewConsentEnvelope;
|
|
5890
6512
|
consentDigest: string;
|
|
5891
|
-
expiresAt: number;
|
|
5892
|
-
expiry?: ReturnType<typeof setTimeout>;
|
|
5893
6513
|
}
|
|
5894
6514
|
|
|
5895
6515
|
const PENDING_REVIEW_CONSENT_DISPOSITION = {
|
|
5896
|
-
EXPIRED: "expired",
|
|
5897
6516
|
CONSUMED: "consumed",
|
|
5898
6517
|
} as const;
|
|
5899
6518
|
|
|
@@ -5966,12 +6585,6 @@ export class PendingReviewConsentRegistry {
|
|
|
5966
6585
|
return true;
|
|
5967
6586
|
}
|
|
5968
6587
|
|
|
5969
|
-
expire(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean {
|
|
5970
|
-
if (!this.remove(sessionKey, pending)) return false;
|
|
5971
|
-
this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED);
|
|
5972
|
-
return true;
|
|
5973
|
-
}
|
|
5974
|
-
|
|
5975
6588
|
discard(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): void {
|
|
5976
6589
|
this.remove(sessionKey, pending);
|
|
5977
6590
|
}
|
|
@@ -5999,6 +6612,11 @@ const processRetainedNativeStatusSelections = new Map<PendingReviewConsentSessio
|
|
|
5999
6612
|
// and a fresh primary-loop start resets it to 0.
|
|
6000
6613
|
const processAgentEndSubagentDepth = new Map<PendingReviewConsentSessionKey, number>();
|
|
6001
6614
|
|
|
6615
|
+
// gentle-shell#1064 slice 1: the parent-session profile binding store lives in
|
|
6616
|
+
// lib/session-profile-binding.ts (in-process, keyed by parent session id). The
|
|
6617
|
+
// panel binds on Enter; the launch resolver, the shell status reader, and the
|
|
6618
|
+
// usage provider scope read the same store through the lib.
|
|
6619
|
+
|
|
6002
6620
|
// gentle-pi#677: gentle-ai#4309 owns anonymous usage telemetry end to end;
|
|
6003
6621
|
// Pi only nudges it once per process. This is a plain process-lifetime
|
|
6004
6622
|
// guard, not a session-keyed map, because the nudge is meant to fire at most
|
|
@@ -6021,24 +6639,13 @@ function pendingReviewConsentSessionKey(context: ExtensionContext | undefined, f
|
|
|
6021
6639
|
}
|
|
6022
6640
|
|
|
6023
6641
|
function consumePendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): boolean {
|
|
6024
|
-
|
|
6025
|
-
if (pending.expiry !== undefined) clearTimeout(pending.expiry);
|
|
6026
|
-
pending.expiry = undefined;
|
|
6027
|
-
return true;
|
|
6642
|
+
return registry.consume(sessionKey, pending);
|
|
6028
6643
|
}
|
|
6029
6644
|
|
|
6030
6645
|
function discardPendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void {
|
|
6031
|
-
if (pending.expiry !== undefined) clearTimeout(pending.expiry);
|
|
6032
|
-
pending.expiry = undefined;
|
|
6033
6646
|
registry.discard(sessionKey, pending);
|
|
6034
6647
|
}
|
|
6035
6648
|
|
|
6036
|
-
function expirePendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void {
|
|
6037
|
-
if (pending.expiry !== undefined) clearTimeout(pending.expiry);
|
|
6038
|
-
pending.expiry = undefined;
|
|
6039
|
-
if (registry.expire(sessionKey, pending)) pending.cleanupCandidate();
|
|
6040
|
-
}
|
|
6041
|
-
|
|
6042
6649
|
function cleanupPendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void {
|
|
6043
6650
|
discardPendingReviewConsent(pending, registry, sessionKey);
|
|
6044
6651
|
pending.cleanupCandidate();
|
|
@@ -6048,21 +6655,8 @@ function cleanupAllPendingReviewConsents(registry: PendingReviewConsentRegistry,
|
|
|
6048
6655
|
for (const pending of registry.take(sessionKey)) cleanupPendingReviewConsent(pending, registry, sessionKey);
|
|
6049
6656
|
}
|
|
6050
6657
|
|
|
6051
|
-
//
|
|
6052
|
-
//
|
|
6053
|
-
// moment synchronous time says `expiresAt <= now`, so cleanup must be
|
|
6054
|
-
// synchronous with respect to that observation — the queued cleanup
|
|
6055
|
-
// macrotask is a safety net, not the authority. Pruning here (before any
|
|
6056
|
-
// later START may reuse the retained view) keeps timer order from deciding
|
|
6057
|
-
// correctness: a fresh candidate retry never reuses a view whose binding
|
|
6058
|
-
// already expired, so it cannot trip `candidate-target-projection-drift`.
|
|
6059
|
-
function pruneExpiredReviewConsents(registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey, now: () => number): void {
|
|
6060
|
-
const pendingReviewConsents = registry.get(sessionKey);
|
|
6061
|
-
if (pendingReviewConsents === undefined) return;
|
|
6062
|
-
for (const pending of [...pendingReviewConsents.values()]) {
|
|
6063
|
-
if (pending.expiresAt <= now()) expirePendingReviewConsent(pending, registry, sessionKey);
|
|
6064
|
-
}
|
|
6065
|
-
}
|
|
6658
|
+
// Pending consent waits for the human without a deadline. Candidate verification,
|
|
6659
|
+
// one-shot consumption, replacement, and session teardown still own its lifecycle.
|
|
6066
6660
|
|
|
6067
6661
|
function reviewConsentDigest(consent: ReviewConsentEnvelope): string {
|
|
6068
6662
|
return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
|
|
@@ -6124,13 +6718,12 @@ function completedGrantedReviewConsent(outcome: Record<string, unknown>): boolea
|
|
|
6124
6718
|
}
|
|
6125
6719
|
|
|
6126
6720
|
// gentle-pi#516: a binding this session does not hold (already answered,
|
|
6127
|
-
//
|
|
6721
|
+
// or issued by another Pi process) used to fall through
|
|
6128
6722
|
// to the plain negotiated STATUS, which reads exactly like a healthy pre-start
|
|
6129
6723
|
// "ready" and sent the model back into START for a second consent prompt. The
|
|
6130
6724
|
// fact is local and proven before any provider call, so the outcome names the
|
|
6131
6725
|
// binding and the exit; the current STATUS rides along as context only.
|
|
6132
6726
|
const STALE_CONSENT_BINDING_DIAGNOSTIC_CODE = {
|
|
6133
|
-
EXPIRED: "consent-binding-expired",
|
|
6134
6727
|
ALREADY_CONSUMED: "consent-binding-already-consumed",
|
|
6135
6728
|
UNKNOWN: "consent-binding-unknown",
|
|
6136
6729
|
} as const;
|
|
@@ -6144,17 +6737,14 @@ interface StaleConsentBindingDiagnostics {
|
|
|
6144
6737
|
|
|
6145
6738
|
function staleConsentBindingDiagnostics(binding: string, disposition: PendingReviewConsentDisposition | undefined): StaleConsentBindingDiagnostics {
|
|
6146
6739
|
const exit = "Run START again for this candidate to obtain a fresh consent envelope and answer that envelope's binding once; do not resend this binding.";
|
|
6147
|
-
if (disposition === PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED) {
|
|
6148
|
-
return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.EXPIRED, message: `consent binding ${binding} expired after ${PENDING_REVIEW_CONSENT_TTL_MS / 60_000} minutes without an answer. ${exit}` };
|
|
6149
|
-
}
|
|
6150
6740
|
if (disposition === PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED) {
|
|
6151
6741
|
return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.ALREADY_CONSUMED, message: `consent binding ${binding} was already consumed by an earlier answer. ${exit}` };
|
|
6152
6742
|
}
|
|
6153
6743
|
return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.UNKNOWN, message: `consent binding ${binding} is not held by this Pi session. ${exit}` };
|
|
6154
6744
|
}
|
|
6155
6745
|
|
|
6156
|
-
function staleConsentBindingOutcome(operation: ReviewControllerOperation, binding: string, diagnostics: ReturnType<typeof staleConsentBindingDiagnostics>, status: ReviewStatusV3): Record<string, unknown> {
|
|
6157
|
-
const mapped = mapNativeTargetStatus(operation, status);
|
|
6746
|
+
function staleConsentBindingOutcome(operation: ReviewControllerOperation, binding: string, diagnostics: ReturnType<typeof staleConsentBindingDiagnostics>, status: ReviewStatusV3, workspaceRoot?: string): Record<string, unknown> {
|
|
6747
|
+
const mapped = mapNativeTargetStatus(operation, status, undefined, workspaceRoot);
|
|
6158
6748
|
return {
|
|
6159
6749
|
...mapped,
|
|
6160
6750
|
status: "blocked",
|
|
@@ -6344,10 +6934,10 @@ function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_r
|
|
|
6344
6934
|
const nativeCliError = asNativeReviewCliError(error);
|
|
6345
6935
|
if (nativeCliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) return nativeStatusPackageBinaryMissing(operation, nativeCliError.diagnostics);
|
|
6346
6936
|
const nativeDiagnostics = nativeCliError?.diagnostics;
|
|
6347
|
-
//
|
|
6348
|
-
// Preserve
|
|
6349
|
-
//
|
|
6350
|
-
const preservesNativeTargetStatusDiagnostic = nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.
|
|
6937
|
+
// Target-status probes use the adapter's review/status diagnostic domain.
|
|
6938
|
+
// Preserve that sanitized diagnostic on every controller route rather than
|
|
6939
|
+
// relabeling an actionable failure as an opaque controller failure.
|
|
6940
|
+
const preservesNativeTargetStatusDiagnostic = nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.STATUS;
|
|
6351
6941
|
const preservesAnswerConsentStartDiagnostic = operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT && nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.START;
|
|
6352
6942
|
const diagnostics = operation === REVIEW_CONTROLLER_OPERATION.START && error instanceof CandidateViewError && value.candidateViewPreNative === true
|
|
6353
6943
|
? error.diagnostics ?? { code: error.reason, message: "candidate view rejected before native START" }
|
|
@@ -6547,7 +7137,7 @@ function retainNativeUntrackedSelection(selections: Map<string, RetainedNativeSt
|
|
|
6547
7137
|
|
|
6548
7138
|
function readRetainedNativeUntrackedSelection(selections: Map<string, RetainedNativeStatusSelection>, workspaceRoot: string, lineageId: string): NativeStartUntrackedSelection {
|
|
6549
7139
|
const selection = selections.get(reviewLifecycleStorageKey(workspaceRoot, lineageId));
|
|
6550
|
-
return selection === undefined || "
|
|
7140
|
+
return selection === undefined || !("untrackedScope" in selection) || "targetIdentity" in selection
|
|
6551
7141
|
? {}
|
|
6552
7142
|
: {
|
|
6553
7143
|
untrackedScope: selection.untrackedScope,
|
|
@@ -6729,11 +7319,16 @@ function reviewHostRelaySelection(lens: string | undefined, config: AgentModelCo
|
|
|
6729
7319
|
function mapLastEventClosure(
|
|
6730
7320
|
closure: ReviewLastEventClosureV1,
|
|
6731
7321
|
binding: ReviewLastEventClosureBinding,
|
|
7322
|
+
workspaceRoot?: string,
|
|
7323
|
+
implicitWorkspaceRoot: string = process.cwd(),
|
|
6732
7324
|
): Record<string, unknown> {
|
|
6733
7325
|
if (closure.lineageId !== binding.lineageId) throw new CandidateViewError("last-event closure returned a different lineage", "last-event-closure-binding-drift");
|
|
6734
7326
|
if (binding.targetIdentity !== undefined && closure.targetIdentity !== undefined && closure.targetIdentity !== binding.targetIdentity) {
|
|
6735
7327
|
throw new CandidateViewError("last-event closure returned a different target", "last-event-closure-binding-drift");
|
|
6736
7328
|
}
|
|
7329
|
+
const nextAction = closure.acknowledgement !== undefined
|
|
7330
|
+
? `gentle_review {"operation":"acknowledge-approved","lineageId":"${closure.lineageId}"${workspaceRoot && (workspaceRoot !== implicitWorkspaceRoot || workspaceRoot !== process.cwd()) ? `,"workspaceRoot":${JSON.stringify(workspaceRoot)}` : ""}}`
|
|
7331
|
+
: undefined;
|
|
6737
7332
|
return {
|
|
6738
7333
|
tool: "gentle_review_capture",
|
|
6739
7334
|
status: "closed",
|
|
@@ -6749,6 +7344,12 @@ function mapLastEventClosure(
|
|
|
6749
7344
|
...(closure.requestHash === undefined ? {} : { request_hash: closure.requestHash }),
|
|
6750
7345
|
...(closure.correctionLines === undefined ? {} : { correction_lines: closure.correctionLines }),
|
|
6751
7346
|
...(closure.advisoryFindings === undefined ? {} : { advisory_findings: closure.advisoryFindings }),
|
|
7347
|
+
...(closure.escalation === undefined ? {} : { escalation: {
|
|
7348
|
+
cause: closure.escalation.cause,
|
|
7349
|
+
finding_ids: closure.escalation.findingIds,
|
|
7350
|
+
...(closure.escalation.refuterOutcomes === undefined ? {} : { refuter_outcomes: closure.escalation.refuterOutcomes.map(({ findingId, ...outcome }) => ({ finding_id: findingId, ...outcome })) }),
|
|
7351
|
+
} }),
|
|
7352
|
+
...(closure.targetedValidatorEvidence === undefined ? {} : { targeted_validator_evidence: closure.targetedValidatorEvidence.raw }),
|
|
6752
7353
|
...(closure.statusContinuation === undefined ? {} : { status_continuation: closure.statusContinuation.raw }),
|
|
6753
7354
|
// The host has to see the acknowledgement to run it: approval now
|
|
6754
7355
|
// waits for that exact invocation instead of burning on its own, so
|
|
@@ -6758,10 +7359,12 @@ function mapLastEventClosure(
|
|
|
6758
7359
|
// host is approved and cannot end it here, and silence would read as
|
|
6759
7360
|
// nothing left to do.
|
|
6760
7361
|
...(closure.acknowledgementUndecodable === undefined ? {} : { acknowledgement_undecodable: true }),
|
|
7362
|
+
...(nextAction === undefined ? {} : { next_action: nextAction }),
|
|
6761
7363
|
},
|
|
6762
7364
|
lineage_id: closure.lineageId,
|
|
6763
7365
|
state: closure.state,
|
|
6764
7366
|
store_revision: closure.storeRevision,
|
|
7367
|
+
...(nextAction === undefined ? {} : { next_action: nextAction }),
|
|
6765
7368
|
};
|
|
6766
7369
|
}
|
|
6767
7370
|
|
|
@@ -6770,8 +7373,9 @@ function mapAndClearLastEventClosure(
|
|
|
6770
7373
|
binding: ReviewLastEventClosureBinding,
|
|
6771
7374
|
selections: Map<string, RetainedNativeStatusSelection>,
|
|
6772
7375
|
workspaceRoot: string,
|
|
7376
|
+
implicitWorkspaceRoot?: string,
|
|
6773
7377
|
): Record<string, unknown> {
|
|
6774
|
-
const mapped = mapLastEventClosure(closure, binding);
|
|
7378
|
+
const mapped = mapLastEventClosure(closure, binding, workspaceRoot, implicitWorkspaceRoot);
|
|
6775
7379
|
clearRetainedNativeStatusSelectionsOnTerminal(selections, workspaceRoot, closure.lineageId, closure.state);
|
|
6776
7380
|
return mapped;
|
|
6777
7381
|
}
|
|
@@ -6831,6 +7435,8 @@ async function executeReviewHostRelayCapture(
|
|
|
6831
7435
|
// attribution header. Appended last so every existing positional call site
|
|
6832
7436
|
// keeps compiling unchanged.
|
|
6833
7437
|
reviewerSessionId?: string,
|
|
7438
|
+
implicitWorkspaceRoot?: string,
|
|
7439
|
+
getReviewerTransport?: () => ReviewHostRelayRequest["reviewerTransport"],
|
|
6834
7440
|
): Promise<Record<string, unknown>> {
|
|
6835
7441
|
try {
|
|
6836
7442
|
if (slot.submission === undefined) {
|
|
@@ -6840,6 +7446,7 @@ async function executeReviewHostRelayCapture(
|
|
|
6840
7446
|
REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
|
|
6841
7447
|
);
|
|
6842
7448
|
}
|
|
7449
|
+
const reviewerTransport = getReviewerTransport?.();
|
|
6843
7450
|
const result = await activeReviewHostRelayRunner((() => {
|
|
6844
7451
|
// gentle-pi#311 P2 / P3: the lens's (or, for a v9 host-mediated role
|
|
6845
7452
|
// slot, the fixed review-refuter/review-validator routing key's)
|
|
@@ -6852,7 +7459,7 @@ async function executeReviewHostRelayCapture(
|
|
|
6852
7459
|
// global routing. A pin that omits a required role stays omitted: the
|
|
6853
7460
|
// typed reviewer-config-invalid refusal below is fail-closed, never a
|
|
6854
7461
|
// silent per-role fallback to another account's routing.
|
|
6855
|
-
const launch = reviewHostRelaySelection(slot.routingKey ?? slot.lens,
|
|
7462
|
+
const launch = reviewHostRelaySelection(slot.routingKey ?? slot.lens, readReviewerModelConfig(cwd, reviewerSessionId));
|
|
6856
7463
|
return {
|
|
6857
7464
|
captureArgumentTokens: slot.captureArgumentTokens,
|
|
6858
7465
|
targetCwd: cwd,
|
|
@@ -6860,11 +7467,12 @@ async function executeReviewHostRelayCapture(
|
|
|
6860
7467
|
...launch,
|
|
6861
7468
|
...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
|
|
6862
7469
|
...(reviewerSessionId === undefined ? {} : { reviewerSessionId }),
|
|
7470
|
+
...(reviewerTransport === undefined ? {} : { reviewerTransport }),
|
|
6863
7471
|
...(signal === undefined ? {} : { signal }),
|
|
6864
7472
|
};
|
|
6865
7473
|
})());
|
|
6866
7474
|
const closure = decodeRelayLastEventClosure(result.submission);
|
|
6867
|
-
if (closure !== undefined) return mapAndClearLastEventClosure(closure, binding, selections, cwd);
|
|
7475
|
+
if (closure !== undefined) return mapAndClearLastEventClosure(closure, binding, selections, cwd, implicitWorkspaceRoot);
|
|
6868
7476
|
return {
|
|
6869
7477
|
tool: "gentle_review_capture",
|
|
6870
7478
|
status: "captured",
|
|
@@ -7049,6 +7657,7 @@ async function executeProviderRoleVectorCapture(
|
|
|
7049
7657
|
selections: Map<string, RetainedNativeStatusSelection>,
|
|
7050
7658
|
route: RetainedNativeCaptureRoute | undefined,
|
|
7051
7659
|
signal?: AbortSignal,
|
|
7660
|
+
implicitWorkspaceRoot?: string,
|
|
7052
7661
|
): Promise<Record<string, unknown>> {
|
|
7053
7662
|
if (nativeReviewCli.captureProviderRole === undefined) {
|
|
7054
7663
|
return {
|
|
@@ -7067,7 +7676,7 @@ async function executeProviderRoleVectorCapture(
|
|
|
7067
7676
|
cwd,
|
|
7068
7677
|
...(signal === undefined ? {} : { signal }),
|
|
7069
7678
|
});
|
|
7070
|
-
if ("operation" in artifact) return mapAndClearLastEventClosure(artifact, binding, selections, cwd);
|
|
7679
|
+
if ("operation" in artifact) return mapAndClearLastEventClosure(artifact, binding, selections, cwd, implicitWorkspaceRoot);
|
|
7071
7680
|
return {
|
|
7072
7681
|
tool: "gentle_review_capture",
|
|
7073
7682
|
status: "captured",
|
|
@@ -7213,20 +7822,32 @@ async function negotiatedStatusForHostTransport(
|
|
|
7213
7822
|
// an effective-off mode, or any STATUS error or transport refusal. Startup
|
|
7214
7823
|
// negotiation and mutation-gated `agent_end` use the same native whole-target
|
|
7215
7824
|
// path; neither derives candidate scope from local mutation receipts.
|
|
7825
|
+
// S5 (startup-home-repo-and-changes): the startup negotiation runs after
|
|
7826
|
+
// session_start returns, bounded by this deadline. Its in-flight review mode
|
|
7827
|
+
// read is kept per cwd so the first prompt waits for it instead of spawning
|
|
7828
|
+
// the same STATUS again.
|
|
7829
|
+
const STARTUP_NEGOTIATION_TIMEOUT_MS = 10_000;
|
|
7830
|
+
const startupModeReads = new Map<string, Promise<unknown>>();
|
|
7831
|
+
const startupNegotiations = new Set<Promise<void>>();
|
|
7832
|
+
|
|
7216
7833
|
async function resolveNegotiatedReviewStatusForSession(
|
|
7217
7834
|
nativeReviewCli: NativeReviewCli | null,
|
|
7218
7835
|
ctx: ExtensionContext,
|
|
7219
7836
|
sessionKey: PendingReviewConsentSessionKey,
|
|
7837
|
+
signal?: AbortSignal,
|
|
7220
7838
|
): Promise<ReviewStatusV3 | undefined> {
|
|
7221
7839
|
if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return undefined;
|
|
7222
7840
|
if (ctx.hasUI !== true || !resolveSessionWorktree(ctx.cwd, ctx.cwd)) return undefined;
|
|
7223
7841
|
// STATUS can prepare an unversioned directory natively. Only a successfully
|
|
7224
7842
|
// decoded effective-on mode authorizes that entry; bootstrap never counts as
|
|
7225
7843
|
// an own mutation and native owns all metadata/ancestor safety decisions.
|
|
7226
|
-
|
|
7844
|
+
const modeRead = nativeRepositoryPreparationEnabled(nativeReviewCli, ctx.cwd, signal, true);
|
|
7845
|
+
startupModeReads.set(ctx.cwd, modeRead);
|
|
7846
|
+
const enabled = await modeRead.finally(() => { if (startupModeReads.get(ctx.cwd) === modeRead) startupModeReads.delete(ctx.cwd); });
|
|
7847
|
+
if (!enabled) return undefined;
|
|
7227
7848
|
try {
|
|
7228
7849
|
const retainedSelections = ((key: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(key) ?? processRetainedNativeStatusSelections.set(key, new Map()).get(key)!)(sessionKey);
|
|
7229
|
-
const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: ctx.cwd }, retainedSelections, ctx.cwd);
|
|
7850
|
+
const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: ctx.cwd, ...(signal === undefined ? {} : { signal }) }, retainedSelections, ctx.cwd);
|
|
7230
7851
|
return negotiated.status;
|
|
7231
7852
|
} catch {
|
|
7232
7853
|
return undefined;
|
|
@@ -7381,10 +8002,15 @@ function captureGroupAuthorityDrift(status: ReviewStatusV3): Record<string, unkn
|
|
|
7381
8002
|
}
|
|
7382
8003
|
|
|
7383
8004
|
interface SelectedReviewCaptureGroup {
|
|
8005
|
+
selected: true;
|
|
7384
8006
|
slots: readonly ReviewHostRelaySlot[];
|
|
7385
8007
|
binding: ReviewLastEventClosureBinding;
|
|
7386
8008
|
}
|
|
7387
8009
|
|
|
8010
|
+
function isSelectedReviewCaptureGroup(value: SelectedReviewCaptureGroup | Record<string, unknown>): value is SelectedReviewCaptureGroup {
|
|
8011
|
+
return value.selected === true;
|
|
8012
|
+
}
|
|
8013
|
+
|
|
7388
8014
|
function selectExactReviewCaptureGroup(
|
|
7389
8015
|
status: ReviewStatusV3,
|
|
7390
8016
|
lineageId: string,
|
|
@@ -7430,7 +8056,7 @@ function selectExactReviewCaptureGroup(
|
|
|
7430
8056
|
}
|
|
7431
8057
|
lenses.add(lens); orders.add(order); subjectHashes.add(subject.subjectHash);
|
|
7432
8058
|
}
|
|
7433
|
-
return { slots, binding: first.binding };
|
|
8059
|
+
return { selected: true, slots, binding: first.binding };
|
|
7434
8060
|
}
|
|
7435
8061
|
|
|
7436
8062
|
function reviewHostRelayGroupFailure(
|
|
@@ -7468,6 +8094,8 @@ async function executeReviewCaptureOperation(
|
|
|
7468
8094
|
// x-opencode-session attribution header. Appended last for the same
|
|
7469
8095
|
// positional-call-site reason as modelRegistry above.
|
|
7470
8096
|
reviewerSessionId?: string,
|
|
8097
|
+
// Read effective settings only when the acknowledged capture actually runs.
|
|
8098
|
+
getReviewerTransport?: () => ReviewHostRelayRequest["reviewerTransport"],
|
|
7471
8099
|
): Promise<Record<string, unknown>> {
|
|
7472
8100
|
const parameters = parseReviewCaptureParameters(parametersValue);
|
|
7473
8101
|
if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) {
|
|
@@ -7481,6 +8109,7 @@ async function executeReviewCaptureOperation(
|
|
|
7481
8109
|
}
|
|
7482
8110
|
const canonicalBinding = parseCanonicalReviewCaptureBinding(parameters.collectBinding);
|
|
7483
8111
|
const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
|
|
8112
|
+
const implicitWorkspaceRoot = candidateViews?.resolveWorkspaceRoot(parameters.lineageId) ?? sessionCwd;
|
|
7484
8113
|
let route = readRetainedNativeCaptureRoute(retainedUntrackedSelections, canonicalBinding);
|
|
7485
8114
|
if (requireRegisteredRoute && route !== undefined && (route.workspaceRoot !== cwd || route.lineageId !== parameters.lineageId)) {
|
|
7486
8115
|
return captureBindingRejected("collectBinding belongs to a different registered route");
|
|
@@ -7531,7 +8160,7 @@ async function executeReviewCaptureOperation(
|
|
|
7531
8160
|
mutation_outcome: "none",
|
|
7532
8161
|
};
|
|
7533
8162
|
}
|
|
7534
|
-
return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId));
|
|
8163
|
+
return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId, implicitWorkspaceRoot, getReviewerTransport));
|
|
7535
8164
|
}
|
|
7536
8165
|
|
|
7537
8166
|
// gentle-pi#311 P3: gentle-ai's v9 contract renders the refuter and
|
|
@@ -7557,7 +8186,7 @@ async function executeReviewCaptureOperation(
|
|
|
7557
8186
|
mutation_outcome: "none",
|
|
7558
8187
|
};
|
|
7559
8188
|
}
|
|
7560
|
-
return withCorrectionTarget(await executeReviewHostRelayCapture(hostMediatedRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId));
|
|
8189
|
+
return withCorrectionTarget(await executeReviewHostRelayCapture(hostMediatedRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId, implicitWorkspaceRoot, getReviewerTransport));
|
|
7561
8190
|
}
|
|
7562
8191
|
|
|
7563
8192
|
if (selected.input.captureOperation === "review.capture-correction-plan") {
|
|
@@ -7587,7 +8216,7 @@ async function executeReviewCaptureOperation(
|
|
|
7587
8216
|
cwd,
|
|
7588
8217
|
...(signal === undefined ? {} : { signal }),
|
|
7589
8218
|
});
|
|
7590
|
-
return withCorrectionTarget(mapAndClearLastEventClosure(closure, selected.binding, retainedUntrackedSelections, cwd));
|
|
8219
|
+
return withCorrectionTarget(mapAndClearLastEventClosure(closure, selected.binding, retainedUntrackedSelections, cwd, implicitWorkspaceRoot));
|
|
7591
8220
|
} catch (error) {
|
|
7592
8221
|
return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route);
|
|
7593
8222
|
}
|
|
@@ -7598,7 +8227,7 @@ async function executeReviewCaptureOperation(
|
|
|
7598
8227
|
if (parameters.reviewerRunAcknowledged !== undefined || parameters.correctionLines !== undefined) {
|
|
7599
8228
|
return captureBindingRejected("reviewerRunAcknowledged and correctionLines are not valid for a provider role capture");
|
|
7600
8229
|
}
|
|
7601
|
-
return withCorrectionTarget(await executeProviderRoleVectorCapture(providerRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal));
|
|
8230
|
+
return withCorrectionTarget(await executeProviderRoleVectorCapture(providerRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, implicitWorkspaceRoot));
|
|
7602
8231
|
}
|
|
7603
8232
|
return captureBindingRejected(`unsupported provider capture operation: ${selected.input.captureOperation}`);
|
|
7604
8233
|
}
|
|
@@ -7642,11 +8271,13 @@ async function executeReviewCaptureGroupOperation(
|
|
|
7642
8271
|
// OpenCode-routed reviewer model carries its x-opencode-session attribution
|
|
7643
8272
|
// header. Appended last for the same positional-call-site reason as above.
|
|
7644
8273
|
reviewerSessionId?: string,
|
|
8274
|
+
getReviewerTransport?: () => ReviewHostRelayRequest["reviewerTransport"],
|
|
7645
8275
|
): Promise<Record<string, unknown>> {
|
|
7646
8276
|
const parameters = parseReviewCaptureGroupParameters(parametersValue);
|
|
7647
8277
|
if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) return { ...captureGroupRejected("native target STATUS is unavailable"), outcome: "native-status-unsupported" };
|
|
7648
8278
|
const canonicalBindings = parameters.collectBindings.map((binding) => parseCanonicalReviewCaptureBinding(binding));
|
|
7649
8279
|
const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId);
|
|
8280
|
+
const implicitWorkspaceRoot = candidateViews?.resolveWorkspaceRoot(parameters.lineageId) ?? sessionCwd;
|
|
7650
8281
|
const routes = canonicalBindings.map((binding) => readRetainedNativeCaptureRoute(retainedUntrackedSelections, binding));
|
|
7651
8282
|
let route = routes.find((candidate) => candidate !== undefined);
|
|
7652
8283
|
if (requireRegisteredRoute && routes.some((candidate) => candidate !== undefined && (candidate.workspaceRoot !== cwd || candidate.lineageId !== parameters.lineageId || candidate.baseRef !== route?.baseRef))) {
|
|
@@ -7668,7 +8299,7 @@ async function executeReviewCaptureGroupOperation(
|
|
|
7668
8299
|
return { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "native-status-failed" };
|
|
7669
8300
|
}
|
|
7670
8301
|
const group = selectExactReviewCaptureGroup(status, parameters.lineageId, canonicalBindings);
|
|
7671
|
-
if (!(
|
|
8302
|
+
if (!isSelectedReviewCaptureGroup(group)) return group;
|
|
7672
8303
|
route = { workspaceRoot: cwd, lineageId: parameters.lineageId, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }) };
|
|
7673
8304
|
if (parameters.reviewerRunAcknowledged !== true) {
|
|
7674
8305
|
return {
|
|
@@ -7680,10 +8311,10 @@ async function executeReviewCaptureGroupOperation(
|
|
|
7680
8311
|
mutation_outcome: "none",
|
|
7681
8312
|
};
|
|
7682
8313
|
}
|
|
7683
|
-
//
|
|
7684
|
-
//
|
|
7685
|
-
|
|
7686
|
-
const
|
|
8314
|
+
// Resolve session-over-pin-over-global once for the entire reviewer group,
|
|
8315
|
+
// so every slot uses the same complete routing snapshot.
|
|
8316
|
+
const reviewerRouting = readReviewerModelConfig(cwd, reviewerSessionId);
|
|
8317
|
+
const reviewerTransport = getReviewerTransport?.();
|
|
7687
8318
|
const requests: readonly ReviewHostRelayRequest[] = group.slots.map((slot) => ({
|
|
7688
8319
|
captureArgumentTokens: slot.captureArgumentTokens,
|
|
7689
8320
|
targetCwd: cwd,
|
|
@@ -7691,6 +8322,7 @@ async function executeReviewCaptureGroupOperation(
|
|
|
7691
8322
|
...reviewHostRelaySelection(slot.lens, reviewerRouting),
|
|
7692
8323
|
...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
|
|
7693
8324
|
...(reviewerSessionId === undefined ? {} : { reviewerSessionId }),
|
|
8325
|
+
...(reviewerTransport === undefined ? {} : { reviewerTransport }),
|
|
7694
8326
|
...(signal === undefined ? {} : { signal }),
|
|
7695
8327
|
}));
|
|
7696
8328
|
let prepared: readonly ReviewHostRelayPreparedResult[];
|
|
@@ -7717,7 +8349,7 @@ async function executeReviewCaptureGroupOperation(
|
|
|
7717
8349
|
const result = await activeReviewHostRelaySubmissionRunner(prepared[index]!);
|
|
7718
8350
|
const closure = decodeRelayLastEventClosure(result.submission);
|
|
7719
8351
|
if (closure !== undefined) {
|
|
7720
|
-
const closed = mapAndClearLastEventClosure(closure, current.binding, retainedUntrackedSelections, cwd);
|
|
8352
|
+
const closed = mapAndClearLastEventClosure(closure, current.binding, retainedUntrackedSelections, cwd, implicitWorkspaceRoot);
|
|
7721
8353
|
return { ...closed, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index + 1) };
|
|
7722
8354
|
}
|
|
7723
8355
|
} catch (error) {
|
|
@@ -7770,8 +8402,6 @@ async function executeReviewControllerOperation(
|
|
|
7770
8402
|
retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
|
|
7771
8403
|
pendingReviewConsentRegistry: PendingReviewConsentRegistry = processPendingReviewConsentRegistry,
|
|
7772
8404
|
pendingReviewConsentFallbackKey: symbol = Symbol("pending-review-consent-fallback"),
|
|
7773
|
-
reviewConsentNow: () => number = Date.now,
|
|
7774
|
-
reviewConsentScheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void } = setTimeout,
|
|
7775
8405
|
intendedUntrackedSelection?: NativeIntendedUntrackedSelectionSubmission,
|
|
7776
8406
|
): Promise<Record<string, unknown>> {
|
|
7777
8407
|
const parameters = parseReviewControllerParameters(parametersValue);
|
|
@@ -7821,6 +8451,10 @@ async function executeReviewControllerOperation(
|
|
|
7821
8451
|
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
7822
8452
|
return await executeNativeLegacyAliasRepair(input, defaultCwd, nativeReviewCli, signal, context);
|
|
7823
8453
|
}
|
|
8454
|
+
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ABANDON) {
|
|
8455
|
+
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
8456
|
+
return await executeNativeAbandon(input, parameters.lineageId, defaultCwd, nativeReviewCli, signal, context);
|
|
8457
|
+
}
|
|
7824
8458
|
const maintenance = nativeMaintenanceOperation(parameters.operation);
|
|
7825
8459
|
if (maintenance !== undefined) {
|
|
7826
8460
|
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
@@ -7885,6 +8519,7 @@ async function executeReviewControllerOperation(
|
|
|
7885
8519
|
parameters.operation,
|
|
7886
8520
|
status,
|
|
7887
8521
|
undefined,
|
|
8522
|
+
includeWorkspaceRoot ? defaultCwd : undefined,
|
|
7888
8523
|
);
|
|
7889
8524
|
if (parameters.untrackedScope === undefined) {
|
|
7890
8525
|
if (canonicalBaseRef !== undefined && typeof plainMapped.selectionBinding === "string") {
|
|
@@ -7996,6 +8631,7 @@ async function executeReviewControllerOperation(
|
|
|
7996
8631
|
parameters.operation,
|
|
7997
8632
|
resolvedStatus,
|
|
7998
8633
|
undefined,
|
|
8634
|
+
includeWorkspaceRoot ? defaultCwd : undefined,
|
|
7999
8635
|
);
|
|
8000
8636
|
return {
|
|
8001
8637
|
...resolvedMapped,
|
|
@@ -8020,7 +8656,7 @@ async function executeReviewControllerOperation(
|
|
|
8020
8656
|
// A stuck legacy mutation lock is an incomplete in-flight entry; the
|
|
8021
8657
|
// audited native quarantine owns its removal. Lock recovery is not a
|
|
8022
8658
|
// destructive authority reset, so pending authorizations survive.
|
|
8023
|
-
return await executeNativeRecoveryRoute(parameters.operation, "reclaim", input, defaultCwd, nativeReviewCli,
|
|
8659
|
+
return await executeNativeRecoveryRoute(parameters.operation, "reclaim", input, defaultCwd, nativeReviewCli, signal);
|
|
8024
8660
|
}
|
|
8025
8661
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RECOVER) {
|
|
8026
8662
|
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
@@ -8114,7 +8750,7 @@ async function executeReviewControllerOperation(
|
|
|
8114
8750
|
next_action: "reinspect-and-reauthorize-recovery",
|
|
8115
8751
|
};
|
|
8116
8752
|
}
|
|
8117
|
-
return await executeNativeRecoveryRoute(parameters.operation, "recover", { ...input, disposition: status.actionDisposition, maintainerAuthorization: recoverAuthorization }, defaultCwd, nativeReviewCli, signal);
|
|
8753
|
+
return await executeNativeRecoveryRoute(parameters.operation, "recover", { ...input, disposition: status.actionDisposition, maintainerAuthorization: recoverAuthorization }, defaultCwd, nativeReviewCli, signal, statusRequest.baseRef === undefined ? {} : { baseRef: statusRequest.baseRef, committedOnly: true });
|
|
8118
8754
|
}
|
|
8119
8755
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET) {
|
|
8120
8756
|
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
@@ -8130,8 +8766,8 @@ async function executeReviewControllerOperation(
|
|
|
8130
8766
|
return nativeStatusFailed(parameters.operation, error);
|
|
8131
8767
|
}
|
|
8132
8768
|
clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state); retainNativeCaptureRoutes(retainedUntrackedSelections, defaultCwd, status, frozenTarget?.committedOnly === true ? nativeCommittedRangeSelector(frozenTarget) : undefined);
|
|
8133
|
-
if (status.authority?.version === "compact-v2") return { operation: parameters.operation, repaired: false, compact_authority: "immutable-untouched", status: mapNativeTargetStatus(parameters.operation, status, parameters.lineageId) };
|
|
8134
|
-
if (status.authority?.version !== "legacy-v1") return mapNativeTargetStatus(parameters.operation, status, parameters.lineageId);
|
|
8769
|
+
if (status.authority?.version === "compact-v2") return { operation: parameters.operation, repaired: false, compact_authority: "immutable-untouched", status: mapNativeTargetStatus(parameters.operation, status, parameters.lineageId, includeWorkspaceRoot ? defaultCwd : undefined) };
|
|
8770
|
+
if (status.authority?.version !== "legacy-v1") return mapNativeTargetStatus(parameters.operation, status, parameters.lineageId, includeWorkspaceRoot ? defaultCwd : undefined);
|
|
8135
8771
|
const store = ReviewTransactionStore.forRepository(defaultCwd);
|
|
8136
8772
|
store.repairCurrentAuthority();
|
|
8137
8773
|
return { operation: parameters.operation, repaired: true };
|
|
@@ -8140,6 +8776,11 @@ async function executeReviewControllerOperation(
|
|
|
8140
8776
|
const controllerOnlyInput = ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource"]
|
|
8141
8777
|
.find((key) => parameters[key as keyof ReviewControllerParameters] !== undefined);
|
|
8142
8778
|
if (controllerOnlyInput !== undefined || !isCanonicalProcessString(parameters.lineageId)) {
|
|
8779
|
+
const implicitRoot = resolveReviewControllerWorkspaceRoot(undefined, sessionCwd, candidateViews, parameters.lineageId);
|
|
8780
|
+
const needsExplicitWorkspaceRoot = parameters.workspaceRoot !== undefined && parameters.workspaceRoot !== implicitRoot;
|
|
8781
|
+
const nextAction = isCanonicalProcessString(parameters.lineageId)
|
|
8782
|
+
? `gentle_review {"operation":"acknowledge-approved","lineageId":"${parameters.lineageId}"${needsExplicitWorkspaceRoot ? `,"workspaceRoot":${JSON.stringify(parameters.workspaceRoot)}` : ""}}`
|
|
8783
|
+
: "resubmit-the-exact-lineage-without-controller-only-input";
|
|
8143
8784
|
return {
|
|
8144
8785
|
operation: parameters.operation,
|
|
8145
8786
|
status: "blocked",
|
|
@@ -8148,7 +8789,7 @@ async function executeReviewControllerOperation(
|
|
|
8148
8789
|
...(controllerOnlyInput === undefined ? {} : { field: controllerOnlyInput }),
|
|
8149
8790
|
mutation_performed: false,
|
|
8150
8791
|
mutation_outcome: "none",
|
|
8151
|
-
next_action:
|
|
8792
|
+
next_action: nextAction,
|
|
8152
8793
|
};
|
|
8153
8794
|
}
|
|
8154
8795
|
const acknowledgementCli = nativeReviewCli as NativeReviewAcknowledgementCli | null;
|
|
@@ -8243,9 +8884,9 @@ async function executeReviewControllerOperation(
|
|
|
8243
8884
|
outcome: "native-approved-acknowledgement-completed",
|
|
8244
8885
|
lineage_id: parameters.lineageId,
|
|
8245
8886
|
target_identity: status.targetIdentity,
|
|
8246
|
-
...(acknowledged
|
|
8887
|
+
...(acknowledged ? { consumed_revision: acknowledged.consumedRevision } : {}),
|
|
8247
8888
|
authority: "burned",
|
|
8248
|
-
...(acknowledged
|
|
8889
|
+
...(acknowledged ? { burn_evidence: acknowledged.schema } : {}),
|
|
8249
8890
|
delivery: "ordinary-repository-policy",
|
|
8250
8891
|
mutation_performed: true,
|
|
8251
8892
|
mutation_outcome: "committed",
|
|
@@ -8264,12 +8905,8 @@ async function executeReviewControllerOperation(
|
|
|
8264
8905
|
const resolved = pendingReviewConsentRegistry.resolve(input.consentBinding);
|
|
8265
8906
|
const pending = resolved?.pending;
|
|
8266
8907
|
const owningSession = resolved?.sessionKey ?? pendingReviewConsentSession;
|
|
8267
|
-
if (pending === undefined
|
|
8268
|
-
const
|
|
8269
|
-
? pendingReviewConsentRegistry.staleDisposition(input.consentBinding)
|
|
8270
|
-
: PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED;
|
|
8271
|
-
const stale = staleConsentBindingDiagnostics(input.consentBinding, disposition);
|
|
8272
|
-
if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession);
|
|
8908
|
+
if (pending === undefined) {
|
|
8909
|
+
const stale = staleConsentBindingDiagnostics(input.consentBinding, pendingReviewConsentRegistry.staleDisposition(input.consentBinding));
|
|
8273
8910
|
if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
|
|
8274
8911
|
try {
|
|
8275
8912
|
const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
|
|
@@ -8277,7 +8914,7 @@ async function executeReviewControllerOperation(
|
|
|
8277
8914
|
...(signal === undefined ? {} : { signal }),
|
|
8278
8915
|
}, retainedUntrackedSelections, defaultCwd);
|
|
8279
8916
|
if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport);
|
|
8280
|
-
return staleConsentBindingOutcome(parameters.operation, input.consentBinding, stale, negotiated.status
|
|
8917
|
+
return staleConsentBindingOutcome(parameters.operation, input.consentBinding, stale, negotiated.status!, includeWorkspaceRoot ? defaultCwd : undefined);
|
|
8281
8918
|
} catch (error) {
|
|
8282
8919
|
return nativeStatusFailed(parameters.operation, error);
|
|
8283
8920
|
}
|
|
@@ -8299,7 +8936,7 @@ async function executeReviewControllerOperation(
|
|
|
8299
8936
|
...(signal === undefined ? {} : { signal }),
|
|
8300
8937
|
}, retainedUntrackedSelections, defaultCwd);
|
|
8301
8938
|
if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport);
|
|
8302
|
-
return staleConsentBindingOutcome(parameters.operation, input.consentBinding, stale, negotiated.status
|
|
8939
|
+
return staleConsentBindingOutcome(parameters.operation, input.consentBinding, stale, negotiated.status!, includeWorkspaceRoot ? defaultCwd : undefined);
|
|
8303
8940
|
} catch (error) {
|
|
8304
8941
|
return nativeStatusFailed(parameters.operation, error);
|
|
8305
8942
|
}
|
|
@@ -8377,7 +9014,7 @@ async function executeReviewControllerOperation(
|
|
|
8377
9014
|
const rejected = (stopSelector !== undefined && stopSelector.targetIdentity !== status.targetIdentity) || input === undefined || canonicalReviewCaptureBinding(input) !== canonicalBinding || exactCollectArgument(input, "target_identity") !== status.targetIdentity || exactCollectArgument(input, "projection") !== status.projection.projection || exactCollectArgument(input, "base_tree") !== status.projection.baseTree || exactCollectArgument(input, "candidate_tree") !== status.projection.currentCandidateTree || !Array.isArray(eligible) || selected.reason !== undefined || selected.intendedUntracked!.some((path) => !eligible.includes(path));
|
|
8378
9015
|
if (rejected) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" };
|
|
8379
9016
|
const submission = { argumentTokens: input.submission!.argumentTokens, value: JSON.stringify({ schema: "gentle-ai.review-intended-untracked-selection/v1", untracked_scope: scope, expected_untracked_inventory: inventory, intended_untracked: selected.intendedUntracked }) };
|
|
8380
|
-
const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, ...committedSelector, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey,
|
|
9017
|
+
const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, ...committedSelector, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, submission);
|
|
8381
9018
|
return { ...result, operation: parameters.operation };
|
|
8382
9019
|
}
|
|
8383
9020
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
|
|
@@ -8387,10 +9024,12 @@ async function executeReviewControllerOperation(
|
|
|
8387
9024
|
);
|
|
8388
9025
|
if (rawStart.mode === REVIEW_MODE.ORDINARY) {
|
|
8389
9026
|
if ("policyHash" in rawStart) return nativeStartRejection("legacy-policy-hash-unsupported");
|
|
8390
|
-
const unknownField = Object.keys(rawStart).find((field) => !["mode", "baseRef", "committedOnly", "policyPath", "focus", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"].includes(field));
|
|
9027
|
+
const unknownField = Object.keys(rawStart).find((field) => !["mode", "baseRef", "committedOnly", "policyPath", "focus", "lenses", "lensesReason", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"].includes(field));
|
|
8391
9028
|
if (unknownField !== undefined) return nativeStartRejection("unknown-field", unknownField);
|
|
8392
9029
|
const focus = rawStart.focus;
|
|
8393
9030
|
if (focus !== undefined && !isNativeStartFocus(focus)) return nativeStartRejection("focus-invalid");
|
|
9031
|
+
const lensSelection = validateNativeStartLensSelection(rawStart);
|
|
9032
|
+
if (lensSelection === undefined) return nativeStartRejection("lenses-invalid");
|
|
8394
9033
|
const policy: NativeStartPolicyValidation = rawStart.policyPath === undefined
|
|
8395
9034
|
? {}
|
|
8396
9035
|
: validateNativeStartPolicyPath(defaultCwd, rawStart.policyPath);
|
|
@@ -8416,7 +9055,7 @@ async function executeReviewControllerOperation(
|
|
|
8416
9055
|
: undefined;
|
|
8417
9056
|
let canonicalBaseRef: string | undefined;
|
|
8418
9057
|
let providerBaseTree: string | undefined;
|
|
8419
|
-
if (baseRef
|
|
9058
|
+
if (typeof baseRef === "string") {
|
|
8420
9059
|
try {
|
|
8421
9060
|
canonicalBaseRef = resolveCanonicalCandidateBase(defaultCwd, baseRef).commit;
|
|
8422
9061
|
} catch (error) {
|
|
@@ -8543,26 +9182,20 @@ async function executeReviewControllerOperation(
|
|
|
8543
9182
|
next_action: "inspect-and-resolve-the-current-intended-untracked-selection",
|
|
8544
9183
|
};
|
|
8545
9184
|
}
|
|
8546
|
-
if (target.nextTransition?.kind === "collect" || target.applicability !== "unrelated" || target.action !== "start") return mapNativeTargetStatus(parameters.operation, target, parameters.lineageId);
|
|
9185
|
+
if (target.nextTransition?.kind === "collect" || target.applicability !== "unrelated" || target.action !== "start") return mapNativeTargetStatus(parameters.operation, target, parameters.lineageId, includeWorkspaceRoot ? defaultCwd : undefined);
|
|
8547
9186
|
} catch (error) {
|
|
8548
9187
|
return nativeOperationFailure(parameters.operation, error);
|
|
8549
9188
|
}
|
|
8550
9189
|
// gentle-pi#323: the replay key must fold in the current candidate
|
|
8551
9190
|
// content identity. Without it, a second START with identical
|
|
8552
9191
|
// {cwd, lineageId, input, inputPath} reuses a still-live (never
|
|
8553
|
-
// lineage-bound) frozen candidate view
|
|
8554
|
-
//
|
|
9192
|
+
// lineage-bound) frozen candidate view retained for pending consent
|
|
9193
|
+
// even after the live candidate content changed underneath
|
|
8555
9194
|
// it, and dead-ends at candidate-target-projection-drift with no
|
|
8556
9195
|
// recovery. Folding in currentCandidateTree makes a content change
|
|
8557
9196
|
// mint a fresh replay key -- and therefore a fresh candidate view --
|
|
8558
9197
|
// instead of reusing the stale one.
|
|
8559
9198
|
const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null, candidateTree: target.projection.currentCandidateTree, providerBaseTree: providerBaseTree ?? null });
|
|
8560
|
-
// Synchronously drop any binding whose TTL has already elapsed
|
|
8561
|
-
// before reusing its retained candidate view, so a fresh-candidate
|
|
8562
|
-
// retry cannot reuse a view tied to an expired binding and trip
|
|
8563
|
-
// candidate-target-projection-drift. Timer order must not decide
|
|
8564
|
-
// correctness: the queued cleanup macrotask may not have fired yet.
|
|
8565
|
-
pruneExpiredReviewConsents(pendingReviewConsentRegistry, pendingReviewConsentSession, reviewConsentNow);
|
|
8566
9199
|
const candidateIntendedUntracked = target.projection.intendedUntracked;
|
|
8567
9200
|
let candidateView: ReturnType<CandidateViewRegistry["create"]> | undefined;
|
|
8568
9201
|
let nativeStartAttempted = false;
|
|
@@ -8585,7 +9218,8 @@ async function executeReviewControllerOperation(
|
|
|
8585
9218
|
...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
|
|
8586
9219
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
8587
9220
|
...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
|
|
8588
|
-
...(focus
|
|
9221
|
+
...(isNativeStartFocus(focus) ? { focus } : {}),
|
|
9222
|
+
...lensSelection,
|
|
8589
9223
|
...(signal === undefined ? {} : { signal }),
|
|
8590
9224
|
});
|
|
8591
9225
|
} catch (error) {
|
|
@@ -8595,7 +9229,7 @@ async function executeReviewControllerOperation(
|
|
|
8595
9229
|
const repositoryCwd = realpathSync(defaultCwd);
|
|
8596
9230
|
const consentDigest = reviewConsentDigest(error.consent);
|
|
8597
9231
|
const pendingReviewConsents = pendingReviewConsentRegistry.get(pendingReviewConsentSession);
|
|
8598
|
-
const existing = [...(pendingReviewConsents?.values() ?? [])].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest
|
|
9232
|
+
const existing = [...(pendingReviewConsents?.values() ?? [])].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest);
|
|
8599
9233
|
if (existing === undefined) {
|
|
8600
9234
|
for (const pending of [...(pendingReviewConsents?.values() ?? [])]) {
|
|
8601
9235
|
if (pending.candidateView.token === consentCandidateView.token) {
|
|
@@ -8616,19 +9250,13 @@ async function executeReviewControllerOperation(
|
|
|
8616
9250
|
cleanupCandidate: () => {
|
|
8617
9251
|
if (candidateCleaned) return;
|
|
8618
9252
|
candidateCleaned = true;
|
|
8619
|
-
try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent
|
|
9253
|
+
try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent teardown still completes. */ }
|
|
8620
9254
|
},
|
|
8621
9255
|
...(retainedUntrackedSelection === undefined ? {} : { untrackedSelection: retainedUntrackedSelection }),
|
|
8622
9256
|
consent: error.consent,
|
|
8623
9257
|
consentDigest,
|
|
8624
|
-
expiresAt: reviewConsentNow() + PENDING_REVIEW_CONSENT_TTL_MS,
|
|
8625
9258
|
};
|
|
8626
9259
|
pendingReviewConsentRegistry.add(pendingReviewConsentSession, pending);
|
|
8627
|
-
pending.expiry = reviewConsentScheduleTimer(
|
|
8628
|
-
() => expirePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession),
|
|
8629
|
-
PENDING_REVIEW_CONSENT_TTL_MS,
|
|
8630
|
-
);
|
|
8631
|
-
pending.expiry.unref();
|
|
8632
9260
|
}
|
|
8633
9261
|
return {
|
|
8634
9262
|
operation: parameters.operation,
|
|
@@ -8776,7 +9404,7 @@ async function executeReviewControllerOperation(
|
|
|
8776
9404
|
const retainedCommittedTarget = rawStatus === undefined && parameters.lineageId !== undefined && candidateViews?.hasProjection(parameters.lineageId, defaultCwd)
|
|
8777
9405
|
? candidateViews.resolveProjection(parameters.lineageId, defaultCwd)
|
|
8778
9406
|
: undefined;
|
|
8779
|
-
const effectiveBaseRef = baseRef
|
|
9407
|
+
const effectiveBaseRef = typeof baseRef === "string" ? baseRef : (retainedCommittedTarget?.committedOnly === true ? nativeCommittedRangeSelector(retainedCommittedTarget) : undefined);
|
|
8780
9408
|
if (nativeReviewCli?.targetStatus !== undefined) {
|
|
8781
9409
|
try {
|
|
8782
9410
|
const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
|
|
@@ -8801,7 +9429,7 @@ async function executeReviewControllerOperation(
|
|
|
8801
9429
|
) retainNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId, retainedUntrackedSelection);
|
|
8802
9430
|
clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state);
|
|
8803
9431
|
hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, status);
|
|
8804
|
-
return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
|
|
9432
|
+
return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId, includeWorkspaceRoot ? defaultCwd : undefined), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
|
|
8805
9433
|
} catch (error) {
|
|
8806
9434
|
return nativeOperationFailure(parameters.operation, error);
|
|
8807
9435
|
}
|
|
@@ -8813,8 +9441,13 @@ async function executeReviewControllerOperation(
|
|
|
8813
9441
|
|
|
8814
9442
|
/** @internal */
|
|
8815
9443
|
export const __testing = {
|
|
9444
|
+
parseReviewControllerParameters,
|
|
9445
|
+
parseReviewCaptureParameters,
|
|
9446
|
+
parseReviewCaptureGroupParameters,
|
|
9447
|
+
runProfilesPanelAction,
|
|
8816
9448
|
resolveReviewModeGate,
|
|
8817
9449
|
readEffectiveModelConfig,
|
|
9450
|
+
readReviewerModelConfig,
|
|
8818
9451
|
readEffectiveModelConfigAsync,
|
|
8819
9452
|
followRenamedPin,
|
|
8820
9453
|
profilePinScopeNote,
|
|
@@ -8827,8 +9460,8 @@ export const __testing = {
|
|
|
8827
9460
|
guardedCommandPreview,
|
|
8828
9461
|
guardedCommandTitle,
|
|
8829
9462
|
loadRuntimeGuardrailsConfig,
|
|
8830
|
-
|
|
8831
|
-
|
|
9463
|
+
isReversibleYoloCommand,
|
|
9464
|
+
yoloConfiguredRestriction,
|
|
8832
9465
|
buildGentlePrompt,
|
|
8833
9466
|
nativeStatusUnsupported,
|
|
8834
9467
|
nativeStartRejection,
|
|
@@ -8859,6 +9492,9 @@ export const __testing = {
|
|
|
8859
9492
|
RDD_STATUS_TIMEOUT_MS,
|
|
8860
9493
|
RDD_STATUS_MEMO_TTL_MS,
|
|
8861
9494
|
clearRddStatusMemoForTesting,
|
|
9495
|
+
async startupNegotiationSettled(): Promise<void> {
|
|
9496
|
+
await Promise.all([...startupNegotiations]);
|
|
9497
|
+
},
|
|
8862
9498
|
readNativeReviewOutcome,
|
|
8863
9499
|
recordNativeReviewOutcome,
|
|
8864
9500
|
clearNativeReviewOutcomeMemoForTesting,
|
|
@@ -8875,12 +9511,6 @@ export interface GentleAiRuntimeDependencies {
|
|
|
8875
9511
|
// An injected registry gives tests and host integrations explicit ownership;
|
|
8876
9512
|
// normal package registrations share the module-local process-memory registry.
|
|
8877
9513
|
pendingReviewConsentRegistry?: PendingReviewConsentRegistry;
|
|
8878
|
-
// Deterministic test seam for the consent-binding TTL clock. Production
|
|
8879
|
-
// leaves both undefined so the consent path observes real wall-clock time;
|
|
8880
|
-
// tests inject a fake clock so expiry is observable without a 10-minute
|
|
8881
|
-
// sleep and without relying on the queued cleanup macrotask firing.
|
|
8882
|
-
now?: () => number;
|
|
8883
|
-
scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
|
|
8884
9514
|
// The environment the session's child processes inherit; tests inject a
|
|
8885
9515
|
// plain object so the handshake declaration is observable without
|
|
8886
9516
|
// touching the test runner's own process.env.
|
|
@@ -8897,6 +9527,8 @@ export interface GentleAiRuntimeDependencies {
|
|
|
8897
9527
|
// bounded exec; production leaves this undefined and uses the real node
|
|
8898
9528
|
// exec-file adapter shared with the rest of the extension.
|
|
8899
9529
|
telemetryExecFileAdapter?: ExecFileAdapter;
|
|
9530
|
+
/** Test-only inference seam; production always uses the active session model. */
|
|
9531
|
+
autoPermissionClassifier?: AutoRuntimeClassifier;
|
|
8900
9532
|
}
|
|
8901
9533
|
|
|
8902
9534
|
export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void {
|
|
@@ -8911,8 +9543,6 @@ function createGentleAiExtensionForTesting(
|
|
|
8911
9543
|
? acquireChildStandingReviewPermissionClient(dependencies.processEnv ?? process.env)
|
|
8912
9544
|
: undefined;
|
|
8913
9545
|
const childStandingReviewPermission = dependencies.childStandingReviewPermissionClient ?? childStandingReviewPermissionLease?.client;
|
|
8914
|
-
const reviewConsentNow = dependencies.now ?? (() => Date.now());
|
|
8915
|
-
const reviewConsentScheduleTimer = dependencies.scheduleTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
8916
9546
|
const pendingReviewConsentRegistry = dependencies.pendingReviewConsentRegistry ?? processPendingReviewConsentRegistry;
|
|
8917
9547
|
const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary;
|
|
8918
9548
|
const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter();
|
|
@@ -8922,7 +9552,24 @@ function createGentleAiExtensionForTesting(
|
|
|
8922
9552
|
const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
|
|
8923
9553
|
const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events);
|
|
8924
9554
|
const permissionEnvironment = dependencies.processEnv ?? process.env;
|
|
8925
|
-
|
|
9555
|
+
let autoPermissions: AutoPermissionRuntime | undefined;
|
|
9556
|
+
const yolo = registerYoloSessionPolicy(pi, permissionEnvironment, { onChange: () => autoPermissions?.cancelPending() });
|
|
9557
|
+
let classifier: AutoPermissionClassifier | undefined;
|
|
9558
|
+
autoPermissions = new AutoPermissionRuntime(pi, yolo, dependencies.autoPermissionClassifier ?? {
|
|
9559
|
+
run: request => {
|
|
9560
|
+
classifier ??= new AutoPermissionClassifier(request.modelRegistry);
|
|
9561
|
+
return classifier.run(request);
|
|
9562
|
+
},
|
|
9563
|
+
cancel: () => classifier?.cancel(),
|
|
9564
|
+
}, { configHome: gentlePiConfigHome(permissionEnvironment) });
|
|
9565
|
+
const auto = autoPermissions;
|
|
9566
|
+
pi.on("tool_result", (event, ctx) => {
|
|
9567
|
+
if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") auto.toolResult(event, ctx);
|
|
9568
|
+
});
|
|
9569
|
+
pi.on("input", (event, ctx) => {
|
|
9570
|
+
if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") auto.input(event, ctx);
|
|
9571
|
+
return { action: "continue" };
|
|
9572
|
+
});
|
|
8926
9573
|
|
|
8927
9574
|
const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => {
|
|
8928
9575
|
try {
|
|
@@ -8952,15 +9599,23 @@ function createGentleAiExtensionForTesting(
|
|
|
8952
9599
|
return revoked;
|
|
8953
9600
|
};
|
|
8954
9601
|
|
|
9602
|
+
const statusTiming = new StatusTimingDiagnostics();
|
|
8955
9603
|
const reviewSidebar = createReviewSidebarPublisher(pi);
|
|
8956
|
-
|
|
9604
|
+
const observedReviewTool: typeof reviewSidebar.tool = (definition) => statusTiming.tool(reviewSidebar.tool(definition));
|
|
9605
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
9606
|
+
auto.reset();
|
|
9607
|
+
reviewSidebar.reset(ctx);
|
|
9608
|
+
statusTiming.reset(ctx);
|
|
9609
|
+
});
|
|
8957
9610
|
let reminderSessionActive = true;
|
|
8958
9611
|
let reminderEpoch = 0;
|
|
8959
9612
|
let reminderManager: ExtensionContext["sessionManager"] | undefined;
|
|
8960
9613
|
let unbindPreparation: (() => void) | undefined;
|
|
8961
9614
|
pi.on("session_shutdown", (event, context) => {
|
|
9615
|
+
auto.reset();
|
|
8962
9616
|
yolo.reset(context);
|
|
8963
9617
|
reviewSidebar.reset();
|
|
9618
|
+
statusTiming.reset();
|
|
8964
9619
|
reminderSessionActive = false;
|
|
8965
9620
|
reminderEpoch += 1;
|
|
8966
9621
|
unbindPreparation?.();
|
|
@@ -9100,7 +9755,7 @@ function createGentleAiExtensionForTesting(
|
|
|
9100
9755
|
return named.length === 0 ? operation : `${operation} · ${named.join(" · ")}`;
|
|
9101
9756
|
};
|
|
9102
9757
|
|
|
9103
|
-
pi.registerTool(
|
|
9758
|
+
pi.registerTool(observedReviewTool({
|
|
9104
9759
|
name: "gentle_review_capture_group",
|
|
9105
9760
|
renderShell: "self",
|
|
9106
9761
|
label: "Gentle Review Capture Group",
|
|
@@ -9135,12 +9790,13 @@ function createGentleAiExtensionForTesting(
|
|
|
9135
9790
|
// OpenCode-routed completion carries its attribution headers
|
|
9136
9791
|
// (pi adds those inside the main agent loop; this is not that loop).
|
|
9137
9792
|
reviewSessionManagerAndId(ctx)?.sessionId,
|
|
9793
|
+
() => pi.getSettings?.().transport,
|
|
9138
9794
|
);
|
|
9139
9795
|
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
9140
9796
|
},
|
|
9141
9797
|
}));
|
|
9142
9798
|
|
|
9143
|
-
pi.registerTool(
|
|
9799
|
+
pi.registerTool(observedReviewTool({
|
|
9144
9800
|
name: "gentle_review_capture",
|
|
9145
9801
|
renderShell: "self",
|
|
9146
9802
|
label: "Gentle Review Capture",
|
|
@@ -9178,6 +9834,7 @@ function createGentleAiExtensionForTesting(
|
|
|
9178
9834
|
// OpenCode-routed completion carries its attribution headers
|
|
9179
9835
|
// (pi adds those inside the main agent loop; this is not that loop).
|
|
9180
9836
|
reviewSessionManagerAndId(ctx)?.sessionId,
|
|
9837
|
+
() => pi.getSettings?.().transport,
|
|
9181
9838
|
);
|
|
9182
9839
|
return {
|
|
9183
9840
|
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
@@ -9186,7 +9843,7 @@ function createGentleAiExtensionForTesting(
|
|
|
9186
9843
|
},
|
|
9187
9844
|
}));
|
|
9188
9845
|
|
|
9189
|
-
pi.registerTool(
|
|
9846
|
+
pi.registerTool(observedReviewTool({
|
|
9190
9847
|
name: "gentle_review",
|
|
9191
9848
|
renderShell: "self",
|
|
9192
9849
|
label: "Gentle Review Controller",
|
|
@@ -9194,15 +9851,15 @@ function createGentleAiExtensionForTesting(
|
|
|
9194
9851
|
"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.",
|
|
9195
9852
|
promptSnippet: "Inspect authority, then start native ordinary review; use gentle_review_capture for one current collect slot",
|
|
9196
9853
|
promptGuidelines: [
|
|
9197
|
-
'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).',
|
|
9854
|
+
'Call {"operation":"inspect"} before START. New native ordinary START uses a JSON string such as "{\\"mode\\":\\"ordinary\\",\\"lenses\\":[\\"reliability\\"],\\"lensesReason\\":\\"<what you touched and how>\\"}": pick every pertinent 4R lens (risk, resilience, readability, reliability) for what you touched and how; 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).',
|
|
9198
9855
|
'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.',
|
|
9199
9856
|
"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.",
|
|
9200
|
-
"Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and
|
|
9857
|
+
"Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision. ABANDON accepts only lineage, actor, and reason (reason must be the native enum `operator_disposition` or `retired_schema`): Pi freshly reads the native authority inventory, derives the lineage's revision, snapshot identity, and discarded-work summary, and renders the exact eight-line binding before interactive approval; caller-supplied inventory-derived fields are rejected. 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.",
|
|
9201
9858
|
"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.",
|
|
9202
9859
|
"For blocked-legacy or blocked-mixed, do not call START repeatedly. Explain invalidation, request explicit user authorization, then call RESET or RECOVER only after authorization. RESET and RECOVER_LOCK route to audited native `gentle-ai review reclaim`; only RESET carries the legacy repositoryId, commonDirHash, inventoryHash, and confirmation challenge. RECOVER routes to native `gentle-ai review recover` with exactly six inputs: predecessorLineage, expectedPredecessorRevision, successorLineage, disposition, actor, and reason. Never send RECOVER the reset challenge and never send it a maintainerAuthorization: Pi reads fresh native target status, pins the predecessor lineage, revision, provider-selected disposition, and target identity, derives the exact six-line native authorization binding, displays it for fresh UI approval, and re-reads status before mutating. Negotiated target status supplies the sole accepted recovery disposition, and a caller-supplied substitute is rejected. Treat a native-input-required envelope as a request for exact values, never as permission to invent them. After a committed native recovery record, INSPECT before any fresh ordinary START.",
|
|
9203
9860
|
"A consent-required START may be resolved inside the eligible interactive Pi host. Its third UI action is host-owned: it runs this envelope's exact provider grant once and allows later fresh validated envelopes only for the same live SessionManager, nonempty session ID, and canonical Git common-directory identity, including sibling worktrees; an unrelated repository requires a new explicit human grant. Revoke removes the current repository grant, while nonreload replacement, quit, and process exit remove all session grants; reload preserves them. It grants no provider mode, verdict, acknowledgement, maintenance, delivery, or cross-repository authority. A package-owned child may ask its parent only with the canonical digest of its exact pending target; the parent binds that digest to the task repository and fails closed otherwise. If the tool returns an unresolved envelope, present the original two provider choices without changing machine tokens, commands, target IDs, or invocations; never add the host action to the decoded provider envelope. After one explicit relayed human answer, call answer-consent exactly once with only consentBinding and answer (`granted` or `declined`). Never create host permission from tool arguments, model prose, child/headless responses, or an uncertain native result. A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START output, the controller calls target-scoped native status once and returns only its declared action. An ambiguous gentle_review_capture outcome independently reconciles once and never replays the capture.",
|
|
9204
9861
|
"Use gentle_review only for native review authority operations; delivery commands follow ordinary repository policy.",
|
|
9205
|
-
'ASSESS (gentle-pi#662/#668) is read-only and needs no lineageId: after a delegated writer returns, call {"operation":"assess"} over its diff and follow the returned plan (writerSelfVerification, structuralReadbackOnly, independentVerifier, reason) instead of judging non-triviality from the task description. Pass input as JSON only to assess a committed range ({"baseRef":"<ref>","committedOnly":true}), to supply a fallback writer profile ({"writerModelId":"...", "writerEffort":"..."}), or to state that the native review was declined or unavailable for this candidate ({"nativeReviewOutcome":"declined|unavailable|unknown"}). The writer profile comes from the runtime-recorded model and effort of the pending mutations for this root (writerProfileSource runtime); caller writerModelId/writerEffort are only a fallback when no runtime evidence exists (caller), otherwise the profile is small (fallback). A missing model, a mini model token (gemini is not mini), or low effort keeps the conservative small-model bias. The
|
|
9862
|
+
'ASSESS (gentle-pi#662/#668) is read-only and needs no lineageId: after a delegated writer returns, call {"operation":"assess"} over its diff and follow the returned plan (writerSelfVerification, structuralReadbackOnly, independentVerifier, reason) instead of judging non-triviality from the task description. Pass input as JSON only to assess a committed range ({"baseRef":"<ref>","committedOnly":true}), to supply a fallback writer profile ({"writerModelId":"...", "writerEffort":"..."}), or to state that the native review was declined or unavailable for this candidate ({"nativeReviewOutcome":"declined|unavailable|unknown"}), or to raise a candidate you know is high risk ({"escalate":{"item":1-6,"reason":"<one line>"}}, item from the Task Size high-risk list; it raises passive or medium to high, keeps nativeRisk, returns agentEscalation, and can never lower a tier). The writer profile comes from the runtime-recorded model and effort of the pending mutations for this root (writerProfileSource runtime); caller writerModelId/writerEffort are only a fallback when no runtime evidence exists (caller), otherwise the profile is small (fallback). A missing model, a mini model token (gemini is not mini), or low effort keeps the conservative small-model bias. The plan follows the native risk tier whatever RDD reads and whatever the native review outcome: the native review is an additional outside view and never removes a verifier the tier requires (verify-always-rdd-high S1). ASSESS derives closed only from the native candidate.consumed fact for this exact candidate, written natively when the approved review for that candidate is acknowledged; a caller-supplied closed is not authority and, without that fact, resolves to unknown; the outcome only explains the plan. A decline or unavailable review recorded by this process is bound to that exact candidate\'s own target identity, never to a different candidate or to bare repository state, and wins over closure. The result\'s outcome_source (explicit|derived|unknown) and writerProfileSource (runtime|caller|fallback) state which evidence produced each value. When native reports them, ASSESS projects reviewDue, reviewDueReason, candidate.consumed, and the native continuation verbatim; older binaries omit them and nothing is invented. Relay that continuation unchanged; never rebuild it. A native code review is not a substitute for applicable functional checks: tests, builds, and functional verification such as browser checks for UI changes still run when applicable. A failed or unavailable native assessment reports risk "unassessable", verified exactly like "high". This never mutates review authority state.',
|
|
9206
9863
|
],
|
|
9207
9864
|
parameters: REVIEW_CONTROLLER_PARAMETERS,
|
|
9208
9865
|
executionMode: "sequential",
|
|
@@ -9243,8 +9900,6 @@ function createGentleAiExtensionForTesting(
|
|
|
9243
9900
|
retainedSelections,
|
|
9244
9901
|
pendingReviewConsentRegistry,
|
|
9245
9902
|
pendingReviewConsentFallbackKey,
|
|
9246
|
-
reviewConsentNow,
|
|
9247
|
-
reviewConsentScheduleTimer,
|
|
9248
9903
|
);
|
|
9249
9904
|
if (details.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED &&
|
|
9250
9905
|
details.outcome === "native-approved-acknowledgement-completed" &&
|
|
@@ -9280,8 +9935,6 @@ function createGentleAiExtensionForTesting(
|
|
|
9280
9935
|
retainedSelections,
|
|
9281
9936
|
pendingReviewConsentRegistry,
|
|
9282
9937
|
pendingReviewConsentFallbackKey,
|
|
9283
|
-
reviewConsentNow,
|
|
9284
|
-
reviewConsentScheduleTimer,
|
|
9285
9938
|
);
|
|
9286
9939
|
let permissionWorkspaceRoot: string | undefined;
|
|
9287
9940
|
try {
|
|
@@ -9335,13 +9988,46 @@ function createGentleAiExtensionForTesting(
|
|
|
9335
9988
|
}));
|
|
9336
9989
|
|
|
9337
9990
|
pi.on("session_start", async (event, ctx) => {
|
|
9991
|
+
auto.reset();
|
|
9338
9992
|
yolo.reset(ctx);
|
|
9993
|
+
// Package-owned children reset local state but never initialize primary defaults.
|
|
9994
|
+
if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") await yolo.mode(ctx);
|
|
9339
9995
|
reviewSidebar.reset(ctx);
|
|
9996
|
+
statusTiming.reset(ctx);
|
|
9340
9997
|
elapsedTiming = new GentleAiElapsedTimingLedger(ctx.sessionManager, pi);
|
|
9341
9998
|
reminderSessionActive = true;
|
|
9342
9999
|
reminderEpoch += 1;
|
|
9343
10000
|
unbindPreparation?.();
|
|
9344
10001
|
reminderManager = ctx.sessionManager;
|
|
10002
|
+
// gentle-shell#1690: a delegated child runs in the parent's resolved
|
|
10003
|
+
// worktree. Repository preparation, review negotiation, asset install and
|
|
10004
|
+
// model config belong to the parent session and write shared state. The
|
|
10005
|
+
// standing review grant is host-only (a child never captures an identity),
|
|
10006
|
+
// so revoke/refresh have nothing to act on; the child relay is load-time.
|
|
10007
|
+
// Only that parent-owned work is skipped: the session-local resets above,
|
|
10008
|
+
// the dev-binary notice, and any step added after this call, still run in
|
|
10009
|
+
// children.
|
|
10010
|
+
if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") await startParentSession(event, ctx);
|
|
10011
|
+
else await surfaceDevBinaryOverride(ctx);
|
|
10012
|
+
});
|
|
10013
|
+
|
|
10014
|
+
// Loud, every session: an active dev-binary override means this session
|
|
10015
|
+
// runs an unpinned gentle-ai. One visible startup notice: the gentle-shell
|
|
10016
|
+
// 🌹 card owns the announcement when it can render (shell enabled with UI);
|
|
10017
|
+
// this toast is only the fallback for when the card is unavailable. The
|
|
10018
|
+
// hasUI guard stays: headless contexts have no toast to show.
|
|
10019
|
+
const surfaceDevBinaryOverride = async (ctx: ExtensionContext): Promise<void> => {
|
|
10020
|
+
const devBinaryToastFallback = ctx.hasUI && !shellEnabled();
|
|
10021
|
+
try {
|
|
10022
|
+
const devBinary = await describeDevBinaryOverride();
|
|
10023
|
+
if (devBinaryToastFallback && devBinary.state === "active") ctx.ui.notify(devBinary.line, "warning");
|
|
10024
|
+
if (devBinaryToastFallback && devBinary.state === "invalid") ctx.ui.notify(devBinary.line, "error");
|
|
10025
|
+
} catch (error) {
|
|
10026
|
+
if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
10027
|
+
}
|
|
10028
|
+
};
|
|
10029
|
+
|
|
10030
|
+
const startParentSession = async (event: unknown, ctx: ExtensionContext): Promise<void> => {
|
|
9345
10031
|
const epoch = reminderEpoch;
|
|
9346
10032
|
const manager = ctx.sessionManager;
|
|
9347
10033
|
const originalCwd = manager?.getCwd?.() ?? ctx.cwd;
|
|
@@ -9358,19 +10044,16 @@ function createGentleAiExtensionForTesting(
|
|
|
9358
10044
|
return negotiated.status !== undefined && current() && safeBootstrapDirectory(originalCwd) === root && identity?.root === root;
|
|
9359
10045
|
} catch { return false; }
|
|
9360
10046
|
}, () => reminderSessionActive && reminderEpoch === epoch && reminderManager === manager) : undefined;
|
|
10047
|
+
const homeRoot = homeRootedRepository(ctx.cwd);
|
|
10048
|
+
if (homeRoot !== undefined && ctx.hasUI && !homeRepositoryWarnings.has(homeRoot)) {
|
|
10049
|
+
homeRepositoryWarnings.add(homeRoot);
|
|
10050
|
+
try { ctx.ui.notify(HOME_REPOSITORY_WARNING, "warning"); } catch { /* Nonblocking notice only. */ }
|
|
10051
|
+
}
|
|
9361
10052
|
try { candidateViews?.sweepOrphans(ctx.cwd); } catch { /* Ownership sweeping must not block startup. */ }
|
|
9362
10053
|
const reason = (event as { reason?: unknown }).reason;
|
|
9363
10054
|
if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx);
|
|
9364
10055
|
await refreshReviewSessionPermissionStatus(ctx);
|
|
9365
|
-
|
|
9366
|
-
// runs an unpinned gentle-ai. Announce which one before anything else.
|
|
9367
|
-
try {
|
|
9368
|
-
const devBinary = await describeDevBinaryOverride();
|
|
9369
|
-
if (ctx.hasUI && devBinary.state === "active") ctx.ui.notify(devBinary.line, "warning");
|
|
9370
|
-
if (ctx.hasUI && devBinary.state === "invalid") ctx.ui.notify(devBinary.line, "error");
|
|
9371
|
-
} catch (error) {
|
|
9372
|
-
if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
9373
|
-
}
|
|
10056
|
+
await surfaceDevBinaryOverride(ctx);
|
|
9374
10057
|
try {
|
|
9375
10058
|
const installResult = installPackageAssets(ctx.cwd, true, ["delegation", "review"]);
|
|
9376
10059
|
migrateLegacyProjectModelOverrides(ctx.cwd);
|
|
@@ -9400,13 +10083,16 @@ function createGentleAiExtensionForTesting(
|
|
|
9400
10083
|
}
|
|
9401
10084
|
// Keep the startup transport negotiation, but do not treat its target as
|
|
9402
10085
|
// an ownership baseline: reload may have outstanding durable receipts.
|
|
9403
|
-
|
|
9404
|
-
|
|
9405
|
-
|
|
9406
|
-
|
|
9407
|
-
|
|
9408
|
-
|
|
9409
|
-
|
|
10086
|
+
// A home-rooted repository skips it: native STATUS would scan the whole home.
|
|
10087
|
+
if (homeRoot !== undefined) return;
|
|
10088
|
+
// Pi awaits session_start handlers serially, so this runs after startup
|
|
10089
|
+
// returns: two native spawns no longer hold the shell's first render.
|
|
10090
|
+
const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
10091
|
+
const negotiation = resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey, AbortSignal.timeout(STARTUP_NEGOTIATION_TIMEOUT_MS))
|
|
10092
|
+
.then(() => undefined, () => undefined) // Best-effort only; never surface or throw.
|
|
10093
|
+
.finally(() => { startupNegotiations.delete(negotiation); });
|
|
10094
|
+
startupNegotiations.add(negotiation);
|
|
10095
|
+
};
|
|
9410
10096
|
|
|
9411
10097
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
9412
10098
|
const isNamedAgent = isNamedAgentStartEvent(event);
|
|
@@ -9444,20 +10130,32 @@ function createGentleAiExtensionForTesting(
|
|
|
9444
10130
|
// this branch, so no line is resolved or computed for them.
|
|
9445
10131
|
// resolveRddStatusLine never throws and never hangs past
|
|
9446
10132
|
// RDD_STATUS_TIMEOUT_MS: an absent/timed-out/aborted/failing native
|
|
9447
|
-
// binary
|
|
9448
|
-
|
|
10133
|
+
// binary keeps the last known line for this cwd, or renders the
|
|
10134
|
+
// fail-closed "unknown" line when none was ever read (gentle-shell#1029).
|
|
10135
|
+
const rddStatusSignal = AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS);
|
|
10136
|
+
// A startup read still in flight fills the memo: wait for it within the
|
|
10137
|
+
// same deadline instead of spawning the same STATUS a second time.
|
|
10138
|
+
const inFlightModeRead = isPrimarySession ? startupModeReads.get(ctx.cwd) : undefined;
|
|
10139
|
+
if (inFlightModeRead !== undefined) await Promise.race([inFlightModeRead, rddAbortRejection(rddStatusSignal)]).catch(() => undefined);
|
|
10140
|
+
const rddStatusLine = !isPrimarySession
|
|
10141
|
+
? undefined
|
|
10142
|
+
: await resolveRddStatusLine(nativeReviewCli, ctx.cwd, rddStatusSignal, undefined, ctx);
|
|
10143
|
+
const gentlePrompt = rddStatusLine === undefined
|
|
9449
10144
|
? ""
|
|
9450
10145
|
: `\n\n${buildGentlePrompt(
|
|
9451
10146
|
readPersonaMode(ctx.cwd),
|
|
9452
10147
|
ctx.cwd,
|
|
9453
10148
|
readActiveToolNames(pi),
|
|
9454
|
-
|
|
10149
|
+
rddStatusLine,
|
|
10150
|
+
ctx.mode,
|
|
9455
10151
|
)}`;
|
|
9456
10152
|
// gentle-pi#560 / gentle-ai#4056, #4057: inject the mirrored provider
|
|
9457
10153
|
// contract bundle's review execution contract for the primary session
|
|
9458
10154
|
// only, and only when a native review CLI is actually present.
|
|
10155
|
+
// gentle-shell#1494: skip it while RDD reads off, since no review can
|
|
10156
|
+
// start; on and unknown keep it so the reviewed path never loses it.
|
|
9459
10157
|
const reviewContractPrompt =
|
|
9460
|
-
isPrimarySession && nativeReviewCli !== null
|
|
10158
|
+
isPrimarySession && nativeReviewCli !== null && !isRddStatusLineOff(rddStatusLine)
|
|
9461
10159
|
? (() => {
|
|
9462
10160
|
const fragment = loadReviewContractPromptFragment(ctx);
|
|
9463
10161
|
return fragment === null ? "" : `\n\n${fragment}`;
|
|
@@ -9468,6 +10166,7 @@ function createGentleAiExtensionForTesting(
|
|
|
9468
10166
|
// through the mutable appendSystemPrompt section instead of a replacement.
|
|
9469
10167
|
appendSystemPromptOnce(event.systemPromptOptions, `${gentlePrompt}${reviewContractPrompt}`);
|
|
9470
10168
|
updateYoloPrompt(event.systemPromptOptions, isPrimarySession && await yolo.active(ctx));
|
|
10169
|
+
updateAutoPrompt(event.systemPromptOptions, isPrimarySession && await yolo.mode(ctx) === "auto");
|
|
9471
10170
|
return undefined;
|
|
9472
10171
|
});
|
|
9473
10172
|
|
|
@@ -9499,6 +10198,18 @@ function createGentleAiExtensionForTesting(
|
|
|
9499
10198
|
// Another concurrent end or ACK may already have consumed this prefix.
|
|
9500
10199
|
if (!pendingReviewMutation(ctx.sessionManager, root, mutation)) return;
|
|
9501
10200
|
if (status.nextTransition?.kind !== "execute" || status.nextTransition.execute.operation !== "review.start") return;
|
|
10201
|
+
// verify-always-rdd-high S2: automatic review is for high risk only, so a
|
|
10202
|
+
// candidate the native assessment reports not review_due earns no nudge.
|
|
10203
|
+
// An assessment that cannot answer, or an older binary without
|
|
10204
|
+
// review_due, keeps the nudge.
|
|
10205
|
+
if (nativeReviewCli.assess !== undefined) {
|
|
10206
|
+
let reviewDue: boolean | undefined;
|
|
10207
|
+
try { reviewDue = (await nativeReviewCli.assess({ cwd: root })).reviewDue; }
|
|
10208
|
+
catch { reviewDue = undefined; }
|
|
10209
|
+
if (reviewDue === false) return;
|
|
10210
|
+
if (!reminderSessionActive || epoch !== reminderEpoch || pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) !== sessionKey) return;
|
|
10211
|
+
if (!pendingReviewMutation(ctx.sessionManager, root, mutation)) return;
|
|
10212
|
+
}
|
|
9502
10213
|
const targetIdentity = status.targetIdentity;
|
|
9503
10214
|
pi.sendMessage(
|
|
9504
10215
|
{
|
|
@@ -9521,12 +10232,12 @@ function createGentleAiExtensionForTesting(
|
|
|
9521
10232
|
// Persist the observed own write before any await. Preparation is not
|
|
9522
10233
|
// mutation evidence, and cannot invent a pre-write Changes baseline.
|
|
9523
10234
|
if (root) recordReviewMutation(pi, ctx.sessionManager, root, { source: "direct", toolName: event.toolName, toolCallId: event.toolCallId, ...directWriterProfile(pi, ctx) });
|
|
9524
|
-
if (prospectiveRoot && !resolveSessionWorktree(ctx.cwd, ctx.cwd)) await prepareBoundSessionRepository(ctx.sessionManager, ctx.sessionManager.getCwd?.() ?? ctx.cwd, ctx.signal);
|
|
10235
|
+
if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1" && prospectiveRoot && !resolveSessionWorktree(ctx.cwd, ctx.cwd)) await prepareBoundSessionRepository(ctx.sessionManager, ctx.sessionManager.getCwd?.() ?? ctx.cwd, ctx.signal);
|
|
9525
10236
|
} catch { /* Preparation and receipt persistence cannot change a successful tool result. */ }
|
|
9526
10237
|
});
|
|
9527
10238
|
|
|
9528
10239
|
pi.on("tool_call", async (event, ctx) => {
|
|
9529
|
-
const primaryToolCall = (processAgentEndSubagentDepth.get(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)) ?? 0) === 0;
|
|
10240
|
+
const primaryToolCall = permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1" && (processAgentEndSubagentDepth.get(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)) ?? 0) === 0;
|
|
9530
10241
|
const yoloActive = primaryToolCall && await yolo.active(ctx);
|
|
9531
10242
|
const sensitivePathDenied = evaluateSensitivePathTool(
|
|
9532
10243
|
event.toolName,
|
|
@@ -9548,15 +10259,27 @@ function createGentleAiExtensionForTesting(
|
|
|
9548
10259
|
};
|
|
9549
10260
|
}
|
|
9550
10261
|
}
|
|
9551
|
-
|
|
9552
|
-
if (
|
|
9553
|
-
|
|
9554
|
-
|
|
10262
|
+
const modeConfigDenied = auto.guard(event, ctx);
|
|
10263
|
+
if (modeConfigDenied) return modeConfigDenied;
|
|
10264
|
+
const gate = async (): Promise<ToolCallEventResult | undefined> => {
|
|
10265
|
+
try { return await auto.check(event, ctx, primaryToolCall); }
|
|
10266
|
+
catch { return { block: true, reason: "Auto permission checking failed. This action was not run; do not bypass the check." }; }
|
|
10267
|
+
};
|
|
10268
|
+
if (!SHELL_COMMAND_TOOLS.has(event.toolName) || !isRecord(event.input)) return gate();
|
|
10269
|
+
const command = (event.input as { command?: unknown }).command;
|
|
10270
|
+
if (typeof command !== "string") return gate();
|
|
9555
10271
|
if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD === "1") {
|
|
9556
|
-
const childDenied = blockChildDestructiveCommand(
|
|
10272
|
+
const childDenied = blockChildDestructiveCommand(command);
|
|
9557
10273
|
if (childDenied) return childDenied;
|
|
9558
10274
|
}
|
|
9559
|
-
|
|
10275
|
+
const modeEpoch = yolo.epoch();
|
|
10276
|
+
const permissionMode = primaryToolCall ? await yolo.mode(ctx) : "normal";
|
|
10277
|
+
// A superseded lookup returns Normal for display, not permission to skip Auto.
|
|
10278
|
+
if (primaryToolCall && modeEpoch !== yolo.epoch()) {
|
|
10279
|
+
return { block: true, reason: "Permission mode changed during tool admission. This action was not run; retry under the current mode." };
|
|
10280
|
+
}
|
|
10281
|
+
const autoGate = permissionMode === "auto" ? gate : undefined;
|
|
10282
|
+
return await confirmCommand(command, ctx, pi.events, herdrLifecycle, yoloActive, autoGate);
|
|
9560
10283
|
});
|
|
9561
10284
|
|
|
9562
10285
|
for (const owner of ["delegation", "review"] as const) {
|
|
@@ -9710,6 +10433,11 @@ function createGentleAiExtensionForTesting(
|
|
|
9710
10433
|
},
|
|
9711
10434
|
});
|
|
9712
10435
|
|
|
10436
|
+
pi.registerCommand("gentle:status-timing", {
|
|
10437
|
+
description: "Arm session-only timing for the next authorized STATUS-bearing tool call, clear it, or show the last summary (enable|disable|show). Never invokes or retries STATUS.",
|
|
10438
|
+
handler: async (args, ctx) => { statusTiming.command(args, ctx); },
|
|
10439
|
+
});
|
|
10440
|
+
|
|
9713
10441
|
pi.registerCommand("gentle:review-mode", {
|
|
9714
10442
|
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.",
|
|
9715
10443
|
handler: async (args, ctx) => {
|