gentle-pi 1.2.0 → 2.1.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 (111) hide show
  1. package/README.md +32 -10
  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 +647 -58
  65. package/lib/gentle-ai-binary.ts +65 -18
  66. package/lib/native-review-cli.ts +824 -56
  67. package/lib/review-candidate-view.ts +135 -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 +1567 -0
  72. package/package.json +15 -11
  73. package/runtime/gentle-ai-binary.mjs +65 -18
  74. package/runtime/native-review-cli.mjs +808 -40
  75. package/runtime/review-integration-v2.mjs +1568 -0
  76. package/scripts/build-git-commit-transaction-runner.mjs +1 -1
  77. package/scripts/gentle-ai-installer.mjs +464 -76
  78. package/scripts/install-gentle-ai.mjs +7 -3
  79. package/scripts/test-packed-runner.mjs +3 -3
  80. package/scripts/verify-package-files.mjs +216 -64
  81. package/skills/_shared/review-ledger-contract.md +3 -1
  82. package/skills/release/SKILL.md +38 -11
  83. package/tests/devbinary/native-review-parity.devtest.ts +348 -0
  84. package/tests/gentle-ai-binary.test.ts +77 -3
  85. package/tests/gentle-ai-installer.test.ts +561 -22
  86. package/tests/gentle-ai.test.ts +2 -2
  87. package/tests/native-binary-gate.test.ts +145 -0
  88. package/tests/native-review-capability-contract.test.ts +87 -0
  89. package/tests/native-review-cli.test.ts +44 -1
  90. package/tests/native-review-consent.test.ts +128 -0
  91. package/tests/native-review-parity-runtime.test.ts +85 -29
  92. package/tests/native-review-parity.test.ts +782 -0
  93. package/tests/native-risk-evidence-parity.test.ts +91 -0
  94. package/tests/package-manifest.test.ts +80 -8
  95. package/tests/review-actor-tool-deny.test.ts +20 -5
  96. package/tests/review-candidate-view.test.ts +374 -1
  97. package/tests/review-compact-contract.test.ts +5 -0
  98. package/tests/review-consent-latch.test.ts +67 -0
  99. package/tests/review-controller-native-recovery.test.ts +180 -5
  100. package/tests/review-controller-native-routing.test.ts +632 -18
  101. package/tests/review-controller-workspace-root.test.ts +26 -6
  102. package/tests/review-controller.test.ts +1 -1
  103. package/tests/review-correction-lifecycle.test.ts +174 -0
  104. package/tests/review-integration-v2.test.ts +548 -0
  105. package/tests/runtime-harness.mjs +211 -12
  106. package/tests/support/native-binary-gate.ts +86 -0
  107. package/tests/verify-package-files.test.ts +119 -0
  108. package/lib/review-integration-v1.ts +0 -795
  109. package/runtime/review-integration-v1.mjs +0 -796
  110. package/tests/native-review-integration-v1.test.ts +0 -102
  111. package/tests/review-integration-v1.test.ts +0 -530
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { nativeRiskEvidencePhrases } from "../lib/native-review-cli.ts";
4
+
5
+ // gentle-ai's negotiated `start/v2` envelope is a closed schema, so it cannot
6
+ // carry the `risk_evidence` phrases its plain sibling emits. Pi renders them
7
+ // from `risk_reasons`, which start/v2 does carry, and that makes Pi a second
8
+ // surface for a vocabulary whose Go home (internal/cli/review_mode.go) is
9
+ // documented as the single phrasing source precisely so surfaces cannot drift.
10
+ //
11
+ // These cases are field truth, not hand-written expectations: each pair was
12
+ // captured from the real gentle-ai v2.2.0 binary run twice over one candidate,
13
+ // once plain for `risk_evidence` and once negotiated for `risk_reasons`. If a
14
+ // future gentle-ai changes a phrase, this fails instead of Pi quietly showing
15
+ // an operator a sentence the CLI stopped saying.
16
+ const FIELD_CAPTURED_V220 = [
17
+ {
18
+ name: "hot path, auth signal",
19
+ riskLevel: "high",
20
+ riskReasons: [{ code: "hot_path", signal: "auth", path: "auth/login.go" }],
21
+ riskEvidence: ["authentication in auth/login.go"],
22
+ },
23
+ {
24
+ name: "hot path, payments signal",
25
+ riskLevel: "high",
26
+ riskReasons: [{ code: "hot_path", signal: "payments", path: "billing/payments.go" }],
27
+ riskEvidence: ["payments in billing/payments.go"],
28
+ },
29
+ {
30
+ name: "medium risk leads with the consolidated-review reason",
31
+ riskLevel: "medium",
32
+ riskReasons: [{ code: "configuration_change", path: "config.yaml" }],
33
+ riskEvidence: [
34
+ "this change is not purely passive documentation, so it gets one consolidated review.",
35
+ "a configuration change in config.yaml",
36
+ ],
37
+ },
38
+ {
39
+ name: "multiple reasons keep native order",
40
+ riskLevel: "high",
41
+ riskReasons: [
42
+ { code: "executable_mode", signal: "permissions", path: "run.sh", old_mode: "000000", new_mode: "100755" },
43
+ { code: "process_boundary", signal: "shell_process", path: "run.sh" },
44
+ { code: "shell_source", signal: "shell_process", path: "run.sh" },
45
+ ],
46
+ riskEvidence: [
47
+ "an executable permission change in run.sh",
48
+ "code that starts other processes in run.sh",
49
+ "shell scripting in run.sh",
50
+ ],
51
+ },
52
+ ] as const;
53
+
54
+ for (const scenario of FIELD_CAPTURED_V220) {
55
+ test(`derived risk evidence matches native v2.2.0: ${scenario.name}`, () => {
56
+ assert.deepEqual(
57
+ nativeRiskEvidencePhrases(scenario.riskLevel, scenario.riskReasons as unknown as Record<string, unknown>[]),
58
+ scenario.riskEvidence,
59
+ );
60
+ });
61
+ }
62
+
63
+ test("low risk speaks no evidence at all", () => {
64
+ assert.deepEqual(nativeRiskEvidencePhrases("low", [{ code: "hot_path", signal: "auth", path: "a.go" }]), []);
65
+ });
66
+
67
+ test("an unrecognized reason code renders nothing rather than guessing", () => {
68
+ // Degrading to silence keeps Pi from inventing a phrase for a reason a
69
+ // newer gentle-ai understands and this mirror does not.
70
+ assert.deepEqual(nativeRiskEvidencePhrases("high", [{ code: "reason_from_a_newer_cli", path: "x.go" }]), []);
71
+ });
72
+
73
+ test("an unrecognized hot-path signal still names the path", () => {
74
+ // The Go signal switch has no silent default: it degrades to a generic
75
+ // subject so the operator still learns which file drove the tier.
76
+ assert.deepEqual(
77
+ nativeRiskEvidencePhrases("high", [{ code: "hot_path", signal: "signal_from_a_newer_cli", path: "x.go" }]),
78
+ ["a sensitive area in x.go"],
79
+ );
80
+ });
81
+
82
+ test("an empty file is named before it is described", () => {
83
+ assert.deepEqual(
84
+ nativeRiskEvidencePhrases("high", [{ code: "empty_content", path: "blank.txt" }]),
85
+ ["blank.txt, an empty file whose type cannot be determined from its content"],
86
+ );
87
+ });
88
+
89
+ test("a reason with no path contributes no phrase", () => {
90
+ assert.deepEqual(nativeRiskEvidencePhrases("high", [{ code: "empty_content" }]), []);
91
+ });
@@ -85,6 +85,10 @@ interface PackageJson {
85
85
  dependencies?: Record<string, string>;
86
86
  bundledDependencies?: string[];
87
87
  bundleDependencies?: string[];
88
+ repository?: {
89
+ type?: string;
90
+ url?: string;
91
+ };
88
92
  pi?: PackageJsonPiManifest;
89
93
  }
90
94
 
@@ -126,6 +130,67 @@ test("package verification names the native review runtime boundary and packaged
126
130
  );
127
131
  });
128
132
 
133
+ test("npm publication is bound to the exact package tag and triggering commit", () => {
134
+ const workflow = readFileSync(join(PACKAGE_ROOT, ".github", "workflows", "publish.yml"), "utf8");
135
+ const releaseSkill = readFileSync(join(PACKAGE_ROOT, "skills", "release", "SKILL.md"), "utf8");
136
+ const packageJson = readPackageJson();
137
+ const dispatchBlock = workflow.match(
138
+ /^ {2}workflow_dispatch:\n([\s\S]*?)^\npermissions:/m,
139
+ )?.[1];
140
+ assert.ok(dispatchBlock);
141
+ const inputNames = [
142
+ ...dispatchBlock.matchAll(/^ {6}([A-Za-z0-9_-]+):$/gm),
143
+ ].map((match) => match[1]);
144
+
145
+ assert.match(workflow, /on:\n\s+workflow_dispatch:\s*\n/);
146
+ assert.deepEqual(inputNames, ["tag"], "the trusted workflow must expose exactly one caller input");
147
+ assert.match(workflow, /inputs:\n\s+tag:/, "the trusted main workflow must accept only the release tag");
148
+ assert.match(workflow, /RELEASE_TAG: \$\{\{ inputs\.tag \}\}/);
149
+ assert.doesNotMatch(workflow, /checkout-ref|dist-tag.*inputs|inputs\.(?!tag)/, "the release workflow must not accept checkout or dist-tag inputs");
150
+ assert.match(workflow, /ref: \$\{\{ github\.sha \}\}/, "checkout must use the immutable event SHA");
151
+ assert.match(workflow, /persist-credentials: false/, "the release checkout must not retain GitHub credentials");
152
+ assert.match(workflow, /DEFAULT_BRANCH: \$\{\{ github\.event\.repository\.default_branch \}\}/);
153
+ assert.match(workflow, /\$\{DEFAULT_BRANCH\}" != "main"/);
154
+ assert.match(workflow, /\$\{GITHUB_REF\}" != "refs\/heads\/main"/);
155
+ assert.match(workflow, /\$\{GITHUB_REF_TYPE\}" != "branch"/);
156
+ assert.match(workflow, /Release tag is not exact vSemVer/);
157
+ assert.match(workflow, /git ls-remote origin[\s\S]*"refs\/heads\/main"[\s\S]*"refs\/tags\/\$\{tag\}"/);
158
+ assert.match(workflow, /git fetch --atomic --no-tags origin/);
159
+ assert.match(workflow, /refs\/heads\/main:refs\/remotes\/origin\/release-main/);
160
+ assert.match(workflow, /refs\/tags\/\$\{tag\}:refs\/release-verification\/tag/);
161
+ assert.match(workflow, /git cat-file -t refs\/release-verification\/tag/);
162
+ assert.match(workflow, /git checkout --detach "\$\{tag_commit\}"/);
163
+ assert.match(workflow, /git rev-parse "\$\{GITHUB_SHA\}\^\{commit\}"/);
164
+ assert.match(workflow, /git rev-parse ['"]HEAD\^\{commit\}['"]/);
165
+ assert.match(workflow, /Reverify protected release authority and publish/);
166
+ assert.match(workflow, /Release authority changed after verification/);
167
+ assert.match(workflow, /id-token: write/, "trusted publishing requires OIDC");
168
+ assert.match(workflow, /node-version: "24"/, "trusted publishing must use a supported Node.js version");
169
+ assert.match(workflow, /const minimum = \[11, 5, 1\]/, "trusted publishing must reject npm versions below 11.5.1");
170
+ assert.match(workflow, /packageJson\.repository\?\.type !== expectedRepository\.type/);
171
+ assert.match(workflow, /packageJson\.repository\?\.url !== expectedRepository\.url/);
172
+ assert.deepEqual(
173
+ packageJson.repository,
174
+ {
175
+ type: "git",
176
+ url: "git+https://github.com/Gentleman-Programming/gentle-pi.git",
177
+ },
178
+ "trusted publishing requires the exact case-sensitive npm repository identity",
179
+ );
180
+ assert.match(workflow, /npm publish --provenance --access public/);
181
+ assert.doesNotMatch(workflow, /pnpm publish|--no-git-checks|NODE_AUTH_TOKEN/);
182
+
183
+ assert.match(releaseSkill, /tag="v\$\{version\}"/);
184
+ assert.match(releaseSkill, /release_sha="\$\(git rev-parse 'origin\/main\^\{commit\}'\)"/);
185
+ assert.match(releaseSkill, /git rev-parse "\$\{tag\}\^\{commit\}"/);
186
+ assert.match(releaseSkill, /git fetch --no-tags origin "refs\/tags\/\$\{tag\}"/);
187
+ assert.match(releaseSkill, /gh release create "\$\{tag\}"[\s\S]*--verify-tag/);
188
+ assert.match(releaseSkill, /--ref main/);
189
+ assert.match(releaseSkill, /-f tag="\$\{tag\}"/);
190
+ assert.match(releaseSkill, /trusted OIDC with provenance/);
191
+ assert.doesNotMatch(releaseSkill, /--ref "\$\{tag\}"|-f dist-tag=/);
192
+ });
193
+
129
194
  test("publication gate is isolated from graph-v1 authority storage", () => {
130
195
  const extension = readFileSync(join(PACKAGE_ROOT, "extensions", "gentle-ai.ts"), "utf8");
131
196
  const gate = readFileSync(join(PACKAGE_ROOT, "lib", "review-publication-gate.ts"), "utf8");
@@ -147,11 +212,12 @@ test("installed commit transaction runner loads JavaScript only and has determin
147
212
  assert.equal(packageJson.scripts?.["test:packed-runner"], "node scripts/test-packed-runner.mjs");
148
213
  assert.match(packageJson.scripts?.prepublishOnly ?? "", /pnpm run test:packed-runner/);
149
214
  assert.match(ci, /pnpm run test:packed-runner/);
150
- assert.doesNotMatch(packedRunner, /\["install"[^\]]*"--ignore-scripts"/s);
215
+ assert.match(packedRunner, /\["install"[^\]]*"--ignore-scripts=false"/s, "packed install must explicitly enable postinstall");
216
+ assert.doesNotMatch(packedRunner, /\["install"[^\]]*"--ignore-scripts"(?!\=false)/s);
151
217
  assert.match(packedRunner, /execFileSync\("where\.exe", \["npm"\]/);
152
218
  assert.match(packedRunner, /could not resolve npm-cli\.js without a command shell/);
153
219
  assert.doesNotMatch(packedRunner, /ComSpec|cmd\.exe/);
154
- assert.match(packedRunner, /review", "capabilities", "--contract", "gentle-ai\.review-integration\/v1"/);
220
+ assert.match(packedRunner, /review", "capabilities", "--contract", "gentle-ai\.review-integration\/v2"/);
155
221
  assert.match(runner, /\.\.\/runtime\/git-commit-transaction\.mjs/);
156
222
  assert.doesNotMatch(runner, /\.ts["']/);
157
223
  assert.doesNotMatch(runner, /experimental-strip-types/);
@@ -204,14 +270,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
204
270
  );
205
271
  });
206
272
 
207
- test("package verification binds the published Gentle AI v2.1.11 runtime pin", () => {
273
+ test("package verification binds the published Gentle AI v2.2.2 runtime pin", () => {
208
274
  const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
209
275
  const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
210
276
  const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
211
277
 
212
- assert.match(installer, /INSTALLER_VERSION = "2\.1\.11"/);
213
- assert.match(binary, /GENTLE_AI_VERSION = "2\.1\.11"/);
214
- assert.match(verifier, /v2\.1\.11/);
278
+ assert.match(installer, /INSTALLER_VERSION = "2\.2\.2"/);
279
+ assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
280
+ assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:YZcI5dRvoHm82I2CULvgBkB2M3UQQGarYO\/u\/Nt5LSc="/);
281
+ assert.match(installer, /GOTOOLCHAIN: "local"/);
282
+ assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
283
+ assert.match(binary, /GENTLE_AI_VERSION = "2\.2\.2"/);
284
+ assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
285
+ assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
286
+ assert.match(verifier, /v2\.2\.2/);
215
287
  });
216
288
 
217
289
 
@@ -1065,9 +1137,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
1065
1137
  assert.match(wrapper, /quietToolsEnabled/);
1066
1138
  });
1067
1139
 
1068
- test("v1.2.0 release package and runtime stop before publication", () => {
1140
+ test("v2.1.0 release package and runtime stop before publication", () => {
1069
1141
  const packageJson = readPackageJson();
1070
- assert.equal(packageJson.version, "1.2.0", "the release manifest must remain explicitly pinned to v1.2.0");
1142
+ assert.equal(packageJson.version, "2.1.0", "the release manifest must remain explicitly pinned to v2.1.0");
1071
1143
  assert.equal(
1072
1144
  packageJson.scripts?.test,
1073
1145
  "node --experimental-strip-types --test tests/*.test.ts && pnpm run test:harness",
@@ -109,11 +109,26 @@ test("a tool absent from the allowlist resolves to denied under the deny-all rul
109
109
  assert.ok(!refuterActive.includes(omitted), `review-refuter must deny omitted tool ${omitted}`);
110
110
  }
111
111
 
112
- const riskEntries = readToolEntries(join(ASSETS_AGENTS_DIR, "review-risk.md"));
113
- const riskActive = resolveActiveTools(riskEntries, registry);
114
- assert.ok(riskActive.includes("bash"), "review-risk must keep its existing explicit bash allowance");
115
- assert.ok(!riskActive.includes("mem_save"), "review-risk must deny omitted memory tools");
116
- assert.ok(!riskActive.includes("mcp__slack__slack_send_message"), "review-risk must deny omitted MCP-namespaced tools");
112
+ // The four lenses deny bash, matching gentle-ai's own Claude-runtime lens
113
+ // agents (`tools: Read, Grep, Glob`) exactly. Contract v2 is explicit: a
114
+ // runtime that cannot enforce a per-command Git shell boundary exposes no
115
+ // shell and reports incomplete inspection rather than claiming backend
116
+ // enforcement. Pi has no per-command scoping layer, so unrestricted bash
117
+ // would let a reviewer read the LIVE worktree instead of the frozen
118
+ // candidate -- and approve bytes that are not the ones shipping. The frozen
119
+ // read-only candidate view Pi already materializes is the sanctioned path;
120
+ // bash was the one thing making it optional.
121
+ //
122
+ // gentle-ai's OpenCode variant does grant bash, but only behind permissions
123
+ // whose broad deny precedes narrow allows for specific Git shapes. That is
124
+ // the other half of the same rule, not an exception to it.
125
+ for (const lens of ["review-risk", "review-resilience", "review-readability", "review-reliability"]) {
126
+ const lensActive = resolveActiveTools(readToolEntries(join(ASSETS_AGENTS_DIR, `${lens}.md`)), registry);
127
+ assert.deepEqual(lensActive, ["read", "grep", "glob"], `${lens} must expose exactly read, grep, and glob`);
128
+ for (const omitted of ["bash", "edit", "write", "mem_save", "codegraph_explore", "mcp__slack__slack_send_message", "subagent_run"]) {
129
+ assert.ok(!lensActive.includes(omitted), `${lens} must deny ${omitted}`);
130
+ }
131
+ }
117
132
  });
118
133
 
119
134
  test("the deny-all rule never enables a tool and never empties the runtime allowlist", () => {
@@ -9,6 +9,8 @@ import {
9
9
  CandidateViewError,
10
10
  type CandidateGitExecutor,
11
11
  createCandidateView,
12
+ deriveChangedPathManifest,
13
+ digestChangedPathManifest,
12
14
  injectReviewCandidateView,
13
15
  } from "../lib/review-candidate-view.ts";
14
16
 
@@ -582,6 +584,49 @@ test("native projections reconstruct symlink, intended-untracked, and gitlink id
582
584
  assert.deepEqual(projection.gitlinks, { "vendor/dependency": gitlinkCommit });
583
585
  });
584
586
 
587
+ test("native staged projections restore the exact current index over HEAD", (t) => {
588
+ const contributorRoot = repository(t);
589
+ const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
590
+ writeFileSync(join(contributorRoot, "tracked.txt"), "staged candidate\n");
591
+ git(contributorRoot, "add", "tracked.txt");
592
+ const candidateTree = git(contributorRoot, "write-tree");
593
+ const registry = new CandidateViewRegistry();
594
+
595
+ registry.restoreProjectionFromNative("staged-index-projection", contributorRoot, {
596
+ baseTree,
597
+ currentCandidateTree: candidateTree,
598
+ paths: ["tracked.txt"],
599
+ intendedUntracked: [],
600
+ projection: "staged",
601
+ });
602
+
603
+ const projection = registry.resolveProjection("staged-index-projection", contributorRoot);
604
+ assert.equal(projection.baseTree, baseTree);
605
+ assert.equal(projection.candidateTree, candidateTree);
606
+ assert.equal(projection.committedOnly, false);
607
+ });
608
+
609
+ test("native staged projections reject a workspace snapshot that differs from the current index", (t) => {
610
+ const contributorRoot = repository(t);
611
+ const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
612
+ writeFileSync(join(contributorRoot, "tracked.txt"), "workspace candidate\n");
613
+ const workspace = new CandidateViewRegistry().create({ contributorRoot });
614
+ try {
615
+ assert.throws(
616
+ () => new CandidateViewRegistry().restoreProjectionFromNative("workspace-as-staged", contributorRoot, {
617
+ baseTree,
618
+ currentCandidateTree: workspace.candidateTree,
619
+ paths: ["tracked.txt"],
620
+ intendedUntracked: [],
621
+ projection: "staged",
622
+ }),
623
+ (error: unknown) => error instanceof CandidateViewError && error.reason === "projection-kind-drift",
624
+ );
625
+ } finally {
626
+ workspace.cleanup();
627
+ }
628
+ });
629
+
585
630
  test("native projections recover a committed range base from its frozen tree", (t) => {
586
631
  const contributorRoot = repository(t);
587
632
  const baseCommit = git(contributorRoot, "rev-parse", "HEAD");
@@ -596,7 +641,8 @@ test("native projections recover a committed range base from its frozen tree", (
596
641
  currentCandidateTree: candidateTree,
597
642
  paths: ["tracked.txt"],
598
643
  intendedUntracked: [],
599
- projection: "workspace",
644
+ // A committed range: HEAD moved past base with no dirty overlay.
645
+ projection: "staged",
600
646
  });
601
647
  const projection = registry.resolveProjection("committed-projection", contributorRoot);
602
648
  assert.equal(projection.baseCommit, baseCommit);
@@ -666,3 +712,330 @@ test("live candidate drift blocks dispatch before candidate text can be injected
666
712
  registry.cleanup(view.token);
667
713
  }
668
714
  });
715
+
716
+ // --- Phase 3: field-wise changed-path manifest binding -----------------------
717
+ //
718
+ // The sorted-path-set check these tests replace could not see a mode-only or a
719
+ // type change: a file turning executable between START and dispatch produced an
720
+ // identical path set, so the candidate Pi materialized could diverge from the
721
+ // provider's frozen one and the comparison still passed. `--name-status` never
722
+ // carried old_mode, which is why the manifest needs its own derivation.
723
+
724
+ function treeOf(cwd: string): string {
725
+ git(cwd, "add", "-A");
726
+ return git(cwd, "write-tree");
727
+ }
728
+
729
+ function baseTreeOf(cwd: string): string {
730
+ return git(cwd, "rev-parse", "HEAD^{tree}");
731
+ }
732
+
733
+ test("deriveChangedPathManifest reports old and new mode, and flags a mode-only change", (t) => {
734
+ const cwd = repository(t);
735
+ chmodSync(join(cwd, "tracked.txt"), 0o755);
736
+ const candidate = treeOf(cwd);
737
+
738
+ const manifest = deriveChangedPathManifest(cwd, baseTreeOf(cwd), candidate);
739
+
740
+ assert.deepEqual(manifest, [{
741
+ path: "tracked.txt",
742
+ status: "M",
743
+ oldMode: "100644",
744
+ newMode: "100755",
745
+ deleted: false,
746
+ typeChanged: false,
747
+ modeOnly: true,
748
+ }]);
749
+ });
750
+
751
+ test("deriveChangedPathManifest distinguishes content change from mode-only", (t) => {
752
+ const cwd = repository(t);
753
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
754
+ const manifest = deriveChangedPathManifest(cwd, baseTreeOf(cwd), treeOf(cwd));
755
+
756
+ assert.equal(manifest.length, 1);
757
+ assert.equal(manifest[0]?.modeOnly, false);
758
+ assert.equal(manifest[0]?.oldMode, "100644");
759
+ assert.equal(manifest[0]?.newMode, "100644");
760
+ });
761
+
762
+ test("deriveChangedPathManifest marks an added path and a deleted path", (t) => {
763
+ const cwd = repository(t);
764
+ writeFileSync(join(cwd, "added.txt"), "new\n");
765
+ rmSync(join(cwd, "tracked.txt"));
766
+ const manifest = deriveChangedPathManifest(cwd, baseTreeOf(cwd), treeOf(cwd));
767
+
768
+ const byPath = new Map(manifest.map((entry) => [entry.path, entry]));
769
+ assert.equal(byPath.get("added.txt")?.status, "A");
770
+ assert.equal(byPath.get("added.txt")?.deleted, false);
771
+ assert.equal(byPath.get("tracked.txt")?.status, "D");
772
+ assert.equal(byPath.get("tracked.txt")?.deleted, true);
773
+ });
774
+
775
+ test("a mode-only divergence is rejected even though the sorted path set matches", (t) => {
776
+ const cwd = repository(t);
777
+ chmodSync(join(cwd, "tracked.txt"), 0o755);
778
+ const candidate = treeOf(cwd);
779
+ const base = baseTreeOf(cwd);
780
+
781
+ // The provider froze the path as non-executable; Git now reports 100755.
782
+ // The old check compared only sorted paths and would accept this.
783
+ assert.throws(
784
+ () => new CandidateViewRegistry().restoreProjectionFromNative("review-mode-drift", cwd, {
785
+ baseTree: base,
786
+ currentCandidateTree: candidate,
787
+ paths: ["tracked.txt"],
788
+ intendedUntracked: [],
789
+ projection: "workspace",
790
+ manifest: [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }],
791
+ }),
792
+ (error: unknown) => error instanceof CandidateViewError && error.reason === "manifest-mode-drift",
793
+ );
794
+ });
795
+
796
+ test("a manifest whose path set differs from Git content is rejected as path-set drift", (t) => {
797
+ const cwd = repository(t);
798
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
799
+ const candidate = treeOf(cwd);
800
+ const base = baseTreeOf(cwd);
801
+
802
+ assert.throws(
803
+ () => new CandidateViewRegistry().restoreProjectionFromNative("review-path-drift", cwd, {
804
+ baseTree: base,
805
+ currentCandidateTree: candidate,
806
+ paths: ["tracked.txt", "ghost.txt"],
807
+ intendedUntracked: [],
808
+ projection: "workspace",
809
+ manifest: [
810
+ { path: "ghost.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false },
811
+ { path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false },
812
+ ],
813
+ }),
814
+ (error: unknown) => error instanceof CandidateViewError && error.reason === "manifest-path-set-drift",
815
+ );
816
+ });
817
+
818
+ test("a manifest whose status disagrees with Git is rejected as status drift", (t) => {
819
+ const cwd = repository(t);
820
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
821
+ const candidate = treeOf(cwd);
822
+ const base = baseTreeOf(cwd);
823
+
824
+ assert.throws(
825
+ () => new CandidateViewRegistry().restoreProjectionFromNative("review-status-drift", cwd, {
826
+ baseTree: base,
827
+ currentCandidateTree: candidate,
828
+ paths: ["tracked.txt"],
829
+ intendedUntracked: [],
830
+ projection: "workspace",
831
+ manifest: [{ path: "tracked.txt", status: "A", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }],
832
+ }),
833
+ (error: unknown) => error instanceof CandidateViewError && error.reason === "manifest-status-drift",
834
+ );
835
+ });
836
+
837
+ test("intended-untracked outside the manifest path set is rejected as a subset violation", (t) => {
838
+ const cwd = repository(t);
839
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
840
+ const candidate = treeOf(cwd);
841
+ const base = baseTreeOf(cwd);
842
+
843
+ // intended_untracked is provider-only knowledge no Git command reproduces,
844
+ // so it is checked structurally as a subset rather than compared field-wise.
845
+ // That is a deliberate, documented deviation from the spec's field list.
846
+ assert.throws(
847
+ () => new CandidateViewRegistry().restoreProjectionFromNative("review-untracked-drift", cwd, {
848
+ baseTree: base,
849
+ currentCandidateTree: candidate,
850
+ paths: ["tracked.txt"],
851
+ intendedUntracked: ["not-in-manifest.txt"],
852
+ projection: "workspace",
853
+ manifest: [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }],
854
+ }),
855
+ (error: unknown) => error instanceof CandidateViewError,
856
+ );
857
+ });
858
+
859
+ test("a manifest that disagrees with the descriptor's own paths is rejected as input divergence", (t) => {
860
+ const cwd = repository(t);
861
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
862
+ const candidate = treeOf(cwd);
863
+ const base = baseTreeOf(cwd);
864
+
865
+ assert.throws(
866
+ () => new CandidateViewRegistry().restoreProjectionFromNative("review-input-drift", cwd, {
867
+ baseTree: base,
868
+ currentCandidateTree: candidate,
869
+ paths: ["tracked.txt"],
870
+ intendedUntracked: [],
871
+ projection: "workspace",
872
+ manifest: [{ path: "other.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }],
873
+ }),
874
+ (error: unknown) => error instanceof CandidateViewError && error.reason === "manifest-input-divergence",
875
+ );
876
+ });
877
+
878
+ test("a manifest matching Git field-wise restores the projection", (t) => {
879
+ const cwd = repository(t);
880
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
881
+ const candidate = treeOf(cwd);
882
+ const base = baseTreeOf(cwd);
883
+
884
+ assert.doesNotThrow(() => new CandidateViewRegistry().restoreProjectionFromNative("review-manifest-ok", cwd, {
885
+ baseTree: base,
886
+ currentCandidateTree: candidate,
887
+ paths: ["tracked.txt"],
888
+ intendedUntracked: [],
889
+ projection: "workspace",
890
+ manifest: [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }],
891
+ }));
892
+ });
893
+
894
+ test("a descriptor without a manifest keeps the legacy path-set behavior", (t) => {
895
+ const cwd = repository(t);
896
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
897
+ const candidate = treeOf(cwd);
898
+ const base = baseTreeOf(cwd);
899
+
900
+ // Phase 3 is additive: the manifest is optional, and its absence must not
901
+ // change how existing callers behave.
902
+ assert.doesNotThrow(() => new CandidateViewRegistry().restoreProjectionFromNative("review-no-manifest", cwd, {
903
+ baseTree: base,
904
+ currentCandidateTree: candidate,
905
+ paths: ["tracked.txt"],
906
+ intendedUntracked: [],
907
+ projection: "workspace",
908
+ }));
909
+ });
910
+
911
+ // --- 3.1 completion: manifest-subject-drift ---------------------------------
912
+ //
913
+ // The v2 artifact-subject schema (contracts/review-integration/v2/schemas/
914
+ // artifact-subject.schema.json) requires `changed_path_manifest_sha256` on
915
+ // every subject. That is the provider's own claim about what the manifest it
916
+ // handed the dispatch digests to. A manifest that does not digest to that
917
+ // claim is a self-consistency failure Pi can catch without touching Git at
918
+ // all: the descriptor disagrees with itself before any content comparison.
919
+
920
+ test("a manifest whose digest disagrees with the subject's claim is rejected as subject drift", (t) => {
921
+ const cwd = repository(t);
922
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
923
+ const candidate = treeOf(cwd);
924
+ const base = baseTreeOf(cwd);
925
+ const manifest = [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }];
926
+
927
+ assert.throws(
928
+ () => new CandidateViewRegistry().restoreProjectionFromNative("review-subject-drift", cwd, {
929
+ baseTree: base,
930
+ currentCandidateTree: candidate,
931
+ paths: ["tracked.txt"],
932
+ intendedUntracked: [],
933
+ projection: "workspace",
934
+ manifest,
935
+ // A subject claim that cannot possibly match any real digest.
936
+ manifestSha256: `sha256:${"0".repeat(64)}`,
937
+ }),
938
+ (error: unknown) => error instanceof CandidateViewError && error.reason === "manifest-subject-drift",
939
+ );
940
+ });
941
+
942
+ test("a manifest digest matching the subject's claim restores the projection", (t) => {
943
+ const cwd = repository(t);
944
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
945
+ const candidate = treeOf(cwd);
946
+ const base = baseTreeOf(cwd);
947
+ const manifest = [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }];
948
+
949
+ assert.doesNotThrow(() => new CandidateViewRegistry().restoreProjectionFromNative("review-subject-ok", cwd, {
950
+ baseTree: base,
951
+ currentCandidateTree: candidate,
952
+ paths: ["tracked.txt"],
953
+ intendedUntracked: [],
954
+ projection: "workspace",
955
+ manifest,
956
+ manifestSha256: digestChangedPathManifest(manifest),
957
+ }));
958
+ });
959
+
960
+ test("a descriptor without a subject digest keeps validating the manifest without a subject-drift check", (t) => {
961
+ const cwd = repository(t);
962
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
963
+ const candidate = treeOf(cwd);
964
+ const base = baseTreeOf(cwd);
965
+ const manifest = [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }];
966
+
967
+ // `manifestSha256` is additive, mirroring `manifest` itself: its absence
968
+ // must not change existing manifest-bound behavior.
969
+ assert.doesNotThrow(() => new CandidateViewRegistry().restoreProjectionFromNative("review-no-subject-digest", cwd, {
970
+ baseTree: base,
971
+ currentCandidateTree: candidate,
972
+ paths: ["tracked.txt"],
973
+ intendedUntracked: [],
974
+ projection: "workspace",
975
+ manifest,
976
+ }));
977
+ });
978
+
979
+ // --- 3.4 threat matrix: Git repository selection ----------------------------
980
+ //
981
+ // A manifest binding must never widen the existing root-scoping guarantee:
982
+ // a projection frozen against one contributor root stays rejected when
983
+ // resolved against a different one, exactly as it was before manifests
984
+ // existed.
985
+
986
+ test("a manifest-bound projection is rejected when resolved against a different contributor root", (t) => {
987
+ const cwd = repository(t);
988
+ writeFileSync(join(cwd, "tracked.txt"), "changed\n");
989
+ const candidate = treeOf(cwd);
990
+ const base = baseTreeOf(cwd);
991
+ const manifest = [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }];
992
+ const registry = new CandidateViewRegistry();
993
+ registry.restoreProjectionFromNative("review-root-scope", cwd, {
994
+ baseTree: base,
995
+ currentCandidateTree: candidate,
996
+ paths: ["tracked.txt"],
997
+ intendedUntracked: [],
998
+ projection: "workspace",
999
+ manifest,
1000
+ });
1001
+
1002
+ const otherRoot = repository(t);
1003
+ assert.throws(
1004
+ () => registry.resolveProjection("review-root-scope", otherRoot),
1005
+ (error: unknown) => error instanceof CandidateViewError && /different contributor root/.test(error.message),
1006
+ );
1007
+ // The root guard still resolves correctly against the true root.
1008
+ assert.equal(registry.resolveProjection("review-root-scope", cwd).candidateTree, candidate);
1009
+ });
1010
+
1011
+ // --- 3.5 threat matrix: Commit state ----------------------------------------
1012
+ //
1013
+ // `projection` on the descriptor claims "staged" (a committed range) or
1014
+ // "workspace" (dirty-inclusive). `restoreProjectionFromNative` independently
1015
+ // derives `committedOnly` from the descriptor's own base/candidate
1016
+ // relationship to HEAD; the claimed label must agree with that derivation, or
1017
+ // the request is rejected rather than silently trusting — or silently
1018
+ // overriding — a mislabeled projection kind.
1019
+
1020
+ test("a projection labeled workspace but derived as a genuinely committed range is rejected as a projection-kind mismatch", (t) => {
1021
+ const cwd = repository(t);
1022
+ const baseTree = baseTreeOf(cwd);
1023
+ writeFileSync(join(cwd, "tracked.txt"), "committed candidate\n");
1024
+ git(cwd, "add", "tracked.txt");
1025
+ git(cwd, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "candidate");
1026
+ const candidateTree = git(cwd, "rev-parse", "HEAD^{tree}");
1027
+
1028
+ assert.throws(
1029
+ () => new CandidateViewRegistry().restoreProjectionFromNative("review-projection-kind-drift", cwd, {
1030
+ baseTree,
1031
+ currentCandidateTree: candidateTree,
1032
+ paths: ["tracked.txt"],
1033
+ intendedUntracked: [],
1034
+ // Git facts derive committedOnly=true (HEAD moved past base with no
1035
+ // dirty overlay), but the descriptor mislabels it as a workspace
1036
+ // snapshot.
1037
+ projection: "workspace",
1038
+ }),
1039
+ (error: unknown) => error instanceof CandidateViewError && error.reason === "projection-kind-drift",
1040
+ );
1041
+ });
@@ -121,6 +121,11 @@ test("compact finalize parser rejects malformed nested findings and incomplete f
121
121
  test("native finalize preserves arbitrary non-empty evidence text and binds refuter batches", () => {
122
122
  const evidence = " \tleading evidence\nterminal newlines\n\n";
123
123
  assert.equal(parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: evidence, final_verification_passed: true }).final_evidence, evidence);
124
+ for (const outcome of ["passed", "verification_failed", "procedural_tooling_failed"] as const) {
125
+ assert.equal(parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: evidence, final_verification_outcome: outcome }).final_verification_outcome, outcome);
126
+ }
127
+ assert.throws(() => parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: evidence, final_verification_outcome: "failed" }), CompactReviewContractError);
128
+ assert.throws(() => parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: evidence, final_verification_passed: true, final_verification_outcome: "passed" }), CompactReviewContractError);
124
129
  assert.throws(() => parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: "", final_verification_passed: true }), CompactReviewContractError);
125
130
 
126
131
  const proof = "differential-test:candidate still fails";