gentle-pi 2.2.0 → 2.4.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 (160) hide show
  1. package/README.md +76 -79
  2. package/assets/agents/review-readability.md +0 -2
  3. package/assets/agents/review-reliability.md +0 -2
  4. package/assets/agents/review-resilience.md +0 -2
  5. package/assets/agents/review-risk.md +0 -2
  6. package/assets/agents/sdd-apply.md +5 -3
  7. package/assets/agents/sdd-explore.md +1 -0
  8. package/assets/agents/sdd-proposal.md +2 -0
  9. package/assets/agents/sdd-research.md +54 -0
  10. package/assets/agents/sdd-status.md +4 -4
  11. package/assets/agents/sdd-tasks.md +2 -3
  12. package/assets/agents/sdd-verify.md +21 -1
  13. package/assets/chains/sdd-full.chain.md +1 -1
  14. package/assets/chains/sdd-verify.chain.md +1 -1
  15. package/assets/orchestrator-delegation.md +56 -249
  16. package/assets/orchestrator-memory.md +2 -0
  17. package/assets/orchestrator.md +20 -40
  18. package/assets/sdd-orchestrator-workflow.md +30 -37
  19. package/assets/support/sdd-status-contract.md +7 -7
  20. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  21. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  22. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  23. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  24. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  25. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  26. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  27. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  28. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  29. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  30. package/docs/native-authority-architecture.md +9 -11
  31. package/docs/review-integration.md +27 -373
  32. package/extensions/ask-user-choice.ts +151 -0
  33. package/extensions/gentle-ai.ts +2349 -3785
  34. package/extensions/quiet-tools.ts +515 -32
  35. package/extensions/sdd-init.ts +4 -8
  36. package/lib/agent-home.ts +8 -0
  37. package/lib/gentle-ai-renderer.ts +70 -0
  38. package/lib/model-routing-authority.ts +133 -0
  39. package/lib/native-review-cli.ts +387 -940
  40. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  41. package/lib/provider-contract-bundle.ts +88 -6
  42. package/lib/review-candidate-view.ts +341 -132
  43. package/lib/review-host-relay.ts +324 -96
  44. package/lib/review-integration-v2.ts +868 -307
  45. package/lib/review-last-event-controller.ts +35 -0
  46. package/lib/sdd-preflight.ts +179 -68
  47. package/lib/sdd-status.ts +66 -111
  48. package/lib/terminal-theme.ts +1 -1
  49. package/package.json +83 -82
  50. package/runtime/gentle-ai-binary.mjs +1 -1
  51. package/runtime/native-review-cli.mjs +337 -890
  52. package/runtime/review-integration-v2.mjs +816 -255
  53. package/runtime/review-relay-contract.mjs +1 -1
  54. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  55. package/scripts/gentle-ai-installer.mjs +75 -22
  56. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  57. package/scripts/test-packed-runner.mjs +4 -7
  58. package/scripts/verify-package-files.mjs +23 -23
  59. package/skills/_shared/review-ledger-contract.md +14 -14
  60. package/skills/chained-pr/SKILL.md +3 -0
  61. package/skills/cognitive-doc-design/SKILL.md +1 -1
  62. package/skills/comment-writer/SKILL.md +1 -1
  63. package/skills/gentle-ai/SKILL.md +7 -74
  64. package/skills/issue-creation/SKILL.md +53 -93
  65. package/skills/judgment-day/SKILL.md +5 -7
  66. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  67. package/skills/release/SKILL.md +3 -3
  68. package/skills/skill-registry/SKILL.md +1 -1
  69. package/skills/work-unit-commits/SKILL.md +3 -1
  70. package/tests/artifact-language.test.ts +24 -11
  71. package/tests/ask-user-choice.test.ts +264 -0
  72. package/tests/codegraph-tools.test.ts +3 -3
  73. package/tests/crosslane/cross-lane.mjs +15 -1168
  74. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  75. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  76. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  77. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  78. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  79. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  80. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  81. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  82. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  83. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  84. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  85. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  86. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  87. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  89. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  92. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  93. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  94. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  95. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  96. package/tests/gentle-ai-binary.test.ts +83 -13
  97. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  98. package/tests/gentle-ai-installer.test.ts +137 -47
  99. package/tests/gentle-ai.test.ts +821 -131
  100. package/tests/gentle-theme.test.ts +133 -0
  101. package/tests/issue-creation-skill.test.ts +103 -0
  102. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  103. package/tests/model-routing-authority.test.ts +257 -0
  104. package/tests/native-review-capability-contract.test.ts +45 -1
  105. package/tests/native-review-cli.test.ts +586 -962
  106. package/tests/native-review-consent.test.ts +150 -66
  107. package/tests/native-review-parity-runtime.test.ts +80 -359
  108. package/tests/native-review-parity.test.ts +674 -925
  109. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  110. package/tests/orchestrator-budget.test.ts +132 -88
  111. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  112. package/tests/package-manifest.test.ts +152 -36
  113. package/tests/provider-contract-bundle.test.ts +76 -0
  114. package/tests/provider-contract-mirror.test.ts +19 -0
  115. package/tests/provider-defect-handoff.test.ts +15 -118
  116. package/tests/quiet-tool-rendering.test.ts +1055 -28
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-authority-recovery-docs.test.ts +1 -2
  119. package/tests/review-candidate-view.test.ts +409 -3
  120. package/tests/review-compact-contract.test.ts +4 -30
  121. package/tests/review-contract-prompt.test.ts +139 -0
  122. package/tests/review-controller-lock-status.test.ts +2 -2
  123. package/tests/review-controller-native-recovery.test.ts +363 -1450
  124. package/tests/review-controller-native-routing.test.ts +1251 -5642
  125. package/tests/review-controller-retired-ops.test.ts +1 -1
  126. package/tests/review-controller-workspace-root.test.ts +222 -71
  127. package/tests/review-controller.test.ts +26 -816
  128. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  129. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  130. package/tests/review-gate.test.ts +0 -45
  131. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  132. package/tests/review-host-relay-routing.test.ts +328 -91
  133. package/tests/review-host-relay.test.ts +436 -14
  134. package/tests/review-integration-v2-forward.test.ts +623 -553
  135. package/tests/review-integration-v2.test.ts +239 -165
  136. package/tests/review-last-event-closure.test.ts +408 -0
  137. package/tests/review-ledger-contract.test.ts +97 -35
  138. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  139. package/tests/review-relay-transport-agent.test.ts +119 -46
  140. package/tests/review-snapshot.test.ts +3 -2
  141. package/tests/runtime-harness.mjs +362 -196
  142. package/tests/sdd-agent-tools.test.ts +36 -0
  143. package/tests/sdd-preflight.test.ts +81 -15
  144. package/tests/sdd-status.test.ts +109 -110
  145. package/tests/skill-collision-prefixes.test.ts +5 -8
  146. package/tests/writer-edit-surface-scope.test.ts +366 -0
  147. package/themes/Gentleman-Cute.json +94 -0
  148. package/themes/Gentleman-Sexy.json +92 -0
  149. package/lib/git-commit-transaction.ts +0 -861
  150. package/runtime/git-commit-transaction.mjs +0 -862
  151. package/scripts/run-git-commit-transaction.mjs +0 -35
  152. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  153. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  154. package/tests/git-commit-transaction.test.ts +0 -530
  155. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  156. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  157. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  158. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  159. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  160. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -1,6 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
+ import fs from "node:fs";
5
+ import { syncBuiltinESMExports } from "node:module";
4
6
  import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
7
  import { tmpdir } from "node:os";
6
8
  import { join } from "node:path";
@@ -351,6 +353,66 @@ test("candidate view materializes exact tracked and initially-untracked content
351
353
  view.cleanup();
352
354
  });
353
355
 
356
+ test("candidate view preserves staged additions with explicit intended-untracked selection in its private index", (t) => {
357
+ const contributorRoot = repository(t);
358
+ writeFileSync(join(contributorRoot, "tracked.txt"), "tracked selection\n");
359
+ writeFileSync(join(contributorRoot, "staged-addition.txt"), "staged addition\n");
360
+ git(contributorRoot, "add", "staged-addition.txt");
361
+ git(contributorRoot, "update-index", "--split-index");
362
+ writeFileSync(join(contributorRoot, "selected.txt"), "selected\n");
363
+ writeFileSync(join(contributorRoot, "excluded.txt"), "excluded\n");
364
+ const indexBefore = readFileSync(join(contributorRoot, ".git", "index"));
365
+ const all = createCandidateView({ contributorRoot });
366
+ const excluded = createCandidateView({ contributorRoot, intendedUntracked: [] });
367
+ const selected = createCandidateView({ contributorRoot, intendedUntracked: ["selected.txt"] });
368
+ try {
369
+ assert.deepEqual(all.paths, ["excluded.txt", "selected.txt", "staged-addition.txt", "tracked.txt"]);
370
+ assert.deepEqual(excluded.paths, ["staged-addition.txt", "tracked.txt"]);
371
+ assert.deepEqual(selected.paths, ["selected.txt", "staged-addition.txt", "tracked.txt"]);
372
+ assert.equal(readFileSync(join(selected.root, "staged-addition.txt"), "utf8"), "staged addition\n");
373
+ assert.equal(lstatSync(join(selected.root, "excluded.txt"), { throwIfNoEntry: false }), undefined);
374
+ assert.deepEqual(readFileSync(join(contributorRoot, ".git", "index")), indexBefore);
375
+ } finally {
376
+ all.cleanup();
377
+ excluded.cleanup();
378
+ selected.cleanup();
379
+ }
380
+ });
381
+
382
+ test("candidate view skips a shared index that disappears during stat or copy", (t) => {
383
+ const contributorRoot = repository(t);
384
+ writeFileSync(join(contributorRoot, "tracked.txt"), "tracked selection\n");
385
+ writeFileSync(join(contributorRoot, "staged-addition.txt"), "staged addition\n");
386
+ git(contributorRoot, "add", "staged-addition.txt");
387
+ git(contributorRoot, "update-index", "--split-index");
388
+ const sharedIndexName = readdirSync(join(contributorRoot, ".git")).find((name) => /^sharedindex\.[0-9a-f]+$/.test(name));
389
+ assert.ok(sharedIndexName, "split index must create a shared index fixture");
390
+ const sharedIndexPath = join(contributorRoot, ".git", sharedIndexName);
391
+ for (const phase of ["stat", "copy"] as const) {
392
+ const originalLstatSync = fs.lstatSync;
393
+ const originalCopyFileSync = fs.copyFileSync;
394
+ fs.lstatSync = ((path: string | Buffer, options?: Parameters<typeof lstatSync>[1]) => {
395
+ if (phase === "stat" && path === sharedIndexPath) throw Object.assign(new Error("shared index disappeared"), { code: "ENOENT" });
396
+ return originalLstatSync(path, options);
397
+ }) as typeof fs.lstatSync;
398
+ fs.copyFileSync = ((source: string | Buffer, destination: string | Buffer) => {
399
+ if (phase === "copy" && source === sharedIndexPath) throw Object.assign(new Error("shared index disappeared"), { code: "ENOENT" });
400
+ return originalCopyFileSync(source, destination);
401
+ }) as typeof fs.copyFileSync;
402
+ syncBuiltinESMExports();
403
+ let view: ReturnType<typeof createCandidateView> | undefined;
404
+ try {
405
+ view = createCandidateView({ contributorRoot, intendedUntracked: [] });
406
+ assert.equal(view.paths.includes("staged-addition.txt"), true, phase);
407
+ } finally {
408
+ fs.lstatSync = originalLstatSync;
409
+ fs.copyFileSync = originalCopyFileSync;
410
+ syncBuiltinESMExports();
411
+ view?.cleanup();
412
+ }
413
+ }
414
+ });
415
+
354
416
  test("candidate view recursively protects nested content and worktree metadata, and rejects injected untracked entries", (t) => {
355
417
  const contributorRoot = repository(t);
356
418
  mkdirSync(join(contributorRoot, "nested", "deeper"), { recursive: true });
@@ -395,6 +457,56 @@ test("candidate view registry rejects unsafe, moved, writable, stale, and unsele
395
457
  registry.cleanup(view.token);
396
458
  });
397
459
 
460
+ test("candidate registry isolates replay, projection, current, and cleanup state by target root plus lineage", (t) => {
461
+ const rootA = repository(t);
462
+ const rootB = repository(t);
463
+ writeFileSync(join(rootA, "tracked.txt"), "candidate A\n");
464
+ writeFileSync(join(rootB, "tracked.txt"), "candidate B\n");
465
+ const registry = new CandidateViewRegistry();
466
+ t.after(() => registry.cleanupAll());
467
+ const viewA = registry.createOrReuse({ contributorRoot: rootA, replayKey: "same-replay" });
468
+ const viewB = registry.createOrReuse({ contributorRoot: rootB, replayKey: "same-replay" });
469
+ assert.notEqual(viewA.token, viewB.token);
470
+ registry.bindCurrent({ token: viewA.token, lineageId: "same-lineage", selectedLenses: ["review-reliability"] });
471
+ registry.bindCurrent({ token: viewB.token, lineageId: "same-lineage", selectedLenses: ["review-reliability"] });
472
+ assert.equal(registry.resolveForLens("same-lineage", "review-reliability", rootA).root, viewA.root);
473
+ assert.equal(registry.resolveForLens("same-lineage", "review-reliability", rootB).root, viewB.root);
474
+ assert.equal(registry.resolveForFinalize("same-lineage", rootA).root, viewA.root);
475
+ assert.equal(registry.resolveForFinalize("same-lineage", rootB).root, viewB.root);
476
+ writeFileSync(join(rootA, "tracked.txt"), "corrected A\n");
477
+ writeFileSync(join(rootB, "tracked.txt"), "corrected B\n");
478
+ const correctedA = registry.createCorrected("same-lineage", rootA, "same-correction-replay");
479
+ const correctedB = registry.createCorrected("same-lineage", rootB, "same-correction-replay");
480
+ assert.notEqual(correctedA.token, correctedB.token);
481
+ registry.promoteCorrected("same-lineage", correctedA.token, rootA);
482
+ registry.promoteCorrected("same-lineage", correctedB.token, rootB);
483
+ assert.equal(registry.resolveForFinalize("same-lineage", rootA).root, correctedA.root);
484
+ assert.equal(registry.resolveForFinalize("same-lineage", rootB).root, correctedB.root);
485
+ assert.throws(() => registry.resolveForLens("same-lineage", "review-reliability"), /workspaceRoot/);
486
+ assert.throws(() => registry.resolveWorkspaceRoot("same-lineage"), /workspaceRoot/);
487
+ registry.cleanupTerminal("same-lineage", "approved", rootB);
488
+ assert.equal(registry.resolveWorkspaceRoot("same-lineage"), realpathSync(rootA));
489
+ assert.equal(registry.resolveForFinalize("same-lineage", rootA).root, correctedA.root);
490
+ registry.cleanupTerminal("same-lineage", "approved", rootA);
491
+ });
492
+
493
+ test("candidate registry rejects a lineage target whose authorized symlink was replaced", (t) => {
494
+ const root = repository(t);
495
+ const replacement = repository(t);
496
+ const alias = join(tmpdir(), `gentle-pi-candidate-alias-${process.pid}-${Date.now()}`);
497
+ t.after(() => rmSync(alias, { recursive: true, force: true }));
498
+ symlinkSync(root, alias, "dir");
499
+ const registry = new CandidateViewRegistry();
500
+ t.after(() => registry.cleanupAll());
501
+ const view = registry.create({ contributorRoot: root });
502
+ registry.bindCurrent({ token: view.token, lineageId: "symlink-lineage", selectedLenses: ["review-reliability"] });
503
+ assert.doesNotThrow(() => registry.assertWorkspaceRoot("symlink-lineage", alias));
504
+ rmSync(alias, { recursive: true, force: true });
505
+ symlinkSync(replacement, alias, "dir");
506
+ assert.throws(() => registry.assertWorkspaceRoot("symlink-lineage", alias), /workspaceRoot|bound to/);
507
+ registry.cleanupTerminal("symlink-lineage", "approved", root);
508
+ });
509
+
398
510
  test("review subagent dispatch rejects missing candidate views and uses the explicitly current overlapping lens", (t) => {
399
511
  const missing = new CandidateViewRegistry();
400
512
  assert.throws(
@@ -434,6 +546,16 @@ test("candidate view cleanup is confined and idempotent", (t) => {
434
546
  assert.equal(lstatSync(view.root, { throwIfNoEntry: false }), undefined);
435
547
  });
436
548
 
549
+ test("candidate cleanup removes a readonly root when Git reports success without deleting it", (t) => {
550
+ const registry = new CandidateViewRegistry((file, arguments_, options) =>
551
+ arguments_[0] === "worktree" && arguments_[1] === "remove" ? "" : execFileSync(file, arguments_, options));
552
+ const view = registry.create({ contributorRoot: repository(t) });
553
+ assert.equal(lstatSync(view.root).mode & 0o222, 0);
554
+ view.cleanup();
555
+ view.cleanup();
556
+ assert.equal(lstatSync(view.root, { throwIfNoEntry: false }), undefined);
557
+ });
558
+
437
559
  test("corrected views stay within frozen scope and replace projections only when promoted", (t) => {
438
560
  const contributorRoot = repository(t);
439
561
  writeFileSync(join(contributorRoot, "tracked.txt"), "reviewed\n");
@@ -518,13 +640,16 @@ test("candidate view derives deletion, rename, executable, and symlink scope fro
518
640
  const registry = new CandidateViewRegistry();
519
641
  const view = registry.create({ contributorRoot });
520
642
  try {
521
- assert.deepEqual(view.paths, ["deleted.txt", "linked.sh", "renamed.txt", "script.sh"]);
522
- assert.deepEqual(view.deletedPaths, ["deleted.txt"]);
643
+ // The rename source (tracked.txt) is frozen as a deletion next to its
644
+ // destination: native STATUS projects the rename as both paths
645
+ // (gentle-pi#518), and the reviewer scope carries the same identity.
646
+ assert.deepEqual(view.paths, ["deleted.txt", "linked.sh", "renamed.txt", "script.sh", "tracked.txt"]);
647
+ assert.deepEqual(view.deletedPaths, ["deleted.txt", "tracked.txt"]);
523
648
  assert.deepEqual(view.modes, { "linked.sh": "120000", "renamed.txt": "100644", "script.sh": "100755" });
524
649
  registry.bind({ token: view.token, lineageId: "scope-kinds", selectedLenses: ["review-risk"] });
525
650
  const dispatch = { agent: "review-risk", task: "review", mode: "task" };
526
651
  injectReviewCandidateView(dispatch, registry);
527
- assert.match(dispatch.task, /Frozen changed scope by mode: .*"deleted":\["deleted\.txt"\]/);
652
+ assert.match(dispatch.task, /Frozen changed scope by mode: .*"deleted":\["deleted\.txt","tracked\.txt"\]/);
528
653
  assert.doesNotMatch(dispatch.task, /Frozen paths:|Frozen modes:/);
529
654
  view.verify();
530
655
  } finally {
@@ -532,6 +657,40 @@ test("candidate view derives deletion, rename, executable, and symlink scope fro
532
657
  }
533
658
  });
534
659
 
660
+ // gentle-pi#518: native STATUS projects every path whose state changed between
661
+ // the frozen trees (Go diffs with --no-renames), so a staged exact rename is
662
+ // its source deletion plus its destination addition. The candidate view must
663
+ // project the same identity or ordinary START is rejected before native
664
+ // admission with candidate-target-projection-drift.
665
+ test("candidate view projects a staged exact rename as its source and destination, matching the native no-renames projection", (t) => {
666
+ const contributorRoot = repository(t);
667
+ mkdirSync(join(contributorRoot, "active"));
668
+ writeFileSync(join(contributorRoot, "active", "document.md"), "line one\nline two\n");
669
+ git(contributorRoot, "add", "active/document.md");
670
+ git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "rename base");
671
+ mkdirSync(join(contributorRoot, "archive"));
672
+ git(contributorRoot, "mv", "active/document.md", "archive/document.md");
673
+ writeFileSync(join(contributorRoot, "report.md"), "report\n");
674
+ git(contributorRoot, "add", "-A");
675
+ const view = createCandidateView({ contributorRoot });
676
+ try {
677
+ const nativeProjection = git(contributorRoot, "diff-tree", "--no-commit-id", "--name-only", "-r", "-z", "--no-renames", view.baseTree, view.candidateTree)
678
+ .split("\0").filter((path) => path.length > 0).sort();
679
+ assert.deepEqual(nativeProjection, ["active/document.md", "archive/document.md", "report.md"]);
680
+ assert.deepEqual(view.paths, nativeProjection);
681
+ assert.deepEqual(view.deletedPaths, ["active/document.md"]);
682
+ assert.deepEqual(view.modes, { "archive/document.md": "100644", "report.md": "100644" });
683
+ const manifest = deriveChangedPathManifest(contributorRoot, view.baseTree, view.candidateTree);
684
+ assert.deepEqual(manifest.map((entry) => [entry.path, entry.status, entry.deleted]), [
685
+ ["active/document.md", "D", true],
686
+ ["archive/document.md", "A", false],
687
+ ["report.md", "A", false],
688
+ ]);
689
+ } finally {
690
+ view.cleanup();
691
+ }
692
+ });
693
+
535
694
  test("candidate view verifies unchanged tree entries even when they are absent from changed scope", (t) => {
536
695
  const contributorRoot = repository(t);
537
696
  writeFileSync(join(contributorRoot, "unchanged.txt"), "base\n");
@@ -832,6 +991,30 @@ test("candidate views freeze gitlinks as immutable metadata without materializin
832
991
  }
833
992
  });
834
993
 
994
+ test("native projection reconstruction retains its selected untracked subset", (t) => {
995
+ const contributorRoot = repository(t);
996
+ writeFileSync(join(contributorRoot, "tracked.txt"), "tracked selection\n");
997
+ writeFileSync(join(contributorRoot, "selected.txt"), "selected\n");
998
+ writeFileSync(join(contributorRoot, "excluded.txt"), "excluded\n");
999
+ const source = new CandidateViewRegistry();
1000
+ const selected = source.create({ contributorRoot, intendedUntracked: ["selected.txt"] });
1001
+ const restored = new CandidateViewRegistry();
1002
+ try {
1003
+ const view = restored.restoreForFinalizeFromNative("selected-native", contributorRoot, {
1004
+ baseTree: selected.baseTree,
1005
+ currentCandidateTree: selected.candidateTree,
1006
+ paths: selected.paths,
1007
+ intendedUntracked: ["selected.txt"],
1008
+ projection: "workspace",
1009
+ });
1010
+ assert.deepEqual(view.paths, ["selected.txt", "tracked.txt"]);
1011
+ assert.equal(lstatSync(join(view.root, "excluded.txt"), { throwIfNoEntry: false }), undefined);
1012
+ view.cleanup();
1013
+ } finally {
1014
+ selected.cleanup();
1015
+ }
1016
+ });
1017
+
835
1018
  test("native projections reconstruct symlink, intended-untracked, and gitlink identity from Git objects", (t) => {
836
1019
  const contributorRoot = repository(t);
837
1020
  const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
@@ -960,6 +1143,229 @@ test("fresh registries restore only one exact authoritative reviewing candidate
960
1143
  }
961
1144
  });
962
1145
 
1146
+ interface CandidateViewRegistryInternals {
1147
+ records: Map<string, unknown>;
1148
+ bindRecord: (token: string, lineageId: string, selectedLenses: readonly unknown[]) => unknown;
1149
+ }
1150
+
1151
+ function bindingFailureRegistry(t: test.TestContext): { registry: CandidateViewRegistry; materializedRoot: () => string | undefined; internals: CandidateViewRegistryInternals } {
1152
+ let root: string | undefined;
1153
+ const registry = new CandidateViewRegistry((file, arguments_, options) => {
1154
+ if (arguments_[0] === "worktree" && arguments_[1] === "add") root = arguments_[arguments_.indexOf("--no-checkout") + 1];
1155
+ return execFileSync(file, arguments_, options);
1156
+ });
1157
+ t.after(() => registry.cleanupAll());
1158
+ return { registry, materializedRoot: () => root, internals: registry as unknown as CandidateViewRegistryInternals };
1159
+ }
1160
+
1161
+ test("failed authoritative restores remove the inserted registry record and readonly worktree", (t) => {
1162
+ const contributorRoot = repository(t);
1163
+ writeFileSync(join(contributorRoot, "tracked.txt"), "reviewing\n");
1164
+ const source = new CandidateViewRegistry();
1165
+ const frozen = source.create({ contributorRoot });
1166
+ const state = {
1167
+ lineageId: "restore-bind-failure",
1168
+ contributorRoot,
1169
+ baseCommit: frozen.baseCommit,
1170
+ baseTree: frozen.baseTree,
1171
+ candidateTree: frozen.candidateTree,
1172
+ paths: frozen.paths,
1173
+ modes: frozen.modes,
1174
+ deletedPaths: frozen.deletedPaths,
1175
+ selectedLenses: ["review-reliability"],
1176
+ };
1177
+ const { registry, materializedRoot, internals } = bindingFailureRegistry(t);
1178
+ const originalBindRecord = internals.bindRecord;
1179
+ internals.bindRecord = (token) => {
1180
+ assert.equal(internals.records.has(token), true, "the failure must occur after insertion");
1181
+ throw new CandidateViewError("test-only bind failure");
1182
+ };
1183
+ try {
1184
+ assert.throws(
1185
+ () => registry.restoreCurrentFromAuthoritativeReviewingStates(contributorRoot, [state]),
1186
+ CandidateViewError,
1187
+ );
1188
+ } finally {
1189
+ internals.bindRecord = originalBindRecord;
1190
+ source.cleanup(frozen.token);
1191
+ }
1192
+ assert.equal(internals.records.size, 0);
1193
+ assert.ok(materializedRoot(), "the restore must have created a candidate worktree");
1194
+ assert.equal(existsSync(materializedRoot()!), false);
1195
+ });
1196
+
1197
+ test("failed finalize restores remove the inserted registry record and readonly worktree", (t) => {
1198
+ const contributorRoot = repository(t);
1199
+ writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
1200
+ const source = new CandidateViewRegistry();
1201
+ const frozen = source.create({ contributorRoot });
1202
+ const { registry, materializedRoot, internals } = bindingFailureRegistry(t);
1203
+ const originalBindRecord = internals.bindRecord;
1204
+ internals.bindRecord = (token) => {
1205
+ assert.equal(internals.records.has(token), true, "the failure must occur after insertion");
1206
+ throw new CandidateViewError("test-only bind failure");
1207
+ };
1208
+ try {
1209
+ assert.throws(
1210
+ () => registry.restoreForFinalizeFromNative("finalize-bind-failure", contributorRoot, {
1211
+ baseTree: frozen.baseTree,
1212
+ currentCandidateTree: frozen.candidateTree,
1213
+ paths: frozen.paths,
1214
+ intendedUntracked: [],
1215
+ projection: "workspace",
1216
+ }),
1217
+ CandidateViewError,
1218
+ );
1219
+ } finally {
1220
+ internals.bindRecord = originalBindRecord;
1221
+ source.cleanup(frozen.token);
1222
+ }
1223
+ assert.equal(internals.records.size, 0);
1224
+ assert.ok(materializedRoot(), "the restore must have created a candidate worktree");
1225
+ assert.equal(existsSync(materializedRoot()!), false);
1226
+ });
1227
+
1228
+ function materializationFailureRegistry(t: test.TestContext, failureAt: number): { registry: CandidateViewRegistry; roots: () => readonly string[]; removalAttempts: (root: string) => number; internals: CandidateViewRegistryInternals } {
1229
+ const roots: string[] = [];
1230
+ const removalAttempts = new Map<string, number>();
1231
+ let materializations = 0;
1232
+ let failPending = true;
1233
+ const registry = new CandidateViewRegistry((file, arguments_, options) => {
1234
+ if (arguments_[0] === "worktree" && arguments_[1] === "add") {
1235
+ const root = arguments_[arguments_.indexOf("--no-checkout") + 1];
1236
+ assert.equal(typeof root, "string", "worktree add must name its candidate root");
1237
+ roots.push(root);
1238
+ materializations += 1;
1239
+ }
1240
+ if (arguments_[0] === "worktree" && arguments_[1] === "remove") {
1241
+ const root = arguments_[arguments_.indexOf("--force") + 1];
1242
+ assert.equal(typeof root, "string", "worktree remove must name its candidate root");
1243
+ removalAttempts.set(root, (removalAttempts.get(root) ?? 0) + 1);
1244
+ }
1245
+ if (failPending && materializations === failureAt && options.cwd === roots.at(-1) && arguments_[0] === "read-tree") {
1246
+ failPending = false;
1247
+ throw Object.assign(new Error("test-only materialization failure"), { status: 1 });
1248
+ }
1249
+ return execFileSync(file, arguments_, options);
1250
+ });
1251
+ t.after(() => registry.cleanupAll());
1252
+ return { registry, roots: () => roots, removalAttempts: (root) => removalAttempts.get(root) ?? 0, internals: registry as unknown as CandidateViewRegistryInternals };
1253
+ }
1254
+
1255
+ test("finalize restore removes a registered projection when its first materialization fails and retries cleanly", (t) => {
1256
+ const contributorRoot = repository(t);
1257
+ writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
1258
+ const source = new CandidateViewRegistry();
1259
+ const frozen = source.create({ contributorRoot });
1260
+ const lineageId = "finalize-materialization-failure";
1261
+ const descriptor = {
1262
+ baseTree: frozen.baseTree,
1263
+ currentCandidateTree: frozen.candidateTree,
1264
+ paths: frozen.paths,
1265
+ intendedUntracked: [],
1266
+ projection: "workspace" as const,
1267
+ };
1268
+ const baselineWorktrees = git(contributorRoot, "worktree", "list", "--porcelain");
1269
+ const { registry, roots, internals } = materializationFailureRegistry(t, 1);
1270
+ try {
1271
+ assert.throws(() => registry.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor), CandidateViewError);
1272
+ assert.equal(registry.hasProjection(lineageId, contributorRoot), false);
1273
+ assert.equal(internals.records.size, 0);
1274
+ assert.equal(git(contributorRoot, "worktree", "list", "--porcelain"), baselineWorktrees);
1275
+ assert.equal(existsSync(roots()[0]!), false);
1276
+
1277
+ const restored = registry.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor);
1278
+ assert.equal(registry.resolveForFinalize(lineageId, contributorRoot).token, restored.token);
1279
+ registry.cleanup(restored.token);
1280
+ } finally {
1281
+ source.cleanup(frozen.token);
1282
+ }
1283
+ });
1284
+
1285
+ test("finalize restore does not re-remove its first fallback record when the empty-untracked retry materialization fails", (t) => {
1286
+ const contributorRoot = repository(t);
1287
+ writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
1288
+ writeFileSync(join(contributorRoot, "excluded.txt"), "excluded\n");
1289
+ const source = new CandidateViewRegistry();
1290
+ const frozen = source.create({ contributorRoot, intendedUntracked: [] });
1291
+ const lineageId = "finalize-fallback-materialization-failure";
1292
+ const descriptor = {
1293
+ baseTree: frozen.baseTree,
1294
+ currentCandidateTree: frozen.candidateTree,
1295
+ paths: frozen.paths,
1296
+ intendedUntracked: [],
1297
+ projection: "workspace" as const,
1298
+ };
1299
+ const baselineWorktrees = git(contributorRoot, "worktree", "list", "--porcelain");
1300
+ const { registry, roots, removalAttempts, internals } = materializationFailureRegistry(t, 2);
1301
+ try {
1302
+ assert.throws(() => registry.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor), CandidateViewError);
1303
+ assert.equal(removalAttempts(roots()[0]!), 1, "the first mismatched record must receive exactly one physical worktree removal");
1304
+ assert.equal(registry.hasProjection(lineageId, contributorRoot), false);
1305
+ assert.equal(internals.records.size, 0);
1306
+ assert.equal(git(contributorRoot, "worktree", "list", "--porcelain"), baselineWorktrees);
1307
+ assert.equal(existsSync(roots()[0]!), false);
1308
+ assert.equal(existsSync(roots()[1]!), false);
1309
+
1310
+ const restored = registry.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor);
1311
+ assert.equal(registry.resolveForFinalize(lineageId, contributorRoot).token, restored.token);
1312
+ registry.cleanup(restored.token);
1313
+ } finally {
1314
+ source.cleanup(frozen.token);
1315
+ }
1316
+ });
1317
+
1318
+ test("dispatch restore removes a registered projection when materialization fails and records the hydration failure before retrying", (t) => {
1319
+ const contributorRoot = repository(t);
1320
+ writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
1321
+ const source = new CandidateViewRegistry();
1322
+ const frozen = source.create({ contributorRoot });
1323
+ const lineageId = "dispatch-materialization-failure";
1324
+ const descriptor = {
1325
+ baseTree: frozen.baseTree,
1326
+ currentCandidateTree: frozen.candidateTree,
1327
+ paths: frozen.paths,
1328
+ intendedUntracked: [],
1329
+ projection: "workspace" as const,
1330
+ };
1331
+ const baselineWorktrees = git(contributorRoot, "worktree", "list", "--porcelain");
1332
+ const { registry, roots, internals } = materializationFailureRegistry(t, 1);
1333
+ try {
1334
+ let failure: unknown;
1335
+ try {
1336
+ registry.restoreCurrentForDispatchFromNative(lineageId, contributorRoot, descriptor, ["review-reliability"]);
1337
+ } catch (error) {
1338
+ failure = error;
1339
+ }
1340
+ assert.ok(failure instanceof CandidateViewError);
1341
+ assert.equal(registry.hasProjection(lineageId, contributorRoot), false);
1342
+ assert.equal(registry.hasCurrentBinding(contributorRoot), false);
1343
+ assert.equal(internals.records.size, 0);
1344
+ assert.equal(git(contributorRoot, "worktree", "list", "--porcelain"), baselineWorktrees);
1345
+ assert.equal(existsSync(roots()[0]!), false);
1346
+ assert.deepEqual(registry.lastDispatchHydrationFailure(contributorRoot), {
1347
+ lineageId,
1348
+ reason: failure.reason,
1349
+ message: failure.message,
1350
+ });
1351
+
1352
+ registry.restoreCurrentForDispatchFromNative(lineageId, contributorRoot, descriptor, ["review-reliability"]);
1353
+ const restored = registry.resolveCurrentForLens("review-reliability", contributorRoot);
1354
+ assert.equal(registry.lastDispatchHydrationFailure(contributorRoot), undefined);
1355
+ registry.cleanup(restored.token);
1356
+ } finally {
1357
+ source.cleanup(frozen.token);
1358
+ }
1359
+ });
1360
+
1361
+ test("candidate root resolution drift is exposed as a typed candidate-view error", () => {
1362
+ const missingRoot = join(tmpdir(), `gentle-pi-missing-root-${process.pid}-${Date.now()}`);
1363
+ assert.throws(
1364
+ () => new CandidateViewRegistry().hasCurrentBinding(missingRoot),
1365
+ (error: unknown) => error instanceof CandidateViewError && error.reason === "contributor-root-unresolvable",
1366
+ );
1367
+ });
1368
+
963
1369
  test("live candidate drift blocks dispatch before candidate text can be injected", (t) => {
964
1370
  const contributorRoot = repository(t);
965
1371
  writeFileSync(join(contributorRoot, "tracked.txt"), "reviewed\n");
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import * as nativeReviewCli from "../lib/native-review-cli.ts";
3
4
  import {
4
5
  CompactReviewContractError,
5
6
  parseNativeCompactFinalizeInput,
@@ -8,22 +9,8 @@ import {
8
9
 
9
10
  const REQUEST_HASH = "a".repeat(64);
10
11
 
11
- // gentle-pi#311 P5: the FINALIZE input contract carries only the negotiated
12
- // collection answers. Pi-authored reviewer, refuter, and validator-proof
13
- // payloads are retired — lens results are admitted natively, and the
14
- // adversarial roles execute through Go-owned pi processes via
15
- // provider-rendered self-contained vectors.
16
- test("compact finalize parser rejects the retired Pi-authored payload fields", () => {
17
- for (const retired of [
18
- { review_result: { lens_results: [] } },
19
- { refuter_batch: { schema: "gentle-ai.refuter-result-batch/v1", request_hash: REQUEST_HASH, results: [] } },
20
- { validation_proof: { original_criteria: { passed: true, evidence: ["ok"] }, correction_regression: { passed: true, evidence: ["ok"] } } },
21
- ]) {
22
- assert.throws(
23
- () => parseNativeCompactFinalizeInput({ cwd: "/repo", ...retired }),
24
- (error: unknown) => error instanceof CompactReviewContractError && error.code === "unknown-key",
25
- );
26
- }
12
+ test("the native client exposes no legacy compact input parser", () => {
13
+ assert.equal("parseNativeCompactFinalizeInput" in nativeReviewCli, false);
27
14
  });
28
15
 
29
16
  test("compact finalize parser enforces the final evidence pairing and forecast range", () => {
@@ -46,8 +33,6 @@ test("native finalize preserves arbitrary non-empty evidence text byte-for-byte"
46
33
  for (const outcome of ["passed", "verification_failed", "procedural_tooling_failed"] as const) {
47
34
  assert.equal(parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: evidence, final_verification_outcome: outcome }).final_verification_outcome, outcome);
48
35
  }
49
- assert.throws(() => parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: evidence, final_verification_outcome: "failed" }), CompactReviewContractError);
50
- assert.throws(() => parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: evidence, final_verification_passed: true, final_verification_outcome: "passed" }), CompactReviewContractError);
51
36
  assert.throws(() => parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: "", final_verification_passed: true }), CompactReviewContractError);
52
37
  });
53
38
 
@@ -61,21 +46,10 @@ test("targeted validation document keeps its strict provider-bound shape", () =>
61
46
  follow_ups: [{ finding_id: "RISK-001", location: "lib/a.ts:1", summary: "Track the remaining cleanup", proof_refs: ["differential-test:covered"] }],
62
47
  };
63
48
  const parsed = parseNativeCompactFinalizeInput({ cwd: "/repo", validation, final_evidence: "full suite passed", final_verification_passed: true });
64
- assert.deepEqual(parsed.validation, validation);
65
49
  assert.deepEqual(toNativeValidatorDocument(parsed.validation!), {
66
50
  original_criteria: validation.original_criteria,
67
51
  correction_regression: validation.correction_regression,
68
52
  follow_ups: [{ observation: "Track the remaining cleanup", proof_refs: ["differential-test:covered"] }],
69
53
  });
70
-
71
- for (const invalid of [
72
- { ...validation, request_hash: "not-a-digest" },
73
- { ...validation, fix_caused_findings: [{ id: "FIX-001" }] },
74
- { ...validation, original_criteria: { passed: true, evidence: [] } },
75
- { ...validation, correction_regression: { passed: "yes", evidence: ["ok"] } },
76
- { ...validation, follow_ups: [{ ...validation.follow_ups[0], proof_refs: [] }] },
77
- { ...validation, extra_field: true },
78
- ]) {
79
- assert.throws(() => parseNativeCompactFinalizeInput({ cwd: "/repo", validation: invalid, final_evidence: "evidence", final_verification_passed: true }), CompactReviewContractError);
80
- }
54
+ assert.throws(() => parseNativeCompactFinalizeInput({ cwd: "/repo", validation: { ...validation, request_hash: "not-a-digest" }, final_evidence: "evidence", final_verification_passed: true }), CompactReviewContractError);
81
55
  });