gentle-pi 2.2.0 → 2.3.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 (130) hide show
  1. package/README.md +51 -80
  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-proposal.md +2 -0
  8. package/assets/agents/sdd-research.md +54 -0
  9. package/assets/agents/sdd-status.md +4 -4
  10. package/assets/agents/sdd-tasks.md +2 -3
  11. package/assets/agents/sdd-verify.md +21 -1
  12. package/assets/chains/sdd-full.chain.md +1 -1
  13. package/assets/chains/sdd-verify.chain.md +1 -1
  14. package/assets/orchestrator-delegation.md +54 -248
  15. package/assets/orchestrator-memory.md +2 -0
  16. package/assets/orchestrator.md +22 -38
  17. package/assets/sdd-orchestrator-workflow.md +30 -37
  18. package/assets/support/sdd-status-contract.md +7 -7
  19. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  20. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  21. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  22. package/docs/native-authority-architecture.md +9 -11
  23. package/docs/review-integration.md +27 -373
  24. package/extensions/ask-user-choice.ts +151 -0
  25. package/extensions/gentle-ai.ts +1778 -3673
  26. package/extensions/quiet-tools.ts +515 -32
  27. package/extensions/sdd-init.ts +4 -8
  28. package/lib/gentle-ai-renderer.ts +70 -0
  29. package/lib/model-routing-authority.ts +133 -0
  30. package/lib/native-review-cli.ts +372 -940
  31. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  32. package/lib/review-candidate-view.ts +341 -132
  33. package/lib/review-host-relay.ts +210 -68
  34. package/lib/review-integration-v2.ts +839 -307
  35. package/lib/review-last-event-controller.ts +35 -0
  36. package/lib/sdd-preflight.ts +177 -66
  37. package/lib/sdd-status.ts +66 -111
  38. package/lib/terminal-theme.ts +1 -1
  39. package/package.json +83 -82
  40. package/runtime/gentle-ai-binary.mjs +1 -1
  41. package/runtime/native-review-cli.mjs +322 -890
  42. package/runtime/review-integration-v2.mjs +789 -257
  43. package/runtime/review-relay-contract.mjs +1 -1
  44. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  45. package/scripts/gentle-ai-installer.mjs +75 -22
  46. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  47. package/scripts/test-packed-runner.mjs +4 -7
  48. package/scripts/verify-package-files.mjs +12 -13
  49. package/skills/_shared/review-ledger-contract.md +8 -14
  50. package/skills/chained-pr/SKILL.md +3 -0
  51. package/skills/cognitive-doc-design/SKILL.md +1 -1
  52. package/skills/comment-writer/SKILL.md +1 -1
  53. package/skills/gentle-ai/SKILL.md +7 -74
  54. package/skills/judgment-day/SKILL.md +5 -7
  55. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  56. package/skills/release/SKILL.md +3 -3
  57. package/skills/skill-registry/SKILL.md +1 -1
  58. package/skills/work-unit-commits/SKILL.md +3 -1
  59. package/tests/artifact-language.test.ts +24 -11
  60. package/tests/ask-user-choice.test.ts +264 -0
  61. package/tests/codegraph-tools.test.ts +3 -3
  62. package/tests/crosslane/cross-lane.mjs +15 -1168
  63. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  64. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  65. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  66. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  67. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  68. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  69. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  70. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  71. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  72. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  73. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  74. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  75. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  76. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  77. package/tests/gentle-ai-binary.test.ts +83 -13
  78. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  79. package/tests/gentle-ai-installer.test.ts +137 -47
  80. package/tests/gentle-ai.test.ts +820 -131
  81. package/tests/gentle-theme.test.ts +133 -0
  82. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  83. package/tests/model-routing-authority.test.ts +257 -0
  84. package/tests/native-review-capability-contract.test.ts +28 -1
  85. package/tests/native-review-cli.test.ts +586 -962
  86. package/tests/native-review-consent.test.ts +150 -66
  87. package/tests/native-review-parity-runtime.test.ts +80 -359
  88. package/tests/native-review-parity.test.ts +674 -925
  89. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  90. package/tests/orchestrator-budget.test.ts +132 -88
  91. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  92. package/tests/package-manifest.test.ts +37 -35
  93. package/tests/provider-defect-handoff.test.ts +15 -118
  94. package/tests/quiet-tool-rendering.test.ts +1055 -28
  95. package/tests/review-authority-recovery-docs.test.ts +1 -2
  96. package/tests/review-candidate-view.test.ts +409 -3
  97. package/tests/review-compact-contract.test.ts +4 -30
  98. package/tests/review-controller-lock-status.test.ts +2 -2
  99. package/tests/review-controller-native-recovery.test.ts +363 -1450
  100. package/tests/review-controller-native-routing.test.ts +1251 -5642
  101. package/tests/review-controller-retired-ops.test.ts +1 -1
  102. package/tests/review-controller-workspace-root.test.ts +222 -71
  103. package/tests/review-controller.test.ts +26 -816
  104. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  105. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  106. package/tests/review-gate.test.ts +0 -45
  107. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  108. package/tests/review-host-relay-routing.test.ts +117 -82
  109. package/tests/review-host-relay.test.ts +242 -8
  110. package/tests/review-integration-v2-forward.test.ts +576 -553
  111. package/tests/review-integration-v2.test.ts +239 -165
  112. package/tests/review-last-event-closure.test.ts +408 -0
  113. package/tests/review-ledger-contract.test.ts +97 -35
  114. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  115. package/tests/review-relay-transport-agent.test.ts +119 -46
  116. package/tests/review-snapshot.test.ts +3 -2
  117. package/tests/runtime-harness.mjs +361 -196
  118. package/tests/sdd-agent-tools.test.ts +36 -0
  119. package/tests/sdd-preflight.test.ts +81 -15
  120. package/tests/sdd-status.test.ts +109 -110
  121. package/tests/skill-collision-prefixes.test.ts +5 -8
  122. package/tests/writer-edit-surface-scope.test.ts +230 -0
  123. package/themes/Gentleman-Cute.json +94 -0
  124. package/themes/Gentleman-Sexy.json +92 -0
  125. package/lib/git-commit-transaction.ts +0 -861
  126. package/runtime/git-commit-transaction.mjs +0 -862
  127. package/scripts/run-git-commit-transaction.mjs +0 -35
  128. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  129. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  130. package/tests/git-commit-transaction.test.ts +0 -530
@@ -17,11 +17,10 @@ test("recovery guidance documents the narrow published native maintenance contra
17
17
  assert.match(README, /review dispose-result.*unsupported.*pending.*design/i);
18
18
  assert.match(README, /RESET.*RECOVER.*destructive/i);
19
19
  assert.match(README, /typed envelopes/i);
20
- assert.match(README, /pre-commit.*pre-push.*pre-PR.*release/is);
21
20
  });
22
21
 
23
22
  test("controller help keeps authorization, blocked outcomes, and recovery boundaries explicit", () => {
24
- assert.match(CONTROLLER, /v2\.1\.11 repair-legacy-alias.*fresh native inventory.*fresh UI approval/is);
23
+ assert.match(CONTROLLER, /REPAIR_LEGACY_ALIAS.*freshly reads native inventory.*interactive approval/is);
25
24
  assert.match(CONTROLLER, /unchanged_target,malformed_recovery_authorization/);
26
25
  assert.match(CONTROLLER, /provider-selected recovery disposition/);
27
26
  assert.match(CONTROLLER, /headlessly|headless/i);
@@ -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
  });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
@@ -47,7 +47,7 @@ function context(cwd: string): ExtensionContext {
47
47
  }
48
48
 
49
49
  function repository(t: test.TestContext): string {
50
- const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-lock-status-"));
50
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-lock-status-")));
51
51
  t.after(() => rmSync(cwd, { recursive: true, force: true }));
52
52
  execFileSync("git", ["init", "-b", "main"], { cwd });
53
53
  writeFileSync(join(cwd, "app.ts"), "export const value = 1;\n");