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.
Files changed (107) hide show
  1. package/README.md +40 -9
  2. package/assets/agents/review-readability.md +0 -1
  3. package/assets/agents/review-reliability.md +0 -1
  4. package/assets/agents/review-resilience.md +0 -1
  5. package/assets/agents/review-risk.md +0 -1
  6. package/contracts/review-integration/v1/fixtures/binding-revision-conflict.fixture.json +25 -0
  7. package/contracts/review-integration/v1/fixtures/capabilities-v1.2.fixture.json +263 -0
  8. package/contracts/review-integration/v1/fixtures/capabilities-v1.3.fixture.json +301 -0
  9. package/contracts/review-integration/v1/fixtures/capabilities-v1.4.fixture.json +312 -0
  10. package/contracts/review-integration/v1/fixtures/capabilities-v1.5.fixture.json +321 -0
  11. package/contracts/review-integration/v1/fixtures/consent.fixture.json +36 -0
  12. package/contracts/review-integration/v1/fixtures/final-verification-incident.fixture.json +1 -0
  13. package/contracts/review-integration/v1/fixtures/operation.fixture.json +4 -0
  14. package/contracts/review-integration/v1/fixtures/repair-preflight.fixture.json +54 -0
  15. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +97 -0
  16. package/contracts/review-integration/v1/fixtures/start.fixture.json +25 -1
  17. package/contracts/review-integration/v1/fixtures/status-ambiguous.fixture.json +4 -0
  18. package/contracts/review-integration/v1/fixtures/status-corrupted.fixture.json +4 -0
  19. package/contracts/review-integration/v1/fixtures/status-recover.fixture.json +4 -0
  20. package/contracts/review-integration/v1/fixtures/status-unrelated.fixture.json +4 -0
  21. package/contracts/review-integration/v1/fixtures/status-v2-ambiguous.fixture.json +110 -0
  22. package/contracts/review-integration/v1/fixtures/status-v2-corrupted.fixture.json +107 -0
  23. package/contracts/review-integration/v1/fixtures/status-v2-final-verification-retry.fixture.json +192 -0
  24. package/contracts/review-integration/v1/fixtures/status-v2-recover.fixture.json +130 -0
  25. package/contracts/review-integration/v1/fixtures/status-v2-repair.fixture.json +161 -0
  26. package/contracts/review-integration/v1/fixtures/status-v2-unrelated.fixture.json +103 -0
  27. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +193 -0
  28. package/contracts/review-integration/v1/fixtures/status.fixture.json +19 -5
  29. package/contracts/review-integration/v1/fixtures/verification-evidence.fixture.json +1 -0
  30. package/contracts/review-integration/v1/schemas/admitted-result.schema.json +35 -0
  31. package/contracts/review-integration/v1/schemas/artifact-subject.schema.json +33 -0
  32. package/contracts/review-integration/v1/schemas/authority-repair-assessment.schema.json +247 -0
  33. package/contracts/review-integration/v1/schemas/capabilities-v1.2.schema.json +249 -0
  34. package/contracts/review-integration/v1/schemas/capabilities-v1.3.schema.json +429 -0
  35. package/contracts/review-integration/v1/schemas/capabilities-v1.4.schema.json +432 -0
  36. package/contracts/review-integration/v1/schemas/capabilities-v1.5.schema.json +154 -0
  37. package/contracts/review-integration/v1/schemas/consent.schema.json +86 -0
  38. package/contracts/review-integration/v1/schemas/failure.schema.json +54 -8
  39. package/contracts/review-integration/v1/schemas/final-verification-incident.schema.json +51 -0
  40. package/contracts/review-integration/v1/schemas/operation.schema.json +204 -31
  41. package/contracts/review-integration/v1/schemas/repair.schema.json +251 -0
  42. package/contracts/review-integration/v1/schemas/result-artifact-v2.schema.json +38 -0
  43. package/contracts/review-integration/v1/schemas/start-v2.schema.json +216 -0
  44. package/contracts/review-integration/v1/schemas/start.schema.json +79 -3
  45. package/contracts/review-integration/v1/schemas/status-v2.schema.json +313 -0
  46. package/contracts/review-integration/v1/schemas/status.schema.json +37 -10
  47. package/contracts/review-integration/v1/schemas/targeted-validation-request.schema.json +46 -0
  48. package/contracts/review-integration/v1/schemas/verification-evidence.schema.json +64 -0
  49. package/contracts/review-integration/v2/fixtures/capabilities.fixture.json +111 -0
  50. package/contracts/review-integration/v2/fixtures/consent.fixture.json +36 -0
  51. package/contracts/review-integration/v2/fixtures/start.fixture.json +93 -0
  52. package/contracts/review-integration/v2/fixtures/status.fixture.json +113 -0
  53. package/contracts/review-integration/v2/schemas/admitted-result.schema.json +14 -0
  54. package/contracts/review-integration/v2/schemas/artifact-subject.schema.json +36 -0
  55. package/contracts/review-integration/v2/schemas/capabilities.schema.json +111 -0
  56. package/contracts/review-integration/v2/schemas/consent.schema.json +70 -0
  57. package/contracts/review-integration/v2/schemas/failure.schema.json +35 -0
  58. package/contracts/review-integration/v2/schemas/operation.schema.json +46 -0
  59. package/contracts/review-integration/v2/schemas/repair.schema.json +22 -0
  60. package/contracts/review-integration/v2/schemas/start.schema.json +106 -0
  61. package/contracts/review-integration/v2/schemas/status.schema.json +104 -0
  62. package/docs/native-authority-architecture.md +18 -3
  63. package/docs/review-integration.md +175 -27
  64. package/extensions/gentle-ai.ts +414 -19
  65. package/lib/gentle-ai-binary.ts +1 -1
  66. package/lib/native-review-cli.ts +679 -57
  67. package/lib/review-candidate-view.ts +131 -1
  68. package/lib/review-compact-contract.ts +12 -3
  69. package/lib/review-consent-latch.ts +51 -0
  70. package/lib/review-correction-lifecycle.ts +170 -0
  71. package/lib/review-integration-v2.ts +1563 -0
  72. package/package.json +15 -11
  73. package/runtime/gentle-ai-binary.mjs +1 -1
  74. package/runtime/native-review-cli.mjs +663 -41
  75. package/runtime/review-integration-v2.mjs +1564 -0
  76. package/scripts/build-git-commit-transaction-runner.mjs +1 -1
  77. package/scripts/gentle-ai-installer.mjs +16 -11
  78. package/scripts/install-gentle-ai.mjs +7 -3
  79. package/scripts/test-packed-runner.mjs +2 -2
  80. package/scripts/verify-package-files.mjs +216 -64
  81. package/skills/release/SKILL.md +38 -11
  82. package/tests/devbinary/native-review-parity.devtest.ts +348 -0
  83. package/tests/gentle-ai-binary.test.ts +8 -1
  84. package/tests/gentle-ai-installer.test.ts +24 -21
  85. package/tests/native-binary-gate.test.ts +145 -0
  86. package/tests/native-review-capability-contract.test.ts +87 -0
  87. package/tests/native-review-cli.test.ts +44 -1
  88. package/tests/native-review-parity-runtime.test.ts +70 -23
  89. package/tests/native-review-parity.test.ts +673 -0
  90. package/tests/native-risk-evidence-parity.test.ts +91 -0
  91. package/tests/package-manifest.test.ts +72 -7
  92. package/tests/review-actor-tool-deny.test.ts +20 -5
  93. package/tests/review-candidate-view.test.ts +331 -1
  94. package/tests/review-compact-contract.test.ts +5 -0
  95. package/tests/review-consent-latch.test.ts +67 -0
  96. package/tests/review-controller-native-recovery.test.ts +180 -5
  97. package/tests/review-controller-native-routing.test.ts +385 -12
  98. package/tests/review-controller-workspace-root.test.ts +26 -6
  99. package/tests/review-correction-lifecycle.test.ts +174 -0
  100. package/tests/review-integration-v2.test.ts +539 -0
  101. package/tests/runtime-harness.mjs +187 -0
  102. package/tests/support/native-binary-gate.ts +86 -0
  103. package/tests/verify-package-files.test.ts +119 -0
  104. package/lib/review-integration-v1.ts +0 -795
  105. package/runtime/review-integration-v1.mjs +0 -796
  106. package/tests/native-review-integration-v1.test.ts +0 -102
  107. 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
- if (JSON.stringify(scope.paths) !== JSON.stringify([...descriptor.paths].sort())) throw new CandidateViewError("native projection paths do not match Git content");
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
- if ((input.final_evidence === undefined) !== (input.final_verification_passed === undefined)) fail("review/finalize", "field-pair", "final evidence and result must appear together");
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
+ }