gentle-pi 1.2.0 → 2.0.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 +40 -9
- package/assets/agents/review-readability.md +0 -1
- package/assets/agents/review-reliability.md +0 -1
- package/assets/agents/review-resilience.md +0 -1
- package/assets/agents/review-risk.md +0 -1
- package/contracts/review-integration/v1/fixtures/binding-revision-conflict.fixture.json +25 -0
- package/contracts/review-integration/v1/fixtures/capabilities-v1.2.fixture.json +263 -0
- package/contracts/review-integration/v1/fixtures/capabilities-v1.3.fixture.json +301 -0
- package/contracts/review-integration/v1/fixtures/capabilities-v1.4.fixture.json +312 -0
- package/contracts/review-integration/v1/fixtures/capabilities-v1.5.fixture.json +321 -0
- package/contracts/review-integration/v1/fixtures/consent.fixture.json +36 -0
- package/contracts/review-integration/v1/fixtures/final-verification-incident.fixture.json +1 -0
- package/contracts/review-integration/v1/fixtures/operation.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/repair-preflight.fixture.json +54 -0
- package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +97 -0
- package/contracts/review-integration/v1/fixtures/start.fixture.json +25 -1
- package/contracts/review-integration/v1/fixtures/status-ambiguous.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/status-corrupted.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/status-recover.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/status-unrelated.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/status-v2-ambiguous.fixture.json +110 -0
- package/contracts/review-integration/v1/fixtures/status-v2-corrupted.fixture.json +107 -0
- package/contracts/review-integration/v1/fixtures/status-v2-final-verification-retry.fixture.json +192 -0
- package/contracts/review-integration/v1/fixtures/status-v2-recover.fixture.json +130 -0
- package/contracts/review-integration/v1/fixtures/status-v2-repair.fixture.json +161 -0
- package/contracts/review-integration/v1/fixtures/status-v2-unrelated.fixture.json +103 -0
- package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +193 -0
- package/contracts/review-integration/v1/fixtures/status.fixture.json +19 -5
- package/contracts/review-integration/v1/fixtures/verification-evidence.fixture.json +1 -0
- package/contracts/review-integration/v1/schemas/admitted-result.schema.json +35 -0
- package/contracts/review-integration/v1/schemas/artifact-subject.schema.json +33 -0
- package/contracts/review-integration/v1/schemas/authority-repair-assessment.schema.json +247 -0
- package/contracts/review-integration/v1/schemas/capabilities-v1.2.schema.json +249 -0
- package/contracts/review-integration/v1/schemas/capabilities-v1.3.schema.json +429 -0
- package/contracts/review-integration/v1/schemas/capabilities-v1.4.schema.json +432 -0
- package/contracts/review-integration/v1/schemas/capabilities-v1.5.schema.json +154 -0
- package/contracts/review-integration/v1/schemas/consent.schema.json +86 -0
- package/contracts/review-integration/v1/schemas/failure.schema.json +54 -8
- package/contracts/review-integration/v1/schemas/final-verification-incident.schema.json +51 -0
- package/contracts/review-integration/v1/schemas/operation.schema.json +204 -31
- package/contracts/review-integration/v1/schemas/repair.schema.json +251 -0
- package/contracts/review-integration/v1/schemas/result-artifact-v2.schema.json +38 -0
- package/contracts/review-integration/v1/schemas/start-v2.schema.json +216 -0
- package/contracts/review-integration/v1/schemas/start.schema.json +79 -3
- package/contracts/review-integration/v1/schemas/status-v2.schema.json +313 -0
- package/contracts/review-integration/v1/schemas/status.schema.json +37 -10
- package/contracts/review-integration/v1/schemas/targeted-validation-request.schema.json +46 -0
- package/contracts/review-integration/v1/schemas/verification-evidence.schema.json +64 -0
- package/contracts/review-integration/v2/fixtures/capabilities.fixture.json +111 -0
- package/contracts/review-integration/v2/fixtures/consent.fixture.json +36 -0
- package/contracts/review-integration/v2/fixtures/start.fixture.json +93 -0
- package/contracts/review-integration/v2/fixtures/status.fixture.json +113 -0
- package/contracts/review-integration/v2/schemas/admitted-result.schema.json +14 -0
- package/contracts/review-integration/v2/schemas/artifact-subject.schema.json +36 -0
- package/contracts/review-integration/v2/schemas/capabilities.schema.json +111 -0
- package/contracts/review-integration/v2/schemas/consent.schema.json +70 -0
- package/contracts/review-integration/v2/schemas/failure.schema.json +35 -0
- package/contracts/review-integration/v2/schemas/operation.schema.json +46 -0
- package/contracts/review-integration/v2/schemas/repair.schema.json +22 -0
- package/contracts/review-integration/v2/schemas/start.schema.json +106 -0
- package/contracts/review-integration/v2/schemas/status.schema.json +104 -0
- package/docs/native-authority-architecture.md +18 -3
- package/docs/review-integration.md +175 -27
- package/extensions/gentle-ai.ts +414 -19
- package/lib/gentle-ai-binary.ts +1 -1
- package/lib/native-review-cli.ts +679 -57
- package/lib/review-candidate-view.ts +131 -1
- package/lib/review-compact-contract.ts +12 -3
- package/lib/review-consent-latch.ts +51 -0
- package/lib/review-correction-lifecycle.ts +170 -0
- package/lib/review-integration-v2.ts +1563 -0
- package/package.json +15 -11
- package/runtime/gentle-ai-binary.mjs +1 -1
- package/runtime/native-review-cli.mjs +663 -41
- package/runtime/review-integration-v2.mjs +1564 -0
- package/scripts/build-git-commit-transaction-runner.mjs +1 -1
- package/scripts/gentle-ai-installer.mjs +16 -11
- package/scripts/install-gentle-ai.mjs +7 -3
- package/scripts/test-packed-runner.mjs +2 -2
- package/scripts/verify-package-files.mjs +216 -64
- package/skills/release/SKILL.md +38 -11
- package/tests/devbinary/native-review-parity.devtest.ts +348 -0
- package/tests/gentle-ai-binary.test.ts +8 -1
- package/tests/gentle-ai-installer.test.ts +24 -21
- package/tests/native-binary-gate.test.ts +145 -0
- package/tests/native-review-capability-contract.test.ts +87 -0
- package/tests/native-review-cli.test.ts +44 -1
- package/tests/native-review-parity-runtime.test.ts +70 -23
- package/tests/native-review-parity.test.ts +673 -0
- package/tests/native-risk-evidence-parity.test.ts +91 -0
- package/tests/package-manifest.test.ts +72 -7
- package/tests/review-actor-tool-deny.test.ts +20 -5
- package/tests/review-candidate-view.test.ts +331 -1
- package/tests/review-compact-contract.test.ts +5 -0
- package/tests/review-consent-latch.test.ts +67 -0
- package/tests/review-controller-native-recovery.test.ts +180 -5
- package/tests/review-controller-native-routing.test.ts +385 -12
- package/tests/review-controller-workspace-root.test.ts +26 -6
- package/tests/review-correction-lifecycle.test.ts +174 -0
- package/tests/review-integration-v2.test.ts +539 -0
- package/tests/runtime-harness.mjs +187 -0
- package/tests/support/native-binary-gate.ts +86 -0
- package/tests/verify-package-files.test.ts +119 -0
- package/lib/review-integration-v1.ts +0 -795
- package/runtime/review-integration-v1.mjs +0 -796
- package/tests/native-review-integration-v1.test.ts +0 -102
- package/tests/review-integration-v1.test.ts +0 -530
|
@@ -116,6 +116,18 @@ export interface NativeCandidateProjectionDescriptor {
|
|
|
116
116
|
paths: readonly string[];
|
|
117
117
|
intendedUntracked: readonly string[];
|
|
118
118
|
projection: "workspace" | "staged";
|
|
119
|
+
// Optional so Phase 3 stays additive: existing callers keep the legacy
|
|
120
|
+
// sorted-path behavior until the v2 switchover supplies a manifest.
|
|
121
|
+
manifest?: readonly ChangedPathEntry[];
|
|
122
|
+
// The provider artifact-subject's `changed_path_manifest_sha256` claim.
|
|
123
|
+
// Production callers verify this field across all collect inputs and their
|
|
124
|
+
// artifact subjects; hand-built descriptors may still use Pi's local digest
|
|
125
|
+
// as a self-consistency check.
|
|
126
|
+
manifestSha256?: string;
|
|
127
|
+
// Set only by the production status adapter after it has checked every
|
|
128
|
+
// provider-issued collect input and artifact subject for one identical hash.
|
|
129
|
+
// Provider canonicalization is intentionally not reimplemented here.
|
|
130
|
+
providerManifestHashVerified?: true;
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
export class CandidateViewError extends Error {
|
|
@@ -233,6 +245,109 @@ function gitPathTokens(cwd: string, arguments_: readonly string[], executor: Can
|
|
|
233
245
|
return splitNulTerminated(raw, "candidate scope Git output is not NUL-terminated");
|
|
234
246
|
}
|
|
235
247
|
|
|
248
|
+
// One manifest entry per changed path, carrying the state contract v2 ships in
|
|
249
|
+
// `changed_path_manifest`. `deriveChangedScope` cannot produce this: it runs
|
|
250
|
+
// `--name-status`, which reports a status and a path but never an old mode, so
|
|
251
|
+
// a mode-only or type change is invisible to it. `--raw` carries both modes and
|
|
252
|
+
// both blob ids, which is what makes `modeOnly` decidable at all.
|
|
253
|
+
export interface ChangedPathEntry {
|
|
254
|
+
readonly path: string;
|
|
255
|
+
readonly status: string;
|
|
256
|
+
readonly oldMode: string;
|
|
257
|
+
readonly newMode: string;
|
|
258
|
+
readonly deleted: boolean;
|
|
259
|
+
readonly typeChanged: boolean;
|
|
260
|
+
readonly modeOnly: boolean;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function deriveChangedPathManifest(cwd: string, baseTree: string, candidateTree: string, executor: CandidateGitExecutor = defaultCandidateGitExecutor): readonly ChangedPathEntry[] {
|
|
264
|
+
const tokens = gitPathTokens(cwd, ["diff", "--raw", "-z", "--abbrev=40", "--no-ext-diff", "--find-renames=100%", baseTree, candidateTree], executor);
|
|
265
|
+
const entries: ChangedPathEntry[] = [];
|
|
266
|
+
for (let index = 0; index < tokens.length;) {
|
|
267
|
+
const header = tokens[index++]?.toString("ascii");
|
|
268
|
+
if (header === undefined) break;
|
|
269
|
+
// `:<old_mode> <new_mode> <old_sha> <new_sha> <status>`
|
|
270
|
+
const match = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{7,64}) ([0-9a-f]{7,64}) ([AMDT]|R[0-9]{3})$/.exec(header);
|
|
271
|
+
if (match === null) throw new CandidateViewError("candidate manifest Git output contains an unsafe raw header", "manifest-derivation-invalid");
|
|
272
|
+
const [, oldMode, newMode, oldSha, newSha, status] = match;
|
|
273
|
+
const firstPath = tokens[index++];
|
|
274
|
+
if (firstPath === undefined) throw new CandidateViewError("candidate manifest Git output is incomplete", "manifest-derivation-invalid");
|
|
275
|
+
// A rename emits both the old and the new path; the new one is the scope.
|
|
276
|
+
const path = status.startsWith("R") ? decodeCanonicalPath(tokens[index++] ?? firstPath) : decodeCanonicalPath(firstPath);
|
|
277
|
+
entries.push(Object.freeze({
|
|
278
|
+
path,
|
|
279
|
+
status: status.startsWith("R") ? "A" : status,
|
|
280
|
+
oldMode,
|
|
281
|
+
newMode,
|
|
282
|
+
deleted: status === "D",
|
|
283
|
+
typeChanged: status === "T",
|
|
284
|
+
// Identical blob on both sides with different modes is the case the
|
|
285
|
+
// sorted-path comparison could never see.
|
|
286
|
+
modeOnly: oldSha === newSha && oldMode !== newMode,
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
return Object.freeze([...entries].sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Pi's own canonical digest of a changed-path manifest: sorted by path, with
|
|
293
|
+
// the wire (snake_case) field names the v2 `changed_path` schema uses. This
|
|
294
|
+
// is deliberately NOT an attempt to reproduce the provider's undocumented
|
|
295
|
+
// `changed_path_manifest_sha256` canonicalization byte-for-byte (design.md's
|
|
296
|
+
// open question). It is Pi's own self-consistency check: does the manifest a
|
|
297
|
+
// descriptor carries digest to the same value the descriptor claims for it?
|
|
298
|
+
// A caller (or a corrupted transport) that supplies a manifest and a claimed
|
|
299
|
+
// digest that disagree with each other is caught here, independent of and
|
|
300
|
+
// before any comparison against live Git content.
|
|
301
|
+
export function digestChangedPathManifest(manifest: readonly ChangedPathEntry[]): string {
|
|
302
|
+
const canonical = [...manifest]
|
|
303
|
+
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
|
304
|
+
.map((entry) => ({
|
|
305
|
+
path: entry.path,
|
|
306
|
+
status: entry.status,
|
|
307
|
+
old_mode: entry.oldMode,
|
|
308
|
+
new_mode: entry.newMode,
|
|
309
|
+
deleted: entry.deleted,
|
|
310
|
+
type_changed: entry.typeChanged,
|
|
311
|
+
mode_only: entry.modeOnly,
|
|
312
|
+
}));
|
|
313
|
+
return `sha256:${createHash("sha256").update(JSON.stringify(canonical)).digest("hex")}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function assertManifestMatchesGit(descriptor: NativeCandidateProjectionDescriptor, derived: readonly ChangedPathEntry[]): void {
|
|
317
|
+
const claimed = descriptor.manifest;
|
|
318
|
+
if (claimed === undefined) return;
|
|
319
|
+
|
|
320
|
+
// Self-consistency: does the manifest digest to what the subject claims
|
|
321
|
+
// for it? Checked before any Git comparison, same as input-divergence.
|
|
322
|
+
if (descriptor.manifestSha256 !== undefined && descriptor.providerManifestHashVerified !== true && digestChangedPathManifest(claimed) !== descriptor.manifestSha256) {
|
|
323
|
+
throw new CandidateViewError("native manifest does not digest to its own artifact-subject claim", "manifest-subject-drift");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// First: is the provider's own input self-consistent? A manifest that does
|
|
327
|
+
// not describe the descriptor's paths is not a drift observation, it is a
|
|
328
|
+
// malformed input, and saying so separately keeps the diagnosis honest.
|
|
329
|
+
const claimedPaths = [...claimed.map((entry) => entry.path)].sort();
|
|
330
|
+
if (JSON.stringify(claimedPaths) !== JSON.stringify([...descriptor.paths].sort())) {
|
|
331
|
+
throw new CandidateViewError("native manifest does not describe the same paths as its own projection", "manifest-input-divergence");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const derivedByPath = new Map(derived.map((entry) => [entry.path, entry]));
|
|
335
|
+
if (JSON.stringify(claimedPaths) !== JSON.stringify(derived.map((entry) => entry.path))) {
|
|
336
|
+
throw new CandidateViewError("native manifest paths do not match Git content", "manifest-path-set-drift");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const entry of claimed) {
|
|
340
|
+
const actual = derivedByPath.get(entry.path);
|
|
341
|
+
if (actual === undefined) throw new CandidateViewError("native manifest paths do not match Git content", "manifest-path-set-drift");
|
|
342
|
+
if (entry.status !== actual.status || entry.deleted !== actual.deleted) {
|
|
343
|
+
throw new CandidateViewError(`native manifest status for ${entry.path} does not match Git content`, "manifest-status-drift");
|
|
344
|
+
}
|
|
345
|
+
if (entry.oldMode !== actual.oldMode || entry.newMode !== actual.newMode || entry.modeOnly !== actual.modeOnly || entry.typeChanged !== actual.typeChanged) {
|
|
346
|
+
throw new CandidateViewError(`native manifest mode state for ${entry.path} does not match Git content`, "manifest-mode-drift");
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
236
351
|
function deriveChangedScope(cwd: string, baseCommit: string, candidateTree: string, entries: readonly CandidateTreeEntry[], executor: CandidateGitExecutor): CandidateViewScope {
|
|
237
352
|
const present = new Map(entries.map((entry) => [entry.path, entry]));
|
|
238
353
|
const paths = new Set<string>();
|
|
@@ -683,9 +798,24 @@ export class CandidateViewRegistry {
|
|
|
683
798
|
const base = head.tree === descriptor.baseTree ? head : resolveCandidateBaseTree(root, descriptor.baseTree, this.gitExecutor);
|
|
684
799
|
const committedOnly = head.tree === descriptor.currentCandidateTree && base.tree !== head.tree;
|
|
685
800
|
if (!committedOnly && head.tree !== descriptor.baseTree) throw new CandidateViewError("native projection base no longer matches HEAD");
|
|
801
|
+
// The descriptor's own `projection` label ("staged" = a committed range,
|
|
802
|
+
// "workspace" = dirty-inclusive) must agree with what Pi independently
|
|
803
|
+
// derives from the base/candidate/HEAD relationship. Neither trusting
|
|
804
|
+
// nor silently overriding a mislabeled claim is safe: fail closed.
|
|
805
|
+
if ((descriptor.projection === "staged") !== committedOnly) {
|
|
806
|
+
throw new CandidateViewError("native projection commit-state does not match its declared projection kind", "projection-kind-drift");
|
|
807
|
+
}
|
|
686
808
|
const tree = parseTree(root, descriptor.currentCandidateTree, this.gitExecutor);
|
|
687
809
|
const scope = deriveChangedScope(root, descriptor.baseTree, descriptor.currentCandidateTree, [...tree.entries, ...tree.gitlinks], this.gitExecutor);
|
|
688
|
-
|
|
810
|
+
// A manifest SUBSUMES the sorted-path comparison rather than stacking on
|
|
811
|
+
// top of it: it checks the same path set plus the mode, status, and
|
|
812
|
+
// type state the path set cannot express, and it names which of those
|
|
813
|
+
// drifted. Descriptors without a manifest keep the legacy check.
|
|
814
|
+
if (descriptor.manifest !== undefined) {
|
|
815
|
+
assertManifestMatchesGit(descriptor, deriveChangedPathManifest(root, descriptor.baseTree, descriptor.currentCandidateTree, this.gitExecutor));
|
|
816
|
+
} else if (JSON.stringify(scope.paths) !== JSON.stringify([...descriptor.paths].sort())) {
|
|
817
|
+
throw new CandidateViewError("native projection paths do not match Git content");
|
|
818
|
+
}
|
|
689
819
|
this.projections.set(lineageId, {
|
|
690
820
|
contributorRoot: root,
|
|
691
821
|
baseCommit: base.commit,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { REVIEW_LENS } from "./review-triggers.ts";
|
|
14
14
|
import { canonicalJsonV1, domainHashV1 } from "./review-canonical.ts";
|
|
15
15
|
import { normalizeRefuterBatch, type RefuterBatch } from "./review-refuter-adapter.ts";
|
|
16
|
+
import { CORRECTION_OUTCOMES, type CorrectionOutcome } from "./review-correction-lifecycle.ts";
|
|
16
17
|
|
|
17
18
|
const DIGEST = /^[0-9a-f]{64}$/;
|
|
18
19
|
const LINEAGE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
@@ -45,6 +46,7 @@ export interface CompactFinalizeContractInput {
|
|
|
45
46
|
validation?: CompactTargetedValidationInput;
|
|
46
47
|
final_evidence?: string;
|
|
47
48
|
final_verification_passed?: boolean;
|
|
49
|
+
final_verification_outcome?: CorrectionOutcome;
|
|
48
50
|
refuter_batch?: unknown;
|
|
49
51
|
}
|
|
50
52
|
|
|
@@ -263,14 +265,21 @@ export function parseCompactStartInput(value: unknown): CompactStartContractInpu
|
|
|
263
265
|
}
|
|
264
266
|
|
|
265
267
|
function parseCompactFinalizeInputValue(value: unknown, preserveFinalEvidence: boolean): CompactFinalizeContractInput {
|
|
266
|
-
const input = exact(value, "review/finalize", ["cwd"], ["lineageId", "review_result", "correction_line_forecast", "validation_proof", "validation", "final_evidence", "final_verification_passed", "refuter_batch"]);
|
|
267
|
-
|
|
268
|
+
const input = exact(value, "review/finalize", ["cwd"], ["lineageId", "review_result", "correction_line_forecast", "validation_proof", "validation", "final_evidence", "final_verification_passed", "final_verification_outcome", "refuter_batch"]);
|
|
269
|
+
const outcomeFields = Number(input.final_verification_passed !== undefined) + Number(input.final_verification_outcome !== undefined);
|
|
270
|
+
if ((input.final_evidence === undefined && outcomeFields !== 0) || (input.final_evidence !== undefined && outcomeFields !== 1)) fail("review/finalize", "field-pair", "final evidence requires exactly one verification result or outcome");
|
|
268
271
|
let correction_line_forecast: number | undefined;
|
|
269
272
|
if (input.correction_line_forecast !== undefined) {
|
|
270
273
|
if (!Number.isSafeInteger(input.correction_line_forecast) || input.correction_line_forecast <= 0) fail("review/finalize.correction_line_forecast", "range", "must be a positive safe integer");
|
|
271
274
|
correction_line_forecast = input.correction_line_forecast;
|
|
272
275
|
}
|
|
273
276
|
if (input.final_verification_passed !== undefined && typeof input.final_verification_passed !== "boolean") fail("review/finalize.final_verification_passed", "type", "must be boolean");
|
|
277
|
+
let final_verification_outcome: CorrectionOutcome | undefined;
|
|
278
|
+
if (input.final_verification_outcome !== undefined) {
|
|
279
|
+
const outcome = string(input.final_verification_outcome, "review/finalize.final_verification_outcome");
|
|
280
|
+
if (!(CORRECTION_OUTCOMES as readonly string[]).includes(outcome)) fail("review/finalize.final_verification_outcome", "enum", "contains an unsupported value");
|
|
281
|
+
final_verification_outcome = outcome as CorrectionOutcome;
|
|
282
|
+
}
|
|
274
283
|
let final_evidence: string | undefined;
|
|
275
284
|
if (input.final_evidence !== undefined) {
|
|
276
285
|
if (preserveFinalEvidence) {
|
|
@@ -280,7 +289,7 @@ function parseCompactFinalizeInputValue(value: unknown, preserveFinalEvidence: b
|
|
|
280
289
|
final_evidence = string(input.final_evidence, "review/finalize.final_evidence");
|
|
281
290
|
}
|
|
282
291
|
}
|
|
283
|
-
return { cwd: string(input.cwd, "review/finalize.cwd"), ...(optionalLineage(input.lineageId, "review/finalize.lineageId") === undefined ? {} : { lineageId: optionalLineage(input.lineageId, "review/finalize.lineageId")! }), ...(input.review_result === undefined ? {} : { review_result: parseReviewResult(input.review_result, "review/finalize.review_result") }), ...(correction_line_forecast === undefined ? {} : { correction_line_forecast }), ...(input.validation_proof === undefined ? {} : { validation_proof: parseValidationProof(input.validation_proof, "review/finalize.validation_proof") }), ...(input.validation === undefined ? {} : { validation: parseValidation(input.validation, "review/finalize.validation") }), ...(final_evidence === undefined ? {} : { final_evidence }), ...(input.final_verification_passed === undefined ? {} : { final_verification_passed: input.final_verification_passed }), ...(input.refuter_batch === undefined ? {} : { refuter_batch: input.refuter_batch }) };
|
|
292
|
+
return { cwd: string(input.cwd, "review/finalize.cwd"), ...(optionalLineage(input.lineageId, "review/finalize.lineageId") === undefined ? {} : { lineageId: optionalLineage(input.lineageId, "review/finalize.lineageId")! }), ...(input.review_result === undefined ? {} : { review_result: parseReviewResult(input.review_result, "review/finalize.review_result") }), ...(correction_line_forecast === undefined ? {} : { correction_line_forecast }), ...(input.validation_proof === undefined ? {} : { validation_proof: parseValidationProof(input.validation_proof, "review/finalize.validation_proof") }), ...(input.validation === undefined ? {} : { validation: parseValidation(input.validation, "review/finalize.validation") }), ...(final_evidence === undefined ? {} : { final_evidence }), ...(input.final_verification_passed === undefined ? {} : { final_verification_passed: input.final_verification_passed }), ...(final_verification_outcome === undefined ? {} : { final_verification_outcome }), ...(input.refuter_batch === undefined ? {} : { refuter_batch: input.refuter_batch }) };
|
|
284
293
|
}
|
|
285
294
|
|
|
286
295
|
export function parseCompactFinalizeInput(value: unknown): CompactFinalizeContractInput {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { assertManagedStorePathV1, resolveRepositoryAuthorityV1 } from "./review-repository.ts";
|
|
4
|
+
|
|
5
|
+
// Pi-owned, clone-local (Git-common-dir) latch recording that the one-time
|
|
6
|
+
// "run the review now?" question has already been put to the user for this
|
|
7
|
+
// clone. Design Decision #2 (organic-rdd-parity): scope, direction, and
|
|
8
|
+
// asymmetry mirror gentle-ai's own RDDConsentAsked/RecordRDDConsentAsked
|
|
9
|
+
// exactly (per clone, accept-only, never committed, never inherited) —
|
|
10
|
+
// but this is Pi's OWN latch, at Pi's OWN path, never gentle-ai's private
|
|
11
|
+
// rdd-mode/asked.json. Writing another product's private authority store
|
|
12
|
+
// would be a boundary violation.
|
|
13
|
+
export const REVIEW_CONSENT_LATCH_SCHEMA = "gentle-pi.review-consent-asked/v1";
|
|
14
|
+
const REVIEW_CONSENT_LATCH_PAYLOAD = `{"schema":"${REVIEW_CONSENT_LATCH_SCHEMA}"}\n`;
|
|
15
|
+
|
|
16
|
+
function reviewConsentLatchPath(cwd: string): string {
|
|
17
|
+
const authority = resolveRepositoryAuthorityV1(cwd);
|
|
18
|
+
return assertManagedStorePathV1(authority.common_directory, join(authority.common_directory, "gentle-pi", "review-consent", "asked.json"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reads whether the one-time consent question has already been asked for
|
|
23
|
+
* this clone. Returns false when no latch has ever been recorded. Throws
|
|
24
|
+
* when the repository/Git-common-dir authority cannot be resolved at all
|
|
25
|
+
* (unresolvable, non-Git, or shallow repository) — callers must treat that
|
|
26
|
+
* as "no latch write, review proceeds" per the Threat Matrix rather than
|
|
27
|
+
* silently reporting a latch.
|
|
28
|
+
*/
|
|
29
|
+
export function readReviewConsentLatch(cwd: string): boolean {
|
|
30
|
+
const path = reviewConsentLatchPath(cwd);
|
|
31
|
+
let payload: string;
|
|
32
|
+
try {
|
|
33
|
+
payload = readFileSync(path, "utf8");
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
return payload === REVIEW_CONSENT_LATCH_PAYLOAD;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Records the one-time consent question as asked for this clone. One-way:
|
|
43
|
+
* called only on accept, never on decline. Idempotent — recording twice
|
|
44
|
+
* writes the same exact canonical bytes.
|
|
45
|
+
*/
|
|
46
|
+
export function recordReviewConsentLatch(cwd: string): void {
|
|
47
|
+
const path = reviewConsentLatchPath(cwd);
|
|
48
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
49
|
+
writeFileSync(path, REVIEW_CONSENT_LATCH_PAYLOAD, { mode: 0o600 });
|
|
50
|
+
chmodSync(path, 0o600);
|
|
51
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Evidence-first correction lifecycle, protocol v1.5.
|
|
2
|
+
//
|
|
3
|
+
// An ordinary review permits exactly one bounded correction transaction. Since
|
|
4
|
+
// v1.5 the provider collects candidate-bound verification evidence BEFORE it
|
|
5
|
+
// offers `targeted_validation`, and the outcome it records decides which of
|
|
6
|
+
// three terminal branches follows. Those branches differ in what they consume:
|
|
7
|
+
// only `procedural_tooling_failed` ends the lifecycle, only `passed` unlocks
|
|
8
|
+
// validation, and `verification_failed` must cost nothing at all.
|
|
9
|
+
//
|
|
10
|
+
// This module is deliberately pure — no subprocess, no filesystem, no clock.
|
|
11
|
+
// The provider owns the evidence directory and the budget ledger; what Pi owns
|
|
12
|
+
// is the decision, and a decision made from data can be tested without a live
|
|
13
|
+
// binary. The rejected alternative was inlining these branches beside the
|
|
14
|
+
// existing validation conditionals, where the single-correction invariant would
|
|
15
|
+
// have been enforced by control flow instead of by data.
|
|
16
|
+
|
|
17
|
+
export const CORRECTION_OUTCOMES = Object.freeze(["passed", "verification_failed", "procedural_tooling_failed"] as const);
|
|
18
|
+
export type CorrectionOutcome = (typeof CORRECTION_OUTCOMES)[number];
|
|
19
|
+
|
|
20
|
+
export class CorrectionOutcomeError extends Error {
|
|
21
|
+
constructor(received: unknown) {
|
|
22
|
+
super(`review correction evidence outcome must be exactly one of ${CORRECTION_OUTCOMES.join(", ")}; received ${JSON.stringify(received)}`);
|
|
23
|
+
this.name = "CorrectionOutcomeError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class CorrectionEvidenceReplacedError extends Error {
|
|
28
|
+
constructor(detail: string) {
|
|
29
|
+
super(`correction-evidence-replaced: ${detail}`);
|
|
30
|
+
this.name = "CorrectionEvidenceReplacedError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CorrectionStatus {
|
|
35
|
+
readonly lineageId: string;
|
|
36
|
+
readonly targetIdentity: string;
|
|
37
|
+
readonly authorityRevision: string;
|
|
38
|
+
readonly correctionBudget: number;
|
|
39
|
+
readonly changedLinesCharged: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CorrectionEvidence {
|
|
43
|
+
readonly outcome: string;
|
|
44
|
+
readonly evidenceIdentity: string;
|
|
45
|
+
readonly recordDigest: string;
|
|
46
|
+
readonly candidateTree?: string;
|
|
47
|
+
readonly rawPayloadSha256?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface CorrectionStepBase {
|
|
51
|
+
readonly kind: string;
|
|
52
|
+
readonly lineageId: string;
|
|
53
|
+
readonly transactionOpen: boolean;
|
|
54
|
+
readonly unlocksTargetedValidation: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RunTargetedValidationStep extends CorrectionStepBase {
|
|
58
|
+
readonly kind: "run-targeted-validation";
|
|
59
|
+
readonly transactionOpen: false;
|
|
60
|
+
readonly unlocksTargetedValidation: true;
|
|
61
|
+
// The provider issues the request. Pi re-queries STATUS for it instead of
|
|
62
|
+
// fabricating one, so the step names the operation it expects to collect.
|
|
63
|
+
readonly expectCaptureOperation: "external.run_targeted_validation";
|
|
64
|
+
readonly evidenceIdentity: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RecaptureRequiredStep extends CorrectionStepBase {
|
|
68
|
+
readonly kind: "recapture-required";
|
|
69
|
+
readonly transactionOpen: true;
|
|
70
|
+
readonly unlocksTargetedValidation: false;
|
|
71
|
+
readonly attemptConsumed: false;
|
|
72
|
+
readonly budgetConsumed: 0;
|
|
73
|
+
readonly changedLinesCharged: number;
|
|
74
|
+
readonly autoRetry: false;
|
|
75
|
+
// Carries the identity this capture supersedes. A new capture is mandatory:
|
|
76
|
+
// there is deliberately no field that would let a caller resubmit the same
|
|
77
|
+
// candidate bytes under the prior identity.
|
|
78
|
+
readonly supersedes: string;
|
|
79
|
+
readonly requiresNewCapture: true;
|
|
80
|
+
readonly guidance: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface TerminalEscalationStep extends CorrectionStepBase {
|
|
84
|
+
readonly kind: "terminal-escalation";
|
|
85
|
+
readonly transactionOpen: false;
|
|
86
|
+
readonly unlocksTargetedValidation: false;
|
|
87
|
+
readonly retryEligible: false;
|
|
88
|
+
readonly launchesReviewer: false;
|
|
89
|
+
readonly launchesValidator: false;
|
|
90
|
+
readonly launchesCorrection: false;
|
|
91
|
+
readonly escalation: string;
|
|
92
|
+
readonly evidenceIdentity: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type CorrectionStep = RunTargetedValidationStep | RecaptureRequiredStep | TerminalEscalationStep;
|
|
96
|
+
|
|
97
|
+
function requireOutcome(value: unknown): CorrectionOutcome {
|
|
98
|
+
if (typeof value !== "string" || !(CORRECTION_OUTCOMES as readonly string[]).includes(value)) throw new CorrectionOutcomeError(value);
|
|
99
|
+
return value as CorrectionOutcome;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveCorrectionStep(status: CorrectionStatus, evidence: CorrectionEvidence): CorrectionStep {
|
|
103
|
+
const outcome = requireOutcome(evidence?.outcome);
|
|
104
|
+
|
|
105
|
+
if (outcome === "passed") {
|
|
106
|
+
return Object.freeze({
|
|
107
|
+
kind: "run-targeted-validation",
|
|
108
|
+
lineageId: status.lineageId,
|
|
109
|
+
transactionOpen: false,
|
|
110
|
+
unlocksTargetedValidation: true,
|
|
111
|
+
expectCaptureOperation: "external.run_targeted_validation",
|
|
112
|
+
evidenceIdentity: evidence.evidenceIdentity,
|
|
113
|
+
} as const);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (outcome === "verification_failed") {
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
kind: "recapture-required",
|
|
119
|
+
lineageId: status.lineageId,
|
|
120
|
+
transactionOpen: true,
|
|
121
|
+
unlocksTargetedValidation: false,
|
|
122
|
+
attemptConsumed: false,
|
|
123
|
+
budgetConsumed: 0,
|
|
124
|
+
// Reported, never recomputed: a failed verification must not move the
|
|
125
|
+
// accounting the provider already holds in either direction.
|
|
126
|
+
changedLinesCharged: status.changedLinesCharged,
|
|
127
|
+
autoRetry: false,
|
|
128
|
+
supersedes: evidence.evidenceIdentity,
|
|
129
|
+
requiresNewCapture: true,
|
|
130
|
+
guidance: "Verification failed. Change the candidate and capture new evidence; the correction transaction stays open and nothing has been charged. Do not retry the same bytes.",
|
|
131
|
+
} as const);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return Object.freeze({
|
|
135
|
+
kind: "terminal-escalation",
|
|
136
|
+
lineageId: status.lineageId,
|
|
137
|
+
transactionOpen: false,
|
|
138
|
+
unlocksTargetedValidation: false,
|
|
139
|
+
retryEligible: false,
|
|
140
|
+
launchesReviewer: false,
|
|
141
|
+
launchesValidator: false,
|
|
142
|
+
launchesCorrection: false,
|
|
143
|
+
escalation: "Procedural tooling failed while capturing verification evidence. This is a terminal escalation: no reviewer, correction, or validator runs afterwards, and retry eligibility is not considered. It needs one human decision.",
|
|
144
|
+
evidenceIdentity: evidence.evidenceIdentity,
|
|
145
|
+
} as const);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface DistinctEvidenceCheck {
|
|
149
|
+
readonly prior: CorrectionEvidence;
|
|
150
|
+
readonly next: CorrectionEvidence;
|
|
151
|
+
// Whether the earlier record is still resolvable, and what its bytes hash to
|
|
152
|
+
// NOW. Both are observations the caller supplies; this function judges them
|
|
153
|
+
// rather than performing IO, which keeps the invariant unit-testable.
|
|
154
|
+
readonly priorStillResolvable: boolean;
|
|
155
|
+
readonly priorRecordDigestNow: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function assertDistinctCorrectionEvidence(check: DistinctEvidenceCheck): void {
|
|
159
|
+
const { prior, next, priorStillResolvable, priorRecordDigestNow } = check;
|
|
160
|
+
|
|
161
|
+
if (next.evidenceIdentity === prior.evidenceIdentity) {
|
|
162
|
+
throw new CorrectionEvidenceReplacedError(`the provider reused evidence identity ${prior.evidenceIdentity} for a second capture; each capture must land in its own immutable directory`);
|
|
163
|
+
}
|
|
164
|
+
if (!priorStillResolvable) {
|
|
165
|
+
throw new CorrectionEvidenceReplacedError(`the earlier evidence record ${prior.evidenceIdentity} is no longer resolvable; a failed capture must survive alongside its successor, not be replaced`);
|
|
166
|
+
}
|
|
167
|
+
if (priorRecordDigestNow !== prior.recordDigest) {
|
|
168
|
+
throw new CorrectionEvidenceReplacedError(`the earlier evidence record ${prior.evidenceIdentity} now digests to ${priorRecordDigestNow} instead of ${prior.recordDigest}; its bytes must be immutable`);
|
|
169
|
+
}
|
|
170
|
+
}
|