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
@@ -1,530 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { execFileSync } from "node:child_process";
3
- import { createHash } from "node:crypto";
4
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
7
- import test from "node:test";
8
- import { setTimeout as delay } from "node:timers/promises";
9
- import {
10
- COMMIT_TRANSACTION_STATE,
11
- assertNoUnresolvedCommitTransaction,
12
- abandonCommitTransaction,
13
- inspectCommitTransaction,
14
- prepareCommitTransactionInvocation,
15
- reconcileCommitTransaction,
16
- runGitCommitTransaction,
17
- verifyCommitTransactionResult,
18
- } from "../lib/git-commit-transaction.ts";
19
- import type { CommitTransactionInvocation } from "../lib/git-commit-transaction.ts";
20
- import type { NativeReviewCli, NativeValidateResult } from "../lib/native-review-cli.ts";
21
-
22
- function git(cwd: string, ...arguments_: string[]): string {
23
- return execFileSync("git", arguments_, { cwd, encoding: "utf8" }).trim();
24
- }
25
-
26
- function repository(t: test.TestContext): string {
27
- const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-commit-transaction-"));
28
- t.after(() => rmSync(cwd, { recursive: true, force: true }));
29
- git(cwd, "init", "-b", "main");
30
- git(cwd, "config", "user.name", "Commit Transaction Test");
31
- git(cwd, "config", "user.email", "commit-transaction@example.invalid");
32
- writeFileSync(join(cwd, "tracked.txt"), "base\n");
33
- git(cwd, "add", "tracked.txt");
34
- git(cwd, "commit", "-m", "base");
35
- return cwd;
36
- }
37
-
38
- function installHook(cwd: string, name: string, body: string): void {
39
- const hooks = git(cwd, "rev-parse", "--path-format=absolute", "--git-path", "hooks");
40
- mkdirSync(hooks, { recursive: true });
41
- const path = join(hooks, name);
42
- writeFileSync(path, `#!/bin/sh\nset -eu\n${body}\n`);
43
- chmodSync(path, 0o700);
44
- }
45
-
46
- function stage(cwd: string, content = "candidate\n"): string {
47
- writeFileSync(join(cwd, "tracked.txt"), content);
48
- git(cwd, "add", "tracked.txt");
49
- return git(cwd, "write-tree");
50
- }
51
-
52
- function invocation(cwd: string, lineageId: string, arguments_: readonly string[] = ["-m", "candidate"]) {
53
- const intendedTree = git(cwd, "write-tree");
54
- const command = `git commit ${arguments_.map((value) => JSON.stringify(value)).join(" ")}`;
55
- return prepareCommitTransactionInvocation({
56
- command,
57
- cwd,
58
- arguments: arguments_,
59
- authorization: {
60
- lineageId,
61
- storeRevision: "sha256:" + "a".repeat(64),
62
- fingerprint: "sha256:" + "b".repeat(64),
63
- intendedTree,
64
- },
65
- });
66
- }
67
-
68
- function native(cwd: string, lineageId: string, result: NativeValidateResult["result"] = "allow"): NativeReviewCli {
69
- return {
70
- async validate(request) {
71
- const tree = git(cwd, "write-tree");
72
- return {
73
- allowed: result === "allow",
74
- result,
75
- action: result === "allow" ? "continue" : result === "scope-changed" ? "create-new-lineage" : "explicit-maintainer-action",
76
- reason: result === "allow" ? "receipt allows exact tree" : "post-hook tree differs from receipt",
77
- gateContext: {
78
- lineageId,
79
- storeRevision: "sha256:" + "c".repeat(64),
80
- raw: { gate: request.gate, lineage_id: lineageId, candidate_tree: tree },
81
- },
82
- };
83
- },
84
- } as NativeReviewCli;
85
- }
86
-
87
- test("a non-mutating pre-commit hook runs once and HEAD proves the native-authorized tree", async (t) => {
88
- const cwd = repository(t);
89
- stage(cwd);
90
- const count = join(cwd, ".git", "hook-count");
91
- installHook(cwd, "pre-commit", `printf '1\\n' >> ${JSON.stringify(count)}`);
92
- const before = git(cwd, "rev-parse", "HEAD");
93
- const result = await runGitCommitTransaction(invocation(cwd, "non-mutating"), { nativeReviewCli: native(cwd, "non-mutating") });
94
- assert.notEqual(result.head, before);
95
- assert.equal(result.tree, git(cwd, "rev-parse", "HEAD^{tree}"));
96
- assert.equal(readFileSync(count, "utf8"), "1\n");
97
- assert.deepEqual(inspectCommitTransaction(cwd), { status: "clean" });
98
- });
99
-
100
- test("a mutating hook creates no commit until the post-hook tree is reviewed, then exact retry skips the hook", async (t) => {
101
- const cwd = repository(t);
102
- stage(cwd, "unformatted\n");
103
- const count = join(cwd, ".git", "hook-count");
104
- installHook(cwd, "pre-commit", `printf 'formatted\\n' > tracked.txt\ngit add tracked.txt\nprintf '1\\n' >> ${JSON.stringify(count)}`);
105
- const before = git(cwd, "rev-parse", "HEAD");
106
- await assert.rejects(
107
- runGitCommitTransaction(invocation(cwd, "before-format"), { nativeReviewCli: native(cwd, "before-format", "scope-changed") }),
108
- /not the authorized tree/,
109
- );
110
- assert.equal(git(cwd, "rev-parse", "HEAD"), before);
111
- assert.equal(inspectCommitTransaction(cwd).record?.state, COMMIT_TRANSACTION_STATE.AWAITING_REVIEW);
112
- assert.throws(() => assertNoUnresolvedCommitTransaction(cwd), /publication is blocked/);
113
- const result = await runGitCommitTransaction(invocation(cwd, "after-format"), { nativeReviewCli: native(cwd, "after-format") });
114
- assert.equal(result.tree, git(cwd, "rev-parse", "HEAD^{tree}"));
115
- assert.equal(readFileSync(count, "utf8"), "1\n");
116
- });
117
-
118
- test("a failing pre-commit hook creates no commit and leaves explicit recovery state", async (t) => {
119
- const cwd = repository(t);
120
- stage(cwd);
121
- installHook(cwd, "pre-commit", "exit 23");
122
- const before = git(cwd, "rev-parse", "HEAD");
123
- await assert.rejects(runGitCommitTransaction(invocation(cwd, "hook-failure"), { nativeReviewCli: native(cwd, "hook-failure") }), /hook failed/);
124
- assert.equal(git(cwd, "rev-parse", "HEAD"), before);
125
- assert.equal(inspectCommitTransaction(cwd).record?.state, COMMIT_TRANSACTION_STATE.HOOK_FAILED);
126
- assert.match(inspectCommitTransaction(cwd).record?.error ?? "", /exit 23/);
127
- });
128
-
129
- test("a native scope-changed denial on the unmutated authorized tree awaits explicit review", async (t) => {
130
- const cwd = repository(t);
131
- const authorizedTree = stage(cwd);
132
- const before = git(cwd, "rev-parse", "HEAD");
133
- let validations = 0;
134
- const denying = native(cwd, "scope-changed-denial", "scope-changed");
135
- const counting = {
136
- async validate(request) {
137
- validations += 1;
138
- return denying.validate(request);
139
- },
140
- } as NativeReviewCli;
141
- await assert.rejects(
142
- runGitCommitTransaction(invocation(cwd, "scope-changed-denial"), { nativeReviewCli: counting }),
143
- /native pre-commit validation denied the post-hook tree: scope-changed/,
144
- );
145
- assert.equal(validations, 1, "the unmutated tree must pass the mutation guard and reach native validation");
146
- assert.equal(git(cwd, "rev-parse", "HEAD"), before);
147
- assert.equal(git(cwd, "write-tree"), authorizedTree, "the denied index stays exactly as authorized");
148
- const inspection = inspectCommitTransaction(cwd);
149
- assert.equal(inspection.record?.state, COMMIT_TRANSACTION_STATE.AWAITING_REVIEW);
150
- assert.equal(inspection.record?.error, "post-hook tree differs from receipt");
151
- assert.equal(typeof inspection.record?.native_result, "object", "the native denial must be recorded on the transaction");
152
- });
153
-
154
- test("a non-scope-changed native denial fails validation and requires explicit recovery", async (t) => {
155
- const cwd = repository(t);
156
- stage(cwd);
157
- const before = git(cwd, "rev-parse", "HEAD");
158
- await assert.rejects(
159
- runGitCommitTransaction(invocation(cwd, "invalidated-denial"), { nativeReviewCli: native(cwd, "invalidated-denial", "invalidated") }),
160
- /native pre-commit validation denied the post-hook tree: invalidated/,
161
- );
162
- assert.equal(git(cwd, "rev-parse", "HEAD"), before);
163
- assert.equal(inspectCommitTransaction(cwd).record?.state, COMMIT_TRANSACTION_STATE.VALIDATION_FAILED);
164
- await assert.rejects(
165
- runGitCommitTransaction(invocation(cwd, "invalidated-denial"), { nativeReviewCli: native(cwd, "invalidated-denial") }),
166
- /requires explicit recovery from state validation-failed/,
167
- );
168
- });
169
-
170
- test("a mutating pre-commit hook fails closed even when native validation would allow the post-hook tree", async (t) => {
171
- const cwd = repository(t);
172
- const authorizedTree = stage(cwd, "unformatted\n");
173
- const count = join(cwd, ".git", "hook-count");
174
- installHook(cwd, "pre-commit", `printf 'formatted\\n' > tracked.txt\ngit add tracked.txt\nprintf '1\\n' >> ${JSON.stringify(count)}`);
175
- const before = git(cwd, "rev-parse", "HEAD");
176
- // The fake native CLI allows whatever tree the index holds, so the only
177
- // defense against committing the hook-mutated tree is the transaction's
178
- // own binding to the invocation's authorized tree.
179
- await assert.rejects(
180
- runGitCommitTransaction(invocation(cwd, "permissive-native"), { nativeReviewCli: native(cwd, "permissive-native") }),
181
- /pre-commit hook mutated the staged candidate/,
182
- );
183
- assert.equal(git(cwd, "rev-parse", "HEAD"), before, "no commit object may be created from the mutated index");
184
- assert.equal(readFileSync(count, "utf8"), "1\n", "the mutating hook must have run exactly once");
185
- assert.notEqual(git(cwd, "write-tree"), authorizedTree, "the mutated index is preserved for inspection");
186
- const inspection = inspectCommitTransaction(cwd);
187
- assert.equal(inspection.record?.state, COMMIT_TRANSACTION_STATE.AWAITING_REVIEW);
188
- assert.match(inspection.record?.error ?? "", /normalize sources and re-run review explicitly, or make the hook convergent/);
189
- assert.throws(() => assertNoUnresolvedCommitTransaction(cwd), /publication is blocked/);
190
- });
191
-
192
- test("a convergent pre-commit hook runs exactly once and commits the exact authorized tree", async (t) => {
193
- const cwd = repository(t);
194
- const authorizedTree = stage(cwd, "formatted\n");
195
- const count = join(cwd, ".git", "hook-count");
196
- installHook(cwd, "pre-commit", `printf 'formatted\\n' > tracked.txt\ngit add tracked.txt\nprintf '1\\n' >> ${JSON.stringify(count)}`);
197
- const before = git(cwd, "rev-parse", "HEAD");
198
- const result = await runGitCommitTransaction(invocation(cwd, "convergent"), { nativeReviewCli: native(cwd, "convergent") });
199
- assert.notEqual(result.head, before);
200
- assert.equal(result.tree, authorizedTree);
201
- assert.equal(git(cwd, "rev-parse", "HEAD^{tree}"), authorizedTree);
202
- assert.equal(readFileSync(count, "utf8"), "1\n", "the convergent hook must have run exactly once");
203
- assert.deepEqual(inspectCommitTransaction(cwd), { status: "clean" });
204
- });
205
-
206
- test("a repository with no hooks commits the authorized tree unchanged", async (t) => {
207
- const cwd = repository(t);
208
- const authorizedTree = stage(cwd);
209
- const before = git(cwd, "rev-parse", "HEAD");
210
- const result = await runGitCommitTransaction(invocation(cwd, "no-hooks"), { nativeReviewCli: native(cwd, "no-hooks") });
211
- assert.notEqual(result.head, before);
212
- assert.equal(result.tree, authorizedTree);
213
- assert.equal(git(cwd, "rev-parse", "HEAD^{tree}"), authorizedTree);
214
- assert.deepEqual(inspectCommitTransaction(cwd), { status: "clean" });
215
- });
216
-
217
- test("the Git-created HEAD tree is proven equal to the authorized tree on success", async (t) => {
218
- const cwd = repository(t);
219
- const authorizedTree = stage(cwd);
220
- const result = await runGitCommitTransaction(invocation(cwd, "head-proof"), { nativeReviewCli: native(cwd, "head-proof") });
221
- assert.equal(result.tree, authorizedTree);
222
- assert.equal(git(cwd, "rev-parse", "HEAD^{tree}"), authorizedTree);
223
- const verified = verifyCommitTransactionResult(cwd, result.transactionId);
224
- assert.deepEqual(verified, { transactionId: result.transactionId, status: "committed", head: result.head, tree: authorizedTree });
225
- });
226
-
227
- test("message hooks cannot change the index after native authorization", async (t) => {
228
- const cwd = repository(t);
229
- stage(cwd);
230
- installHook(cwd, "prepare-commit-msg", "printf 'late mutation\\n' > tracked.txt\ngit add tracked.txt");
231
- const before = git(cwd, "rev-parse", "HEAD");
232
- await assert.rejects(runGitCommitTransaction(invocation(cwd, "late-mutation"), { nativeReviewCli: native(cwd, "late-mutation") }), /Git commit failed/);
233
- assert.equal(git(cwd, "rev-parse", "HEAD"), before);
234
- assert.equal(inspectCommitTransaction(cwd).record?.state, COMMIT_TRANSACTION_STATE.COMMIT_FAILED);
235
- });
236
-
237
- test("amend and post-commit crash reconciliation preserve the exact authorized tree", async (t) => {
238
- const cwd = repository(t);
239
- const authorizedTree = stage(cwd, "amended\n");
240
- const amended = await runGitCommitTransaction(invocation(cwd, "amend", ["--amend", "--no-edit"]), { nativeReviewCli: native(cwd, "amend") });
241
- assert.equal(amended.tree, authorizedTree);
242
- stage(cwd, "after-crash\n");
243
- await assert.rejects(
244
- runGitCommitTransaction(invocation(cwd, "crash"), { nativeReviewCli: native(cwd, "crash"), failpoint: "after-commit-before-proof" }),
245
- /test interruption/,
246
- );
247
- assert.equal(inspectCommitTransaction(cwd).record?.state, COMMIT_TRANSACTION_STATE.COMMIT_RUNNING);
248
- assert.deepEqual(reconcileCommitTransaction(cwd), { status: "clean" });
249
- assert.deepEqual(inspectCommitTransaction(cwd), { status: "clean" });
250
- });
251
-
252
- test("a post-commit hook cannot replace the exact commit created by Git", async (t) => {
253
- const cwd = repository(t);
254
- stage(cwd, "intermediate\n");
255
- git(cwd, "commit", "-m", "intermediate");
256
- stage(cwd);
257
- installHook(cwd, "post-commit", [
258
- "original=$(git rev-parse HEAD)",
259
- "tree=$(git rev-parse HEAD^{tree})",
260
- "alternate_parent=$(git rev-parse HEAD~2)",
261
- "replacement=$(printf 'replacement\\n' | git commit-tree \"$tree\" -p \"$alternate_parent\")",
262
- "git update-ref HEAD \"$replacement\" \"$original\"",
263
- ].join("\n"));
264
- await assert.rejects(
265
- runGitCommitTransaction(invocation(cwd, "post-commit-replacement"), { nativeReviewCli: native(cwd, "post-commit-replacement") }),
266
- /different commit|identity changed/,
267
- );
268
- assert.equal(inspectCommitTransaction(cwd).record?.state, COMMIT_TRANSACTION_STATE.INCIDENT);
269
- });
270
-
271
- test("cancellation cannot strand a commit after HEAD advances", async (t) => {
272
- const cwd = repository(t);
273
- stage(cwd);
274
- // Deterministic cancellation ordering (issue #178): a wall-clock
275
- // AbortSignal.timeout raced Git's own progress on loaded CI runners and
276
- // could abort before HEAD advanced. Git only runs the post-commit hook
277
- // after the commit object exists and HEAD has advanced, so the hook
278
- // reports that it started and then blocks until the test releases it.
279
- // Aborting between those two events guarantees the cancellation lands
280
- // while the commit process is still running and strictly after HEAD
281
- // advanced, on every runner.
282
- const started = join(cwd, ".git", "post-commit-started");
283
- const release = join(cwd, ".git", "post-commit-release");
284
- installHook(cwd, "post-commit", [
285
- `printf '1\\n' > ${JSON.stringify(started)}`,
286
- `while [ ! -e ${JSON.stringify(release)} ]; do sleep 0.02; done`,
287
- ].join("\n"));
288
- const before = git(cwd, "rev-parse", "HEAD");
289
- const abort = new AbortController();
290
- const pending = runGitCommitTransaction(invocation(cwd, "commit-cancellation"), {
291
- nativeReviewCli: native(cwd, "commit-cancellation"),
292
- signal: abort.signal,
293
- });
294
- let settled = false;
295
- void pending.then(() => { settled = true; }, () => { settled = true; });
296
- while (!existsSync(started) && !settled) await delay(10);
297
- assert.equal(settled, false, "commit transaction finished before the post-commit hook confirmed HEAD advanced");
298
- abort.abort();
299
- writeFileSync(release, "release\n");
300
- const result = await pending;
301
- assert.equal(abort.signal.aborted, true);
302
- assert.notEqual(result.head, before);
303
- assert.equal(result.status, "committed");
304
- assert.deepEqual(inspectCommitTransaction(cwd), { status: "clean" });
305
- });
306
-
307
- function unbornRepository(t: test.TestContext): string {
308
- const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-commit-transaction-unborn-"));
309
- t.after(() => rmSync(cwd, { recursive: true, force: true }));
310
- git(cwd, "init", "-b", "main");
311
- git(cwd, "config", "user.name", "Commit Transaction Test");
312
- git(cwd, "config", "user.email", "commit-transaction@example.invalid");
313
- writeFileSync(join(cwd, "initial.txt"), "first commit\n");
314
- git(cwd, "add", "initial.txt");
315
- return cwd;
316
- }
317
-
318
- test("an unborn repository creates its first reviewed commit without a prior HEAD", async (t) => {
319
- const cwd = unbornRepository(t);
320
- const intendedTree = git(cwd, "write-tree");
321
- const command = `git commit ${JSON.stringify("-m")} ${JSON.stringify("initial")}`;
322
- const invocation = prepareCommitTransactionInvocation({
323
- command,
324
- cwd,
325
- arguments: ["-m", "initial"],
326
- authorization: {
327
- lineageId: "unborn-first",
328
- storeRevision: "sha256:" + "a".repeat(64),
329
- fingerprint: "sha256:" + "b".repeat(64),
330
- intendedTree,
331
- },
332
- });
333
- const result = await runGitCommitTransaction(invocation, { nativeReviewCli: native(cwd, "unborn-first") });
334
- assert.equal(result.status, "committed");
335
- assert.equal(result.tree, intendedTree);
336
- assert.equal(result.head, git(cwd, "rev-parse", "HEAD"));
337
- assert.equal(git(cwd, "rev-parse", "HEAD^{tree}"), intendedTree);
338
- assert.deepEqual(inspectCommitTransaction(cwd), { status: "clean" });
339
- const verified = verifyCommitTransactionResult(cwd, result.transactionId);
340
- assert.equal(verified.tree, intendedTree);
341
- });
342
-
343
- test("an unborn repository that fails to commit leaves no HEAD and allows exact retry", async (t) => {
344
- const cwd = unbornRepository(t);
345
- const intendedTree = git(cwd, "write-tree");
346
- const command = `git commit ${JSON.stringify("-m")} ${JSON.stringify("initial")}`;
347
- const invocation = prepareCommitTransactionInvocation({
348
- command,
349
- cwd,
350
- arguments: ["-m", "initial"],
351
- authorization: {
352
- lineageId: "unborn-retry",
353
- storeRevision: "sha256:" + "a".repeat(64),
354
- fingerprint: "sha256:" + "b".repeat(64),
355
- intendedTree,
356
- },
357
- });
358
- await assert.rejects(
359
- runGitCommitTransaction(invocation, { nativeReviewCli: native(cwd, "unborn-retry", "invalidated") }),
360
- /native pre-commit validation denied/,
361
- );
362
- assert.throws(() => git(cwd, "rev-parse", "--verify", "HEAD"), /fatal|Needed/i);
363
- assert.equal(inspectCommitTransaction(cwd).record?.state, COMMIT_TRANSACTION_STATE.VALIDATION_FAILED);
364
- // A denied attempt leaves the index intact and HEAD unborn, so the exact
365
- // commit command can be retried. The failed transaction requires explicit
366
- // recovery: abandon archives it (HEAD never moved, so abandon is allowed),
367
- // then a fresh invocation authorizes the same tree and commits successfully.
368
- abandonCommitTransaction(cwd);
369
- assert.equal(inspectCommitTransaction(cwd).status, "clean");
370
- const retry = prepareCommitTransactionInvocation({
371
- command,
372
- cwd,
373
- arguments: ["-m", "initial"],
374
- authorization: {
375
- lineageId: "unborn-retry",
376
- storeRevision: "sha256:" + "a".repeat(64),
377
- fingerprint: "sha256:" + "b".repeat(64),
378
- intendedTree,
379
- },
380
- });
381
- const result = await runGitCommitTransaction(retry, { nativeReviewCli: native(cwd, "unborn-retry") });
382
- assert.equal(result.status, "committed");
383
- assert.equal(result.tree, intendedTree);
384
- assert.equal(result.head, git(cwd, "rev-parse", "HEAD"));
385
- assert.equal(git(cwd, "rev-parse", "HEAD^{tree}"), intendedTree);
386
- assert.deepEqual(inspectCommitTransaction(cwd), { status: "clean" });
387
- const verified = verifyCommitTransactionResult(cwd, result.transactionId);
388
- assert.equal(verified.tree, intendedTree);
389
- });
390
-
391
- function unbornInvocation(cwd: string, lineageId: string): CommitTransactionInvocation {
392
- return prepareCommitTransactionInvocation({
393
- command: "git commit -m initial",
394
- cwd,
395
- arguments: ["-m", "initial"],
396
- authorization: {
397
- lineageId,
398
- storeRevision: "sha256:" + "a".repeat(64),
399
- fingerprint: "sha256:" + "b".repeat(64),
400
- intendedTree: git(cwd, "write-tree"),
401
- },
402
- });
403
- }
404
-
405
- // Mirrors lib/git-commit-transaction.ts canonicalJson + sha256 so tests can
406
- // prove the durable repository identity is byte-for-byte compatible with the
407
- // previous formula `sha256(canonicalJson({ common_directory: commonDir, roots }))`.
408
- function canonicalJson(value: unknown): string {
409
- if (value === null || typeof value !== "object") return JSON.stringify(value);
410
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
411
- const object = value as Record<string, unknown>;
412
- return `{${Object.keys(object).filter((key) => object[key] !== undefined).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
413
- }
414
- function identitySha256(value: string): string {
415
- return `sha256:${createHash("sha256").update(value).digest("hex")}`;
416
- }
417
-
418
- test("a born repository keeps the durable transaction identity formula with sorted root commits across the unborn-handling upgrade", async (t) => {
419
- const cwd = repository(t);
420
- stage(cwd);
421
- installHook(cwd, "pre-commit", "exit 23");
422
- const commonDir = realpathSync(git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"));
423
- const roots = git(cwd, "rev-list", "--max-parents=0", "HEAD").split(/\r?\n/).filter(Boolean).sort();
424
- const expected = identitySha256(canonicalJson({ common_directory: commonDir, roots }));
425
- await assert.rejects(
426
- runGitCommitTransaction(invocation(cwd, "born-identity"), { nativeReviewCli: native(cwd, "born-identity") }),
427
- /pre-commit hook failed/,
428
- );
429
- const record = inspectCommitTransaction(cwd).record;
430
- assert.ok(record, "a failing hook leaves an active transaction record carrying the repository identity");
431
- assert.equal(record!.repository_id, expected);
432
- assert.equal(record!.common_directory, commonDir);
433
- abandonCommitTransaction(cwd);
434
- assert.equal(inspectCommitTransaction(cwd).status, "clean");
435
- });
436
-
437
- test("an unborn repository derives a deterministic repository identity without rev-list HEAD and without masking errors", async (t) => {
438
- const cwd = unbornRepository(t);
439
- const commonDir = realpathSync(git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"));
440
- // Unborn has no root commits reachable from HEAD; the identity uses the
441
- // empty roots set, which is deterministic and avoids `rev-list HEAD`.
442
- const expected = identitySha256(canonicalJson({ common_directory: commonDir, roots: [] }));
443
- const intendedTree = git(cwd, "write-tree");
444
- const command = `git commit ${JSON.stringify("-m")} ${JSON.stringify("initial")}`;
445
- const invocation = prepareCommitTransactionInvocation({
446
- command,
447
- cwd,
448
- arguments: ["-m", "initial"],
449
- authorization: {
450
- lineageId: "unborn-identity",
451
- storeRevision: "sha256:" + "a".repeat(64),
452
- fingerprint: "sha256:" + "b".repeat(64),
453
- intendedTree,
454
- },
455
- });
456
- await assert.rejects(
457
- runGitCommitTransaction(invocation, { nativeReviewCli: native(cwd, "unborn-identity", "invalidated") }),
458
- /native pre-commit validation denied/,
459
- );
460
- const record = inspectCommitTransaction(cwd).record;
461
- assert.ok(record, "a denied unborn attempt leaves an active transaction record carrying the repository identity");
462
- assert.equal(record!.repository_id, expected);
463
- assert.equal(record!.common_directory, commonDir);
464
- abandonCommitTransaction(cwd);
465
- assert.equal(inspectCommitTransaction(cwd).status, "clean");
466
- });
467
-
468
- // Resolves the canonical absolute git common directory for asserting the
469
- // absence of the transaction state root without assuming a literal `.git`.
470
- function transactionStateRoot(cwd: string): string {
471
- const commonDir = realpathSync(git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"));
472
- return join(commonDir, "gentle-pi", "commit-transactions");
473
- }
474
-
475
- test("preparation fails closed for a corrupt HEAD ref without writing transaction state and reports corruption", (t) => {
476
- const cwd = unbornRepository(t);
477
- // A garbage ref (not a valid SHA) makes HEAD unresolvable. Resolving root
478
- // commits during repository binding rethrows, so preparation must throw,
479
- // no transaction state may be written, and inspection reports corruption
480
- // (repository identity itself cannot be resolved while HEAD is corrupt).
481
- mkdirSync(join(cwd, ".git", "refs", "heads"), { recursive: true });
482
- writeFileSync(join(cwd, ".git", "refs", "heads", "main"), "z".repeat(40));
483
- assert.throws(() => unbornInvocation(cwd, "corrupt-head"));
484
- assert.equal(existsSync(transactionStateRoot(cwd)), false, "no transaction state root is written for a corrupt HEAD");
485
- const inspection = inspectCommitTransaction(cwd);
486
- assert.equal(inspection.status, "corrupted", "repository corruption makes inspection corrupted even with no transaction state written");
487
- assert.ok(typeof inspection.reason === "string" && inspection.reason.length > 0, "inspection reason is present and actionable");
488
- });
489
-
490
- test("preparation fails closed for a symbolic HEAD pointing at a missing object instead of classifying it as unborn", (t) => {
491
- const cwd = unbornRepository(t);
492
- // A valid-format SHA with no object: rev-parse --verify HEAD succeeds, so
493
- // resolveHead returns a commit id rather than classifying HEAD as unborn.
494
- // Resolving root commits then fails on the missing object, so preparation
495
- // must throw, no transaction state may be written, and inspection reports
496
- // corruption. This must never be classified as an unborn empty-roots repo.
497
- mkdirSync(join(cwd, ".git", "refs", "heads"), { recursive: true });
498
- writeFileSync(join(cwd, ".git", "refs", "heads", "main"), "0123456789abcdef0123456789abcdef01234567\n");
499
- assert.throws(() => unbornInvocation(cwd, "missing-object-head"));
500
- assert.equal(existsSync(transactionStateRoot(cwd)), false, "no transaction state root is written for a missing-object HEAD");
501
- const inspection = inspectCommitTransaction(cwd);
502
- assert.equal(inspection.status, "corrupted", "a missing-object HEAD is repository corruption, not a clean or unborn repo");
503
- assert.ok(typeof inspection.reason === "string" && inspection.reason.length > 0, "inspection reason is present and actionable");
504
- });
505
-
506
- test("resolveHead propagates a probe timeout instead of masking it as an unborn HEAD", (t) => {
507
- const cwd = unbornRepository(t);
508
- const wrapperDir = mkdtempSync(join(tmpdir(), "gentle-pi-commit-transaction-timeout-"));
509
- t.after(() => rmSync(wrapperDir, { recursive: true, force: true }));
510
- const realGit = execFileSync("which", ["git"], { encoding: "utf8" }).trim();
511
- writeFileSync(join(wrapperDir, "git"), `#!/bin/sh\nif [ "$1" = "rev-parse" ] && [ "$2" = "--verify" ] && [ "$3" = "HEAD" ] && [ $# -eq 3 ]; then\nsleep 30\nelse\nexec ${realGit} "$@"\nfi\n`);
512
- chmodSync(join(wrapperDir, "git"), 0o755);
513
- const originalPath = process.env.PATH;
514
- process.env.PATH = `${wrapperDir}:${originalPath}`;
515
- try {
516
- // The probe wrapper sleeps past GIT_TIMEOUT_MS (10s), so execFileSync
517
- // kills the process and resolveHead rethrows the raw timeout error
518
- // rather than classifying it as an unborn HEAD. Accept only a failure
519
- // that genuinely represents a timeout, not any arbitrary error.
520
- assert.throws(
521
- () => unbornInvocation(cwd, "timeout-head"),
522
- (error: unknown) => error !== null && typeof error === "object"
523
- && ((error as { code?: unknown }).code === "ETIMEDOUT" || (error as { killed?: unknown }).killed === true),
524
- "resolveHead must propagate a probe timeout instead of masking it as an unborn HEAD",
525
- );
526
- } finally {
527
- process.env.PATH = originalPath;
528
- }
529
- assert.equal(inspectCommitTransaction(cwd).status, "clean");
530
- });