gentle-pi 2.1.2 → 2.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 +70 -12
- package/assets/agents/gentle-ai-worker.md +7 -3
- package/assets/agents/jd-fix-agent.md +1 -1
- package/assets/agents/jd-judge-a.md +3 -1
- package/assets/agents/jd-judge-b.md +3 -1
- package/assets/agents/review-readability.md +4 -1
- package/assets/agents/review-reliability.md +4 -1
- package/assets/agents/review-resilience.md +4 -1
- package/assets/agents/review-risk.md +4 -1
- package/assets/agents/sdd-apply.md +6 -1
- package/assets/agents/sdd-archive.md +6 -1
- package/assets/agents/sdd-design.md +6 -1
- package/assets/agents/sdd-explore.md +6 -2
- package/assets/agents/sdd-init.md +10 -2
- package/assets/agents/sdd-onboard.md +6 -1
- package/assets/agents/sdd-proposal.md +6 -1
- package/assets/agents/sdd-spec.md +6 -1
- package/assets/agents/sdd-status.md +6 -1
- package/assets/agents/sdd-sync.md +6 -1
- package/assets/agents/sdd-tasks.md +6 -1
- package/assets/agents/sdd-verify.md +6 -1
- package/assets/chains/4r-review.chain.md +2 -0
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-plan.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +246 -67
- package/assets/orchestrator.md +7 -14
- package/assets/sdd-orchestrator-workflow.md +154 -9
- package/assets/support/sdd-status-contract.md +19 -1
- package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
- package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
- package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
- package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
- package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
- package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
- package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
- package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
- package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
- package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
- package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
- package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
- package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
- package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
- package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
- package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
- package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
- package/docs/native-authority-architecture.md +5 -5
- package/docs/review-integration.md +22 -2
- package/extensions/gentle-ai.ts +1595 -201
- package/extensions/sdd-init.ts +19 -6
- package/extensions/skill-registry.ts +10 -2
- package/extensions/startup-banner.ts +10 -4
- package/lib/gentle-ai-binary.ts +173 -2
- package/lib/git-commit-transaction.ts +77 -17
- package/lib/native-review-cli.ts +528 -65
- package/lib/provider-contract-bundle.ts +704 -0
- package/lib/review-candidate-view.ts +527 -18
- package/lib/review-compact-contract.ts +59 -248
- package/lib/review-host-relay.ts +436 -0
- package/lib/review-integration-v2.ts +537 -36
- package/lib/review-relay-contract.ts +16 -0
- package/lib/sdd-preflight.ts +53 -1
- package/package.json +5 -2
- package/runtime/gentle-ai-binary.mjs +173 -2
- package/runtime/git-commit-transaction.mjs +75 -15
- package/runtime/native-review-cli.mjs +524 -61
- package/runtime/review-integration-v2.mjs +536 -35
- package/runtime/review-relay-contract.mjs +17 -0
- package/scripts/build-git-commit-transaction-runner.mjs +1 -0
- package/scripts/check-provider-contract.mjs +138 -0
- package/scripts/gentle-ai-installer.mjs +23 -13
- package/scripts/maintainer/provider-relay-matrix.mjs +219 -0
- package/scripts/mirror-provider-contract.mjs +143 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/verify-package-files.mjs +110 -33
- package/skills/_shared/review-ledger-contract.md +4 -6
- package/skills/gentle-ai/SKILL.md +4 -4
- package/skills/issue-creation/SKILL.md +94 -168
- package/skills/judgment-day/SKILL.md +7 -1
- package/skills/judgment-day/references/prompts-and-formats.md +2 -0
- package/skills/rdd-defect-workflow/SKILL.md +54 -0
- package/tests/background-subagents.test.ts +771 -0
- package/tests/crosslane/cross-lane.mjs +1169 -0
- package/tests/delegated-key-learnings-contract.test.ts +238 -0
- package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
- package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
- package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
- package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
- package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
- package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
- package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
- package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
- package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
- package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
- package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
- package/tests/gentle-ai-dev-binary.test.ts +336 -0
- package/tests/gentle-ai-installer.test.ts +46 -46
- package/tests/git-commit-transaction.test.ts +229 -1
- package/tests/maintainer/provider-relay.maintest.ts +265 -0
- package/tests/native-review-capability-contract.test.ts +48 -2
- package/tests/native-review-cli.test.ts +56 -0
- package/tests/native-review-consent.test.ts +164 -3
- package/tests/native-review-parity-runtime.test.ts +37 -0
- package/tests/native-review-parity.test.ts +218 -15
- package/tests/native-sdd-attempt-authority.test.ts +235 -0
- package/tests/orchestrator-budget.test.ts +30 -5
- package/tests/package-manifest.test.ts +98 -72
- package/tests/provider-contract-bundle.test.ts +385 -0
- package/tests/provider-contract-mirror.test.ts +206 -0
- package/tests/provider-defect-handoff.test.ts +355 -0
- package/tests/review-actor-tool-deny.test.ts +12 -13
- package/tests/review-candidate-view.test.ts +489 -9
- package/tests/review-compact-contract.test.ts +52 -119
- package/tests/review-controller-native-recovery.test.ts +643 -47
- package/tests/review-controller-native-routing.test.ts +1667 -222
- package/tests/review-controller-workspace-root.test.ts +17 -2
- package/tests/review-corrected-finalize-binding.test.ts +175 -0
- package/tests/review-dispatch-hydration-gap.test.ts +197 -0
- package/tests/review-host-relay-routing.test.ts +317 -0
- package/tests/review-host-relay.test.ts +520 -0
- package/tests/review-integration-v2-forward.test.ts +631 -0
- package/tests/review-integration-v2.test.ts +114 -0
- package/tests/review-ledger-contract.test.ts +12 -28
- package/tests/review-recovered-lineage-routing.test.ts +246 -0
- package/tests/review-relay-transport-agent.test.ts +249 -0
- package/tests/runtime-harness.mjs +242 -14
- package/tests/sdd-agent-tools.test.ts +18 -33
- package/tests/skill-collision-prefixes.test.ts +1 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/verify-package-files.test.ts +62 -0
- package/assets/agents/review-refuter.md +0 -40
- package/assets/agents/review-validator.md +0 -23
- package/lib/native-review-remediation.ts +0 -49
- package/lib/review-compact.ts +0 -947
- package/lib/review-refuter-adapter.ts +0 -129
- package/lib/review-runtime-contract.ts +0 -68
- package/prompts/gcl.md +0 -54
- package/prompts/gis.md +0 -25
- package/prompts/gpr.md +0 -41
- package/prompts/gwr.md +0 -31
- package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
- package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
- package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
- package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
- package/tests/review-compact.test.ts +0 -243
- package/tests/review-refuter-adapter.test.ts +0 -89
package/extensions/gentle-ai.ts
CHANGED
|
@@ -51,14 +51,24 @@ import {
|
|
|
51
51
|
} from "../lib/sdd-status.ts";
|
|
52
52
|
import type { TriggerEvent } from "../lib/review-triggers.ts";
|
|
53
53
|
import { canonicalJsonV1, domainHashV1 } from "../lib/review-canonical.ts";
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
54
|
+
import { parseNativeCompactFinalizeInput, toNativeValidatorDocument } from "../lib/review-compact-contract.ts";
|
|
55
|
+
import {
|
|
56
|
+
REVIEW_HOST_RELAY_FAILURE,
|
|
57
|
+
REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
|
|
58
|
+
REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
|
|
59
|
+
ReviewHostRelayError,
|
|
60
|
+
reviewHostRelaySlots,
|
|
61
|
+
reviewProviderRoleVectorSlots,
|
|
62
|
+
runReviewHostRelaySlot,
|
|
63
|
+
type ReviewHostRelayRunner,
|
|
64
|
+
type ReviewHostRelaySlot,
|
|
65
|
+
type ReviewProviderRoleVectorSlot,
|
|
66
|
+
} from "../lib/review-host-relay.ts";
|
|
56
67
|
import {
|
|
57
68
|
inheritedUnsafeGitEnvironmentKeys,
|
|
58
69
|
publicationProbeGitEnvironment,
|
|
59
70
|
resolveRepositoryAuthorityV1,
|
|
60
71
|
} from "../lib/review-repository.ts";
|
|
61
|
-
import { captureLiveReviewCandidateBinding } from "../lib/review-snapshot.ts";
|
|
62
72
|
import {
|
|
63
73
|
EXTERNAL_RELEASE_EVIDENCE,
|
|
64
74
|
GATE_RESULT,
|
|
@@ -96,19 +106,30 @@ import {
|
|
|
96
106
|
type ReviewProjectionV1,
|
|
97
107
|
} from "../lib/review-snapshot.ts";
|
|
98
108
|
import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
|
|
99
|
-
import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, resolveCanonicalCandidateBase, type CandidateView
|
|
109
|
+
import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, type CandidateView } from "../lib/review-candidate-view.ts";
|
|
110
|
+
import {
|
|
111
|
+
GentleAiDevBinaryOverrideError,
|
|
112
|
+
registerGentleAiDevBinary,
|
|
113
|
+
resolveGentleAiDevBinaryOverride,
|
|
114
|
+
unregisterGentleAiDevBinary,
|
|
115
|
+
type GentleAiDevBinaryOverride,
|
|
116
|
+
} from "../lib/gentle-ai-binary.ts";
|
|
100
117
|
import {
|
|
101
118
|
createNativeReviewCli,
|
|
119
|
+
createNodeExecFileAdapter,
|
|
102
120
|
isCanonicalProcessString,
|
|
103
121
|
nativeReviewAbandonAuthorization,
|
|
104
122
|
nativeReviewLegacyAliasRepairAuthorization,
|
|
105
123
|
nativeReviewLegacyQuarantineAuthorization,
|
|
106
124
|
nativeReviewReconcileAuthorization,
|
|
125
|
+
nativeReviewRecoverAuthorization,
|
|
107
126
|
normalizeNativeReviewCwd,
|
|
108
127
|
NativeReviewCliError,
|
|
109
128
|
NativeReviewConsentBindingError,
|
|
110
129
|
NativeReviewConsentRequiredError,
|
|
130
|
+
NativeReviewIntegrationError,
|
|
111
131
|
NATIVE_REVIEW_ERROR_CODE,
|
|
132
|
+
NATIVE_REVIEW_OPERATION,
|
|
112
133
|
NATIVE_REVIEW_LEGACY_QUARANTINE,
|
|
113
134
|
NATIVE_REVIEW_LEGACY_ALIAS_REPAIR,
|
|
114
135
|
NATIVE_REVIEW_MODE_OPERATION,
|
|
@@ -116,15 +137,18 @@ import {
|
|
|
116
137
|
NATIVE_REVIEW_RECONCILE_ANOMALIES,
|
|
117
138
|
sanitizeForeignNativeReviewDiagnostics,
|
|
118
139
|
type NativeReviewCli,
|
|
140
|
+
type NativeTargetStatusRequest,
|
|
119
141
|
type NativeFinalizeResult,
|
|
142
|
+
type NativeReviewVerificationEvidenceV2,
|
|
120
143
|
type NativeReviewModeOperation,
|
|
121
144
|
type NativeReviewModeSource,
|
|
122
145
|
type NativeReviewProcessDiagnostics,
|
|
123
146
|
type NativeStartResult,
|
|
124
147
|
type NativeValidateResult,
|
|
125
148
|
} from "../lib/native-review-cli.ts";
|
|
126
|
-
import type {
|
|
149
|
+
import type { ReviewCollectInputV3, ReviewConsentEnvelope, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
127
150
|
import { assertDistinctCorrectionEvidence, resolveCorrectionStep, type CorrectionEvidence, type CorrectionOutcome, type CorrectionStep } from "../lib/review-correction-lifecycle.ts";
|
|
151
|
+
import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
|
|
128
152
|
|
|
129
153
|
const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations";
|
|
130
154
|
import {
|
|
@@ -214,15 +238,353 @@ function sddLocalAgentOverrideCount(cwd: string): number {
|
|
|
214
238
|
return count;
|
|
215
239
|
}
|
|
216
240
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Background subagents policy — project > global > env > default off
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
type BackgroundSubagentsPolicy = "on" | "off";
|
|
246
|
+
type BackgroundSubagentsCapability = "ready" | "absent";
|
|
247
|
+
|
|
248
|
+
interface BackgroundSubagentsRendering {
|
|
249
|
+
policy: BackgroundSubagentsPolicy;
|
|
250
|
+
capability: BackgroundSubagentsCapability;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Which of the four sources decided the effective policy. */
|
|
254
|
+
type BackgroundSubagentsSource =
|
|
255
|
+
| "project_file"
|
|
256
|
+
| "global_file"
|
|
257
|
+
| "environment"
|
|
258
|
+
| "default";
|
|
259
|
+
|
|
260
|
+
interface BackgroundSubagentsResolution {
|
|
261
|
+
policy: BackgroundSubagentsPolicy;
|
|
262
|
+
source: BackgroundSubagentsSource;
|
|
263
|
+
/** The deciding file was present but failed the strict decode. */
|
|
264
|
+
malformed: boolean;
|
|
265
|
+
projectFile: string;
|
|
266
|
+
globalFile: string;
|
|
267
|
+
projectFileExists: boolean;
|
|
268
|
+
globalFileExists: boolean;
|
|
269
|
+
/** The raw env value, reported even when it is unrecognized and inert. */
|
|
270
|
+
envValue: string | undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
interface LoadBackgroundSubagentsOptions {
|
|
274
|
+
/** Override the config home directory (used in tests to avoid touching ~/.pi). */
|
|
275
|
+
gentlePiConfigHome?: string;
|
|
276
|
+
/** Override the environment lookup (used in tests). */
|
|
277
|
+
env?: Record<string, string | undefined>;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const BACKGROUND_SUBAGENTS_SCHEMA = "gentle-pi.background-subagents/v1";
|
|
281
|
+
const BACKGROUND_SUBAGENTS_FILE = "background-subagents.json";
|
|
282
|
+
|
|
283
|
+
const DEFAULT_BACKGROUND_SUBAGENTS_RENDERING: BackgroundSubagentsRendering = {
|
|
284
|
+
policy: "off",
|
|
285
|
+
capability: "absent",
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Strict decode of {"schema":"gentle-pi.background-subagents/v1","policy":"on"|"off"}.
|
|
290
|
+
* Any malformed shape (bad JSON, wrong schema, unknown keys, invalid policy)
|
|
291
|
+
* returns undefined so the caller fails closed to "off".
|
|
292
|
+
*/
|
|
293
|
+
function parseBackgroundSubagentsPolicyFile(
|
|
294
|
+
raw: string,
|
|
295
|
+
): BackgroundSubagentsPolicy | undefined {
|
|
296
|
+
let parsed: unknown;
|
|
297
|
+
try {
|
|
298
|
+
parsed = JSON.parse(raw);
|
|
299
|
+
} catch {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
if (!isRecord(parsed)) return undefined;
|
|
303
|
+
if (parsed.schema !== BACKGROUND_SUBAGENTS_SCHEMA) return undefined;
|
|
304
|
+
if (parsed.policy !== "on" && parsed.policy !== "off") return undefined;
|
|
305
|
+
if (Object.keys(parsed).length !== 2) return undefined;
|
|
306
|
+
return parsed.policy;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Resolve the background-subagents policy AND the source that decided it.
|
|
311
|
+
*
|
|
312
|
+
* Resolution order (first hit wins, mirroring loadRuntimeGuardrailsConfig):
|
|
313
|
+
* 1. Project file `${cwd}/.pi/gentle-ai/background-subagents.json`
|
|
314
|
+
* 2. Global file `${configHome}/background-subagents.json`
|
|
315
|
+
* (configHome honors GENTLE_PI_CONFIG_HOME, default ~/.pi/gentle-ai)
|
|
316
|
+
* 3. Env var GENTLE_PI_BACKGROUND_SUBAGENTS ("on" | "off")
|
|
317
|
+
* 4. Default "off"
|
|
318
|
+
*
|
|
319
|
+
* A present-but-malformed file fails closed to "off" instead of falling
|
|
320
|
+
* through to a lower-priority source, and it stays attributed to that file:
|
|
321
|
+
* "off decided by a broken project file" and "off by default" are different
|
|
322
|
+
* situations, and only the first one is a mistake to fix.
|
|
323
|
+
*
|
|
324
|
+
* Four sources with first-hit-wins is exactly the shape that makes an edit
|
|
325
|
+
* look like it did nothing, so the deciding source is part of the result
|
|
326
|
+
* rather than something a caller has to re-derive.
|
|
327
|
+
*/
|
|
328
|
+
function resolveBackgroundSubagentsPolicy(
|
|
329
|
+
cwd: string,
|
|
330
|
+
options: LoadBackgroundSubagentsOptions = {},
|
|
331
|
+
): BackgroundSubagentsResolution {
|
|
332
|
+
const env = options.env ?? process.env;
|
|
333
|
+
const envValue = env.GENTLE_PI_BACKGROUND_SUBAGENTS;
|
|
334
|
+
let projectFile = "";
|
|
335
|
+
let globalFile = "";
|
|
336
|
+
try {
|
|
337
|
+
const configHome = options.gentlePiConfigHome ?? gentleAiConfigHome();
|
|
338
|
+
projectFile = join(cwd, ".pi", "gentle-ai", BACKGROUND_SUBAGENTS_FILE);
|
|
339
|
+
globalFile = join(configHome, BACKGROUND_SUBAGENTS_FILE);
|
|
340
|
+
const projectFileExists = existsSync(projectFile);
|
|
341
|
+
const globalFileExists = existsSync(globalFile);
|
|
342
|
+
const locations = { projectFile, globalFile, projectFileExists, globalFileExists, envValue };
|
|
343
|
+
for (const [source, path, present] of [
|
|
344
|
+
["project_file", projectFile, projectFileExists],
|
|
345
|
+
["global_file", globalFile, globalFileExists],
|
|
346
|
+
] as const) {
|
|
347
|
+
if (!present) continue;
|
|
348
|
+
let decoded: BackgroundSubagentsPolicy | undefined;
|
|
349
|
+
try {
|
|
350
|
+
decoded = parseBackgroundSubagentsPolicyFile(readFileSync(path, "utf8"));
|
|
351
|
+
} catch {
|
|
352
|
+
// Unreadable is indistinguishable from unusable at this layer, and
|
|
353
|
+
// both must fail closed on the file that claimed the decision.
|
|
354
|
+
decoded = undefined;
|
|
355
|
+
}
|
|
356
|
+
return decoded === undefined
|
|
357
|
+
? { policy: "off", source, malformed: true, ...locations }
|
|
358
|
+
: { policy: decoded, source, malformed: false, ...locations };
|
|
359
|
+
}
|
|
360
|
+
if (envValue === "on" || envValue === "off") {
|
|
361
|
+
return { policy: envValue, source: "environment", malformed: false, ...locations };
|
|
362
|
+
}
|
|
363
|
+
return { policy: "off", source: "default", malformed: false, ...locations };
|
|
364
|
+
} catch {
|
|
365
|
+
return {
|
|
366
|
+
policy: "off",
|
|
367
|
+
source: "default",
|
|
368
|
+
malformed: false,
|
|
369
|
+
projectFile,
|
|
370
|
+
globalFile,
|
|
371
|
+
projectFileExists: false,
|
|
372
|
+
globalFileExists: false,
|
|
373
|
+
envValue,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The effective policy alone, for callers that do not report a source.
|
|
380
|
+
* It delegates so the loader and the resolver can never disagree.
|
|
381
|
+
*/
|
|
382
|
+
function loadBackgroundSubagentsPolicy(
|
|
383
|
+
cwd: string,
|
|
384
|
+
options: LoadBackgroundSubagentsOptions = {},
|
|
385
|
+
): BackgroundSubagentsPolicy {
|
|
386
|
+
return resolveBackgroundSubagentsPolicy(cwd, options).policy;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Write the global policy file, creating the config home when needed. */
|
|
390
|
+
function writeGlobalBackgroundSubagentsPolicy(
|
|
391
|
+
policy: BackgroundSubagentsPolicy,
|
|
392
|
+
configHome: string = gentleAiConfigHome(),
|
|
393
|
+
): string {
|
|
394
|
+
const path = join(configHome, BACKGROUND_SUBAGENTS_FILE);
|
|
395
|
+
mkdirSync(configHome, { recursive: true });
|
|
396
|
+
writeFileSync(
|
|
397
|
+
path,
|
|
398
|
+
`${JSON.stringify({ schema: BACKGROUND_SUBAGENTS_SCHEMA, policy }, null, 2)}\n`,
|
|
399
|
+
);
|
|
400
|
+
return path;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function describeBackgroundSubagentsSource(
|
|
404
|
+
resolution: BackgroundSubagentsResolution,
|
|
405
|
+
): string {
|
|
406
|
+
switch (resolution.source) {
|
|
407
|
+
case "project_file":
|
|
408
|
+
return `project file ${resolution.projectFile}`;
|
|
409
|
+
case "global_file":
|
|
410
|
+
return `global file ${resolution.globalFile}`;
|
|
411
|
+
case "environment":
|
|
412
|
+
return "GENTLE_PI_BACKGROUND_SUBAGENTS";
|
|
413
|
+
default:
|
|
414
|
+
return "built-in default";
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Report the effective policy, the source that decided it, and the resolved
|
|
420
|
+
* capability, plus whatever the user needs to know about the sources that did
|
|
421
|
+
* NOT decide. `wrote` names a policy this invocation just wrote to the global
|
|
422
|
+
* file; a write that a higher-priority file outranks must never be reported as
|
|
423
|
+
* if it had taken effect.
|
|
424
|
+
*/
|
|
425
|
+
function renderBackgroundSubagentsReport(
|
|
426
|
+
resolution: BackgroundSubagentsResolution,
|
|
427
|
+
capability: BackgroundSubagentsCapability,
|
|
428
|
+
wrote?: BackgroundSubagentsPolicy,
|
|
429
|
+
): { message: string; type: "info" | "warning" } {
|
|
430
|
+
const lines = [
|
|
431
|
+
`background subagents: ${resolution.policy} (decided by ${describeBackgroundSubagentsSource(resolution)}; capability: ${capability})`,
|
|
432
|
+
];
|
|
433
|
+
if (wrote !== undefined) {
|
|
434
|
+
lines.push(`Wrote ${wrote} to the global file ${resolution.globalFile}.`);
|
|
435
|
+
}
|
|
436
|
+
if (resolution.malformed) {
|
|
437
|
+
const path =
|
|
438
|
+
resolution.source === "project_file" ? resolution.projectFile : resolution.globalFile;
|
|
439
|
+
lines.push(
|
|
440
|
+
`${path} is present but malformed, so the policy fails closed to off and no lower-priority source is consulted.`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
const outranksTheWrite = wrote !== undefined && resolution.source === "project_file";
|
|
444
|
+
if (outranksTheWrite) {
|
|
445
|
+
lines.push(
|
|
446
|
+
`That global write does not take effect here: the project file ${resolution.projectFile} outranks it. Edit or remove that project file to let the global setting decide.`,
|
|
447
|
+
);
|
|
448
|
+
} else if (
|
|
449
|
+
wrote === undefined &&
|
|
450
|
+
resolution.source === "project_file" &&
|
|
451
|
+
resolution.globalFileExists
|
|
452
|
+
) {
|
|
453
|
+
lines.push(
|
|
454
|
+
`The global file ${resolution.globalFile} exists but is outranked by that project file.`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
if (resolution.envValue !== undefined && resolution.source !== "environment") {
|
|
458
|
+
lines.push(
|
|
459
|
+
resolution.envValue === "on" || resolution.envValue === "off"
|
|
460
|
+
? `GENTLE_PI_BACKGROUND_SUBAGENTS=${resolution.envValue} is set, but both files outrank it and it outranks the built-in default; it decides only when neither file exists.`
|
|
461
|
+
: `GENTLE_PI_BACKGROUND_SUBAGENTS="${resolution.envValue}" is not a recognized value ("on" or "off"), so it is ignored.`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
lines.push(
|
|
465
|
+
"Resolution order (first hit wins): project file, global file, GENTLE_PI_BACKGROUND_SUBAGENTS, built-in default off.",
|
|
466
|
+
);
|
|
467
|
+
return {
|
|
468
|
+
message: lines.join("\n"),
|
|
469
|
+
type: resolution.malformed || outranksTheWrite ? "warning" : "info",
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const SUBAGENTS_PACKAGE_NAMES = ["pi-subagents-j0k3r", "pi-subagents"] as const;
|
|
474
|
+
const SUBAGENT_RUN_TOOL = "subagent_run";
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Roots where an installed subagents package may live. These are the same
|
|
478
|
+
* roots builtinAgentDirs() walks, minus its `/agents` suffix.
|
|
479
|
+
*
|
|
480
|
+
* builtinAgentDirs() looks for markdown agent definitions, which the package
|
|
481
|
+
* legitimately may not ship. Capability is a different question, so it must
|
|
482
|
+
* not reuse that path: pi-subagents-j0k3r v1.5.2 ships index.ts, src/, skills/
|
|
483
|
+
* and scripts/ and no agents/ directory at all, so an agents-dir probe reports
|
|
484
|
+
* "absent" on every real install and leaves the background policy inert.
|
|
485
|
+
*/
|
|
486
|
+
function subagentsPackageRoots(cwd: string): string[] {
|
|
487
|
+
return SUBAGENTS_PACKAGE_NAMES.flatMap((packageName) => [
|
|
488
|
+
join(PACKAGE_ROOT, "..", packageName),
|
|
489
|
+
join(cwd, ".pi", "npm", "node_modules", packageName),
|
|
490
|
+
join(homedir(), ".local", "lib", "node_modules", packageName),
|
|
491
|
+
]);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** A package root counts as installed only when it carries its own manifest. */
|
|
495
|
+
function hasInstalledSubagentsPackage(cwd: string): boolean {
|
|
496
|
+
return subagentsPackageRoots(cwd).some((root) =>
|
|
497
|
+
existsSync(join(root, "package.json")),
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function hasSubagentRunTool(activeTools: readonly string[]): boolean {
|
|
502
|
+
return activeTools.some(
|
|
503
|
+
(name) => name === SUBAGENT_RUN_TOOL || name.endsWith(`.${SUBAGENT_RUN_TOOL}`),
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Read the live pi tool registry, or undefined when it carries no signal.
|
|
509
|
+
*
|
|
510
|
+
* An absent handle, a non-array result, a throwing registry, and an empty list
|
|
511
|
+
* are all "no signal" rather than "no subagents": reporting absent from an
|
|
512
|
+
* uninformative registry would reproduce the very defect this probe fixes.
|
|
513
|
+
*/
|
|
514
|
+
function readActiveToolNames(pi: unknown): readonly string[] | undefined {
|
|
515
|
+
try {
|
|
516
|
+
const getActiveTools = (pi as { getActiveTools?: () => unknown })
|
|
517
|
+
?.getActiveTools;
|
|
518
|
+
if (typeof getActiveTools !== "function") return undefined;
|
|
519
|
+
const tools = getActiveTools.call(pi);
|
|
520
|
+
if (!Array.isArray(tools)) return undefined;
|
|
521
|
+
const names = tools
|
|
522
|
+
.map((tool) =>
|
|
523
|
+
typeof tool === "string"
|
|
524
|
+
? tool
|
|
525
|
+
: isRecord(tool) && typeof tool.name === "string"
|
|
526
|
+
? tool.name
|
|
527
|
+
: "",
|
|
528
|
+
)
|
|
529
|
+
.filter((name) => name.length > 0);
|
|
530
|
+
return names.length > 0 ? names : undefined;
|
|
531
|
+
} catch {
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* `subagent_run` availability probe.
|
|
538
|
+
*
|
|
539
|
+
* The live tool registry answers the question directly and wins whenever it
|
|
540
|
+
* carries any signal. Without it -- prompt rendering outside a session, or a
|
|
541
|
+
* runtime with no getActiveTools -- capability falls back to the presence of
|
|
542
|
+
* an installed subagents package.
|
|
543
|
+
*/
|
|
544
|
+
function resolveBackgroundSubagentsCapability(
|
|
545
|
+
cwd: string,
|
|
546
|
+
activeTools?: readonly string[],
|
|
547
|
+
): BackgroundSubagentsCapability {
|
|
548
|
+
try {
|
|
549
|
+
if (activeTools !== undefined && activeTools.length > 0) {
|
|
550
|
+
return hasSubagentRunTool(activeTools) ? "ready" : "absent";
|
|
551
|
+
}
|
|
552
|
+
return hasInstalledSubagentsPackage(cwd) ? "ready" : "absent";
|
|
553
|
+
} catch {
|
|
554
|
+
return "absent";
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function renderBackgroundSubagentsStatusLine(
|
|
559
|
+
background: BackgroundSubagentsRendering,
|
|
560
|
+
): string {
|
|
561
|
+
return `Background subagent policy: ${background.policy} (capability: ${background.capability})`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Rendered prompts are memoized per background policy/capability key for the
|
|
565
|
+
// process lifetime; the assets bytes themselves are read once per key.
|
|
566
|
+
const orchestratorPromptCache = new Map<string, string>();
|
|
567
|
+
function getOrchestratorPrompt(
|
|
568
|
+
cwd: string = process.cwd(),
|
|
569
|
+
activeTools?: readonly string[],
|
|
570
|
+
): string {
|
|
571
|
+
const background: BackgroundSubagentsRendering = {
|
|
572
|
+
policy: loadBackgroundSubagentsPolicy(cwd),
|
|
573
|
+
capability: resolveBackgroundSubagentsCapability(cwd, activeTools),
|
|
574
|
+
};
|
|
575
|
+
const cacheKey = `${background.policy}:${background.capability}`;
|
|
576
|
+
let prompt = orchestratorPromptCache.get(cacheKey);
|
|
577
|
+
if (prompt === undefined) {
|
|
578
|
+
prompt = renderOrchestratorPrompt(ASSETS_DIR, background);
|
|
579
|
+
orchestratorPromptCache.set(cacheKey, prompt);
|
|
221
580
|
}
|
|
222
|
-
return
|
|
581
|
+
return prompt;
|
|
223
582
|
}
|
|
224
583
|
|
|
225
|
-
function renderOrchestratorPrompt(
|
|
584
|
+
function renderOrchestratorPrompt(
|
|
585
|
+
assetsDir: string,
|
|
586
|
+
background: BackgroundSubagentsRendering = DEFAULT_BACKGROUND_SUBAGENTS_RENDERING,
|
|
587
|
+
): string {
|
|
226
588
|
return readFileSync(join(assetsDir, "orchestrator.md"), "utf8")
|
|
227
589
|
.replaceAll(
|
|
228
590
|
"{{GENTLE_PI_SDD_WORKFLOW_PATH}}",
|
|
@@ -240,6 +602,10 @@ function renderOrchestratorPrompt(assetsDir: string): string {
|
|
|
240
602
|
"{{GENTLE_PI_SKILLS_PATH}}",
|
|
241
603
|
join(assetsDir, "orchestrator-skills.md"),
|
|
242
604
|
)
|
|
605
|
+
.replaceAll(
|
|
606
|
+
"{{GENTLE_PI_BACKGROUND_POLICY}}",
|
|
607
|
+
renderBackgroundSubagentsStatusLine(background),
|
|
608
|
+
)
|
|
243
609
|
.trim();
|
|
244
610
|
}
|
|
245
611
|
|
|
@@ -275,7 +641,11 @@ const NEUTRAL_PERSONA_PROMPT = `Persona:
|
|
|
275
641
|
- Push back when the user asks for code without enough context or understanding.
|
|
276
642
|
- Correct errors directly, explain why, and show the better path.`;
|
|
277
643
|
|
|
278
|
-
function buildGentlePrompt(
|
|
644
|
+
function buildGentlePrompt(
|
|
645
|
+
persona: PersonaMode,
|
|
646
|
+
cwd: string = process.cwd(),
|
|
647
|
+
activeTools?: readonly string[],
|
|
648
|
+
): string {
|
|
279
649
|
const personaPrompt =
|
|
280
650
|
persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT;
|
|
281
651
|
const languageBoundary =
|
|
@@ -309,7 +679,7 @@ Harness principles:
|
|
|
309
679
|
- Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
|
|
310
680
|
- Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
|
|
311
681
|
|
|
312
|
-
${getOrchestratorPrompt()}`;
|
|
682
|
+
${getOrchestratorPrompt(cwd, activeTools)}`;
|
|
313
683
|
}
|
|
314
684
|
|
|
315
685
|
// Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
|
|
@@ -555,7 +925,16 @@ const CORE_MODEL_AGENT_NAMES = [
|
|
|
555
925
|
] as const;
|
|
556
926
|
const CORE_MODEL_AGENT_NAME_SET = new Set<string>(CORE_MODEL_AGENT_NAMES);
|
|
557
927
|
|
|
558
|
-
|
|
928
|
+
const THINKING_LEVELS = [
|
|
929
|
+
"off",
|
|
930
|
+
"minimal",
|
|
931
|
+
"low",
|
|
932
|
+
"medium",
|
|
933
|
+
"high",
|
|
934
|
+
"xhigh",
|
|
935
|
+
"max",
|
|
936
|
+
] as const;
|
|
937
|
+
type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
559
938
|
interface AgentRoutingEntry {
|
|
560
939
|
model?: string;
|
|
561
940
|
thinking?: ThinkingLevel;
|
|
@@ -579,12 +958,7 @@ const CUSTOM_MODEL = "Custom model id";
|
|
|
579
958
|
const INHERIT_THINKING = "Inherit effort";
|
|
580
959
|
const THINKING_OPTIONS: (ThinkingLevel | typeof INHERIT_THINKING)[] = [
|
|
581
960
|
INHERIT_THINKING,
|
|
582
|
-
|
|
583
|
-
"minimal",
|
|
584
|
-
"low",
|
|
585
|
-
"medium",
|
|
586
|
-
"high",
|
|
587
|
-
"xhigh",
|
|
961
|
+
...THINKING_LEVELS,
|
|
588
962
|
];
|
|
589
963
|
|
|
590
964
|
const MODEL_CONTROL_OPTIONS = [
|
|
@@ -804,12 +1178,8 @@ function writePersonaMode(cwd: string, mode: PersonaMode): string[] {
|
|
|
804
1178
|
|
|
805
1179
|
function isThinkingLevel(value: unknown): value is ThinkingLevel {
|
|
806
1180
|
return (
|
|
807
|
-
value === "
|
|
808
|
-
|
|
809
|
-
value === "low" ||
|
|
810
|
-
value === "medium" ||
|
|
811
|
-
value === "high" ||
|
|
812
|
-
value === "xhigh"
|
|
1181
|
+
typeof value === "string" &&
|
|
1182
|
+
(THINKING_LEVELS as readonly string[]).includes(value)
|
|
813
1183
|
);
|
|
814
1184
|
}
|
|
815
1185
|
|
|
@@ -2164,7 +2534,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
|
|
|
2164
2534
|
},
|
|
2165
2535
|
input: {
|
|
2166
2536
|
type: "string",
|
|
2167
|
-
description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. Legacy compact START retains policyHash. FINALIZE supplies
|
|
2537
|
+
description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. Legacy compact START retains policyHash. FINALIZE supplies only the negotiated collection answers: correction forecast, targeted validation, final evidence, and an explicit final_verification_passed boolean; reviewer, refuter, and validator verdicts are admitted natively and never Pi-authored. Judgment Day retains graph-v1 input.",
|
|
2168
2538
|
},
|
|
2169
2539
|
outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
|
|
2170
2540
|
inputPath: { type: "string", description: "Repository-local JSON input file for finalize/advance (alternative to input). Legacy bundle import is retired." },
|
|
@@ -2177,6 +2547,23 @@ const REVIEW_CONTROLLER_PARAMETERS = {
|
|
|
2177
2547
|
},
|
|
2178
2548
|
} as const;
|
|
2179
2549
|
|
|
2550
|
+
const REVIEW_SCOPE_PARAMETERS = {
|
|
2551
|
+
type: "object",
|
|
2552
|
+
additionalProperties: false,
|
|
2553
|
+
required: ["manifest", "sha256"],
|
|
2554
|
+
properties: {
|
|
2555
|
+
manifest: { type: "string", maxLength: 4_096, description: "Exact controller-supplied gzip/base64url frozen changed-scope manifest." },
|
|
2556
|
+
sha256: { type: "string", pattern: "^[0-9a-f]{64}$", description: "Exact controller-supplied SHA-256 of the decompressed canonical manifest bytes." },
|
|
2557
|
+
cursor: { type: "integer", minimum: 0, description: "Pagination cursor. Start at 0 and continue with nextCursor until absent." },
|
|
2558
|
+
},
|
|
2559
|
+
} as const;
|
|
2560
|
+
|
|
2561
|
+
interface ReviewScopeParameters {
|
|
2562
|
+
manifest: string;
|
|
2563
|
+
sha256: string;
|
|
2564
|
+
cursor?: number;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2180
2567
|
interface ReviewControllerParameters {
|
|
2181
2568
|
operation: ReviewControllerOperation;
|
|
2182
2569
|
lineageId?: string;
|
|
@@ -2533,7 +2920,17 @@ async function authorizeDestructiveReviewOperation(
|
|
|
2533
2920
|
ctx: ExtensionContext,
|
|
2534
2921
|
): Promise<void> {
|
|
2535
2922
|
const parameters = parseReviewControllerParameters(parametersValue);
|
|
2536
|
-
|
|
2923
|
+
// RESET alone carries the legacy repository-wide challenge. Native compact-v2
|
|
2924
|
+
// RECOVER has its own six-field contract and its own derived
|
|
2925
|
+
// `gentle-ai.review-recovery-authorization/v1` binding, neither of which the
|
|
2926
|
+
// legacy `repositoryId`/`commonDirHash`/`inventoryHash`/`confirmation` quartet
|
|
2927
|
+
// can express. Native INSPECT never publishes that quartet either, so
|
|
2928
|
+
// demanding it here made the only supported recovery flow unreachable
|
|
2929
|
+
// (issue #212).
|
|
2930
|
+
// RECOVER authorizes itself in `executeReviewControllerOperation`, the way
|
|
2931
|
+
// REPAIR_LEGACY_ALIAS does, because its binding can only be derived from a
|
|
2932
|
+
// fresh native target-status read.
|
|
2933
|
+
const isReset = parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET;
|
|
2537
2934
|
const maintenance = nativeMaintenanceOperation(parameters.operation);
|
|
2538
2935
|
if (!isReset && maintenance === undefined) return;
|
|
2539
2936
|
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
@@ -3849,13 +4246,21 @@ const REVIEW_MODE_DISABLED_OUTCOME = "review-mode-disabled";
|
|
|
3849
4246
|
// and changes nothing — ground-truthed against a real build. Naming it here
|
|
3850
4247
|
// would be naming a dead end, which is worse than naming nothing.
|
|
3851
4248
|
//
|
|
3852
|
-
// The default
|
|
3853
|
-
//
|
|
3854
|
-
// an
|
|
4249
|
+
// The default branch changed with the pinned v2.4.0 runtime, which made
|
|
4250
|
+
// receipt-driven development opt-in. It used to be unreachable as a reason for
|
|
4251
|
+
// reviews being off — an all-sources-unset install resolved to ON with source
|
|
4252
|
+
// `default` — so naming a continuation for it would have been a guess, and
|
|
4253
|
+
// gentle-ai returned an empty scope to say exactly that. v2.4.0 resolves the
|
|
4254
|
+
// same install to OFF with source `default`, which makes it the most common
|
|
4255
|
+
// refusal there is: every install that never opted in. gentle-ai answers
|
|
4256
|
+
// `global` for it now, not because default is a global opinion but because
|
|
4257
|
+
// global is the only scope that can turn reviews on at all, and Pi answers the
|
|
4258
|
+
// same. Leaving this undefined would hand the single most common state a dead
|
|
4259
|
+
// end.
|
|
3855
4260
|
function reviewModeContinuation(source: NativeReviewModeSource): string | undefined {
|
|
3856
4261
|
if (source === NATIVE_REVIEW_MODE_SOURCE.CLONE_LOCAL) return "Run /gentle:review-mode enable to turn reviews back on for this clone.";
|
|
3857
4262
|
if (source === NATIVE_REVIEW_MODE_SOURCE.GLOBAL) return "Run `gentle-ai review mode enable --scope=global` to turn reviews back on; /gentle:review-mode enable only clears the clone-local setting, which cannot override a global off.";
|
|
3858
|
-
return
|
|
4263
|
+
return "Run `gentle-ai review mode enable --scope=global` to turn reviews on; receipt-driven development is opt-in and nothing here has enabled it yet. /gentle:review-mode enable only sets clone scope, which can never turn reviews on.";
|
|
3859
4264
|
}
|
|
3860
4265
|
|
|
3861
4266
|
// Names the situation before the mechanism, then the mechanism, mirroring
|
|
@@ -3927,9 +4332,22 @@ function asNativeReviewConsentBindingError(error: unknown): { reason: string; me
|
|
|
3927
4332
|
return typeof reason !== "string" || reason.length === 0 ? undefined : { reason, message: error.message };
|
|
3928
4333
|
}
|
|
3929
4334
|
|
|
4335
|
+
function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation, diagnostics: NativeReviewProcessDiagnostics): Record<string, unknown> {
|
|
4336
|
+
return {
|
|
4337
|
+
operation,
|
|
4338
|
+
status: "blocked",
|
|
4339
|
+
outcome: "native-status-package-binary-missing",
|
|
4340
|
+
...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { lineage_created: false, mutation_performed: false, mutation_outcome: "none" }),
|
|
4341
|
+
inventory_complete: false,
|
|
4342
|
+
diagnostics,
|
|
4343
|
+
next_action: "reinstall-package-local-gentle-ai",
|
|
4344
|
+
};
|
|
4345
|
+
}
|
|
4346
|
+
|
|
3930
4347
|
function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown): Record<string, unknown> {
|
|
3931
4348
|
const cliError = asNativeReviewCliError(error);
|
|
3932
4349
|
if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) return nativeStatusUnsupported(operation);
|
|
4350
|
+
if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) return nativeStatusPackageBinaryMissing(operation, cliError.diagnostics);
|
|
3933
4351
|
if (cliError !== undefined) {
|
|
3934
4352
|
return {
|
|
3935
4353
|
...nativeOperationFailure(operation, error),
|
|
@@ -3970,7 +4388,14 @@ function nativeMaintenanceOperation(operation: ReviewControllerOperation): Nativ
|
|
|
3970
4388
|
}
|
|
3971
4389
|
|
|
3972
4390
|
function missingNativeMaintenanceInputs(operation: NativeMaintenanceOperation, input: Record<string, unknown>): readonly string[] {
|
|
3973
|
-
|
|
4391
|
+
const missing = NATIVE_MAINTENANCE_INPUT[operation].filter((key) => !isCanonicalProcessString(input[key]));
|
|
4392
|
+
if (operation !== "abandon") return missing;
|
|
4393
|
+
return [
|
|
4394
|
+
...missing,
|
|
4395
|
+
...(Array.isArray(input.capturedLensResults) && input.capturedLensResults.every((entry) => isCanonicalProcessString(entry)) ? [] : ["capturedLensResults"]),
|
|
4396
|
+
...(typeof input.findingsPresent === "boolean" ? [] : ["findingsPresent"]),
|
|
4397
|
+
...(typeof input.evidenceRecordsPresent === "boolean" ? [] : ["evidenceRecordsPresent"]),
|
|
4398
|
+
];
|
|
3974
4399
|
}
|
|
3975
4400
|
|
|
3976
4401
|
function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, input: Record<string, unknown>): boolean {
|
|
@@ -3979,7 +4404,7 @@ function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, in
|
|
|
3979
4404
|
}
|
|
3980
4405
|
|
|
3981
4406
|
function nativeMaintenanceAuthorization(operation: NativeMaintenanceOperation, input: Record<string, unknown>): string {
|
|
3982
|
-
if (operation === "abandon") return nativeReviewAbandonAuthorization({ lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), actor: String(input.actor), reason: String(input.reason) });
|
|
4407
|
+
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, evidenceRecordsPresent: input.evidenceRecordsPresent === true, actor: String(input.actor), reason: String(input.reason) });
|
|
3983
4408
|
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) });
|
|
3984
4409
|
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 }) });
|
|
3985
4410
|
}
|
|
@@ -4008,7 +4433,7 @@ async function executeNativeAuthorityMaintenance(
|
|
|
4008
4433
|
pendingAuthorizations.clear();
|
|
4009
4434
|
try {
|
|
4010
4435
|
const result = nativeOperation === "abandon"
|
|
4011
|
-
? await nativeReviewCli.abandon!({ cwd, lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
|
|
4436
|
+
? await nativeReviewCli.abandon!({ cwd, lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, evidenceRecordsPresent: input.evidenceRecordsPresent === true, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
|
|
4012
4437
|
: nativeOperation === "quarantineLegacy"
|
|
4013
4438
|
? 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 }) })
|
|
4014
4439
|
: 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 }) });
|
|
@@ -4530,6 +4955,18 @@ function validateNativeStartPolicyPath(cwd: string, value: unknown): NativeStart
|
|
|
4530
4955
|
}
|
|
4531
4956
|
}
|
|
4532
4957
|
|
|
4958
|
+
const NATIVE_START_FOCUS = {
|
|
4959
|
+
RISK: "risk",
|
|
4960
|
+
RESILIENCE: "resilience",
|
|
4961
|
+
READABILITY: "readability",
|
|
4962
|
+
RELIABILITY: "reliability",
|
|
4963
|
+
} as const;
|
|
4964
|
+
type NativeStartFocus = (typeof NATIVE_START_FOCUS)[keyof typeof NATIVE_START_FOCUS];
|
|
4965
|
+
|
|
4966
|
+
function isNativeStartFocus(value: unknown): value is NativeStartFocus {
|
|
4967
|
+
return typeof value === "string" && (Object.values(NATIVE_START_FOCUS) as readonly string[]).includes(value);
|
|
4968
|
+
}
|
|
4969
|
+
|
|
4533
4970
|
function nativeStartRejection(reason: string, field?: string): Record<string, unknown> {
|
|
4534
4971
|
return {
|
|
4535
4972
|
operation: REVIEW_CONTROLLER_OPERATION.START,
|
|
@@ -4546,7 +4983,7 @@ function nativeStartRejection(reason: string, field?: string): Record<string, un
|
|
|
4546
4983
|
? "native-start-committed-only-required"
|
|
4547
4984
|
: reason === "committed-only-invalid"
|
|
4548
4985
|
? "native-start-committed-only-invalid"
|
|
4549
|
-
: reason === "unknown-field"
|
|
4986
|
+
: reason === "unknown-field" || reason === "focus-invalid"
|
|
4550
4987
|
? "native-start-input-invalid"
|
|
4551
4988
|
: "native-start-policy-path-invalid",
|
|
4552
4989
|
reason,
|
|
@@ -4562,7 +4999,7 @@ interface PendingReviewConsent {
|
|
|
4562
4999
|
repositoryCwd: string;
|
|
4563
5000
|
authorityCwd: string;
|
|
4564
5001
|
candidateView: CandidateView;
|
|
4565
|
-
consent:
|
|
5002
|
+
consent: ReviewConsentEnvelope;
|
|
4566
5003
|
consentDigest: string;
|
|
4567
5004
|
expiresAt: number;
|
|
4568
5005
|
expiry?: ReturnType<typeof setTimeout>;
|
|
@@ -4583,7 +5020,21 @@ function cleanupAllPendingReviewConsents(pendingReviewConsents: Map<string, Pend
|
|
|
4583
5020
|
for (const pending of [...pendingReviewConsents.values()]) cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
|
|
4584
5021
|
}
|
|
4585
5022
|
|
|
4586
|
-
|
|
5023
|
+
// An unused consent binding and the candidate view retained exclusively for
|
|
5024
|
+
// that binding expire as one lifecycle unit. TTL expiry is observable the
|
|
5025
|
+
// moment synchronous time says `expiresAt <= now`, so cleanup must be
|
|
5026
|
+
// synchronous with respect to that observation — the queued cleanup
|
|
5027
|
+
// macrotask is a safety net, not the authority. Pruning here (before any
|
|
5028
|
+
// later START may reuse the retained view) keeps timer order from deciding
|
|
5029
|
+
// correctness: a fresh candidate retry never reuses a view whose binding
|
|
5030
|
+
// already expired, so it cannot trip `candidate-target-projection-drift`.
|
|
5031
|
+
function pruneExpiredReviewConsents(pendingReviewConsents: Map<string, PendingReviewConsent>, candidateViews: CandidateViewRegistry | null, now: () => number): void {
|
|
5032
|
+
for (const pending of [...pendingReviewConsents.values()]) {
|
|
5033
|
+
if (pending.expiresAt <= now()) cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
|
|
5034
|
+
}
|
|
5035
|
+
}
|
|
5036
|
+
|
|
5037
|
+
function reviewConsentDigest(consent: ReviewConsentEnvelope): string {
|
|
4587
5038
|
return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
|
|
4588
5039
|
}
|
|
4589
5040
|
|
|
@@ -4600,6 +5051,17 @@ function assertNativeStartCandidateBinding(candidateView: CandidateView, target:
|
|
|
4600
5051
|
}
|
|
4601
5052
|
}
|
|
4602
5053
|
|
|
5054
|
+
function assertNativeFinalizeCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void {
|
|
5055
|
+
candidateView.verify();
|
|
5056
|
+
if (
|
|
5057
|
+
target.projection.baseTree !== candidateView.baseTree ||
|
|
5058
|
+
target.projection.currentCandidateTree !== candidateView.candidateTree ||
|
|
5059
|
+
JSON.stringify([...target.projection.paths].sort()) !== JSON.stringify([...candidateView.paths].sort())
|
|
5060
|
+
) {
|
|
5061
|
+
throw new CandidateViewError("native FINALIZE target does not match the immutable reviewer candidate view", "candidate-target-projection-drift");
|
|
5062
|
+
}
|
|
5063
|
+
}
|
|
5064
|
+
|
|
4603
5065
|
function completeNativeStart(
|
|
4604
5066
|
operation: ReviewControllerOperation,
|
|
4605
5067
|
result: NativeStartResult,
|
|
@@ -4665,12 +5127,18 @@ function nativeOperationFailure(operation: ReviewControllerOperation, error: unk
|
|
|
4665
5127
|
};
|
|
4666
5128
|
}
|
|
4667
5129
|
const mutationOutcome = value.mutationOutcome === "unknown" ? "unknown" : "none";
|
|
4668
|
-
const
|
|
5130
|
+
const nativeCliError = asNativeReviewCliError(error);
|
|
5131
|
+
if (nativeCliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) return nativeStatusPackageBinaryMissing(operation, nativeCliError.diagnostics);
|
|
5132
|
+
const nativeDiagnostics = nativeCliError?.diagnostics;
|
|
5133
|
+
// A target-status probe verifies `version` before it invokes `review/status`.
|
|
5134
|
+
// Preserve either already-sanitized diagnostic on every controller route rather
|
|
5135
|
+
// than relabeling an actionable failure as an opaque controller failure.
|
|
5136
|
+
const preservesNativeTargetStatusDiagnostic = nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.VERSION || nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.STATUS;
|
|
4669
5137
|
const diagnostics = operation === REVIEW_CONTROLLER_OPERATION.START && error instanceof CandidateViewError && value.candidateViewPreNative === true
|
|
4670
|
-
? { code: error.reason, message: "candidate view rejected before native START" }
|
|
5138
|
+
? error.diagnostics ?? { code: error.reason, message: "candidate view rejected before native START" }
|
|
4671
5139
|
: error instanceof CandidateViewError
|
|
4672
5140
|
? { code: error.reason, message: error.message }
|
|
4673
|
-
: nativeDiagnostics?.operation === `review/${operation}`
|
|
5141
|
+
: nativeDiagnostics?.operation === `review/${operation}` || preservesNativeTargetStatusDiagnostic
|
|
4674
5142
|
? nativeDiagnostics
|
|
4675
5143
|
: undefined;
|
|
4676
5144
|
return {
|
|
@@ -4708,6 +5176,7 @@ async function reconcileNativeMutationFailure(
|
|
|
4708
5176
|
error: unknown,
|
|
4709
5177
|
nativeReviewCli: NativeReviewCli,
|
|
4710
5178
|
target: { cwd: string; lineageId?: string; baseRef?: string; projection?: "workspace" | "staged" },
|
|
5179
|
+
preOperationRevision?: string,
|
|
4711
5180
|
): Promise<Record<string, unknown>> {
|
|
4712
5181
|
const failure = nativeOperationFailure(operation, error);
|
|
4713
5182
|
if (!nativeMutationRequiresStatus(error)) return failure;
|
|
@@ -4733,6 +5202,28 @@ async function reconcileNativeMutationFailure(
|
|
|
4733
5202
|
...reconcileFinalizeRouting(status, target.lineageId, operation === REVIEW_CONTROLLER_OPERATION.FINALIZE),
|
|
4734
5203
|
};
|
|
4735
5204
|
}
|
|
5205
|
+
// Field defect (fambig, 2026-08-16): an envelope-less mutating failure
|
|
5206
|
+
// is stamped mutationOutcome "unknown", but a reconciled authority
|
|
5207
|
+
// revision identical to the pre-operation revision PROVES the failed
|
|
5208
|
+
// call never mutated. Report that proof as mutation_outcome none and
|
|
5209
|
+
// claim no replay prohibition for it. Every genuinely ambiguous result
|
|
5210
|
+
// — revision moved, no pre-operation revision held, or STATUS
|
|
5211
|
+
// unavailable — stays fail-closed exactly as before.
|
|
5212
|
+
if (preOperationRevision !== undefined && status.authority?.revision === preOperationRevision) {
|
|
5213
|
+
const { replayability: staleReplayability, ...provenBase } = reconciledBase;
|
|
5214
|
+
void staleReplayability;
|
|
5215
|
+
return {
|
|
5216
|
+
...provenBase,
|
|
5217
|
+
outcome: "native-mutation-status-reconciled",
|
|
5218
|
+
reconciliation: status.raw,
|
|
5219
|
+
authority_applicability: status.applicability,
|
|
5220
|
+
provider_action: status.action,
|
|
5221
|
+
mutation_performed: false,
|
|
5222
|
+
mutation_outcome: "none",
|
|
5223
|
+
mutation_outcome_reason: `authority revision unchanged across reconciliation (${preOperationRevision}); the failed operation provably did not mutate`,
|
|
5224
|
+
next_action: status.action,
|
|
5225
|
+
};
|
|
5226
|
+
}
|
|
4736
5227
|
return {
|
|
4737
5228
|
...reconciledBase,
|
|
4738
5229
|
outcome: "native-mutation-status-reconciled",
|
|
@@ -4824,97 +5315,173 @@ function resolveReviewControllerWorkspaceRoot(requested: string | undefined, ses
|
|
|
4824
5315
|
return resolved;
|
|
4825
5316
|
}
|
|
4826
5317
|
|
|
4827
|
-
function
|
|
4828
|
-
|
|
4829
|
-
if (
|
|
4830
|
-
|
|
5318
|
+
function correctionOutcome(input: ReturnType<typeof parseNativeCompactFinalizeInput>): CorrectionOutcome | undefined {
|
|
5319
|
+
if (input.final_verification_outcome !== undefined) return input.final_verification_outcome;
|
|
5320
|
+
if (input.final_verification_passed === undefined) return undefined;
|
|
5321
|
+
return input.final_verification_passed ? "passed" : "verification_failed";
|
|
5322
|
+
}
|
|
5323
|
+
|
|
5324
|
+
// Both targeted-validation collection forms count as "validation offered":
|
|
5325
|
+
// the host-run `external.run_targeted_validation` input and the Go-owned
|
|
5326
|
+
// `review.capture-validation` vector (gentle-pi#311 P4-roles).
|
|
5327
|
+
function isTargetedValidationCollectInput(input: { captureOperation: string }): boolean {
|
|
5328
|
+
return input.captureOperation === "external.run_targeted_validation" || input.captureOperation === "review.capture-validation";
|
|
5329
|
+
}
|
|
5330
|
+
|
|
5331
|
+
function requireEvidenceCollection(status: ReviewStatusV3): ReviewCollectInputV3 {
|
|
5332
|
+
const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : [];
|
|
5333
|
+
// An offered validation STEP is a targeted-validation collect input. The
|
|
5334
|
+
// bare `validation_request` field is descriptive context both live
|
|
5335
|
+
// emitters (pinned 2.2.3 and 2.4.0-main, probed 2026-08-16) publish
|
|
5336
|
+
// alongside the evidence collect at `correction_required`; treating it as
|
|
5337
|
+
// an offered step made the controller demand its validation phase before
|
|
5338
|
+
// capturing the evidence the state itself demanded (field defect).
|
|
5339
|
+
if (inputs.some(isTargetedValidationCollectInput)) {
|
|
5340
|
+
throw new CandidateViewError("targeted validation was offered before correction evidence was captured", "evidence-first-ordering");
|
|
5341
|
+
}
|
|
5342
|
+
const evidence = inputs.filter((input) => input.captureOperation === "review.capture-evidence");
|
|
5343
|
+
if (evidence.length !== 1) {
|
|
5344
|
+
throw new CandidateViewError("provider status must collect exactly one correction evidence record before targeted validation", "evidence-first-ordering");
|
|
4831
5345
|
}
|
|
4832
|
-
return
|
|
5346
|
+
return evidence[0]!;
|
|
4833
5347
|
}
|
|
4834
5348
|
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
}
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
|
|
5349
|
+
interface EvidenceCaptureBinding {
|
|
5350
|
+
/** The identity the collect slot demands; the top-level status identity only as a pre-slot compatibility fallback. */
|
|
5351
|
+
readonly targetIdentity: string;
|
|
5352
|
+
readonly expectedRevision: string;
|
|
5353
|
+
/** True when the slot rendered its own target identity; the record binds the slot's (fix-diff) scope, not the frozen projection scope. */
|
|
5354
|
+
readonly slotBound: boolean;
|
|
5355
|
+
readonly submission?: {
|
|
5356
|
+
readonly argumentTokens: readonly string[];
|
|
5357
|
+
readonly outcomeSubstitutionLocation: number;
|
|
5358
|
+
readonly inputSubstitutionLocation: number;
|
|
5359
|
+
readonly carriesRepositoryContext: boolean;
|
|
5360
|
+
};
|
|
5361
|
+
}
|
|
5362
|
+
|
|
5363
|
+
// Field defect (fambig, 2026-08-16): at every evidence-pending sub-state the
|
|
5364
|
+
// collect slot renders the identity native demands — for a correction that is
|
|
5365
|
+
// the FIX-DIFF identity, never the top-level live workspace snapshot identity.
|
|
5366
|
+
// Collect satisfaction binds to the slot's rendered arguments and submission
|
|
5367
|
+
// tokens; top-level status fields remain only a compatibility fallback for
|
|
5368
|
+
// pre-v5 emitters whose slots render no identities.
|
|
5369
|
+
function resolveEvidenceCaptureBinding(slot: ReviewCollectInputV3, status: ReviewStatusV3, lineageId: string, outcome: CorrectionOutcome): EvidenceCaptureBinding {
|
|
5370
|
+
const named = new Map(slot.arguments.map((argument) => [argument.name, argument.value]));
|
|
5371
|
+
const slotLineage = named.get("lineage");
|
|
5372
|
+
if (slotLineage !== undefined && slotLineage !== lineageId) {
|
|
5373
|
+
throw new CandidateViewError("provider evidence collect slot is bound to a different lineage", "correction-evidence-binding-drift");
|
|
5374
|
+
}
|
|
5375
|
+
const slotRevision = named.get("expected-revision");
|
|
5376
|
+
if (slotRevision !== undefined && slotRevision !== status.authority?.revision) {
|
|
5377
|
+
throw new CandidateViewError("provider evidence collect slot is bound to a different authority revision", "correction-evidence-binding-drift");
|
|
5378
|
+
}
|
|
5379
|
+
const slotTarget = named.get("target");
|
|
5380
|
+
const binding = {
|
|
5381
|
+
targetIdentity: slotTarget ?? status.targetIdentity,
|
|
5382
|
+
expectedRevision: slotRevision ?? status.authority!.revision,
|
|
5383
|
+
slotBound: slotTarget !== undefined,
|
|
5384
|
+
};
|
|
5385
|
+
const descriptor = slot.submissionDescriptor;
|
|
5386
|
+
if (descriptor === undefined || descriptor.operationToken !== "capture-evidence") return binding;
|
|
5387
|
+
const outcomeSlot = descriptor.values?.find((value) => value.slot === "outcome");
|
|
5388
|
+
const inputSlot = descriptor.values?.find((value) => value.slot === "input");
|
|
5389
|
+
if (outcomeSlot === undefined || inputSlot === undefined) {
|
|
5390
|
+
throw new CandidateViewError("provider capture-evidence submission descriptor must render the outcome and input slots", "correction-evidence-binding-drift");
|
|
4873
5391
|
}
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
throw new CandidateViewError("provider manifest intended-untracked fields disagree with its projection", "manifest-intended-untracked-not-subset");
|
|
5392
|
+
if (outcomeSlot.allowedValues !== undefined && !outcomeSlot.allowedValues.includes(outcome)) {
|
|
5393
|
+
throw new CandidateViewError(`provider capture-evidence submission does not admit outcome ${outcome}`, "correction-evidence-binding-drift");
|
|
4877
5394
|
}
|
|
4878
5395
|
return {
|
|
4879
|
-
...
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
typeChanged: entry.typeChanged,
|
|
4887
|
-
modeOnly: entry.modeOnly,
|
|
4888
|
-
})),
|
|
4889
|
-
manifestSha256: manifestHash,
|
|
4890
|
-
providerManifestHashVerified: true,
|
|
5396
|
+
...binding,
|
|
5397
|
+
submission: {
|
|
5398
|
+
argumentTokens: descriptor.argumentTokens,
|
|
5399
|
+
outcomeSubstitutionLocation: outcomeSlot.substitutionLocation,
|
|
5400
|
+
inputSubstitutionLocation: inputSlot.substitutionLocation,
|
|
5401
|
+
carriesRepositoryContext: descriptor.argumentTokens.some((token) => token === "--repository-context" || token.startsWith("--repository-context=")),
|
|
5402
|
+
},
|
|
4891
5403
|
};
|
|
4892
5404
|
}
|
|
4893
5405
|
|
|
4894
|
-
function
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
5406
|
+
async function captureEvidenceForCollection(
|
|
5407
|
+
nativeReviewCli: NativeReviewCli,
|
|
5408
|
+
binding: EvidenceCaptureBinding,
|
|
5409
|
+
cwd: string,
|
|
5410
|
+
lineageId: string,
|
|
5411
|
+
outcome: CorrectionOutcome,
|
|
5412
|
+
evidenceDocument: string,
|
|
5413
|
+
signal: AbortSignal | undefined,
|
|
5414
|
+
): Promise<NativeReviewVerificationEvidenceV2> {
|
|
5415
|
+
if (binding.submission !== undefined) {
|
|
5416
|
+
if (nativeReviewCli.captureEvidenceSubmission === undefined) throw new CandidateViewError("native capture-evidence submission execution is unavailable", "evidence-first-ordering");
|
|
5417
|
+
return nativeReviewCli.captureEvidenceSubmission({
|
|
5418
|
+
// The slot's --repository-context is cwd-independent and
|
|
5419
|
+
// authoritative; a path is passed only when the slot renders none.
|
|
5420
|
+
...(binding.submission.carriesRepositoryContext ? {} : { cwd }),
|
|
5421
|
+
argumentTokens: binding.submission.argumentTokens,
|
|
5422
|
+
outcomeSubstitutionLocation: binding.submission.outcomeSubstitutionLocation,
|
|
5423
|
+
inputSubstitutionLocation: binding.submission.inputSubstitutionLocation,
|
|
5424
|
+
outcome,
|
|
5425
|
+
evidenceDocument,
|
|
5426
|
+
...(signal === undefined ? {} : { signal }),
|
|
5427
|
+
});
|
|
5428
|
+
}
|
|
5429
|
+
if (nativeReviewCli.captureEvidence === undefined) throw new CandidateViewError("native capture-evidence execution is unavailable", "evidence-first-ordering");
|
|
5430
|
+
return nativeReviewCli.captureEvidence({
|
|
5431
|
+
cwd,
|
|
5432
|
+
lineageId,
|
|
5433
|
+
targetIdentity: binding.targetIdentity,
|
|
5434
|
+
expectedRevision: binding.expectedRevision,
|
|
5435
|
+
outcome,
|
|
5436
|
+
evidenceDocument,
|
|
5437
|
+
...(signal === undefined ? {} : { signal }),
|
|
5438
|
+
});
|
|
4898
5439
|
}
|
|
4899
5440
|
|
|
4900
|
-
|
|
5441
|
+
// Same misbinding class as capture-evidence (live smoke, 2026-08-16): the
|
|
5442
|
+
// correction PLAN and TARGETED VALIDATION collect slots render `finalize`
|
|
5443
|
+
// submission descriptors. When one is rendered, the reconstructed legacy
|
|
5444
|
+
// `--correction-lines`/`--validation` argv fails the live emitter's
|
|
5445
|
+
// committed-intent reconciliation, so the rendered tokens execute verbatim.
|
|
5446
|
+
function finalizeSubmissionSlot(
|
|
5447
|
+
status: ReviewStatusV3,
|
|
5448
|
+
slot: "correction_lines" | "validation",
|
|
5449
|
+
): { argumentTokens: readonly string[]; value: NonNullable<NonNullable<ReviewCollectInputV3["submissionDescriptor"]>["value"]> } | undefined {
|
|
4901
5450
|
const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : [];
|
|
4902
|
-
|
|
4903
|
-
|
|
5451
|
+
for (const input of inputs) {
|
|
5452
|
+
const descriptor = input.submissionDescriptor;
|
|
5453
|
+
if (descriptor?.operationToken === "finalize" && descriptor.value?.slot === slot) {
|
|
5454
|
+
return { argumentTokens: descriptor.argumentTokens, value: descriptor.value };
|
|
5455
|
+
}
|
|
4904
5456
|
}
|
|
4905
|
-
|
|
4906
|
-
|
|
5457
|
+
return undefined;
|
|
5458
|
+
}
|
|
5459
|
+
|
|
5460
|
+
// A slot-bound record covers the slot-demanded (fix-diff) scope: its paths are
|
|
5461
|
+
// a subset of the frozen projection paths and its digest binds the record's
|
|
5462
|
+
// own paths (captured 2026-08-16, lineage review-2b6206ed68fb9128). Only the
|
|
5463
|
+
// pre-slot compatibility fallback still demands projection-exact paths.
|
|
5464
|
+
function evidencePathsDrift(captured: NativeReviewVerificationEvidenceV2, binding: EvidenceCaptureBinding, status: ReviewStatusV3): boolean {
|
|
5465
|
+
if (binding.slotBound) {
|
|
5466
|
+
return captured.paths.length === 0 || captured.paths.some((path) => !status.projection.paths.includes(path));
|
|
4907
5467
|
}
|
|
5468
|
+
return captured.pathsDigest !== status.projection.pathsDigest || JSON.stringify([...captured.paths].sort()) !== JSON.stringify([...status.projection.paths].sort());
|
|
4908
5469
|
}
|
|
4909
5470
|
|
|
4910
5471
|
function requireTargetedValidationAfterEvidence(status: ReviewStatusV3): NonNullable<ReviewStatusV3["validationRequest"]> {
|
|
4911
5472
|
const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : [];
|
|
4912
|
-
const targeted = inputs.filter(
|
|
5473
|
+
const targeted = inputs.filter(isTargetedValidationCollectInput);
|
|
4913
5474
|
const request = status.validationRequest;
|
|
4914
5475
|
if (
|
|
4915
|
-
|
|
5476
|
+
// Both live emitters (pinned 2.2.3 and 2.4.0-main, probed 2026-08-16)
|
|
5477
|
+
// keep the post-evidence authority state at `correction_required`;
|
|
5478
|
+
// `validating` is retained for pre-existing fixture compatibility.
|
|
5479
|
+
(status.authority?.state !== "validating" && status.authority?.state !== "correction_required") || targeted.length !== 1 || targeted[0]?.validationRequest === undefined || request === undefined ||
|
|
4916
5480
|
JSON.stringify(targeted[0].validationRequest) !== JSON.stringify(request) || request.lineageId !== status.authority.lineageId ||
|
|
4917
|
-
|
|
5481
|
+
// The live emitter binds the request to the FROZEN authority target
|
|
5482
|
+
// identity; the top-level target identity is the live workspace
|
|
5483
|
+
// snapshot, which already contains the fix (probed 2026-08-16).
|
|
5484
|
+
request.expectedRevision !== status.authority.revision || request.targetIdentity !== (status.authorityTargetIdentity ?? status.targetIdentity) ||
|
|
4918
5485
|
request.correctionCandidateTree !== status.projection.currentCandidateTree ||
|
|
4919
5486
|
JSON.stringify([...request.correctionPaths].sort()) !== JSON.stringify([...status.projection.paths].sort()) ||
|
|
4920
5487
|
request.correctionPathsDigest !== status.projection.pathsDigest
|
|
@@ -4926,11 +5493,278 @@ function requireTargetedValidationAfterEvidence(status: ReviewStatusV3): NonNull
|
|
|
4926
5493
|
|
|
4927
5494
|
function assertNoTargetedValidation(status: ReviewStatusV3): void {
|
|
4928
5495
|
const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : [];
|
|
4929
|
-
|
|
5496
|
+
// Same rule as requireEvidenceCollection: only an offered targeted-
|
|
5497
|
+
// validation collect input counts; the descriptive `validation_request`
|
|
5498
|
+
// context rides along on non-passing outcomes too.
|
|
5499
|
+
if (inputs.some(isTargetedValidationCollectInput)) {
|
|
4930
5500
|
throw new CandidateViewError("non-passing evidence unexpectedly unlocked targeted validation", "evidence-first-ordering");
|
|
4931
5501
|
}
|
|
4932
5502
|
}
|
|
4933
5503
|
|
|
5504
|
+
// gentle-pi#311 P4 — the thin Pi host relay. The provider decides which
|
|
5505
|
+
// capture slots the host satisfies by issuing the --materialize token on a
|
|
5506
|
+
// pi-bound `review.capture-result` collect input; nothing is ever inferred.
|
|
5507
|
+
// The runner is injectable for tests only; production always uses the real
|
|
5508
|
+
// relay in lib/review-host-relay.ts.
|
|
5509
|
+
let activeReviewHostRelayRunner: ReviewHostRelayRunner = runReviewHostRelaySlot;
|
|
5510
|
+
function setReviewHostRelayRunnerForTesting(runner?: ReviewHostRelayRunner): void {
|
|
5511
|
+
activeReviewHostRelayRunner = runner ?? runReviewHostRelaySlot;
|
|
5512
|
+
}
|
|
5513
|
+
|
|
5514
|
+
const REVIEW_HOST_RELAY_RETRY_ACTION =
|
|
5515
|
+
"Re-query negotiated STATUS and relaunch only if the exact same bound slot is reoffered; never rerun from transcript inference.";
|
|
5516
|
+
|
|
5517
|
+
async function executeReviewHostRelayCollection(
|
|
5518
|
+
operation: ReviewControllerOperation,
|
|
5519
|
+
lineageId: string,
|
|
5520
|
+
slots: readonly ReviewHostRelaySlot[],
|
|
5521
|
+
nativeReviewCli: NativeReviewCli,
|
|
5522
|
+
cwd: string,
|
|
5523
|
+
signal?: AbortSignal,
|
|
5524
|
+
): Promise<Record<string, unknown>> {
|
|
5525
|
+
const captured: Array<Record<string, unknown>> = [];
|
|
5526
|
+
for (const slot of slots) {
|
|
5527
|
+
let result: Awaited<ReturnType<ReviewHostRelayRunner>>;
|
|
5528
|
+
try {
|
|
5529
|
+
// A materialize slot without the provider-owned submission form is
|
|
5530
|
+
// a provider contract mismatch: fail closed before any launch and
|
|
5531
|
+
// never synthesize the completing form.
|
|
5532
|
+
if (slot.submission === undefined) {
|
|
5533
|
+
throw new ReviewHostRelayError(
|
|
5534
|
+
REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH,
|
|
5535
|
+
"binding",
|
|
5536
|
+
REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
|
|
5537
|
+
);
|
|
5538
|
+
}
|
|
5539
|
+
result = await activeReviewHostRelayRunner({
|
|
5540
|
+
captureArgumentTokens: slot.captureArgumentTokens,
|
|
5541
|
+
submission: slot.submission,
|
|
5542
|
+
...(signal === undefined ? {} : { signal }),
|
|
5543
|
+
});
|
|
5544
|
+
} catch (error) {
|
|
5545
|
+
if (!(error instanceof ReviewHostRelayError)) throw error;
|
|
5546
|
+
const base = {
|
|
5547
|
+
operation,
|
|
5548
|
+
status: "blocked",
|
|
5549
|
+
captured_slots: captured,
|
|
5550
|
+
mutation_performed: captured.length > 0,
|
|
5551
|
+
mutation_outcome: error.mutationOutcome === "unknown" ? "unknown" : captured.length > 0 ? "committed" : "none",
|
|
5552
|
+
};
|
|
5553
|
+
if (error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE) {
|
|
5554
|
+
return {
|
|
5555
|
+
...base,
|
|
5556
|
+
outcome: "pi-host-relay-unavailable",
|
|
5557
|
+
reason: REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
|
|
5558
|
+
next_action: "Install a gentle-ai release with the pi host relay surface; existing behavior stays untouched and there is no Pi-authored review document fallback.",
|
|
5559
|
+
};
|
|
5560
|
+
}
|
|
5561
|
+
if (error.kind === REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED) {
|
|
5562
|
+
return {
|
|
5563
|
+
...base,
|
|
5564
|
+
outcome: "pi-host-relay-handshake-refused",
|
|
5565
|
+
reason: error.message,
|
|
5566
|
+
refusal: error.stderr,
|
|
5567
|
+
next_action: REVIEW_HOST_RELAY_RETRY_ACTION,
|
|
5568
|
+
};
|
|
5569
|
+
}
|
|
5570
|
+
return {
|
|
5571
|
+
...base,
|
|
5572
|
+
outcome: "pi-host-relay-transport-failure",
|
|
5573
|
+
failure: { kind: error.kind, stage: error.stage, exit_code: error.exitCode, timed_out: error.timedOut },
|
|
5574
|
+
reason: error.message,
|
|
5575
|
+
next_action: REVIEW_HOST_RELAY_RETRY_ACTION,
|
|
5576
|
+
};
|
|
5577
|
+
}
|
|
5578
|
+
captured.push({
|
|
5579
|
+
...(slot.lens === undefined ? {} : { lens: slot.lens }),
|
|
5580
|
+
...(slot.order === undefined ? {} : { order: slot.order }),
|
|
5581
|
+
...(slot.subjectHash === undefined ? {} : { subject_hash: slot.subjectHash }),
|
|
5582
|
+
prompt_bytes: result.promptByteLength,
|
|
5583
|
+
result_bytes: result.resultByteLength,
|
|
5584
|
+
submission: result.submission,
|
|
5585
|
+
});
|
|
5586
|
+
}
|
|
5587
|
+
const after = await nativeReviewCli.targetStatus!({ cwd, lineageId, ...(signal === undefined ? {} : { signal }) });
|
|
5588
|
+
return {
|
|
5589
|
+
...mapNativeTargetStatus(operation, after, lineageId),
|
|
5590
|
+
host_relay: { transport: "pi_host_relay", captured_slots: captured },
|
|
5591
|
+
};
|
|
5592
|
+
}
|
|
5593
|
+
|
|
5594
|
+
// gentle-pi#311 P4-roles — the Go-owned adversarial role route. The provider
|
|
5595
|
+
// renders each non-lens role slot (`review.capture-refuter` /
|
|
5596
|
+
// `review.capture-validation`) as a SELF-CONTAINED authority-advancing
|
|
5597
|
+
// vector: binding tokens plus `--agent=pi --execute=true` and no submission
|
|
5598
|
+
// descriptor. Pi executes each exact vector once, verbatim and in the
|
|
5599
|
+
// foreground; Go materializes the role prompt, spawns its own locked-down pi
|
|
5600
|
+
// subprocess, and admits the raw verdict. On any failure the typed error is
|
|
5601
|
+
// surfaced and nothing is relaunched — the caller re-queries negotiated
|
|
5602
|
+
// STATUS and executes only a vector it reoffers.
|
|
5603
|
+
const REVIEW_PROVIDER_ROLE_RETRY_ACTION =
|
|
5604
|
+
"Re-query negotiated STATUS and execute only the exact role vector it reoffers; never relaunch from transcript inference.";
|
|
5605
|
+
|
|
5606
|
+
async function executeProviderRoleVectorCollection(
|
|
5607
|
+
operation: ReviewControllerOperation,
|
|
5608
|
+
lineageId: string,
|
|
5609
|
+
slots: readonly ReviewProviderRoleVectorSlot[],
|
|
5610
|
+
nativeReviewCli: NativeReviewCli,
|
|
5611
|
+
cwd: string,
|
|
5612
|
+
signal?: AbortSignal,
|
|
5613
|
+
): Promise<Record<string, unknown>> {
|
|
5614
|
+
if (nativeReviewCli.captureProviderRole === undefined) {
|
|
5615
|
+
return {
|
|
5616
|
+
operation,
|
|
5617
|
+
status: "blocked",
|
|
5618
|
+
outcome: "provider-role-capture-unsupported",
|
|
5619
|
+
reason: "The provider issued self-contained role capture vectors, but this runtime has no native provider-role capture surface.",
|
|
5620
|
+
mutation_performed: false,
|
|
5621
|
+
mutation_outcome: "none",
|
|
5622
|
+
next_action: "Install a gentle-pi release with the provider-role capture surface; the vectors stay reoffered by negotiated STATUS.",
|
|
5623
|
+
};
|
|
5624
|
+
}
|
|
5625
|
+
const executed: Array<Record<string, unknown>> = [];
|
|
5626
|
+
for (const slot of slots) {
|
|
5627
|
+
let artifact: Awaited<ReturnType<NonNullable<NativeReviewCli["captureProviderRole"]>>>;
|
|
5628
|
+
try {
|
|
5629
|
+
artifact = await nativeReviewCli.captureProviderRole({
|
|
5630
|
+
captureOperation: slot.captureOperation,
|
|
5631
|
+
argumentTokens: slot.argumentTokens,
|
|
5632
|
+
cwd,
|
|
5633
|
+
...(signal === undefined ? {} : { signal }),
|
|
5634
|
+
});
|
|
5635
|
+
} catch (error) {
|
|
5636
|
+
return {
|
|
5637
|
+
...nativeOperationFailure(operation, error),
|
|
5638
|
+
outcome: "provider-role-vector-failed",
|
|
5639
|
+
provider_roles: { transport: "go_owned_pi_process", executed_slots: executed },
|
|
5640
|
+
retry_discipline: REVIEW_PROVIDER_ROLE_RETRY_ACTION,
|
|
5641
|
+
};
|
|
5642
|
+
}
|
|
5643
|
+
executed.push({
|
|
5644
|
+
capture_operation: slot.captureOperation,
|
|
5645
|
+
role: artifact.role,
|
|
5646
|
+
lineage_id: artifact.lineageId,
|
|
5647
|
+
target_identity: artifact.targetIdentity,
|
|
5648
|
+
captured: artifact.captured,
|
|
5649
|
+
});
|
|
5650
|
+
}
|
|
5651
|
+
const after = await nativeReviewCli.targetStatus!({ cwd, lineageId, ...(signal === undefined ? {} : { signal }) });
|
|
5652
|
+
return {
|
|
5653
|
+
...mapNativeTargetStatus(operation, after, lineageId),
|
|
5654
|
+
provider_roles: { transport: "go_owned_pi_process", executed_slots: executed },
|
|
5655
|
+
};
|
|
5656
|
+
}
|
|
5657
|
+
|
|
5658
|
+
// The provider-named lenses still awaiting a reviewer result: one lens per
|
|
5659
|
+
// pending `review.capture-result` collect input, in provider order.
|
|
5660
|
+
function pendingReviewerLenses(status: ReviewStatusV3): readonly string[] {
|
|
5661
|
+
if (status.nextTransition?.kind !== "collect") return [];
|
|
5662
|
+
return [...new Set((status.nextTransition.collect?.inputs ?? [])
|
|
5663
|
+
.filter((input) => input.captureOperation === "review.capture-result")
|
|
5664
|
+
.map((input) => input.artifactSubject?.lens)
|
|
5665
|
+
.filter((lens): lens is NonNullable<typeof lens> => lens !== undefined))];
|
|
5666
|
+
}
|
|
5667
|
+
|
|
5668
|
+
// Live defect (2026-08-16, Engram #12461): a successor lineage created by
|
|
5669
|
+
// native `review recover` exists only in native authority — this controller
|
|
5670
|
+
// never saw its START, so direct reviewer dispatch refused with
|
|
5671
|
+
// current-binding-missing even though the controller itself had just decoded
|
|
5672
|
+
// the successor's authoritative STATUS. Mirror the START-time registration
|
|
5673
|
+
// from STATUS discovery: when an unknown-but-live lineage still collecting
|
|
5674
|
+
// reviewer results appears in a status this controller decoded, restore its
|
|
5675
|
+
// frozen projection from the native descriptor and bind the dispatch-facing
|
|
5676
|
+
// current candidate view with the provider-named pending lenses.
|
|
5677
|
+
//
|
|
5678
|
+
// Field report (2026-08-16, gentle-pi 402f9f77): hydration must run from
|
|
5679
|
+
// EVERY lane that decodes an authoritative status, not from the STATUS
|
|
5680
|
+
// operation alone — the reported flow was `finalize` (blocked on
|
|
5681
|
+
// review.capture-result) followed by a reviewer dispatch, which never passed
|
|
5682
|
+
// through STATUS. It also never fails its caller: STATUS and the blocked
|
|
5683
|
+
// FINALIZE envelope stay read-only, and the outcome is returned so the caller
|
|
5684
|
+
// can report it instead of swallowing it.
|
|
5685
|
+
// Field defect (2026-08-16, third report): the Pi host relay never ran for a
|
|
5686
|
+
// real lineage. Measured against the live 2.4.0-main provider on a faithful
|
|
5687
|
+
// reproduction — an agent-less `review status` returns a bare capture-result
|
|
5688
|
+
// collect input (lineage, expected-revision, target, repository-context, lens,
|
|
5689
|
+
// order, subject-hash), while the SAME status with `--agent pi` additionally
|
|
5690
|
+
// carries agent=pi, materialize=true and the provider submission. The adapter
|
|
5691
|
+
// never named its agent, so reviewHostRelaySlots() saw zero materialize slots,
|
|
5692
|
+
// the relay was unreachable, and no lens was ever launched.
|
|
5693
|
+
//
|
|
5694
|
+
// The agent is PROBED, never assumed. The pinned provider defines `--agent` as
|
|
5695
|
+
// of v2.4.0 — v2.2.3 did not define it on `review status` at all and refused it
|
|
5696
|
+
// outright — but Pi still never version-sniffs: the installed binary remains
|
|
5697
|
+
// the only authority on whether the flag exists. A typed refusal is remembered
|
|
5698
|
+
// per provider instance and the exact provider cause is reported to the user
|
|
5699
|
+
// rather than degraded into a generic candidate-view message.
|
|
5700
|
+
const REVIEW_HOST_AGENT = "pi" as const;
|
|
5701
|
+
const REVIEW_TRANSPORT_REFUSAL_CODES = new Set([
|
|
5702
|
+
"immutable_review_transport_unsupported",
|
|
5703
|
+
"unsupported_agent",
|
|
5704
|
+
"unknown_flag",
|
|
5705
|
+
]);
|
|
5706
|
+
interface ReviewTransportRefusal { supported: false; code: string; message: string; }
|
|
5707
|
+
const reviewTransportRefusalByProvider = new WeakMap<object, ReviewTransportRefusal>();
|
|
5708
|
+
|
|
5709
|
+
function clearReviewTransportProbeForTesting(nativeReviewCli: NativeReviewCli | null): void {
|
|
5710
|
+
if (nativeReviewCli !== null) reviewTransportRefusalByProvider.delete(nativeReviewCli as unknown as object);
|
|
5711
|
+
}
|
|
5712
|
+
|
|
5713
|
+
/**
|
|
5714
|
+
* Queries negotiated STATUS for the pi reviewer transport so the provider
|
|
5715
|
+
* offers its materialize-marked relay slot, probing the agent exactly once per
|
|
5716
|
+
* provider and falling back to the agent-less status on a typed refusal. The
|
|
5717
|
+
* refusal is returned, never swallowed.
|
|
5718
|
+
*/
|
|
5719
|
+
async function negotiatedStatusForHostTransport(
|
|
5720
|
+
nativeReviewCli: NativeReviewCli,
|
|
5721
|
+
request: NativeTargetStatusRequest,
|
|
5722
|
+
): Promise<{ status: ReviewStatusV3; transport?: ReviewTransportRefusal }> {
|
|
5723
|
+
const provider = nativeReviewCli as unknown as object;
|
|
5724
|
+
const remembered = reviewTransportRefusalByProvider.get(provider);
|
|
5725
|
+
if (remembered !== undefined) {
|
|
5726
|
+
return { status: await nativeReviewCli.targetStatus!(request), transport: remembered };
|
|
5727
|
+
}
|
|
5728
|
+
try {
|
|
5729
|
+
return { status: await nativeReviewCli.targetStatus!({ ...request, agent: REVIEW_HOST_AGENT }) };
|
|
5730
|
+
} catch (error) {
|
|
5731
|
+
const code = error instanceof NativeReviewIntegrationError ? error.failureEnvelope.code : undefined;
|
|
5732
|
+
// Only a transport-shaped refusal falls back; every other failure is
|
|
5733
|
+
// the caller's to handle exactly as before.
|
|
5734
|
+
if (code === undefined || !REVIEW_TRANSPORT_REFUSAL_CODES.has(code)) throw error;
|
|
5735
|
+
const refusal: ReviewTransportRefusal = { supported: false, code, message: error.message };
|
|
5736
|
+
reviewTransportRefusalByProvider.set(provider, refusal);
|
|
5737
|
+
return { status: await nativeReviewCli.targetStatus!(request), transport: refusal };
|
|
5738
|
+
}
|
|
5739
|
+
}
|
|
5740
|
+
|
|
5741
|
+
type DispatchHydrationOutcome =
|
|
5742
|
+
| { hydrated: true; lineage_id: string; lenses: readonly string[] }
|
|
5743
|
+
| { hydrated: false; lineage_id: string; reason: string; message: string }
|
|
5744
|
+
| undefined;
|
|
5745
|
+
|
|
5746
|
+
function hydrateDispatchBindingFromStatus(candidateViews: CandidateViewRegistry | null, contributorRoot: string, status: ReviewStatusV3): DispatchHydrationOutcome {
|
|
5747
|
+
if (candidateViews === null || candidateViews.hasCurrentBinding()) return undefined;
|
|
5748
|
+
const lineageId = status.authority?.lineageId;
|
|
5749
|
+
if (lineageId === undefined || status.applicability !== "current_target" || candidateViews.hasProjection(lineageId)) return undefined;
|
|
5750
|
+
const lenses = pendingReviewerLenses(status);
|
|
5751
|
+
if (lenses.length === 0) return undefined;
|
|
5752
|
+
try {
|
|
5753
|
+
candidateViews.restoreCurrentForDispatchFromNative(lineageId, contributorRoot, status.projection, lenses);
|
|
5754
|
+
return { hydrated: true, lineage_id: lineageId, lenses };
|
|
5755
|
+
} catch (error) {
|
|
5756
|
+
// Never fail the caller on hydration; the registry records the typed
|
|
5757
|
+
// cause so the later dispatch refusal names the attempt instead of
|
|
5758
|
+
// claiming no binding was ever available.
|
|
5759
|
+
return {
|
|
5760
|
+
hydrated: false,
|
|
5761
|
+
lineage_id: lineageId,
|
|
5762
|
+
reason: error instanceof CandidateViewError ? error.reason : "candidate-view-invalid",
|
|
5763
|
+
message: error instanceof Error ? error.message : String(error),
|
|
5764
|
+
};
|
|
5765
|
+
}
|
|
5766
|
+
}
|
|
5767
|
+
|
|
4934
5768
|
async function executeReviewControllerOperation(
|
|
4935
5769
|
parametersValue: unknown,
|
|
4936
5770
|
sessionCwd: string,
|
|
@@ -4943,6 +5777,9 @@ async function executeReviewControllerOperation(
|
|
|
4943
5777
|
context?: ExtensionContext,
|
|
4944
5778
|
correctionEvidenceByLineage: Map<string, CorrectionEvidence> = new Map(),
|
|
4945
5779
|
pendingReviewConsents: Map<string, PendingReviewConsent> = new Map(),
|
|
5780
|
+
writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
|
|
5781
|
+
reviewConsentNow: () => number = Date.now,
|
|
5782
|
+
reviewConsentScheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void } = setTimeout,
|
|
4946
5783
|
): Promise<Record<string, unknown>> {
|
|
4947
5784
|
const parameters = parseReviewControllerParameters(parametersValue);
|
|
4948
5785
|
const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd);
|
|
@@ -4995,6 +5832,20 @@ async function executeReviewControllerOperation(
|
|
|
4995
5832
|
}
|
|
4996
5833
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RECOVER) {
|
|
4997
5834
|
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
5835
|
+
// The authorization binding is Pi-derived, never caller-carried. It is
|
|
5836
|
+
// recorded verbatim as a maintainer attestation, so accepting one the
|
|
5837
|
+
// caller composed would let an unapproved actor sign the recovery edge.
|
|
5838
|
+
if (input.maintainerAuthorization !== undefined) {
|
|
5839
|
+
return {
|
|
5840
|
+
operation: parameters.operation,
|
|
5841
|
+
status: "blocked",
|
|
5842
|
+
outcome: "native-recovery-caller-authorization-rejected",
|
|
5843
|
+
native_operation: "review recover",
|
|
5844
|
+
mutation_performed: false,
|
|
5845
|
+
mutation_outcome: "none",
|
|
5846
|
+
next_action: "resubmit-without-maintainer-authorization",
|
|
5847
|
+
};
|
|
5848
|
+
}
|
|
4998
5849
|
const missing = NATIVE_RECOVERY_INPUT.recover.filter((key) =>
|
|
4999
5850
|
key === "disposition"
|
|
5000
5851
|
? !["scope_changed", "invalidated", "escalated"].includes(input[key] as string)
|
|
@@ -5002,27 +5853,76 @@ async function executeReviewControllerOperation(
|
|
|
5002
5853
|
);
|
|
5003
5854
|
if (missing.length > 0) return await executeNativeRecoveryRoute(parameters.operation, "recover", input, defaultCwd, nativeReviewCli, pendingAuthorizations, signal);
|
|
5004
5855
|
if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
|
|
5856
|
+
const frozenTarget = candidateViews?.hasProjection(String(input.predecessorLineage))
|
|
5857
|
+
? candidateViews.resolveProjection(String(input.predecessorLineage), defaultCwd)
|
|
5858
|
+
: undefined;
|
|
5859
|
+
const statusRequest = {
|
|
5860
|
+
cwd: defaultCwd,
|
|
5861
|
+
lineageId: String(input.predecessorLineage),
|
|
5862
|
+
...(frozenTarget?.committedOnly === true ? { baseRef: frozenTarget.baseCommit } : {}),
|
|
5863
|
+
...(signal === undefined ? {} : { signal }),
|
|
5864
|
+
};
|
|
5005
5865
|
let status: ReviewStatusV3;
|
|
5006
5866
|
try {
|
|
5007
|
-
|
|
5008
|
-
? candidateViews.resolveProjection(String(input.predecessorLineage), defaultCwd)
|
|
5009
|
-
: undefined;
|
|
5010
|
-
status = await nativeReviewCli.targetStatus({
|
|
5011
|
-
cwd: defaultCwd,
|
|
5012
|
-
lineageId: String(input.predecessorLineage),
|
|
5013
|
-
...(frozenTarget?.committedOnly === true ? { baseRef: frozenTarget.baseCommit } : {}),
|
|
5014
|
-
...(signal === undefined ? {} : { signal }),
|
|
5015
|
-
});
|
|
5867
|
+
status = await nativeReviewCli.targetStatus(statusRequest);
|
|
5016
5868
|
} catch (error) {
|
|
5017
5869
|
return nativeStatusFailed(parameters.operation, error);
|
|
5018
5870
|
}
|
|
5019
|
-
|
|
5871
|
+
const pinnedRecoveryStatus = (candidate: ReviewStatusV3): boolean =>
|
|
5872
|
+
candidate.action === "recover"
|
|
5873
|
+
&& candidate.actionDisposition === status.actionDisposition
|
|
5874
|
+
&& candidate.authority?.lineageId === input.predecessorLineage
|
|
5875
|
+
&& candidate.authority?.revision === input.expectedPredecessorRevision
|
|
5876
|
+
&& candidate.targetIdentity === status.targetIdentity;
|
|
5877
|
+
if (status.action !== "recover" || status.actionDisposition === undefined || status.authority?.lineageId !== input.predecessorLineage || status.authority.revision !== input.expectedPredecessorRevision || !isCanonicalProcessString(status.targetIdentity)) {
|
|
5020
5878
|
return { operation: parameters.operation, status: "blocked", outcome: "native-recovery-status-mismatch", mutation_performed: false, mutation_outcome: "none", result: status.raw, next_action: "follow-provider-target-status" };
|
|
5021
5879
|
}
|
|
5022
5880
|
if (input.disposition !== status.actionDisposition) {
|
|
5023
5881
|
return { operation: parameters.operation, status: "blocked", outcome: "native-recovery-disposition-mismatch", mutation_performed: false, mutation_outcome: "none", provider_disposition: status.actionDisposition, next_action: "resubmit-with-provider-disposition" };
|
|
5024
5882
|
}
|
|
5025
|
-
|
|
5883
|
+
const recoverAuthorization = nativeReviewRecoverAuthorization({
|
|
5884
|
+
predecessorLineage: String(input.predecessorLineage),
|
|
5885
|
+
expectedPredecessorRevision: String(input.expectedPredecessorRevision),
|
|
5886
|
+
targetIdentity: status.targetIdentity,
|
|
5887
|
+
actor: String(input.actor),
|
|
5888
|
+
reason: String(input.reason),
|
|
5889
|
+
});
|
|
5890
|
+
if (context?.hasUI !== true) throw new Error("Review controller RECOVER requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed");
|
|
5891
|
+
const approved = await context.ui.confirm(
|
|
5892
|
+
"Authorize destructive review authority RECOVER?",
|
|
5893
|
+
[
|
|
5894
|
+
"Operation: RECOVER",
|
|
5895
|
+
`Provider-selected disposition: ${status.actionDisposition}`,
|
|
5896
|
+
"Exact published authorization binding:",
|
|
5897
|
+
recoverAuthorization,
|
|
5898
|
+
`The native command creates one auditable successor authority (${String(input.successorLineage)}) for this exact predecessor and target identity; the predecessor stays untouched.`,
|
|
5899
|
+
].join("\n"),
|
|
5900
|
+
);
|
|
5901
|
+
if (!approved) throw new Error("Review controller RECOVER was not explicitly authorized");
|
|
5902
|
+
// Time-of-check to time-of-use: the human deliberates for an unbounded
|
|
5903
|
+
// interval, and the authority can advance, be recovered by someone else, or
|
|
5904
|
+
// stop being recovery-eligible while they do. The approval and the derived
|
|
5905
|
+
// binding are pinned to the pre-approval read, so the authority is read once
|
|
5906
|
+
// more and must still match it exactly before anything mutates.
|
|
5907
|
+
let confirmedStatus: ReviewStatusV3;
|
|
5908
|
+
try {
|
|
5909
|
+
confirmedStatus = await nativeReviewCli.targetStatus(statusRequest);
|
|
5910
|
+
} catch (error) {
|
|
5911
|
+
return nativeStatusFailed(parameters.operation, error);
|
|
5912
|
+
}
|
|
5913
|
+
if (!pinnedRecoveryStatus(confirmedStatus)) {
|
|
5914
|
+
return {
|
|
5915
|
+
operation: parameters.operation,
|
|
5916
|
+
status: "blocked",
|
|
5917
|
+
outcome: "native-recovery-authority-changed",
|
|
5918
|
+
native_operation: "review recover",
|
|
5919
|
+
mutation_performed: false,
|
|
5920
|
+
mutation_outcome: "none",
|
|
5921
|
+
result: confirmedStatus.raw,
|
|
5922
|
+
next_action: "reinspect-and-reauthorize-recovery",
|
|
5923
|
+
};
|
|
5924
|
+
}
|
|
5925
|
+
return await executeNativeRecoveryRoute(parameters.operation, "recover", { ...input, disposition: status.actionDisposition, maintainerAuthorization: recoverAuthorization }, defaultCwd, nativeReviewCli, pendingAuthorizations, signal);
|
|
5026
5926
|
}
|
|
5027
5927
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET) {
|
|
5028
5928
|
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
|
@@ -5030,7 +5930,12 @@ async function executeReviewControllerOperation(
|
|
|
5030
5930
|
}
|
|
5031
5931
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.REPAIR) {
|
|
5032
5932
|
if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
|
|
5033
|
-
|
|
5933
|
+
let status: ReviewStatusV3;
|
|
5934
|
+
try {
|
|
5935
|
+
status = await nativeReviewCli.targetStatus({ cwd: defaultCwd, ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), ...(signal === undefined ? {} : { signal }) });
|
|
5936
|
+
} catch (error) {
|
|
5937
|
+
return nativeStatusFailed(parameters.operation, error);
|
|
5938
|
+
}
|
|
5034
5939
|
if (status.authority?.version === "compact-v2") return { operation: parameters.operation, repaired: false, compact_authority: "immutable-untouched", status: mapNativeTargetStatus(parameters.operation, status, parameters.lineageId) };
|
|
5035
5940
|
if (status.authority?.version !== "legacy-v1") return mapNativeTargetStatus(parameters.operation, status, parameters.lineageId);
|
|
5036
5941
|
const store = ReviewTransactionStore.forRepository(defaultCwd);
|
|
@@ -5097,7 +6002,7 @@ async function executeReviewControllerOperation(
|
|
|
5097
6002
|
if (typeof input.consentBinding !== "string" || input.consentBinding.length === 0) throw new Error("Review controller answer-consent requires an opaque consentBinding");
|
|
5098
6003
|
if (input.answer !== "granted" && input.answer !== "declined") throw new Error("Review controller answer-consent answer must be granted or declined");
|
|
5099
6004
|
const pending = pendingReviewConsents.get(input.consentBinding);
|
|
5100
|
-
if (pending === undefined || pending.expiresAt <=
|
|
6005
|
+
if (pending === undefined || pending.expiresAt <= reviewConsentNow()) {
|
|
5101
6006
|
if (pending !== undefined) cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
|
|
5102
6007
|
throw new Error("Review controller consent binding is unknown, expired, or already consumed");
|
|
5103
6008
|
}
|
|
@@ -5117,6 +6022,7 @@ async function executeReviewControllerOperation(
|
|
|
5117
6022
|
// The one-shot binding is consumed before the provider mutation. Any
|
|
5118
6023
|
// ambiguous result reconciles through STATUS and can never be replayed.
|
|
5119
6024
|
consumePendingReviewConsent(pending, pendingReviewConsents);
|
|
6025
|
+
let completed: Record<string, unknown>;
|
|
5120
6026
|
try {
|
|
5121
6027
|
const answered = await nativeReviewCli.answerConsent({
|
|
5122
6028
|
cwd: pending.authorityCwd,
|
|
@@ -5134,7 +6040,7 @@ async function executeReviewControllerOperation(
|
|
|
5134
6040
|
...nativeStartPreAuthorityRejection(),
|
|
5135
6041
|
};
|
|
5136
6042
|
}
|
|
5137
|
-
|
|
6043
|
+
completed = completeNativeStart(parameters.operation, answered.start, pending.repositoryCwd, pending.candidateView, candidateViews);
|
|
5138
6044
|
} catch (error) {
|
|
5139
6045
|
const value = error as { mutationOutcome?: unknown };
|
|
5140
6046
|
if (value.mutationOutcome === "none") candidateViews?.cleanup(pending.candidateView.token);
|
|
@@ -5144,6 +6050,16 @@ async function executeReviewControllerOperation(
|
|
|
5144
6050
|
projection: "workspace",
|
|
5145
6051
|
});
|
|
5146
6052
|
}
|
|
6053
|
+
if (input.answer === "granted") {
|
|
6054
|
+
try {
|
|
6055
|
+
writeReviewConsentLatch(pending.repositoryCwd);
|
|
6056
|
+
} catch (error) {
|
|
6057
|
+
try {
|
|
6058
|
+
context?.ui.notify(`Native review start completed, but Pi could not record the local consent latch: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
6059
|
+
} catch { /* Reporting is best effort; native completion remains authoritative. */ }
|
|
6060
|
+
}
|
|
6061
|
+
}
|
|
6062
|
+
return completed;
|
|
5147
6063
|
}
|
|
5148
6064
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
|
|
5149
6065
|
const rawStart = parseControllerJson(
|
|
@@ -5151,16 +6067,11 @@ async function executeReviewControllerOperation(
|
|
|
5151
6067
|
REVIEW_CONTROLLER_OPERATION.START,
|
|
5152
6068
|
);
|
|
5153
6069
|
if (rawStart.mode === REVIEW_MODE.ORDINARY) {
|
|
5154
|
-
try {
|
|
5155
|
-
const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
|
|
5156
|
-
if (gated !== undefined) return gated;
|
|
5157
|
-
} catch (error) {
|
|
5158
|
-
return nativeOperationFailure(parameters.operation, error);
|
|
5159
|
-
}
|
|
5160
|
-
if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
|
|
5161
6070
|
if ("policyHash" in rawStart) return nativeStartRejection("legacy-policy-hash-unsupported");
|
|
5162
|
-
const unknownField = Object.keys(rawStart).find((field) => !["mode", "baseRef", "committedOnly", "policyPath"].includes(field));
|
|
6071
|
+
const unknownField = Object.keys(rawStart).find((field) => !["mode", "baseRef", "committedOnly", "policyPath", "focus"].includes(field));
|
|
5163
6072
|
if (unknownField !== undefined) return nativeStartRejection("unknown-field", unknownField);
|
|
6073
|
+
const focus = rawStart.focus;
|
|
6074
|
+
if (focus !== undefined && !isNativeStartFocus(focus)) return nativeStartRejection("focus-invalid");
|
|
5164
6075
|
const policy: NativeStartPolicyValidation = rawStart.policyPath === undefined
|
|
5165
6076
|
? {}
|
|
5166
6077
|
: validateNativeStartPolicyPath(defaultCwd, rawStart.policyPath);
|
|
@@ -5174,10 +6085,18 @@ async function executeReviewControllerOperation(
|
|
|
5174
6085
|
try {
|
|
5175
6086
|
canonicalBaseRef = resolveCanonicalCandidateBase(defaultCwd, baseRef).commit;
|
|
5176
6087
|
} catch (error) {
|
|
6088
|
+
if (error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true }));
|
|
5177
6089
|
if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason);
|
|
5178
6090
|
return nativeStartRejection("base-ref-unresolvable");
|
|
5179
6091
|
}
|
|
5180
6092
|
}
|
|
6093
|
+
try {
|
|
6094
|
+
const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
|
|
6095
|
+
if (gated !== undefined) return gated;
|
|
6096
|
+
} catch (error) {
|
|
6097
|
+
return nativeOperationFailure(parameters.operation, error);
|
|
6098
|
+
}
|
|
6099
|
+
if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
|
|
5181
6100
|
let target: ReviewStatusV3;
|
|
5182
6101
|
try {
|
|
5183
6102
|
target = await nativeReviewCli.targetStatus({
|
|
@@ -5191,6 +6110,12 @@ async function executeReviewControllerOperation(
|
|
|
5191
6110
|
return nativeOperationFailure(parameters.operation, error);
|
|
5192
6111
|
}
|
|
5193
6112
|
const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null });
|
|
6113
|
+
// Synchronously drop any binding whose TTL has already elapsed
|
|
6114
|
+
// before reusing its retained candidate view, so a fresh-candidate
|
|
6115
|
+
// retry cannot reuse a view tied to an expired binding and trip
|
|
6116
|
+
// candidate-target-projection-drift. Timer order must not decide
|
|
6117
|
+
// correctness: the queued cleanup macrotask may not have fired yet.
|
|
6118
|
+
pruneExpiredReviewConsents(pendingReviewConsents, candidateViews, reviewConsentNow);
|
|
5194
6119
|
let candidateView: ReturnType<CandidateViewRegistry["create"]> | undefined;
|
|
5195
6120
|
let nativeStartAttempted = false;
|
|
5196
6121
|
try {
|
|
@@ -5208,6 +6133,7 @@ async function executeReviewControllerOperation(
|
|
|
5208
6133
|
projection: target.projection.projection,
|
|
5209
6134
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5210
6135
|
...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
|
|
6136
|
+
...(focus === undefined ? {} : { focus }),
|
|
5211
6137
|
...(signal === undefined ? {} : { signal }),
|
|
5212
6138
|
});
|
|
5213
6139
|
} catch (error) {
|
|
@@ -5216,13 +6142,13 @@ async function executeReviewControllerOperation(
|
|
|
5216
6142
|
const consentCandidateView = candidateView;
|
|
5217
6143
|
const repositoryCwd = realpathSync(defaultCwd);
|
|
5218
6144
|
const consentDigest = reviewConsentDigest(error.consent);
|
|
5219
|
-
const existing = [...pendingReviewConsents.values()].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest && pending.expiresAt >
|
|
6145
|
+
const existing = [...pendingReviewConsents.values()].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest && pending.expiresAt > reviewConsentNow());
|
|
5220
6146
|
if (existing === undefined) for (const pending of [...pendingReviewConsents.values()]) if (pending.candidateView.token === consentCandidateView.token) consumePendingReviewConsent(pending, pendingReviewConsents);
|
|
5221
6147
|
const id = existing?.id ?? randomUUID();
|
|
5222
6148
|
if (existing === undefined) {
|
|
5223
|
-
const pending: PendingReviewConsent = { id, repositoryCwd, authorityCwd: defaultCwd, candidateView: consentCandidateView, consent: error.consent, consentDigest, expiresAt:
|
|
6149
|
+
const pending: PendingReviewConsent = { id, repositoryCwd, authorityCwd: defaultCwd, candidateView: consentCandidateView, consent: error.consent, consentDigest, expiresAt: reviewConsentNow() + PENDING_REVIEW_CONSENT_TTL_MS };
|
|
5224
6150
|
pendingReviewConsents.set(id, pending);
|
|
5225
|
-
pending.expiry =
|
|
6151
|
+
pending.expiry = reviewConsentScheduleTimer(() => cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews), PENDING_REVIEW_CONSENT_TTL_MS);
|
|
5226
6152
|
pending.expiry.unref();
|
|
5227
6153
|
}
|
|
5228
6154
|
return {
|
|
@@ -5236,6 +6162,7 @@ async function executeReviewControllerOperation(
|
|
|
5236
6162
|
}
|
|
5237
6163
|
return completeNativeStart(parameters.operation, result, defaultCwd, candidateView, candidateViews);
|
|
5238
6164
|
} catch (error) {
|
|
6165
|
+
if (error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true }));
|
|
5239
6166
|
if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason);
|
|
5240
6167
|
const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
|
|
5241
6168
|
const provenNoMutation = value.mutationOutcome === "none";
|
|
@@ -5328,22 +6255,180 @@ async function executeReviewControllerOperation(
|
|
|
5328
6255
|
let correctionCompletion = false;
|
|
5329
6256
|
let negotiatedStatus: ReviewStatusV3 | undefined;
|
|
5330
6257
|
let candidateView: ReturnType<CandidateViewRegistry["create"]> | undefined;
|
|
5331
|
-
let
|
|
6258
|
+
let provisionalCandidateView: ReturnType<CandidateViewRegistry["create"]> | undefined;
|
|
6259
|
+
let nativeResult: NativeFinalizeResult | undefined;
|
|
5332
6260
|
let correctionStep: CorrectionStep | undefined;
|
|
6261
|
+
let transportRefusal: ReviewTransportRefusal | undefined;
|
|
5333
6262
|
try {
|
|
5334
6263
|
if (parameters.lineageId === undefined) throw new CandidateViewError("Native FINALIZE requires an explicit lineage");
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
correctionCompletion = input.review_result === undefined && (input.validation !== undefined || input.validation_proof !== undefined) && input.final_evidence !== undefined;
|
|
5338
|
-
const validationAttempt = input.review_result === undefined && input.correction_line_forecast === undefined && input.final_evidence !== undefined;
|
|
5339
|
-
// A correction-forecast-only FINALIZE (the pre-edit forecast step in
|
|
5340
|
-
// correction_required) must also participate in fresh-process projection
|
|
5341
|
-
// reconstruction; before #176 it skipped restoration and failed against
|
|
5342
|
-
// an empty in-memory registry without ever invoking native FINALIZE.
|
|
5343
|
-
const correctionForecast = input.review_result === undefined && input.correction_line_forecast !== undefined;
|
|
6264
|
+
correctionCompletion = input.validation !== undefined && input.final_evidence !== undefined;
|
|
6265
|
+
const validationAttempt = input.correction_line_forecast === undefined && input.final_evidence !== undefined;
|
|
5344
6266
|
const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null });
|
|
5345
|
-
if (
|
|
5346
|
-
|
|
6267
|
+
if (candidateViews?.hasProjection(parameters.lineageId)) {
|
|
6268
|
+
candidateViews.resolveProjection(parameters.lineageId, defaultCwd);
|
|
6269
|
+
candidateView = correctionCompletion || validationAttempt
|
|
6270
|
+
? candidateViews.createCorrected(parameters.lineageId, defaultCwd, replayKey)
|
|
6271
|
+
: candidateViews.resolveForFinalize(parameters.lineageId);
|
|
6272
|
+
} else if (candidateViews) {
|
|
6273
|
+
provisionalCandidateView = candidateViews.createOrReuse({ contributorRoot: defaultCwd, replayKey: `${replayKey}:status-candidate` });
|
|
6274
|
+
candidateView = provisionalCandidateView;
|
|
6275
|
+
}
|
|
6276
|
+
candidateView?.verify();
|
|
6277
|
+
const statusCandidateRoot = candidateView?.root ?? defaultCwd;
|
|
6278
|
+
// Name the host's reviewer transport so the provider offers its
|
|
6279
|
+
// materialize-marked relay slot; a provider without that
|
|
6280
|
+
// transport answers the agent-less status and its typed refusal
|
|
6281
|
+
// travels with the result.
|
|
6282
|
+
const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: statusCandidateRoot, lineageId: parameters.lineageId, ...(signal === undefined ? {} : { signal }) });
|
|
6283
|
+
negotiatedStatus = negotiated.status;
|
|
6284
|
+
transportRefusal = negotiated.transport;
|
|
6285
|
+
if (negotiatedStatus.applicability !== "current_target" || negotiatedStatus.authority?.lineageId !== parameters.lineageId || (negotiatedStatus.action !== "finalize" && negotiatedStatus.action !== "reconcile_finalize")) {
|
|
6286
|
+
if (provisionalCandidateView && candidateViews) {
|
|
6287
|
+
candidateViews.cleanup(provisionalCandidateView.token);
|
|
6288
|
+
provisionalCandidateView = undefined;
|
|
6289
|
+
candidateView = undefined;
|
|
6290
|
+
}
|
|
6291
|
+
return mapNativeTargetStatus(parameters.operation, negotiatedStatus, parameters.lineageId);
|
|
6292
|
+
}
|
|
6293
|
+
if (provisionalCandidateView && candidateViews) {
|
|
6294
|
+
candidateViews.cleanup(provisionalCandidateView.token);
|
|
6295
|
+
provisionalCandidateView = undefined;
|
|
6296
|
+
candidateView = undefined;
|
|
6297
|
+
}
|
|
6298
|
+
// Live smoke root cause (2026-08-16, dev binary 2.4.0-main):
|
|
6299
|
+
// status/v5 mints the opaque --repository-context handle bound to
|
|
6300
|
+
// the STATUS query root, and every rendered payload embeds it. A
|
|
6301
|
+
// context minted from a frozen candidate-view root fails the live
|
|
6302
|
+
// emitter's committed-intent reconciliation on the next lifecycle
|
|
6303
|
+
// operation, so on v5 the lane rebinds its negotiated STATUS to
|
|
6304
|
+
// the workspace root before executing any rendered payload.
|
|
6305
|
+
// Pinned pre-v5 emitters keep the frozen-view status untouched.
|
|
6306
|
+
if (statusCandidateRoot !== defaultCwd && (negotiatedStatus.raw as { schema?: unknown }).schema === "gentle-ai.review-integration.status/v5") {
|
|
6307
|
+
const rebound = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: defaultCwd, lineageId: parameters.lineageId, ...(signal === undefined ? {} : { signal }) });
|
|
6308
|
+
const workspaceStatus = rebound.status;
|
|
6309
|
+
transportRefusal = rebound.transport;
|
|
6310
|
+
if (workspaceStatus.authority?.lineageId !== parameters.lineageId || workspaceStatus.authority.revision !== negotiatedStatus.authority.revision) {
|
|
6311
|
+
throw new CandidateViewError("workspace-root status no longer matches the negotiated lifecycle authority", "workspace-status-rebind-drift");
|
|
6312
|
+
}
|
|
6313
|
+
negotiatedStatus = workspaceStatus;
|
|
6314
|
+
}
|
|
6315
|
+
// gentle-pi#311 P4: pi-slot capture inputs route through the host
|
|
6316
|
+
// relay ONLY when the provider issued the --materialize token on
|
|
6317
|
+
// the collect input. Every other slot and lane stays untouched.
|
|
6318
|
+
const hostRelaySlots = negotiatedStatus.nextTransition?.kind === "collect"
|
|
6319
|
+
? reviewHostRelaySlots(negotiatedStatus.nextTransition.collect?.inputs ?? [])
|
|
6320
|
+
: [];
|
|
6321
|
+
if (hostRelaySlots.length > 0 && input.final_evidence === undefined && input.correction_line_forecast === undefined) {
|
|
6322
|
+
// One cost/side-effect forecast BEFORE launch, once per
|
|
6323
|
+
// FINALIZE and never per lens: each host-relay slot runs a
|
|
6324
|
+
// real locked-down `pi` reviewer subprocess against the
|
|
6325
|
+
// user's own model, so an unacknowledged finalize would spend
|
|
6326
|
+
// tokens as a silent side effect of what reads like
|
|
6327
|
+
// bookkeeping. Same acknowledgement shape the adapter already
|
|
6328
|
+
// uses for consequential inputs (committedOnly): the caller
|
|
6329
|
+
// states the cost it accepts, which keeps headless callers
|
|
6330
|
+
// working without an interactive prompt.
|
|
6331
|
+
if (input.reviewer_run_acknowledged !== true) {
|
|
6332
|
+
const forecastLenses = hostRelaySlots.map((slot, index) => slot.lens ?? `slot-${index}`);
|
|
6333
|
+
return {
|
|
6334
|
+
operation: parameters.operation,
|
|
6335
|
+
status: "blocked",
|
|
6336
|
+
outcome: "reviewer-model-run-forecast",
|
|
6337
|
+
reason: `Finalize is about to run ${hostRelaySlots.length} real reviewer model run${hostRelaySlots.length === 1 ? "" : "s"} through the pi host relay (${forecastLenses.join(", ")}), one locked-down pi subprocess per outstanding lens, in the foreground. This spends model tokens on your configured model and provider.`,
|
|
6338
|
+
cost_forecast: {
|
|
6339
|
+
transport: "pi_host_relay",
|
|
6340
|
+
model_runs: hostRelaySlots.length,
|
|
6341
|
+
lenses: forecastLenses,
|
|
6342
|
+
side_effects: [
|
|
6343
|
+
"one locked-down pi subprocess per lens, in an empty scratch directory with every discovery surface disabled",
|
|
6344
|
+
"each captured reviewer result is admitted natively into this lineage's authority",
|
|
6345
|
+
"no candidate file, index, or commit is modified",
|
|
6346
|
+
],
|
|
6347
|
+
model_selection: "user-owned: the relay never sets --model, --provider, or --profile",
|
|
6348
|
+
},
|
|
6349
|
+
mutation_performed: false,
|
|
6350
|
+
mutation_outcome: "none",
|
|
6351
|
+
next_action: "Re-run finalize with {\"reviewer_run_acknowledged\": true} to authorize exactly this reviewer work.",
|
|
6352
|
+
};
|
|
6353
|
+
}
|
|
6354
|
+
// The relay itself needs no candidate view (it consumes only
|
|
6355
|
+
// provider-issued tokens), but a session that dispatches a
|
|
6356
|
+
// reviewer by hand on this same lineage does. Hydrate here too
|
|
6357
|
+
// so both routes work; it is best-effort and never fails the
|
|
6358
|
+
// relay.
|
|
6359
|
+
const relayDispatchBinding = hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, negotiatedStatus);
|
|
6360
|
+
const relayResult = await executeReviewHostRelayCollection(parameters.operation, parameters.lineageId, hostRelaySlots, nativeReviewCli, defaultCwd, signal);
|
|
6361
|
+
return relayDispatchBinding === undefined ? relayResult : { ...relayResult, dispatch_binding: relayDispatchBinding };
|
|
6362
|
+
}
|
|
6363
|
+
// gentle-pi#311 P4-roles: the provider renders the non-lens
|
|
6364
|
+
// adversarial roles as self-contained --execute vectors; each is
|
|
6365
|
+
// run exactly as rendered and STATUS is re-queried. Nothing here
|
|
6366
|
+
// authors, parses, or transports role output.
|
|
6367
|
+
const roleVectorSlots = negotiatedStatus.nextTransition?.kind === "collect"
|
|
6368
|
+
? reviewProviderRoleVectorSlots(negotiatedStatus.nextTransition.collect?.inputs ?? [])
|
|
6369
|
+
: [];
|
|
6370
|
+
if (roleVectorSlots.length > 0 && input.final_evidence === undefined && input.correction_line_forecast === undefined && input.validation === undefined) {
|
|
6371
|
+
return await executeProviderRoleVectorCollection(parameters.operation, parameters.lineageId, roleVectorSlots, nativeReviewCli, defaultCwd, signal);
|
|
6372
|
+
}
|
|
6373
|
+
// Live defect (2026-08-16, Engram #12466): FINALIZE on a lineage
|
|
6374
|
+
// still at reviewer_results_required misrouted into the correction
|
|
6375
|
+
// evidence-first-ordering lane and failed. Route strictly from the
|
|
6376
|
+
// provider transition: a collect naming review.capture-result means
|
|
6377
|
+
// reviewer results are still outstanding — no correction-evidence
|
|
6378
|
+
// or targeted-validation lane is ever admissible here, so any
|
|
6379
|
+
// document-carrying FINALIZE stops with the provider-offered step.
|
|
6380
|
+
// A document-free FINALIZE stops too on the live status/v5 lane
|
|
6381
|
+
// (the same v5 keying as the workspace rebind above); the pinned
|
|
6382
|
+
// pre-v5 raw-finalize fallback keeps its native captured-results
|
|
6383
|
+
// discovery unchanged. Materialize-marked pi slots were already
|
|
6384
|
+
// routed through the host relay above.
|
|
6385
|
+
const reviewerResultsOutstanding = negotiatedStatus.nextTransition?.kind === "collect"
|
|
6386
|
+
&& (negotiatedStatus.nextTransition.collect?.inputs ?? []).some((collectInput) => collectInput.captureOperation === "review.capture-result");
|
|
6387
|
+
const finalizeDocumentsPresent = input.final_evidence !== undefined || input.validation !== undefined || input.correction_line_forecast !== undefined;
|
|
6388
|
+
if (reviewerResultsOutstanding && (finalizeDocumentsPresent || (negotiatedStatus.raw as { schema?: unknown }).schema === "gentle-ai.review-integration.status/v5")) {
|
|
6389
|
+
const outstandingReviewerLenses = pendingReviewerLenses(negotiatedStatus);
|
|
6390
|
+
if (candidateView !== undefined && candidateViews && (correctionCompletion || validationAttempt)) candidateViews.cleanup(candidateView.token);
|
|
6391
|
+
// Field report (2026-08-16): this lane is where the reported
|
|
6392
|
+
// flow actually learns reviewer results are outstanding, and
|
|
6393
|
+
// the reviewer dispatch follows it directly. Hydrate the
|
|
6394
|
+
// dispatch binding from the authoritative status just decoded
|
|
6395
|
+
// — against the workspace root, never a frozen view root —
|
|
6396
|
+
// and report the outcome either way.
|
|
6397
|
+
const dispatchBinding = hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, negotiatedStatus);
|
|
6398
|
+
return {
|
|
6399
|
+
operation: parameters.operation,
|
|
6400
|
+
status: "blocked",
|
|
6401
|
+
outcome: "reviewer-results-required",
|
|
6402
|
+
reason: transportRefusal === undefined
|
|
6403
|
+
? "Capture the reviewer result first; the provider offers review.capture-result. Correction evidence and targeted validation are never admissible while reviewer results are outstanding."
|
|
6404
|
+
: `Capture the reviewer result first; the provider offers review.capture-result. This provider does not admit the pi reviewer transport (${transportRefusal.code}), so it offers no host-relay slot: ${transportRefusal.message}`,
|
|
6405
|
+
...(outstandingReviewerLenses.length === 0 ? {} : { pending_lenses: outstandingReviewerLenses }),
|
|
6406
|
+
...(dispatchBinding === undefined ? {} : { dispatch_binding: dispatchBinding }),
|
|
6407
|
+
...(transportRefusal === undefined ? {} : { relay_transport: transportRefusal }),
|
|
6408
|
+
result: negotiatedStatus.raw,
|
|
6409
|
+
mutation_performed: false,
|
|
6410
|
+
mutation_outcome: "none",
|
|
6411
|
+
next_action: "review.capture-result",
|
|
6412
|
+
};
|
|
6413
|
+
}
|
|
6414
|
+
// Ordinary final verification (field defect, 2026-08-16): at
|
|
6415
|
+
// native state `validating` the provider collects one final
|
|
6416
|
+
// `review.capture-evidence` record and then offers exactly one
|
|
6417
|
+
// execute `review.finalize --captured-evidence` transition. This
|
|
6418
|
+
// lane is provider-owned end to end: it is not a correction
|
|
6419
|
+
// transaction, no targeted validation exists in it, and no
|
|
6420
|
+
// candidate view is materialized — the workspace root IS the
|
|
6421
|
+
// unchanged frozen candidate, native FINALIZE validates the live
|
|
6422
|
+
// snapshot itself, and the provider binds its repository-context
|
|
6423
|
+
// effects to the root the lifecycle runs from. The correction
|
|
6424
|
+
// lane keeps its pre-capture state `correction_required` and
|
|
6425
|
+
// stays byte-identical below.
|
|
6426
|
+
const ordinaryFinalVerification = validationAttempt &&
|
|
6427
|
+
negotiatedStatus.authority?.state === "validating" &&
|
|
6428
|
+
negotiatedStatus.validationRequest === undefined &&
|
|
6429
|
+
!(negotiatedStatus.nextTransition?.kind === "collect" && (negotiatedStatus.nextTransition.collect?.inputs ?? []).some(isTargetedValidationCollectInput));
|
|
6430
|
+
if (candidateViews && !ordinaryFinalVerification && !candidateViews.hasProjection(parameters.lineageId)) {
|
|
6431
|
+
const projection = negotiatedStatus.projection;
|
|
5347
6432
|
candidateView = validationAttempt
|
|
5348
6433
|
? (candidateViews.restoreProjectionFromNative(parameters.lineageId, defaultCwd, projection), undefined)
|
|
5349
6434
|
: candidateViews.restoreForFinalizeFromNative(parameters.lineageId, defaultCwd, projection);
|
|
@@ -5351,25 +6436,102 @@ async function executeReviewControllerOperation(
|
|
|
5351
6436
|
// Fail closed before any native mutation when the frozen projection
|
|
5352
6437
|
// belongs to a different worktree than the requested workspace (#169).
|
|
5353
6438
|
if (candidateViews && parameters.lineageId && candidateViews.hasProjection(parameters.lineageId)) candidateViews.resolveProjection(parameters.lineageId, defaultCwd);
|
|
5354
|
-
candidateView ??= candidateViews && parameters.lineageId ? (correctionCompletion || validationAttempt) ? candidateViews.createCorrected(parameters.lineageId, defaultCwd, replayKey) : candidateViews.resolveForFinalize(parameters.lineageId) : undefined;
|
|
5355
|
-
|
|
5356
|
-
|
|
6439
|
+
candidateView ??= candidateViews && parameters.lineageId && !ordinaryFinalVerification ? (correctionCompletion || validationAttempt) ? candidateViews.createCorrected(parameters.lineageId, defaultCwd, replayKey) : candidateViews.resolveForFinalize(parameters.lineageId) : undefined;
|
|
6440
|
+
// Field defect (Engram #12547): a FINALIZE that merely follows the
|
|
6441
|
+
// provider's own execute transition carries no documents, so
|
|
6442
|
+
// neither correctionCompletion nor validationAttempt holds and the
|
|
6443
|
+
// START-time reviewer view is resolved. After an admitted bounded
|
|
6444
|
+
// correction the candidate identity has legitimately moved, so
|
|
6445
|
+
// that view is compared against the corrected target the provider
|
|
6446
|
+
// itself authorized and every finalize fails as drift — no receipt
|
|
6447
|
+
// is ever minted, while a fresh process finalizes the same lineage
|
|
6448
|
+
// fine because it restores from the native descriptor. Re-derive
|
|
6449
|
+
// the binding from that same descriptor here instead of reading a
|
|
6450
|
+
// retired reviewer view as drift. It is not a relaxation: the
|
|
6451
|
+
// replacement is materialized from Git, must match the provider
|
|
6452
|
+
// descriptor exactly, and is asserted immediately below.
|
|
6453
|
+
if (
|
|
6454
|
+
candidateView !== undefined && candidateViews && parameters.lineageId && !ordinaryFinalVerification &&
|
|
6455
|
+
candidateView.candidateTree !== negotiatedStatus.projection.currentCandidateTree
|
|
6456
|
+
) {
|
|
6457
|
+
candidateView = candidateViews.rebindForFinalizeFromNative(parameters.lineageId, defaultCwd, negotiatedStatus.projection);
|
|
6458
|
+
}
|
|
6459
|
+
if (candidateView !== undefined) assertNativeFinalizeCandidateBinding(candidateView, negotiatedStatus);
|
|
6460
|
+
if (ordinaryFinalVerification && parameters.lineageId) {
|
|
6461
|
+
// A projection held by this process routes the top-of-try path
|
|
6462
|
+
// through createCorrected before the lane is known; that view
|
|
6463
|
+
// is unused here — the lifecycle runs from the workspace root.
|
|
6464
|
+
if (candidateView !== undefined && candidateViews) {
|
|
6465
|
+
candidateViews.cleanup(candidateView.token);
|
|
6466
|
+
candidateView = undefined;
|
|
6467
|
+
}
|
|
6468
|
+
if (nativeReviewCli.captureEvidence === undefined && nativeReviewCli.captureEvidenceSubmission === undefined) throw new CandidateViewError("native final verification evidence capture is unavailable", "final-verification-provider-owned");
|
|
5357
6469
|
const outcome = correctionOutcome(input);
|
|
5358
|
-
if (outcome === undefined || negotiatedStatus.authority === undefined) throw new CandidateViewError("native
|
|
5359
|
-
|
|
5360
|
-
|
|
6470
|
+
if (outcome === undefined || negotiatedStatus.authority === undefined) throw new CandidateViewError("native final verification evidence requires one authoritative outcome-bound status", "final-verification-provider-owned");
|
|
6471
|
+
if (input.validation !== undefined) {
|
|
6472
|
+
throw new CandidateViewError("native final verification is provider-owned; a targeted validation document is not admissible at state validating", "final-verification-provider-owned");
|
|
6473
|
+
}
|
|
6474
|
+
const evidenceSlot = requireEvidenceCollection(negotiatedStatus);
|
|
6475
|
+
const evidenceBinding = resolveEvidenceCaptureBinding(evidenceSlot, negotiatedStatus, parameters.lineageId, outcome);
|
|
6476
|
+
const captured = await captureEvidenceForCollection(nativeReviewCli, evidenceBinding, defaultCwd, parameters.lineageId, outcome, input.final_evidence!, signal);
|
|
6477
|
+
if (
|
|
6478
|
+
captured.lineageId !== parameters.lineageId || captured.authorityRevision !== evidenceBinding.expectedRevision ||
|
|
6479
|
+
captured.targetIdentity !== evidenceBinding.targetIdentity || captured.candidateTree !== negotiatedStatus.projection.currentCandidateTree ||
|
|
6480
|
+
evidencePathsDrift(captured, evidenceBinding, negotiatedStatus) ||
|
|
6481
|
+
captured.outcome !== outcome
|
|
6482
|
+
) {
|
|
6483
|
+
throw new CandidateViewError("captured final verification evidence does not match the requested lineage, target, and outcome", "correction-evidence-binding-drift");
|
|
6484
|
+
}
|
|
6485
|
+
// Follow the provider transition faithfully: re-query
|
|
6486
|
+
// negotiated STATUS and execute only the exact rendered
|
|
6487
|
+
// `review.finalize` transition it offers for the captured
|
|
6488
|
+
// evidence. Never demand targeted validation here and never
|
|
6489
|
+
// substitute the validate gate.
|
|
6490
|
+
const afterEvidence = await nativeReviewCli.targetStatus({ cwd: defaultCwd, lineageId: parameters.lineageId, ...(signal === undefined ? {} : { signal }) });
|
|
6491
|
+
if (afterEvidence.authority?.lineageId !== parameters.lineageId) throw new CandidateViewError("post-evidence status lost the final-verification lineage", "correction-evidence-binding-drift");
|
|
6492
|
+
if (afterEvidence.validationRequest !== undefined || (afterEvidence.nextTransition?.kind === "collect" && (afterEvidence.nextTransition.collect?.inputs ?? []).some(isTargetedValidationCollectInput))) {
|
|
6493
|
+
throw new CandidateViewError("final verification evidence unexpectedly unlocked targeted validation", "final-verification-provider-owned");
|
|
6494
|
+
}
|
|
6495
|
+
const evidenceTransition = afterEvidence.nextTransition?.kind === "execute" && afterEvidence.nextTransition.execute?.operation === "review.finalize"
|
|
6496
|
+
? afterEvidence.nextTransition.execute
|
|
6497
|
+
: undefined;
|
|
6498
|
+
if (evidenceTransition === undefined || nativeReviewCli.finalizeTransition === undefined) {
|
|
6499
|
+
// Fail closed on an unrecognized transition: report the
|
|
6500
|
+
// committed capture and the provider's own status verbatim.
|
|
6501
|
+
return {
|
|
6502
|
+
...mapNativeTargetStatus(parameters.operation, afterEvidence, parameters.lineageId),
|
|
6503
|
+
outcome: "final-verification-transition-unavailable",
|
|
6504
|
+
verification_evidence: { outcome: captured.outcome, record_digest: captured.recordDigest },
|
|
6505
|
+
mutation_performed: true,
|
|
6506
|
+
mutation_outcome: "committed",
|
|
6507
|
+
};
|
|
6508
|
+
}
|
|
6509
|
+
if (evidenceTransition.binding.lineageId !== undefined && evidenceTransition.binding.lineageId !== parameters.lineageId) {
|
|
6510
|
+
throw new CandidateViewError("provider finalize transition is bound to a different lineage", "finalize-transition-binding-drift");
|
|
6511
|
+
}
|
|
6512
|
+
nativeResult = await nativeReviewCli.finalizeTransition({
|
|
5361
6513
|
cwd: defaultCwd,
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
evidenceDocument: input.final_evidence!,
|
|
6514
|
+
// The exact rendered tokens, verbatim and in provider
|
|
6515
|
+
// order; the hyphenated fallback mirrors the provider's
|
|
6516
|
+
// published rendering rule for older payloads.
|
|
6517
|
+
argumentTokens: evidenceTransition.arguments.map((argument) => argument.token ?? `--${argument.name.replaceAll("_", "-")}=${argument.value}`),
|
|
5367
6518
|
...(signal === undefined ? {} : { signal }),
|
|
5368
6519
|
});
|
|
6520
|
+
if (nativeResult.lineageId !== parameters.lineageId) {
|
|
6521
|
+
throw new CandidateViewError("provider finalize transition answered for a different lineage", "finalize-transition-binding-drift");
|
|
6522
|
+
}
|
|
6523
|
+
}
|
|
6524
|
+
if (validationAttempt && !ordinaryFinalVerification && candidateView && parameters.lineageId) {
|
|
6525
|
+
if (nativeReviewCli.captureEvidence === undefined && nativeReviewCli.captureEvidenceSubmission === undefined) throw new CandidateViewError("native correction evidence capture is unavailable", "evidence-first-ordering");
|
|
6526
|
+
const outcome = correctionOutcome(input);
|
|
6527
|
+
if (outcome === undefined || negotiatedStatus.authority === undefined) throw new CandidateViewError("native correction evidence requires one authoritative outcome-bound status", "evidence-first-ordering");
|
|
6528
|
+
const evidenceSlot = requireEvidenceCollection(negotiatedStatus);
|
|
6529
|
+
const evidenceBinding = resolveEvidenceCaptureBinding(evidenceSlot, negotiatedStatus, parameters.lineageId, outcome);
|
|
6530
|
+
const captured = await captureEvidenceForCollection(nativeReviewCli, evidenceBinding, candidateView.root, parameters.lineageId, outcome, input.final_evidence!, signal);
|
|
5369
6531
|
if (
|
|
5370
|
-
captured.lineageId !== parameters.lineageId || captured.authorityRevision !==
|
|
5371
|
-
captured.targetIdentity !==
|
|
5372
|
-
|
|
6532
|
+
captured.lineageId !== parameters.lineageId || captured.authorityRevision !== evidenceBinding.expectedRevision ||
|
|
6533
|
+
captured.targetIdentity !== evidenceBinding.targetIdentity || captured.candidateTree !== negotiatedStatus.projection.currentCandidateTree || captured.candidateTree !== candidateView.candidateTree ||
|
|
6534
|
+
evidencePathsDrift(captured, evidenceBinding, negotiatedStatus) ||
|
|
5373
6535
|
captured.outcome !== outcome
|
|
5374
6536
|
) {
|
|
5375
6537
|
throw new CandidateViewError("captured correction evidence does not match the requested lineage, target, and outcome", "correction-evidence-binding-drift");
|
|
@@ -5391,11 +6553,13 @@ async function executeReviewControllerOperation(
|
|
|
5391
6553
|
}
|
|
5392
6554
|
correctionStep = resolveCorrectionStep({
|
|
5393
6555
|
lineageId: parameters.lineageId,
|
|
5394
|
-
targetIdentity:
|
|
5395
|
-
authorityRevision:
|
|
6556
|
+
targetIdentity: evidenceBinding.targetIdentity,
|
|
6557
|
+
authorityRevision: evidenceBinding.expectedRevision,
|
|
5396
6558
|
correctionBudget: negotiatedStatus.frozen?.correctionBudget ?? 0,
|
|
5397
6559
|
changedLinesCharged: 0,
|
|
5398
6560
|
}, evidence);
|
|
6561
|
+
// Workspace-bound like the pre-capture rebind above: rendered
|
|
6562
|
+
// validation payloads embed the context this status mints.
|
|
5399
6563
|
const afterEvidence = await nativeReviewCli.targetStatus({ cwd: defaultCwd, lineageId: parameters.lineageId, ...(signal === undefined ? {} : { signal }) });
|
|
5400
6564
|
if (afterEvidence.authority?.lineageId !== parameters.lineageId) throw new CandidateViewError("post-evidence status lost the correction lineage", "correction-evidence-binding-drift");
|
|
5401
6565
|
if (correctionStep.kind === "recapture-required") {
|
|
@@ -5416,55 +6580,143 @@ async function executeReviewControllerOperation(
|
|
|
5416
6580
|
const validationRequest = requireTargetedValidationAfterEvidence(afterEvidence);
|
|
5417
6581
|
correctionEvidenceByLineage.delete(parameters.lineageId);
|
|
5418
6582
|
negotiatedStatus = afterEvidence;
|
|
6583
|
+
// gentle-pi#311 P4-roles: when the provider offers targeted
|
|
6584
|
+
// validation as a self-contained capture-validation vector, the
|
|
6585
|
+
// verdict is Go-owned — a Pi-authored validation document is not
|
|
6586
|
+
// admissible for it, and the next FINALIZE call executes the
|
|
6587
|
+
// vector exactly as rendered.
|
|
6588
|
+
const validationVectors = afterEvidence.nextTransition?.kind === "collect"
|
|
6589
|
+
? reviewProviderRoleVectorSlots(afterEvidence.nextTransition.collect?.inputs ?? [])
|
|
6590
|
+
: [];
|
|
6591
|
+
if (validationVectors.length > 0) {
|
|
6592
|
+
candidateViews.cleanup(candidateView.token);
|
|
6593
|
+
if (input.validation !== undefined) {
|
|
6594
|
+
return {
|
|
6595
|
+
operation: parameters.operation,
|
|
6596
|
+
status: "blocked",
|
|
6597
|
+
outcome: "targeted-validation-is-provider-owned",
|
|
6598
|
+
reason: "The provider rendered targeted validation as a self-contained review.capture-validation vector: Go materializes the validator prompt and runs its own locked-down pi process, so a Pi-authored validation document is not admissible.",
|
|
6599
|
+
correction_step: correctionStep,
|
|
6600
|
+
mutation_performed: true,
|
|
6601
|
+
mutation_outcome: "committed",
|
|
6602
|
+
next_action: "Re-run finalize without validation; the provider-rendered vector executes verbatim and STATUS is re-queried.",
|
|
6603
|
+
};
|
|
6604
|
+
}
|
|
6605
|
+
return { ...mapNativeTargetStatus(parameters.operation, afterEvidence, parameters.lineageId), correction_step: correctionStep };
|
|
6606
|
+
}
|
|
5419
6607
|
if (input.validation === undefined) {
|
|
5420
6608
|
candidateViews.cleanup(candidateView.token);
|
|
5421
6609
|
return { ...mapNativeTargetStatus(parameters.operation, afterEvidence, parameters.lineageId), correction_step: correctionStep };
|
|
5422
6610
|
}
|
|
5423
|
-
if (input.validation_proof !== undefined) throw new Error("Negotiated FINALIZE requires the native targeted validation document");
|
|
5424
6611
|
if (input.validation.request_hash !== validationRequest.requestHash.replace(/^sha256:/, "") || JSON.stringify([...input.validation.correction_ids].sort()) !== JSON.stringify([...validationRequest.fixFindingIds].sort())) {
|
|
5425
6612
|
throw new CandidateViewError("targeted validation document does not match the provider request", "targeted-validation-binding-drift");
|
|
5426
6613
|
}
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
6614
|
+
const validationSubmission = finalizeSubmissionSlot(negotiatedStatus, "validation");
|
|
6615
|
+
if (validationSubmission !== undefined) {
|
|
6616
|
+
if (nativeReviewCli.finalizeSubmission === undefined) throw new CandidateViewError("native finalize submission execution is unavailable", "finalize-transition-binding-drift");
|
|
6617
|
+
nativeResult = await nativeReviewCli.finalizeSubmission({
|
|
6618
|
+
// The rendered tokens are self-contained (the opaque
|
|
6619
|
+
// --repository-context binds the repository); the process
|
|
6620
|
+
// runs from the authority workspace root — a frozen
|
|
6621
|
+
// candidate-view root is a different toplevel of the same
|
|
6622
|
+
// store and fails the live emitter's effect binding.
|
|
5436
6623
|
cwd: defaultCwd,
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
6624
|
+
argumentTokens: validationSubmission.argumentTokens,
|
|
6625
|
+
valueSubstitutionLocation: validationSubmission.value.substitutionLocation,
|
|
6626
|
+
// The rendered submission consumes the raw validator
|
|
6627
|
+
// artifact, so the request binding rides inside it.
|
|
6628
|
+
valueDocument: JSON.stringify({
|
|
6629
|
+
targeted_validation_request_hash: validationRequest.requestHash,
|
|
6630
|
+
correction_target_identity: validationRequest.correctionTargetIdentity,
|
|
6631
|
+
...toNativeValidatorDocument(input.validation),
|
|
6632
|
+
}),
|
|
6633
|
+
...(signal === undefined ? {} : { signal }),
|
|
6634
|
+
});
|
|
6635
|
+
if (nativeResult.lineageId !== parameters.lineageId) {
|
|
6636
|
+
throw new CandidateViewError("provider finalize submission answered for a different lineage", "finalize-transition-binding-drift");
|
|
6637
|
+
}
|
|
5440
6638
|
}
|
|
5441
|
-
|
|
5442
|
-
|
|
6639
|
+
}
|
|
6640
|
+
// gentle-pi#311 P5: when the provider's negotiated transition IS a
|
|
6641
|
+
// review.finalize execution (captured_results_ready), run it exactly
|
|
6642
|
+
// as rendered — the provider discovers its own admitted lens and
|
|
6643
|
+
// role slots. Pi never assembles reviewer, refuter, or validator
|
|
6644
|
+
// documents for this lane; the remaining document lanes below
|
|
6645
|
+
// (correction forecast, targeted validation, final evidence) are the
|
|
6646
|
+
// negotiated collection answers the pinned provider still consumes
|
|
6647
|
+
// through --correction-lines/--validation/--evidence.
|
|
6648
|
+
const finalizeTransition = negotiatedStatus.nextTransition?.kind === "execute" && negotiatedStatus.nextTransition.execute?.operation === "review.finalize"
|
|
6649
|
+
? negotiatedStatus.nextTransition.execute
|
|
6650
|
+
: undefined;
|
|
6651
|
+
if (
|
|
6652
|
+
nativeResult === undefined &&
|
|
6653
|
+
finalizeTransition !== undefined && nativeReviewCli.finalizeTransition !== undefined &&
|
|
6654
|
+
input.validation === undefined && input.final_evidence === undefined && input.correction_line_forecast === undefined
|
|
6655
|
+
) {
|
|
6656
|
+
if (finalizeTransition.binding.lineageId !== undefined && finalizeTransition.binding.lineageId !== parameters.lineageId) {
|
|
6657
|
+
throw new CandidateViewError("provider finalize transition is bound to a different lineage", "finalize-transition-binding-drift");
|
|
5443
6658
|
}
|
|
5444
|
-
|
|
5445
|
-
|
|
6659
|
+
nativeResult = await nativeReviewCli.finalizeTransition({
|
|
6660
|
+
// The provider binds this transition's effects to the root
|
|
6661
|
+
// the lifecycle runs from (live smoke, 2026-08-16): running
|
|
6662
|
+
// the rendered vector from a frozen candidate-view root
|
|
6663
|
+
// records a mismatched repository binding that fails every
|
|
6664
|
+
// later committed-intent reconciliation. The workspace root
|
|
6665
|
+
// IS the frozen candidate here; run from it.
|
|
6666
|
+
cwd: defaultCwd,
|
|
6667
|
+
// The exact rendered tokens, verbatim and in provider order.
|
|
6668
|
+
// The provider tokenizes each argument itself; the hyphenated
|
|
6669
|
+
// fallback mirrors its published rendering rule for older
|
|
6670
|
+
// payloads that omit the token field.
|
|
6671
|
+
argumentTokens: finalizeTransition.arguments.map((argument) => argument.token ?? `--${argument.name.replaceAll("_", "-")}=${argument.value}`),
|
|
6672
|
+
...(signal === undefined ? {} : { signal }),
|
|
6673
|
+
});
|
|
6674
|
+
if (parameters.lineageId !== undefined && nativeResult.lineageId !== parameters.lineageId) {
|
|
6675
|
+
throw new CandidateViewError("provider finalize transition answered for a different lineage", "finalize-transition-binding-drift");
|
|
5446
6676
|
}
|
|
5447
|
-
|
|
5448
|
-
|
|
6677
|
+
} else if (nativeResult === undefined) {
|
|
6678
|
+
const planSubmission = input.correction_line_forecast === undefined ? undefined : finalizeSubmissionSlot(negotiatedStatus, "correction_lines");
|
|
6679
|
+
if (planSubmission !== undefined) {
|
|
6680
|
+
if (nativeReviewCli.finalizeSubmission === undefined) throw new CandidateViewError("native finalize submission execution is unavailable", "finalize-transition-binding-drift");
|
|
6681
|
+
const forecast = input.correction_line_forecast!;
|
|
6682
|
+
if ((planSubmission.value.minimum !== undefined && forecast < planSubmission.value.minimum) || (planSubmission.value.maximum !== undefined && forecast > planSubmission.value.maximum)) {
|
|
6683
|
+
throw new CandidateViewError(`correction line forecast ${forecast} is outside the provider-rendered bounds`, "correction-forecast-out-of-bounds");
|
|
6684
|
+
}
|
|
6685
|
+
nativeResult = await nativeReviewCli.finalizeSubmission({
|
|
6686
|
+
// Self-contained rendered tokens; run from the authority
|
|
6687
|
+
// workspace root, never a frozen candidate-view root.
|
|
6688
|
+
cwd: defaultCwd,
|
|
6689
|
+
argumentTokens: planSubmission.argumentTokens,
|
|
6690
|
+
valueSubstitutionLocation: planSubmission.value.substitutionLocation,
|
|
6691
|
+
valueLiteral: String(forecast),
|
|
6692
|
+
...(signal === undefined ? {} : { signal }),
|
|
6693
|
+
});
|
|
6694
|
+
if (parameters.lineageId !== undefined && nativeResult.lineageId !== parameters.lineageId) {
|
|
6695
|
+
throw new CandidateViewError("provider finalize submission answered for a different lineage", "finalize-transition-binding-drift");
|
|
6696
|
+
}
|
|
6697
|
+
} else {
|
|
6698
|
+
nativeResult = await nativeReviewCli.finalize({
|
|
6699
|
+
cwd: candidateView?.root ?? defaultCwd,
|
|
6700
|
+
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
6701
|
+
...(input.correction_line_forecast === undefined ? {} : { correctionLines: input.correction_line_forecast }),
|
|
6702
|
+
...(input.validation === undefined ? {} : { validationDocument: toNativeValidatorDocument(input.validation) }),
|
|
6703
|
+
...(input.final_evidence === undefined ? {} : { evidenceDocument: input.final_evidence, failed: input.final_verification_passed === false }),
|
|
6704
|
+
...(signal === undefined ? {} : { signal }),
|
|
6705
|
+
});
|
|
5449
6706
|
}
|
|
5450
6707
|
}
|
|
5451
|
-
nativeResult = await nativeReviewCli.finalize({
|
|
5452
|
-
cwd: candidateView?.root ?? defaultCwd,
|
|
5453
|
-
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5454
|
-
...(input.review_result === undefined ? {} : { lensResults: input.review_result.lens_results.map((document, index) => ({ lens: document.lens ?? `lens-${index}`, document: toNativeReviewerDocument(document) })) }),
|
|
5455
|
-
...(input.refuter_batch === undefined ? {} : { refuterDocument: toNativeRefuterDocument(input.refuter_batch) }),
|
|
5456
|
-
...(input.correction_line_forecast === undefined ? {} : { correctionLines: input.correction_line_forecast }),
|
|
5457
|
-
...(input.validation === undefined && input.validation_proof === undefined ? {} : { validationDocument: toNativeValidatorDocument(input.validation ?? input.validation_proof!) }),
|
|
5458
|
-
...(input.final_evidence === undefined ? {} : { evidenceDocument: input.final_evidence, failed: input.final_verification_passed === false }),
|
|
5459
|
-
...(signal === undefined ? {} : { signal }),
|
|
5460
|
-
});
|
|
5461
6708
|
} catch (error) {
|
|
6709
|
+
if (provisionalCandidateView && candidateViews) {
|
|
6710
|
+
candidateViews.cleanup(provisionalCandidateView.token);
|
|
6711
|
+
provisionalCandidateView = undefined;
|
|
6712
|
+
candidateView = undefined;
|
|
6713
|
+
}
|
|
5462
6714
|
if (correctionCompletion && candidateView && candidateViews && !nativeMutationRequiresStatus(error)) candidateViews.cleanup(candidateView.token);
|
|
5463
6715
|
return reconcileNativeMutationFailure(parameters.operation, error, nativeReviewCli, {
|
|
5464
6716
|
cwd: candidateView?.root ?? defaultCwd,
|
|
5465
6717
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5466
6718
|
projection: "workspace",
|
|
5467
|
-
});
|
|
6719
|
+
}, negotiatedStatus?.authority?.revision);
|
|
5468
6720
|
}
|
|
5469
6721
|
try {
|
|
5470
6722
|
if (correctionCompletion && candidateViews && parameters.lineageId) candidateViews.promoteCorrected(parameters.lineageId, candidateView!.token);
|
|
@@ -5534,6 +6786,7 @@ async function executeReviewControllerOperation(
|
|
|
5534
6786
|
...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
|
|
5535
6787
|
...(signal === undefined ? {} : { signal }),
|
|
5536
6788
|
});
|
|
6789
|
+
hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, status);
|
|
5537
6790
|
return mapNativeTargetStatus(parameters.operation, status, parameters.lineageId);
|
|
5538
6791
|
} catch (error) {
|
|
5539
6792
|
return nativeOperationFailure(parameters.operation, error);
|
|
@@ -5940,14 +7193,25 @@ export const __testing = {
|
|
|
5940
7193
|
gateLifecycleCommand,
|
|
5941
7194
|
nativeStatusUnsupported,
|
|
5942
7195
|
executeReviewControllerOperation,
|
|
7196
|
+
setReviewHostRelayRunnerForTesting,
|
|
7197
|
+
clearReviewTransportProbeForTesting,
|
|
5943
7198
|
enforceReviewGateAndCommandSafety,
|
|
5944
7199
|
renderSddModelPanel: renderSddModelPanelForTesting,
|
|
5945
7200
|
getOrchestratorPrompt,
|
|
5946
7201
|
renderOrchestratorPrompt,
|
|
7202
|
+
loadBackgroundSubagentsPolicy,
|
|
7203
|
+
resolveBackgroundSubagentsPolicy,
|
|
7204
|
+
renderBackgroundSubagentsReport,
|
|
7205
|
+
writeGlobalBackgroundSubagentsPolicy,
|
|
7206
|
+
parseBackgroundSubagentsPolicyFile,
|
|
7207
|
+
resolveBackgroundSubagentsCapability,
|
|
7208
|
+
readActiveToolNames,
|
|
7209
|
+
renderBackgroundSubagentsStatusLine,
|
|
5947
7210
|
resolveControllerSddStatus,
|
|
5948
7211
|
resolveStartupControllerSddStatus,
|
|
5949
7212
|
repositoryLocationIdentity,
|
|
5950
7213
|
runPublicationProbeGit,
|
|
7214
|
+
createGentleAiExtension: createGentleAiExtensionForTesting,
|
|
5951
7215
|
publicationProbeErrorCode: PUBLICATION_PROBE_ERROR_CODE,
|
|
5952
7216
|
};
|
|
5953
7217
|
|
|
@@ -6001,13 +7265,28 @@ export interface GentleAiRuntimeDependencies {
|
|
|
6001
7265
|
publicationProbe?: PublicationProbe;
|
|
6002
7266
|
publicationProbeTimeoutMs?: number;
|
|
6003
7267
|
bashTimeRevalidationTimeoutMs?: number;
|
|
7268
|
+
// Deterministic test seam for the consent-binding TTL clock. Production
|
|
7269
|
+
// leaves both undefined so the consent path observes real wall-clock time;
|
|
7270
|
+
// tests inject a fake clock so expiry is observable without a 10-minute
|
|
7271
|
+
// sleep and without relying on the queued cleanup macrotask firing.
|
|
7272
|
+
now?: () => number;
|
|
7273
|
+
scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
|
|
6004
7274
|
}
|
|
6005
7275
|
|
|
6006
7276
|
export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void {
|
|
7277
|
+
return createGentleAiExtensionForTesting(dependencies);
|
|
7278
|
+
}
|
|
7279
|
+
|
|
7280
|
+
function createGentleAiExtensionForTesting(
|
|
7281
|
+
dependencies: GentleAiRuntimeDependencies = {},
|
|
7282
|
+
writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
|
|
7283
|
+
): (pi: ExtensionAPI) => void {
|
|
6007
7284
|
const nativeReviewCli = dependencies.nativeReviewCli === undefined ? createNativeReviewCli() : dependencies.nativeReviewCli;
|
|
6008
7285
|
const publicationProbe = dependencies.publicationProbe ?? nodePublicationProbe;
|
|
6009
7286
|
const publicationProbeTimeoutMs = dependencies.publicationProbeTimeoutMs ?? PUBLICATION_PROBE_TIMEOUT_MS;
|
|
6010
7287
|
const bashTimeRevalidationTimeoutMs = dependencies.bashTimeRevalidationTimeoutMs ?? BASH_TIME_REVALIDATION_TIMEOUT_MS;
|
|
7288
|
+
const reviewConsentNow = dependencies.now ?? (() => Date.now());
|
|
7289
|
+
const reviewConsentScheduleTimer = dependencies.scheduleTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
6011
7290
|
if (!Number.isSafeInteger(publicationProbeTimeoutMs) || publicationProbeTimeoutMs <= 0) throw new TypeError("Publication probe timeout must be a positive safe integer");
|
|
6012
7291
|
if (!Number.isSafeInteger(bashTimeRevalidationTimeoutMs) || bashTimeRevalidationTimeoutMs <= 0) throw new TypeError("Bash-time revalidation timeout must be a positive safe integer");
|
|
6013
7292
|
return function gentleAi(pi: ExtensionAPI): void {
|
|
@@ -6022,18 +7301,31 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
6022
7301
|
cleanupAllPendingReviewConsents(pendingReviewConsents, candidateViews);
|
|
6023
7302
|
});
|
|
6024
7303
|
|
|
7304
|
+
pi.registerTool({
|
|
7305
|
+
name: "gentle_review_scope",
|
|
7306
|
+
label: "Gentle Review Scope",
|
|
7307
|
+
description: "Read one bounded, integrity-checked page of the controller-owned frozen changed scope. This read-only tool never inspects the ambient or candidate tree.",
|
|
7308
|
+
parameters: REVIEW_SCOPE_PARAMETERS,
|
|
7309
|
+
executionMode: "parallel",
|
|
7310
|
+
async execute(_toolCallId, parameters) {
|
|
7311
|
+
const input = parameters as ReviewScopeParameters;
|
|
7312
|
+
const details = readCandidateContextManifestPage(input.manifest, input.sha256, input.cursor ?? 0);
|
|
7313
|
+
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
7314
|
+
},
|
|
7315
|
+
});
|
|
7316
|
+
|
|
6025
7317
|
pi.registerTool({
|
|
6026
7318
|
name: "gentle_review",
|
|
6027
7319
|
label: "Gentle Review Controller",
|
|
6028
7320
|
description:
|
|
6029
|
-
"Inspect and recover review authority, run new native ordinary review through start/finalize/validate, preserve legacy compact compatibility reads and graph-v1 Judgment Day, and authorize one exact lifecycle command.
|
|
7321
|
+
"Inspect and recover review authority, run new native ordinary review through start/finalize/validate, preserve legacy compact compatibility reads and graph-v1 Judgment Day, and authorize one exact lifecycle command. Reviewer, refuter, and validator verdicts are never Pi-authored: lens results are admitted natively (the pi host relay satisfies provider --materialize slots), adversarial roles execute through Go-owned pi processes via provider-rendered self-contained vectors, and FINALIZE follows the provider's negotiated next_transition (captured-results discovery). FINALIZE input is a JSON string carrying only the negotiated collection answers: correction_line_forecast, validation (the targeted validation document when the exact collection input requests it), and final_evidence paired with either the legacy final_verification_passed boolean or one closed final_verification_outcome. RESET/RECOVER remain destructive and are executed by the audited native CLI: RESET and RECOVER_LOCK map to `gentle-ai review reclaim` and RECOVER maps to `gentle-ai review recover` with the provider-selected disposition. Published v2.1.11 repair-legacy-alias derives its fixed repository binding from fresh native inventory before fresh UI approval; dispose-result remains unsupported pending design. Legacy bundle transport is retired: export/import return a legacy-operation-retired envelope pointing at the native gentle-ai review CLI and the Git common-directory store.",
|
|
6030
7322
|
promptSnippet: "Inspect authority, then use native start/finalize/validate for a new ordinary review; use graph-v1 only for explicit Judgment Day",
|
|
6031
7323
|
promptGuidelines: [
|
|
6032
7324
|
'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.',
|
|
6033
7325
|
"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.",
|
|
6034
|
-
"Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, actor, and reason; QUARANTINE_LEGACY accepts only the published malformed freeze-findings diagnostic/disposition. A dual reconciliation may supply only anomalies `unchanged_target,malformed_recovery_authorization` in that exact order. Use REPAIR_LEGACY_ALIAS only with lineage, actor, and reason: Pi freshly reads native inventory and derives repository, revision, diagnostic, disposition, and the exact eight-line binding before interactive approval. `review dispose-result` is unsupported pending design.",
|
|
6035
|
-
"
|
|
6036
|
-
"For blocked-legacy or blocked-mixed, do not call START repeatedly. Explain invalidation, request explicit user authorization
|
|
7326
|
+
"Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, capturedLensResults, findingsPresent, evidenceRecordsPresent, actor, and reason; QUARANTINE_LEGACY accepts only the published malformed freeze-findings diagnostic/disposition. A dual reconciliation may supply only anomalies `unchanged_target,malformed_recovery_authorization` in that exact order. Use REPAIR_LEGACY_ALIAS only with lineage, actor, and reason: Pi freshly reads native inventory and derives repository, revision, diagnostic, disposition, and the exact eight-line binding before interactive approval. `review dispose-result` is unsupported pending design.",
|
|
7327
|
+
"Lens, refuter, and validator verdicts are admitted natively, never Pi-authored: FINALIZE routes provider --materialize lens slots through the host relay, executes provider-rendered self-contained role vectors verbatim, and runs the provider's own review.finalize transition (captured-results discovery). Call FINALIZE with a JSON string carrying only the negotiated collection answers: correction_line_forecast for the pre-edit forecast, validation for the targeted validation document the exact collection input requests, and final_evidence paired with exactly one of final_verification_passed or final_verification_outcome (passed, verification_failed, procedural_tooling_failed). Correction evidence is captured natively before STATUS can expose targeted validation. When the provider offers host-relay lens slots, FINALIZE first returns a `reviewer-model-run-forecast` naming the lenses and the real model runs it would spend; re-run it with `reviewer_run_acknowledged: true` to authorize exactly that reviewer work. Use ADVANCE only for explicit graph-v1 Judgment Day.",
|
|
7328
|
+
"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.",
|
|
6037
7329
|
"A consent-required START returns the complete provider envelope and an opaque consent_binding, then stops. The parent presents and localizes that envelope without changing machine tokens, commands, target IDs, or invocations. After one explicit human answer, call answer-consent exactly once with a JSON string containing only consentBinding and answer (`granted` or `declined`). A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START, answer-consent, or FINALIZE output, the controller calls target-scoped native status first and returns only its declared action. Never infer or prescribe replay unless native explicitly reports exact_replay_safe for the same canonical request and required lineage.",
|
|
6038
7330
|
"Use gentle_review for bounded review transaction operations and exact lifecycle validation; never fabricate bash tool metadata or a separate gate target.",
|
|
6039
7331
|
],
|
|
@@ -6054,6 +7346,9 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
6054
7346
|
ctx,
|
|
6055
7347
|
correctionEvidenceByLineage,
|
|
6056
7348
|
pendingReviewConsents,
|
|
7349
|
+
writeReviewConsentLatch,
|
|
7350
|
+
reviewConsentNow,
|
|
7351
|
+
reviewConsentScheduleTimer,
|
|
6057
7352
|
);
|
|
6058
7353
|
return {
|
|
6059
7354
|
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
@@ -6071,6 +7366,15 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
6071
7366
|
}
|
|
6072
7367
|
|
|
6073
7368
|
pi.on("session_start", async (_event, ctx) => {
|
|
7369
|
+
// Loud, every session: an active dev-binary override means this session
|
|
7370
|
+
// runs an unpinned gentle-ai. Announce which one before anything else.
|
|
7371
|
+
try {
|
|
7372
|
+
const devBinary = await describeDevBinaryOverride();
|
|
7373
|
+
if (ctx.hasUI && devBinary.state === "active") ctx.ui.notify(devBinary.line, "warning");
|
|
7374
|
+
if (ctx.hasUI && devBinary.state === "invalid") ctx.ui.notify(devBinary.line, "error");
|
|
7375
|
+
} catch (error) {
|
|
7376
|
+
if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
7377
|
+
}
|
|
6074
7378
|
try {
|
|
6075
7379
|
const transactionRecovery = reconcileCommitTransaction(ctx.cwd);
|
|
6076
7380
|
if (ctx.hasUI && transactionRecovery.status !== "clean") {
|
|
@@ -6149,7 +7453,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
6149
7453
|
: "";
|
|
6150
7454
|
const gentlePrompt = isNamedAgent || isSddAgent
|
|
6151
7455
|
? ""
|
|
6152
|
-
: `\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd))}`;
|
|
7456
|
+
: `\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd), ctx.cwd, readActiveToolNames(pi))}`;
|
|
6153
7457
|
return {
|
|
6154
7458
|
systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}`,
|
|
6155
7459
|
};
|
|
@@ -6383,6 +7687,66 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
6383
7687
|
},
|
|
6384
7688
|
});
|
|
6385
7689
|
|
|
7690
|
+
// Dev-binary override surfacing (unpinned field-test mode). While the
|
|
7691
|
+
// override is active every diagnostic surface names the exact binary, its
|
|
7692
|
+
// live version, and its fresh content digest, so the maintainer always
|
|
7693
|
+
// knows which gentle-ai actually answered. An invalid override surfaces as
|
|
7694
|
+
// a failure — it is never silently ignored, because the native resolver
|
|
7695
|
+
// refuses to fall back to the pin while an override is declared.
|
|
7696
|
+
const describeDevBinaryOverride = async (): Promise<
|
|
7697
|
+
| { state: "inactive" }
|
|
7698
|
+
| { state: "active"; line: string; override: GentleAiDevBinaryOverride }
|
|
7699
|
+
| { state: "invalid"; line: string }
|
|
7700
|
+
> => {
|
|
7701
|
+
let override: GentleAiDevBinaryOverride | undefined;
|
|
7702
|
+
try {
|
|
7703
|
+
override = resolveGentleAiDevBinaryOverride();
|
|
7704
|
+
} catch (error) {
|
|
7705
|
+
if (error instanceof GentleAiDevBinaryOverrideError) return { state: "invalid", line: `Gentle AI dev binary override invalid — ${error.message}` };
|
|
7706
|
+
throw error;
|
|
7707
|
+
}
|
|
7708
|
+
if (override === undefined) return { state: "inactive" };
|
|
7709
|
+
let version = "version unavailable";
|
|
7710
|
+
try {
|
|
7711
|
+
const adapter = createNodeExecFileAdapter();
|
|
7712
|
+
const result = await adapter({ file: override.path, arguments: ["version"], cwd: dirname(override.path), timeoutMs: 10_000, maxBufferBytes: 1024 * 1024 });
|
|
7713
|
+
const banner = result.stdout.trim();
|
|
7714
|
+
if (result.exitCode === 0 && banner.startsWith("gentle-ai ")) version = banner.slice("gentle-ai ".length);
|
|
7715
|
+
} catch {
|
|
7716
|
+
// The doctor line still names the binary; the version stays unavailable.
|
|
7717
|
+
}
|
|
7718
|
+
return {
|
|
7719
|
+
state: "active",
|
|
7720
|
+
override,
|
|
7721
|
+
line: `Gentle AI dev binary override active (unpinned, field-test only): ${override.path} ${version} sha256:${override.sha256.slice(0, 16)}`,
|
|
7722
|
+
};
|
|
7723
|
+
};
|
|
7724
|
+
|
|
7725
|
+
pi.registerCommand("gentle:dev-binary", {
|
|
7726
|
+
description: "Register, inspect, or clear the persistent Gentle AI dev-binary override (status | <absolute path> | off). Unpinned, field-test only.",
|
|
7727
|
+
handler: async (args, ctx) => {
|
|
7728
|
+
const argument = args.trim();
|
|
7729
|
+
try {
|
|
7730
|
+
if (argument === "off") {
|
|
7731
|
+
const removed = unregisterGentleAiDevBinary();
|
|
7732
|
+
ctx.ui.notify(removed ? "Gentle AI dev binary registration removed; the pinned binary is active again." : "No dev binary registration to remove.", "info");
|
|
7733
|
+
return;
|
|
7734
|
+
}
|
|
7735
|
+
if (argument === "" || argument === "status") {
|
|
7736
|
+
const described = await describeDevBinaryOverride();
|
|
7737
|
+
if (described.state === "inactive") ctx.ui.notify("No dev binary override; the pinned Gentle AI binary is active.", "info");
|
|
7738
|
+
else ctx.ui.notify(described.line, described.state === "active" ? "warning" : "error");
|
|
7739
|
+
return;
|
|
7740
|
+
}
|
|
7741
|
+
registerGentleAiDevBinary(argument);
|
|
7742
|
+
const described = await describeDevBinaryOverride();
|
|
7743
|
+
ctx.ui.notify(described.state === "inactive" ? "Dev binary registration written." : described.line, "warning");
|
|
7744
|
+
} catch (error) {
|
|
7745
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
7746
|
+
}
|
|
7747
|
+
},
|
|
7748
|
+
});
|
|
7749
|
+
|
|
6386
7750
|
pi.registerCommand("gentle:doctor", {
|
|
6387
7751
|
description: "Run read-only Gentle AI diagnostics for this Pi workspace.",
|
|
6388
7752
|
handler: async (_args, ctx) => {
|
|
@@ -6402,6 +7766,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
6402
7766
|
const localSddAgentOverrides = sddLocalAgentOverrideCount(ctx.cwd);
|
|
6403
7767
|
const modelConfig = await readSavedModelConfigAsync(ctx.cwd);
|
|
6404
7768
|
const engramActive = hasWritableEngramTool(pi);
|
|
7769
|
+
const devBinary = await describeDevBinaryOverride();
|
|
6405
7770
|
const lines = [
|
|
6406
7771
|
"el Gentleman doctor",
|
|
6407
7772
|
`${agentsInstalled ? "pass" : "fail"}: Global SDD agents ${agentsInstalled ? "installed" : "missing"}`,
|
|
@@ -6413,6 +7778,8 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
6413
7778
|
`${modelConfig.status === "invalid" ? "fail" : "pass"}: Global model config ${modelConfig.status}`,
|
|
6414
7779
|
"pass: Sensitive-path guard active for read/write/edit tools",
|
|
6415
7780
|
`${engramActive ? "pass" : "warn"}: Engram memory tools ${engramActive ? "active" : "not active in this session"}`,
|
|
7781
|
+
...(devBinary.state === "active" ? [`warn: ${devBinary.line}`] : []),
|
|
7782
|
+
...(devBinary.state === "invalid" ? [`fail: ${devBinary.line}`, "remedy: fix the dev binary override or clear it with /gentle:dev-binary off (or unset GENTLE_PI_GENTLE_AI_DEV_BINARY)"] : []),
|
|
6416
7783
|
];
|
|
6417
7784
|
if (!agentsInstalled || !chainsInstalled) {
|
|
6418
7785
|
lines.push("remedy: run /gentle:install-sdd --force to refresh global SDD assets intentionally");
|
|
@@ -6474,6 +7841,31 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
6474
7841
|
},
|
|
6475
7842
|
});
|
|
6476
7843
|
|
|
7844
|
+
// Mirrors gentle:review-mode: a user-owned switch, never an automated one.
|
|
7845
|
+
// It matters more here than there, because this policy governs whether
|
|
7846
|
+
// background subagents may be launched at all, so nothing in Pi may write
|
|
7847
|
+
// it. The only writer is this handler, reached only by explicit invocation.
|
|
7848
|
+
pi.registerCommand("gentle:background-subagents", {
|
|
7849
|
+
description: "Show or set the managed background-subagents policy (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.",
|
|
7850
|
+
handler: async (args, ctx) => {
|
|
7851
|
+
const subAction = args.trim().length === 0 ? "status" : args.trim();
|
|
7852
|
+
if (subAction !== "status" && subAction !== "enable" && subAction !== "disable") {
|
|
7853
|
+
ctx.ui.notify(`Unknown /gentle:background-subagents sub-action "${subAction}". Use status, enable, or disable.`, "warning");
|
|
7854
|
+
return;
|
|
7855
|
+
}
|
|
7856
|
+
try {
|
|
7857
|
+
const wrote: BackgroundSubagentsPolicy | undefined = subAction === "enable" ? "on" : subAction === "disable" ? "off" : undefined;
|
|
7858
|
+
if (wrote !== undefined) writeGlobalBackgroundSubagentsPolicy(wrote);
|
|
7859
|
+
const resolution = resolveBackgroundSubagentsPolicy(ctx.cwd);
|
|
7860
|
+
const capability = resolveBackgroundSubagentsCapability(ctx.cwd, readActiveToolNames(pi));
|
|
7861
|
+
const report = renderBackgroundSubagentsReport(resolution, capability, wrote);
|
|
7862
|
+
ctx.ui.notify(report.message, report.type);
|
|
7863
|
+
} catch (error) {
|
|
7864
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
7865
|
+
}
|
|
7866
|
+
},
|
|
7867
|
+
});
|
|
7868
|
+
|
|
6477
7869
|
pi.registerCommand("gentle:status", {
|
|
6478
7870
|
description: "Show Gentle AI package status for this project.",
|
|
6479
7871
|
handler: async (_args, ctx) => {
|
|
@@ -6489,9 +7881,11 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
6489
7881
|
const staleSddAssets = sddGlobalAssetDriftCount();
|
|
6490
7882
|
const localSddAgentOverrides = sddLocalAgentOverrideCount(ctx.cwd);
|
|
6491
7883
|
const modelConfig = await readModelConfigAsync(ctx.cwd);
|
|
7884
|
+
const devBinary = await describeDevBinaryOverride();
|
|
6492
7885
|
ctx.ui.notify(
|
|
6493
7886
|
[
|
|
6494
7887
|
"el Gentleman package is active.",
|
|
7888
|
+
...(devBinary.state === "inactive" ? [] : [devBinary.line]),
|
|
6495
7889
|
`Persona: ${readPersonaMode(ctx.cwd)}`,
|
|
6496
7890
|
`Global SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
|
|
6497
7891
|
`Global SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
|
|
@@ -6509,7 +7903,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
|
|
|
6509
7903
|
`Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
|
|
6510
7904
|
...describeModelConfig(ctx.cwd, modelConfig),
|
|
6511
7905
|
].join("\n"),
|
|
6512
|
-
staleSddAssets > 0 || localSddAgentOverrides > 0 ? "warning" : "info",
|
|
7906
|
+
staleSddAssets > 0 || localSddAgentOverrides > 0 || devBinary.state !== "inactive" ? "warning" : "info",
|
|
6513
7907
|
);
|
|
6514
7908
|
},
|
|
6515
7909
|
});
|