gentle-pi 2.1.2 → 2.2.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 (171) hide show
  1. package/README.md +70 -12
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -1
  7. package/assets/agents/review-reliability.md +4 -1
  8. package/assets/agents/review-resilience.md +4 -1
  9. package/assets/agents/review-risk.md +4 -1
  10. package/assets/agents/sdd-apply.md +6 -1
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +6 -1
  17. package/assets/agents/sdd-spec.md +6 -1
  18. package/assets/agents/sdd-status.md +6 -1
  19. package/assets/agents/sdd-sync.md +6 -1
  20. package/assets/agents/sdd-tasks.md +6 -1
  21. package/assets/agents/sdd-verify.md +6 -1
  22. package/assets/chains/4r-review.chain.md +2 -0
  23. package/assets/chains/sdd-full.chain.md +1 -1
  24. package/assets/chains/sdd-plan.chain.md +1 -1
  25. package/assets/chains/sdd-verify.chain.md +1 -1
  26. package/assets/orchestrator-delegation.md +246 -67
  27. package/assets/orchestrator.md +7 -14
  28. package/assets/sdd-orchestrator-workflow.md +154 -9
  29. package/assets/support/sdd-status-contract.md +19 -1
  30. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  31. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  32. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  33. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  34. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  35. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  36. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  37. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  38. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  39. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  40. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  41. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  42. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  43. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  44. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  45. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  46. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  47. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  48. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  49. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  50. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  51. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  52. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  58. package/docs/native-authority-architecture.md +5 -5
  59. package/docs/review-integration.md +22 -2
  60. package/extensions/gentle-ai.ts +1595 -201
  61. package/extensions/sdd-init.ts +19 -6
  62. package/extensions/skill-registry.ts +10 -2
  63. package/extensions/startup-banner.ts +10 -4
  64. package/lib/gentle-ai-binary.ts +173 -2
  65. package/lib/git-commit-transaction.ts +77 -17
  66. package/lib/native-review-cli.ts +528 -65
  67. package/lib/provider-contract-bundle.ts +704 -0
  68. package/lib/review-candidate-view.ts +527 -18
  69. package/lib/review-compact-contract.ts +59 -248
  70. package/lib/review-host-relay.ts +436 -0
  71. package/lib/review-integration-v2.ts +537 -36
  72. package/lib/review-relay-contract.ts +16 -0
  73. package/lib/sdd-preflight.ts +53 -1
  74. package/package.json +5 -2
  75. package/runtime/gentle-ai-binary.mjs +173 -2
  76. package/runtime/git-commit-transaction.mjs +75 -15
  77. package/runtime/native-review-cli.mjs +524 -61
  78. package/runtime/review-integration-v2.mjs +536 -35
  79. package/runtime/review-relay-contract.mjs +17 -0
  80. package/scripts/build-git-commit-transaction-runner.mjs +1 -0
  81. package/scripts/check-provider-contract.mjs +138 -0
  82. package/scripts/gentle-ai-installer.mjs +23 -13
  83. package/scripts/maintainer/provider-relay-matrix.mjs +219 -0
  84. package/scripts/mirror-provider-contract.mjs +143 -0
  85. package/scripts/test-packed-runner.mjs +16 -2
  86. package/scripts/verify-package-files.mjs +110 -33
  87. package/skills/_shared/review-ledger-contract.md +4 -6
  88. package/skills/gentle-ai/SKILL.md +4 -4
  89. package/skills/issue-creation/SKILL.md +94 -168
  90. package/skills/judgment-day/SKILL.md +7 -1
  91. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  92. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  93. package/tests/background-subagents.test.ts +771 -0
  94. package/tests/crosslane/cross-lane.mjs +1169 -0
  95. package/tests/delegated-key-learnings-contract.test.ts +238 -0
  96. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  97. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  98. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  99. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  100. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  101. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  102. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  103. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  104. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  105. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  106. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  107. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  108. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  109. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  110. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  111. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  115. package/tests/gentle-ai-binary.test.ts +1 -1
  116. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  117. package/tests/gentle-ai-dev-binary.test.ts +336 -0
  118. package/tests/gentle-ai-installer.test.ts +46 -46
  119. package/tests/git-commit-transaction.test.ts +229 -1
  120. package/tests/maintainer/provider-relay.maintest.ts +265 -0
  121. package/tests/native-review-capability-contract.test.ts +48 -2
  122. package/tests/native-review-cli.test.ts +56 -0
  123. package/tests/native-review-consent.test.ts +164 -3
  124. package/tests/native-review-parity-runtime.test.ts +37 -0
  125. package/tests/native-review-parity.test.ts +218 -15
  126. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  127. package/tests/orchestrator-budget.test.ts +30 -5
  128. package/tests/package-manifest.test.ts +98 -72
  129. package/tests/provider-contract-bundle.test.ts +385 -0
  130. package/tests/provider-contract-mirror.test.ts +206 -0
  131. package/tests/provider-defect-handoff.test.ts +355 -0
  132. package/tests/review-actor-tool-deny.test.ts +12 -13
  133. package/tests/review-candidate-view.test.ts +489 -9
  134. package/tests/review-compact-contract.test.ts +52 -119
  135. package/tests/review-controller-native-recovery.test.ts +643 -47
  136. package/tests/review-controller-native-routing.test.ts +1667 -222
  137. package/tests/review-controller-workspace-root.test.ts +17 -2
  138. package/tests/review-corrected-finalize-binding.test.ts +175 -0
  139. package/tests/review-dispatch-hydration-gap.test.ts +197 -0
  140. package/tests/review-host-relay-routing.test.ts +317 -0
  141. package/tests/review-host-relay.test.ts +520 -0
  142. package/tests/review-integration-v2-forward.test.ts +631 -0
  143. package/tests/review-integration-v2.test.ts +114 -0
  144. package/tests/review-ledger-contract.test.ts +12 -28
  145. package/tests/review-recovered-lineage-routing.test.ts +246 -0
  146. package/tests/review-relay-transport-agent.test.ts +249 -0
  147. package/tests/runtime-harness.mjs +242 -14
  148. package/tests/sdd-agent-tools.test.ts +18 -33
  149. package/tests/skill-collision-prefixes.test.ts +1 -0
  150. package/tests/skill-registry.test.ts +50 -1
  151. package/tests/verify-package-files.test.ts +62 -0
  152. package/assets/agents/review-refuter.md +0 -40
  153. package/assets/agents/review-validator.md +0 -23
  154. package/lib/native-review-remediation.ts +0 -49
  155. package/lib/review-compact.ts +0 -947
  156. package/lib/review-refuter-adapter.ts +0 -129
  157. package/lib/review-runtime-contract.ts +0 -68
  158. package/prompts/gcl.md +0 -54
  159. package/prompts/gis.md +0 -25
  160. package/prompts/gpr.md +0 -41
  161. package/prompts/gwr.md +0 -31
  162. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  163. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  164. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  165. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  166. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  167. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  168. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  169. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  170. package/tests/review-compact.test.ts +0 -243
  171. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -1,17 +1,21 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
5
  import { tmpdir } from "node:os";
5
6
  import { join } from "node:path";
6
7
  import test from "node:test";
8
+ import { gzipSync } from "node:zlib";
7
9
  import {
8
10
  CandidateViewRegistry,
9
11
  CandidateViewError,
10
12
  type CandidateGitExecutor,
11
13
  createCandidateView,
14
+ decodeCandidateContextManifest,
12
15
  deriveChangedPathManifest,
13
16
  digestChangedPathManifest,
14
17
  injectReviewCandidateView,
18
+ readCandidateContextManifestPage,
15
19
  } from "../lib/review-candidate-view.ts";
16
20
 
17
21
  function git(cwd: string, ...arguments_: string[]): string {
@@ -28,14 +32,153 @@ function repository(t: test.TestContext): string {
28
32
  return cwd;
29
33
  }
30
34
 
31
- test("candidate view Git commands have a finite timeout and block materialization before worktree execution", (t) => {
32
- const calls: Array<{ arguments: readonly string[]; timeout: number | undefined }> = [];
33
- const executor: CandidateGitExecutor = (_file, arguments_, options) => { calls.push({ arguments: arguments_, timeout: options.timeout }); throw Object.assign(new Error("timed out"), { code: "ETIMEDOUT", killed: true }); };
34
- assert.throws(() => new CandidateViewRegistry(executor).create({ contributorRoot: repository(t) }), (error: unknown) => error instanceof CandidateViewError && /timed out/.test(error.message));
35
- assert.deepEqual(calls, [{ arguments: ["rev-parse", "--git-common-dir"], timeout: 10_000 }]);
35
+ function compactCandidateContextManifest(task: string): { encoded: string; sha256: string } {
36
+ const match = /Frozen changed scope manifest \(gzip\+base64url\): `([A-Za-z0-9_-]+)`\.\nFrozen changed scope manifest SHA-256: `([0-9a-f]{64})`\./.exec(task);
37
+ assert.ok(match, "expected a compact candidate context manifest");
38
+ return { encoded: match[1]!, sha256: match[2]! };
39
+ }
40
+
41
+ test("candidate view Git commands classify bounded timeouts and block materialization before worktree execution", (t) => {
42
+ const calls: Array<{ arguments: readonly string[]; timeout: number | undefined; maxBuffer: number | undefined }> = [];
43
+ const executor: CandidateGitExecutor = (_file, arguments_, options) => { calls.push({ arguments: arguments_, timeout: options.timeout, maxBuffer: options.maxBuffer }); throw Object.assign(new Error("timed out"), { code: "ETIMEDOUT", killed: true }); };
44
+ let failure: unknown;
45
+ try {
46
+ new CandidateViewRegistry(executor).create({ contributorRoot: repository(t) });
47
+ } catch (error) {
48
+ failure = error;
49
+ }
50
+ assert.ok(failure instanceof CandidateViewError);
51
+ assert.equal(failure.reason, "candidate-view-timeout");
52
+ assert.deepEqual((failure as CandidateViewError & { diagnostics?: unknown }).diagnostics, {
53
+ phase: "candidate-view",
54
+ category: "timeout",
55
+ git_subcommand: "rev-parse",
56
+ timeout_ms: 10_000,
57
+ max_buffer_bytes: 64 * 1024 * 1024,
58
+ message: "candidate-view Git command rev-parse timed out after 10000ms; inspect the candidate state before any new START",
59
+ });
60
+ assert.deepEqual(calls, [{ arguments: ["rev-parse", "--git-common-dir"], timeout: 10_000, maxBuffer: 64 * 1024 * 1024 }]);
36
61
  assert.equal(calls.some((call) => call.arguments[0] === "worktree"), false);
37
62
  });
38
63
 
64
+ test("candidate-view Git timeouts use a bounded strict-decimal override with safe fallback", (t) => {
65
+ const withTimeout = <T>(value: string | undefined, callback: () => T): T => {
66
+ const previous = process.env.GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS;
67
+ try {
68
+ if (value === undefined) delete process.env.GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS;
69
+ else process.env.GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS = value;
70
+ return callback();
71
+ } finally {
72
+ if (previous === undefined) delete process.env.GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS;
73
+ else process.env.GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS = previous;
74
+ }
75
+ };
76
+ for (const [name, value, expected] of [
77
+ ["default", undefined, 10_000],
78
+ ["valid override", "45000", 45_000],
79
+ ...(["", "0", "0010", "-1", "1.5", "not-a-number", "120001", "Infinity"] as const).map((value) => [`invalid override ${JSON.stringify(value)}`, value, 10_000] as const),
80
+ ] as const) {
81
+ const timeouts: Array<number | undefined> = [];
82
+ let failure: unknown;
83
+ withTimeout(value, () => {
84
+ try {
85
+ new CandidateViewRegistry((_file, _arguments, options) => {
86
+ timeouts.push(options.timeout);
87
+ throw Object.assign(new Error("timed out"), { code: "ETIMEDOUT" });
88
+ }).create({ contributorRoot: repository(t) });
89
+ } catch (error) {
90
+ failure = error;
91
+ }
92
+ });
93
+ assert.ok(failure instanceof CandidateViewError, name);
94
+ assert.equal(timeouts[0], expected, name);
95
+ assert.equal((failure as CandidateViewError & { diagnostics?: { timeout_ms?: unknown } }).diagnostics?.timeout_ms, expected, name);
96
+ }
97
+ });
98
+
99
+ test("explicit base resolution preserves structured for-each-ref output-limit diagnostics", (t) => {
100
+ const contributorRoot = repository(t);
101
+ const executor: CandidateGitExecutor = (file, arguments_, options) => {
102
+ if (arguments_[0] === "for-each-ref") throw Object.assign(new Error("sensitive base-reference output"), { code: "ENOBUFS", killed: true, stderr: Buffer.from("sensitive base-reference output") });
103
+ return execFileSync(file, arguments_, options);
104
+ };
105
+ let failure: unknown;
106
+ try {
107
+ new CandidateViewRegistry(executor).create({ contributorRoot, baseRef: "refs/heads/main", committedOnly: true });
108
+ } catch (error) {
109
+ failure = error;
110
+ }
111
+ assert.ok(failure instanceof CandidateViewError);
112
+ assert.equal(failure.reason, "candidate-view-output-limit");
113
+ assert.deepEqual(failure.diagnostics, {
114
+ phase: "candidate-view",
115
+ category: "output-limit",
116
+ git_subcommand: "for-each-ref",
117
+ timeout_ms: 10_000,
118
+ max_buffer_bytes: 64 * 1024 * 1024,
119
+ message: "candidate-view Git command for-each-ref exceeded the 67108864-byte output limit; inspect the candidate state before any new START",
120
+ });
121
+ assert.doesNotMatch(failure.message, /sensitive base-reference output/);
122
+ });
123
+
124
+ test("every synchronous candidate-view Git command receives the explicit 64 MiB output limit", (t) => {
125
+ const calls: Array<{ arguments: readonly string[]; maxBuffer: number | undefined }> = [];
126
+ const executor: CandidateGitExecutor = (file, arguments_, options) => {
127
+ calls.push({ arguments: arguments_, maxBuffer: options.maxBuffer });
128
+ return execFileSync(file, arguments_, options);
129
+ };
130
+ const view = new CandidateViewRegistry(executor).create({ contributorRoot: repository(t) });
131
+ try {
132
+ assert.ok(calls.length > 1);
133
+ assert.ok(calls.every((call) => call.maxBuffer === 64 * 1024 * 1024), JSON.stringify(calls));
134
+ } finally {
135
+ view.cleanup();
136
+ }
137
+ });
138
+
139
+ test("candidate view classifies both Node synchronous-process output-limit errors ahead of killed state without exposing process output", (t) => {
140
+ const attemptedOutputBytes = 64 * 1024 * 1024 + 1;
141
+ for (const code of ["ENOBUFS", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"]) {
142
+ let failure: unknown;
143
+ try {
144
+ new CandidateViewRegistry((_file, _arguments, options) => {
145
+ assert.ok((options.maxBuffer ?? 0) < attemptedOutputBytes, code);
146
+ throw Object.assign(new Error("sensitive stderr and candidate bytes"), { code, killed: true, stderr: Buffer.from("sensitive stderr and candidate bytes") });
147
+ }).create({ contributorRoot: repository(t) });
148
+ } catch (error) {
149
+ failure = error;
150
+ }
151
+ assert.ok(failure instanceof CandidateViewError, code);
152
+ assert.equal(failure.reason, "candidate-view-output-limit", code);
153
+ assert.deepEqual((failure as CandidateViewError & { diagnostics?: unknown }).diagnostics, {
154
+ phase: "candidate-view",
155
+ category: "output-limit",
156
+ git_subcommand: "rev-parse",
157
+ timeout_ms: 10_000,
158
+ max_buffer_bytes: 64 * 1024 * 1024,
159
+ message: "candidate-view Git command rev-parse exceeded the 67108864-byte output limit; inspect the candidate state before any new START",
160
+ }, code);
161
+ assert.doesNotMatch(failure.message, /sensitive stderr|candidate bytes/, code);
162
+ }
163
+ });
164
+
165
+ test("candidate Git accepts deterministic output above 1 MiB up to its explicit bound", () => {
166
+ const row = `:100644 100644 ${"a".repeat(40)} ${"b".repeat(40)} M\0tracked.txt\0`;
167
+ const copies = Math.ceil((1024 * 1024 + 1) / Buffer.byteLength(row));
168
+ const output = Buffer.from(row.repeat(copies));
169
+ assert.ok(output.length > 1024 * 1024);
170
+ assert.ok(output.length < 64 * 1024 * 1024);
171
+ const calls: Array<{ maxBuffer: number | undefined }> = [];
172
+ const manifest = deriveChangedPathManifest("/candidate", "a".repeat(40), "b".repeat(40), (_file, arguments_, options) => {
173
+ assert.deepEqual(arguments_.slice(0, 2), ["diff", "--raw"]);
174
+ calls.push({ maxBuffer: options.maxBuffer });
175
+ return output;
176
+ });
177
+ assert.equal(manifest.length, copies);
178
+ assert.ok(manifest.every((entry) => entry.path === "tracked.txt" && entry.status === "M"));
179
+ assert.deepEqual(calls, [{ maxBuffer: 64 * 1024 * 1024 }]);
180
+ });
181
+
39
182
  test("committed-only candidate views scope an explicit base to committed changes and exclude dirty worktree files", (t) => {
40
183
  const contributorRoot = repository(t);
41
184
  const baseCommit = git(contributorRoot, "rev-parse", "HEAD");
@@ -170,6 +313,27 @@ function commitFileAfterBase(cwd: string): void {
170
313
  git(cwd, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "committed after base");
171
314
  }
172
315
 
316
+ // An unborn repository has a symbolic HEAD pointing at a branch with no
317
+ // commits yet; its review base is Git's repository-native empty tree, not a
318
+ // missing or malformed commit. `mktree` with empty input derives that empty
319
+ // tree object-format-aware (sha1 or sha256) without hardcoding the SHA-1 id.
320
+ function emptyTreeOf(cwd: string): string {
321
+ return execFileSync("git", ["-C", cwd, "mktree"], { encoding: "utf8", input: "" }).trim();
322
+ }
323
+
324
+ function unbornRepository(t: test.TestContext, stage = true): string {
325
+ const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-candidate-view-unborn-"));
326
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
327
+ git(cwd, "init", "-b", "main");
328
+ git(cwd, "config", "user.name", "Candidate Test");
329
+ git(cwd, "config", "user.email", "candidate@example.invalid");
330
+ if (stage) {
331
+ writeFileSync(join(cwd, "staged.txt"), "first staged\n");
332
+ git(cwd, "add", "staged.txt");
333
+ }
334
+ return cwd;
335
+ }
336
+
173
337
  test("candidate view materializes exact tracked and initially-untracked content while contributor diverges", (t) => {
174
338
  const contributorRoot = repository(t);
175
339
  writeFileSync(join(contributorRoot, "tracked.txt"), "frozen tracked\n");
@@ -388,15 +552,121 @@ test("candidate view verifies unchanged tree entries even when they are absent f
388
552
  }
389
553
  });
390
554
 
391
- test("candidate view fails closed before dispatch when the changed scope itself exceeds 4096 bytes", (t) => {
555
+ test("candidate view compacts an oversized non-ASCII scope losslessly and deterministically", (t) => {
556
+ const contributorRoot = repository(t);
557
+ writeFileSync(join(contributorRoot, "deleted.txt"), "delete me\n");
558
+ git(contributorRoot, "add", "deleted.txt");
559
+ git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "deletion base");
560
+ const baseCommit = git(contributorRoot, "rev-parse", "HEAD");
561
+ const unicodePaths = Array.from({ length: 18 }, (_, index) => `changed-${String(index).padStart(2, "0")}-${"界".repeat(70)}.txt`);
562
+ for (const path of unicodePaths) writeFileSync(join(contributorRoot, path), "candidate\n");
563
+ const gitlinkCommit = "0123456789abcdef0123456789abcdef01234567";
564
+ git(contributorRoot, "add", ...unicodePaths);
565
+ git(contributorRoot, "update-index", "--add", "--cacheinfo", `160000,${gitlinkCommit},vendor/dependency`);
566
+ git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "large scope");
567
+ rmSync(join(contributorRoot, "deleted.txt"));
568
+ git(contributorRoot, "add", "-u", "--", "deleted.txt");
569
+ git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "delete scope path");
570
+ const registry = new CandidateViewRegistry();
571
+ const view = registry.create({ contributorRoot, baseRef: baseCommit, committedOnly: true });
572
+ try {
573
+ registry.bind({ token: view.token, lineageId: "oversized-scope", selectedLenses: ["review-risk"] });
574
+ const first = { agent: "review-risk", task: "review", mode: "task" };
575
+ const second = { agent: "review-risk", task: "review", mode: "task" };
576
+ injectReviewCandidateView(first, registry);
577
+ injectReviewCandidateView(second, registry);
578
+ const compact = compactCandidateContextManifest(first.task);
579
+ assert.deepEqual(compact, compactCandidateContextManifest(second.task));
580
+ const decoded = decodeCandidateContextManifest(compact.encoded, compact.sha256);
581
+ assert.deepEqual(decoded.manifest, {
582
+ version: 1,
583
+ scopeByMode: { "100644": unicodePaths, "160000": ["vendor/dependency"], deleted: ["deleted.txt"] },
584
+ gitlinks: { "vendor/dependency": gitlinkCommit },
585
+ });
586
+ assert.equal(decoded.sha256, createHash("sha256").update(decoded.bytes).digest("hex"));
587
+ assert.ok(JSON.stringify(decoded.manifest).length < 4_096, "UTF-16 code units alone must not decide the dispatch bound");
588
+ assert.ok(decoded.bytes.length > 4_096, "the manifest must retain its full non-ASCII UTF-8 byte sequence");
589
+ const actorEntries: Array<{ path: string; mode: string; gitlinkObjectId?: string }> = [];
590
+ let cursor: number | undefined = 0;
591
+ while (cursor !== undefined) {
592
+ const page = readCandidateContextManifestPage(compact.encoded, compact.sha256, cursor);
593
+ actorEntries.push(...page.entries);
594
+ assert.ok(Buffer.byteLength(JSON.stringify(page), "utf8") <= 16 * 1024);
595
+ cursor = page.nextCursor;
596
+ }
597
+ assert.deepEqual(actorEntries, [
598
+ ...unicodePaths.map((path) => ({ path, mode: "100644" })),
599
+ { path: "vendor/dependency", mode: "160000", gitlinkObjectId: gitlinkCommit },
600
+ { path: "deleted.txt", mode: "deleted" },
601
+ ]);
602
+ assert.ok(Buffer.byteLength(first.task, "utf8") <= Buffer.byteLength("review", "utf8") + 4_096);
603
+ assert.doesNotMatch(first.task, /Frozen changed scope by mode:/);
604
+ assert.match(first.task, /Call `gentle_review_scope`/);
605
+ assert.throws(() => decodeCandidateContextManifest(compact.encoded, `${compact.sha256.slice(0, -1)}0`), /integrity/);
606
+ assert.throws(() => readCandidateContextManifestPage(compact.encoded, compact.sha256, actorEntries.length + 1), /cursor/);
607
+ const nonCanonicalBytes = Buffer.from(JSON.stringify({ gitlinks: decoded.manifest.gitlinks, scopeByMode: decoded.manifest.scopeByMode, version: 1 }), "utf8");
608
+ assert.throws(
609
+ () => decodeCandidateContextManifest(gzipSync(nonCanonicalBytes, { mtime: 0 }).toString("base64url"), createHash("sha256").update(nonCanonicalBytes).digest("hex")),
610
+ /canonical/,
611
+ );
612
+ } finally {
613
+ registry.cleanup(view.token);
614
+ }
615
+ });
616
+
617
+ test("candidate context manifest decoder accepts canonical numeric-looking gitlink paths", () => {
618
+ const gitlinks = {
619
+ "10": "0123456789abcdef0123456789abcdef01234567",
620
+ "2": "89abcdef0123456789abcdef0123456789abcdef",
621
+ };
622
+ const bytes = Buffer.from(JSON.stringify({
623
+ version: 1,
624
+ scopeByMode: { "160000": ["10", "2"] },
625
+ gitlinks,
626
+ }), "utf8");
627
+ const encoded = gzipSync(bytes, { mtime: 0 }).toString("base64url");
628
+ assert.deepEqual(decodeCandidateContextManifest(encoded, createHash("sha256").update(bytes).digest("hex")).manifest, {
629
+ version: 1,
630
+ scopeByMode: { "160000": ["10", "2"] },
631
+ gitlinks,
632
+ });
633
+ });
634
+
635
+ test("candidate context manifest decoder rejects noncanonical nonnumeric gitlink ordering", () => {
636
+ const gitlinks = {
637
+ zeta: "0123456789abcdef0123456789abcdef01234567",
638
+ alpha: "89abcdef0123456789abcdef0123456789abcdef",
639
+ };
640
+ const bytes = Buffer.from(JSON.stringify({
641
+ version: 1,
642
+ scopeByMode: { "160000": ["alpha", "zeta"] },
643
+ gitlinks,
644
+ }), "utf8");
645
+ assert.throws(
646
+ () => decodeCandidateContextManifest(gzipSync(bytes, { mtime: 0 }).toString("base64url"), createHash("sha256").update(bytes).digest("hex")),
647
+ /canonical/,
648
+ );
649
+ });
650
+
651
+ test("candidate context manifest decoder rejects noncanonical gzip transport for verified bytes", () => {
652
+ const bytes = Buffer.from(JSON.stringify({ version: 1, scopeByMode: { "100644": ["file.ts"] }, gitlinks: {} }), "utf8");
653
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
654
+ const canonical = gzipSync(bytes, { mtime: 0 });
655
+ const noncanonical = Buffer.from(canonical);
656
+ noncanonical[4] = (noncanonical[4]! + 1) & 0xff;
657
+ assert.throws(() => decodeCandidateContextManifest(noncanonical.toString("base64url"), sha256), /canonical/);
658
+ });
659
+
660
+ test("candidate view fails closed when an incompressible compact scope exceeds 4096 UTF-8 bytes", (t) => {
392
661
  const contributorRoot = repository(t);
393
662
  for (let index = 0; index < 80; index += 1) {
394
- writeFileSync(join(contributorRoot, `changed-${String(index).padStart(3, "0")}-${"x".repeat(80)}.txt`), "candidate\n");
663
+ const entropy = createHash("sha512").update(`candidate-scope-${index}`).digest("hex");
664
+ writeFileSync(join(contributorRoot, `changed-${String(index).padStart(3, "0")}-${entropy}.txt`), "candidate\n");
395
665
  }
396
666
  const registry = new CandidateViewRegistry();
397
667
  const view = registry.create({ contributorRoot });
398
668
  try {
399
- registry.bind({ token: view.token, lineageId: "oversized-scope", selectedLenses: ["review-risk"] });
669
+ registry.bind({ token: view.token, lineageId: "incompressible-scope", selectedLenses: ["review-risk"] });
400
670
  assert.throws(
401
671
  () => injectReviewCandidateView({ agent: "review-risk", task: "review", mode: "task" }, registry),
402
672
  /candidate view context exceeds the bounded dispatch contract/,
@@ -1039,3 +1309,213 @@ test("a projection labeled workspace but derived as a genuinely committed range
1039
1309
  (error: unknown) => error instanceof CandidateViewError && error.reason === "projection-kind-drift",
1040
1310
  );
1041
1311
  });
1312
+
1313
+ test("candidate view treats an unborn staged repository base as Git's empty tree without needing HEAD^{commit}", (t) => {
1314
+ const contributorRoot = unbornRepository(t);
1315
+ const view = createCandidateView({ contributorRoot });
1316
+ try {
1317
+ const emptyTree = emptyTreeOf(contributorRoot);
1318
+ assert.equal(view.baseTree, emptyTree);
1319
+ assert.equal(view.baseCommit, "HEAD");
1320
+ assert.equal(view.committedOnly, false);
1321
+ assert.notEqual(view.candidateTree, emptyTree);
1322
+ assert.deepEqual(view.paths, ["staged.txt"]);
1323
+ assert.deepEqual(view.deletedPaths, []);
1324
+ } finally {
1325
+ view.cleanup();
1326
+ }
1327
+ });
1328
+
1329
+ test("candidate view of an unborn repository with no content yields an empty candidate scope instead of a misleading base-ref failure", (t) => {
1330
+ const contributorRoot = unbornRepository(t, false);
1331
+ const view = createCandidateView({ contributorRoot });
1332
+ try {
1333
+ const emptyTree = emptyTreeOf(contributorRoot);
1334
+ assert.equal(view.baseTree, emptyTree);
1335
+ assert.equal(view.candidateTree, emptyTree);
1336
+ assert.equal(view.baseCommit, "HEAD");
1337
+ assert.deepEqual(view.paths, []);
1338
+ assert.deepEqual(view.deletedPaths, []);
1339
+ } finally {
1340
+ view.cleanup();
1341
+ }
1342
+ });
1343
+
1344
+ test("candidate view materializes an unborn staged repository without mutating the contributor index or creating a phantom commit", (t) => {
1345
+ const contributorRoot = unbornRepository(t);
1346
+ const indexBefore = readFileSync(join(contributorRoot, ".git", "index"));
1347
+ const view = createCandidateView({ contributorRoot });
1348
+ try {
1349
+ // No phantom commit: HEAD stays unborn and no refs exist.
1350
+ assert.throws(() => git(contributorRoot, "rev-parse", "--verify", "HEAD"), /fatal/i);
1351
+ assert.equal(git(contributorRoot, "rev-list", "--all").length, 0);
1352
+ // Contributor's real index is untouched; materialization uses a private index file.
1353
+ assert.deepEqual(readFileSync(join(contributorRoot, ".git", "index")), indexBefore);
1354
+ // The materialized candidate carries the staged content, isolated from the contributor tree.
1355
+ assert.equal(readFileSync(join(view.root, "staged.txt"), "utf8"), "first staged\n");
1356
+ assert.deepEqual(view.paths, ["staged.txt"]);
1357
+ } finally {
1358
+ view.cleanup();
1359
+ }
1360
+ // Cleanup removed the orphan worktree; no worktree lingers and no commit appeared.
1361
+ assert.equal(git(contributorRoot, "worktree", "list").split("\n").filter((line) => line.includes("gentle-ai-candidate")).length, 0);
1362
+ assert.equal(git(contributorRoot, "rev-list", "--all").length, 0);
1363
+ });
1364
+
1365
+ test("candidate view fails closed for a detached HEAD pointing at a missing commit, not an unborn empty tree", (t) => {
1366
+ const contributorRoot = unbornRepository(t);
1367
+ // Detach HEAD to a non-existent object id. This is a corrupt/missing-object
1368
+ // existing HEAD state, not a valid unborn symbolic HEAD, so it must fail
1369
+ // closed rather than producing an empty-tree unborn candidate.
1370
+ writeFileSync(join(contributorRoot, ".git", "HEAD"), "0".repeat(40));
1371
+ let failure: unknown;
1372
+ try {
1373
+ createCandidateView({ contributorRoot });
1374
+ } catch (error) {
1375
+ failure = error;
1376
+ }
1377
+ assert.ok(failure instanceof CandidateViewError, "a detached missing HEAD must fail closed");
1378
+ assert.notEqual((failure as CandidateViewError).reason, undefined);
1379
+ assert.ok((failure as CandidateViewError).reason !== "base-ref-moved");
1380
+ });
1381
+
1382
+ test("candidate view fails closed when symbolic HEAD target ref exists but points to a missing object, not unborn", (t) => {
1383
+ const contributorRoot = repository(t);
1384
+ // Break the main ref: it exists (show-ref --exists returns 0) but points to
1385
+ // a missing object. This is a broken symbolic HEAD, not a valid unborn repo.
1386
+ writeFileSync(join(contributorRoot, ".git", "refs", "heads", "main"), `${"0".repeat(40)}\n`);
1387
+ let failure: unknown;
1388
+ try {
1389
+ createCandidateView({ contributorRoot });
1390
+ } catch (error) {
1391
+ failure = error;
1392
+ }
1393
+ assert.ok(failure instanceof CandidateViewError, "a broken symbolic HEAD must fail closed, not unborn");
1394
+ });
1395
+
1396
+ test("candidate view probe does not convert a ref-existence probe timeout into an unborn empty-tree base", (t) => {
1397
+ const contributorRoot = unbornRepository(t);
1398
+ const executor: CandidateGitExecutor = (file, args, options) => {
1399
+ if (args[0] === "rev-parse" && args.includes("--quiet")) throw Object.assign(new Error("timed out"), { code: "ETIMEDOUT", killed: true });
1400
+ return execFileSync(file, args, options);
1401
+ };
1402
+ let failure: unknown;
1403
+ try {
1404
+ new CandidateViewRegistry(executor).create({ contributorRoot });
1405
+ } catch (error) {
1406
+ failure = error;
1407
+ }
1408
+ assert.ok(failure instanceof CandidateViewError, "ref-existence probe timeout must fail closed");
1409
+ assert.equal((failure as CandidateViewError).reason, "candidate-view-timeout");
1410
+ });
1411
+
1412
+ test("candidate view fails closed when the unborn ref probe exits with an unexpected status instead of treating it as unborn", (t) => {
1413
+ const contributorRoot = unbornRepository(t);
1414
+ // Only status 1 means "ref absent" (valid unborn). Any other nonzero status
1415
+ // (128, etc.) signals corruption or an I/O failure and must fail closed,
1416
+ // never masquerade as an unborn empty-tree base.
1417
+ const executor: CandidateGitExecutor = (file, args, options) => {
1418
+ if (args[0] === "rev-parse" && args.includes("--quiet")) throw Object.assign(new Error("fatal: probe failed"), { status: 128 });
1419
+ return execFileSync(file, args, options);
1420
+ };
1421
+ let failure: unknown;
1422
+ try {
1423
+ new CandidateViewRegistry(executor).create({ contributorRoot });
1424
+ } catch (error) {
1425
+ failure = error;
1426
+ }
1427
+ assert.ok(failure instanceof CandidateViewError, "an unexpected unborn probe status must fail closed, not unborn");
1428
+ assert.equal((failure as CandidateViewError).reason, "candidate-view-git-failure");
1429
+ });
1430
+
1431
+ test("candidate view treats an unborn sha256 repository base as the repository-native sha256 empty tree", (t) => {
1432
+ const contributorRoot = mkdtempSync(join(tmpdir(), "gentle-pi-candidate-view-unborn-sha256-"));
1433
+ t.after(() => rmSync(contributorRoot, { recursive: true, force: true }));
1434
+ try {
1435
+ git(contributorRoot, "init", "--object-format=sha256", "-b", "main");
1436
+ } catch {
1437
+ t.skip("installed git does not support `git init --object-format=sha256`");
1438
+ return;
1439
+ }
1440
+ git(contributorRoot, "config", "user.name", "Candidate Test");
1441
+ git(contributorRoot, "config", "user.email", "candidate@example.invalid");
1442
+ writeFileSync(join(contributorRoot, "staged.txt"), "first staged\n");
1443
+ git(contributorRoot, "add", "staged.txt");
1444
+ const view = createCandidateView({ contributorRoot });
1445
+ try {
1446
+ const emptyTree = emptyTreeOf(contributorRoot);
1447
+ assert.equal(emptyTree.length, 64, "a sha256 repository must derive a 64-hex empty tree, not the hardcoded SHA-1 id");
1448
+ assert.equal(view.baseTree, emptyTree);
1449
+ assert.equal(view.baseCommit, "HEAD");
1450
+ assert.equal(view.committedOnly, false);
1451
+ assert.notEqual(view.candidateTree, emptyTree);
1452
+ assert.deepEqual(view.paths, ["staged.txt"]);
1453
+ assert.deepEqual(view.deletedPaths, []);
1454
+ } finally {
1455
+ view.cleanup();
1456
+ }
1457
+ });
1458
+
1459
+ test("candidate view unborn worktree falls back when --orphan is unsupported, preserving isolation, no phantom commit, and contributor immutability", (t) => {
1460
+ const contributorRoot = unbornRepository(t);
1461
+ const indexBefore = readFileSync(join(contributorRoot, ".git", "index"));
1462
+ // Intercept --orphan with status 129 (Git < 2.42 unknown option).
1463
+ const executor: CandidateGitExecutor = (file, args, options) => {
1464
+ if (args[0] === "worktree" && args[1] === "add" && args.includes("--orphan")) throw Object.assign(new Error("unknown option"), { status: 129 });
1465
+ return execFileSync(file, args, options);
1466
+ };
1467
+ const view = new CandidateViewRegistry(executor).create({ contributorRoot });
1468
+ try {
1469
+ assert.equal(readFileSync(join(view.root, "staged.txt"), "utf8"), "first staged\n");
1470
+ assert.deepEqual(view.paths, ["staged.txt"]);
1471
+ assert.equal(view.baseTree, emptyTreeOf(contributorRoot));
1472
+ // No phantom commit; contributor index untouched.
1473
+ assert.throws(() => git(contributorRoot, "rev-parse", "--verify", "HEAD"), /fatal/i);
1474
+ assert.equal(git(contributorRoot, "rev-list", "--all").length, 0);
1475
+ assert.deepEqual(readFileSync(join(contributorRoot, ".git", "index")), indexBefore);
1476
+ } finally {
1477
+ view.cleanup();
1478
+ }
1479
+ assert.equal(git(contributorRoot, "worktree", "list").split("\n").filter((line) => line.includes("gentle-ai-candidate")).length, 0);
1480
+ assert.equal(git(contributorRoot, "rev-list", "--all").length, 0);
1481
+ });
1482
+
1483
+ test("candidate view unborn worktree propagates a non-usage --orphan failure instead of falling back", (t) => {
1484
+ const contributorRoot = unbornRepository(t);
1485
+ // A non-129 status must not trigger the fallback.
1486
+ const calls: string[][] = [];
1487
+ const executor: CandidateGitExecutor = (file, args, options) => {
1488
+ calls.push([...args]);
1489
+ if (args[0] === "worktree" && args[1] === "add" && args.includes("--orphan")) throw Object.assign(new Error("genuine failure"), { status: 128 });
1490
+ return execFileSync(file, args, options);
1491
+ };
1492
+ let failure: unknown;
1493
+ try { new CandidateViewRegistry(executor).create({ contributorRoot }); } catch (error) { failure = error; }
1494
+ assert.ok(failure instanceof CandidateViewError, "a non-usage --orphan failure must propagate");
1495
+ assert.equal((failure as CandidateViewError).reason, "candidate-view-git-failure");
1496
+ assert.ok(!calls.some((args) => args[0] === "commit-tree"), "the fallback must not create a temporary commit");
1497
+ assert.ok(!calls.some((args) => args[0] === "worktree" && args.includes("--detach")), "the fallback must not add a detached worktree");
1498
+ });
1499
+
1500
+ test("candidate view cleans up a partially registered unborn fallback worktree when a later step fails", (t) => {
1501
+ const contributorRoot = unbornRepository(t);
1502
+ // Force the --orphan path to take the pre-2.42 fallback, then fail the
1503
+ // symbolic-ref rewrite that follows `worktree add --no-checkout --detach`.
1504
+ // The worktree is already registered with Git at that point; the cleanup
1505
+ // boundary must remove both the registered worktree and its directory.
1506
+ const executor: CandidateGitExecutor = (file, args, options) => {
1507
+ if (args[0] === "worktree" && args[1] === "add" && args.includes("--orphan")) throw Object.assign(new Error("unknown option"), { status: 129 });
1508
+ if (args[0] === "symbolic-ref") throw Object.assign(new Error("symbolic-ref failed"), { status: 1 });
1509
+ return execFileSync(file, args, options);
1510
+ };
1511
+ let failure: unknown;
1512
+ try { new CandidateViewRegistry(executor).create({ contributorRoot }); } catch (error) { failure = error; }
1513
+ assert.ok(failure instanceof CandidateViewError, "the symbolic-ref failure must propagate");
1514
+ assert.equal((failure as CandidateViewError).reason, "candidate-view-git-failure");
1515
+ // No registered/admin worktree remains.
1516
+ assert.equal(git(contributorRoot, "worktree", "list").split("\n").filter((line) => line.includes("gentle-ai-candidate")).length, 0);
1517
+ // No candidate directory remains under the candidate-view parent.
1518
+ const parent = join(realpathSync(git(contributorRoot, "rev-parse", "--path-format=absolute", "--git-common-dir")), "gentle-ai", "candidate-views");
1519
+ const leftover = existsSync(parent) ? readdirSync(parent).filter((entry) => lstatSync(join(parent, entry)).isDirectory()) : [];
1520
+ assert.deepEqual(leftover, [], "no candidate directory remains after a partial unborn fallback failure");
1521
+ });