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
@@ -0,0 +1,16 @@
1
+ // gentle-pi.review-relay/v1 — the Pi host relay handshake (gentle-pi#311 P4,
2
+ // provider side gentle-ai#3249/#3254).
3
+ //
4
+ // gentle-ai admits `pi` as a host-mediated runtime identity only when the
5
+ // launcher that relays the invocation declares this exact contract through
6
+ // this exact environment variable. Any other value (or none) keeps Pi
7
+ // fail-closed at admission, before any repository, target, or authority work.
8
+ // That refusal is correct provider behavior and is never worked around here;
9
+ // the single central gentle-ai process runner injects this declaration on
10
+ // every invocation the extension makes.
11
+ //
12
+ // Both values are compiled constants: the contract identity is versioned by
13
+ // release, never by configuration.
14
+
15
+ export const GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV = "GENTLE_PI_REVIEW_RELAY_CONTRACT";
16
+ export const GENTLE_PI_REVIEW_RELAY_CONTRACT = "gentle-pi.review-relay/v1";
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, isAbsolute, join, relative, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -394,6 +394,57 @@ function copyDirectoryFiles(
394
394
  return { copied, skipped };
395
395
  }
396
396
 
397
+ // Assets retired by gentle-pi#311 P5: the Pi-owned adversarial review actors.
398
+ // The refuter and validator verdicts now execute through Go-owned pi
399
+ // processes via provider-rendered self-contained vectors, so these agent
400
+ // definitions have no runtime consumer. The migration manifests under
401
+ // assets/migrations are append-only legacy-hash HISTORY (adoption evidence
402
+ // for force-installs) with no removal semantics, so history stays untouched
403
+ // and retirement happens here: an installed copy is deleted only when its
404
+ // content hash proves package ownership (current manifest or legacy
405
+ // history); user-modified copies are left in place and only lose managed
406
+ // ownership.
407
+ const RETIRED_MANAGED_ASSETS = Object.freeze([
408
+ "agents/review-refuter.md",
409
+ "agents/review-validator.md",
410
+ ]);
411
+
412
+ function removeRetiredManagedAssets(
413
+ agentHome: string,
414
+ manifest: ManagedAssetsManifest,
415
+ ): void {
416
+ let legacyHashes: Record<string, readonly string[]> | undefined;
417
+ for (const ownershipKey of RETIRED_MANAGED_ASSETS) {
418
+ const installedPath = join(agentHome, ...ownershipKey.split("/"));
419
+ if (!existsSync(installedPath)) {
420
+ delete manifest.assets[ownershipKey];
421
+ continue;
422
+ }
423
+ let installedContent: string | undefined;
424
+ try {
425
+ installedContent = readFileSync(installedPath, "utf8");
426
+ } catch {
427
+ installedContent = undefined;
428
+ }
429
+ if (installedContent === undefined) continue;
430
+ const installedHash = managedAssetHash(installedContent);
431
+ const managed = manifest.assets[ownershipKey] === installedHash;
432
+ const legacy = (legacyHashes ??= readLegacyManagedAssetHashes())[ownershipKey]?.includes(
433
+ managedAssetHash(legacyComparableAssetContent(ownershipKey, installedContent)),
434
+ ) === true;
435
+ if (managed || legacy) {
436
+ try {
437
+ rmSync(installedPath);
438
+ } catch {
439
+ continue;
440
+ }
441
+ }
442
+ // Managed copies are gone; user-modified copies stay but stop being
443
+ // package-managed either way.
444
+ delete manifest.assets[ownershipKey];
445
+ }
446
+ }
447
+
397
448
  export function installSddAssets(
398
449
  _cwd: string,
399
450
  force: boolean,
@@ -407,6 +458,7 @@ export function installSddAssets(
407
458
  (cachedLegacyAssetHashes ??= readLegacyManagedAssetHashes());
408
459
  }
409
460
  const manifest = readManagedAssetsManifest(manifestPath);
461
+ removeRetiredManagedAssets(agentHome, manifest);
410
462
  const agents = copyDirectoryFiles(
411
463
  join(ASSETS_DIR, "agents"),
412
464
  join(agentHome, "agents"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,10 +39,13 @@
39
39
  "scripts": {
40
40
  "build:transaction-runner": "node scripts/build-git-commit-transaction-runner.mjs --write",
41
41
  "check:transaction-runner": "node scripts/build-git-commit-transaction-runner.mjs --check",
42
+ "check:provider-contract": "node scripts/check-provider-contract.mjs",
42
43
  "postinstall": "node scripts/install-gentle-ai.mjs",
43
- "test": "node --experimental-strip-types --test tests/*.test.ts && pnpm run test:harness",
44
+ "test": "node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
44
45
  "test:harness": "node --experimental-strip-types tests/runtime-harness.mjs",
45
46
  "test:dev-binary": "node --experimental-strip-types --test tests/devbinary/*.devtest.ts",
47
+ "test:cross-lane": "node tests/crosslane/cross-lane.mjs",
48
+ "test:maintainer": "node --experimental-strip-types --test tests/maintainer/*.maintest.ts",
46
49
  "test:packed-runner": "node scripts/test-packed-runner.mjs",
47
50
  "prepack": "pnpm test && node scripts/verify-package-files.mjs",
48
51
  "prepublishOnly": "pnpm test && node scripts/verify-package-files.mjs && pnpm run test:packed-runner"
@@ -1,6 +1,7 @@
1
1
  // Generated by scripts/build-git-commit-transaction-runner.mjs. Do not edit.
2
2
  import { createHash } from "node:crypto";
3
- import { existsSync, lstatSync, readFileSync } from "node:fs";
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
4
5
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
6
  import {
6
7
  GENTLE_AI_INSTALL_METHOD,
@@ -8,6 +9,7 @@ import {
8
9
  GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
9
10
  GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH,
10
11
  GENTLE_AI_WINDOWS_SOURCE_TAG,
12
+ INSTALLER_VERSION,
11
13
  isGentleAiWindowsGoVersionSupported,
12
14
  isWindowsGoSumdbSourceTarget,
13
15
  resolveGentleAiReleaseAsset,
@@ -15,7 +17,10 @@ import {
15
17
  import { fileURLToPath } from "node:url";
16
18
 
17
19
  export const GENTLE_AI_BINARY_MISSING_CODE = "package-local-binary-missing";
18
- export const GENTLE_AI_VERSION = "2.2.2";
20
+ // Derived from the one authoritative pinned version in
21
+ // scripts/gentle-ai-installer.mjs rather than repeating the literal here, so
22
+ // the two can never independently drift apart the way they once did.
23
+ export const GENTLE_AI_VERSION = INSTALLER_VERSION;
19
24
 
20
25
  export class PackageLocalGentleAiBinaryMissingError extends Error {
21
26
  code = GENTLE_AI_BINARY_MISSING_CODE;
@@ -43,6 +48,165 @@ function sha256(value ) {
43
48
  return createHash("sha256").update(value).digest("hex");
44
49
  }
45
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // Dev-binary override — the maintainer field-test lane.
53
+ //
54
+ // Two explicit activation paths, in precedence order:
55
+ // 1. GENTLE_PI_GENTLE_AI_DEV_BINARY (session override, absolute path), then
56
+ // 2. the persistent registration file at
57
+ // <GENTLE_PI_CONFIG_HOME|~/.pi/gentle-ai>/dev-binary.json with the strict
58
+ // shape {"schema":"gentle-pi.dev-binary/v1","path":"<absolute path>"}.
59
+ //
60
+ // The registration deliberately pins no digest: it is the unpinned field-test
61
+ // mode, and the binary at that path changes on every rebuild. Every resolution
62
+ // re-validates the file and recomputes the sha256, so a rebuilt binary is
63
+ // followed automatically with a fresh digest and no re-registration.
64
+ //
65
+ // Guardrails per resolution: absolute path, regular non-symlink file, POSIX
66
+ // executable. Any failure — including a malformed registration document or a
67
+ // registered-but-missing binary — is a typed error naming its origin, never a
68
+ // silent fallback to the pinned binary: silently running the pin while the
69
+ // maintainer believes he is field-testing main is the worst possible outcome.
70
+ // With neither activation path present, the pinned supply-chain resolution
71
+ // below stays byte-identical.
72
+ // ---------------------------------------------------------------------------
73
+
74
+ export const GENTLE_AI_DEV_BINARY_ENV = "GENTLE_PI_GENTLE_AI_DEV_BINARY";
75
+ export const GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA = "gentle-pi.dev-binary/v1";
76
+ export const GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE = "dev-binary-override-invalid";
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+ export class GentleAiDevBinaryOverrideError extends Error {
92
+ code = GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE;
93
+ source ;
94
+ origin ;
95
+ constructor(source , origin , reason ) {
96
+ super(`${GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE}: ${origin} ${reason}. Fix or remove the override; the pinned binary is never used silently while an override is declared.`);
97
+ this.name = "GentleAiDevBinaryOverrideError";
98
+ this.source = source;
99
+ this.origin = origin;
100
+ }
101
+ }
102
+
103
+ let devBinaryEnvironmentTestingOverlay ;
104
+
105
+ /** Testing-only environment overlay; production code never calls this. */
106
+ export function setGentleAiDevBinaryEnvironmentForTesting(environment ) {
107
+ devBinaryEnvironmentTestingOverlay = environment;
108
+ }
109
+
110
+ function ambientDevBinaryEnvironment() {
111
+ return devBinaryEnvironmentTestingOverlay ?? { env: process.env, home: homedir() };
112
+ }
113
+
114
+ export function gentleAiDevBinaryRegistrationPath(environment = ambientDevBinaryEnvironment()) {
115
+ const configHome = environment.env.GENTLE_PI_CONFIG_HOME ?? join(environment.home, ".pi", "gentle-ai");
116
+ return join(configHome, "dev-binary.json");
117
+ }
118
+
119
+ function validateDevBinary(source , origin , path , platform ) {
120
+ if (typeof path !== "string" || path.length === 0) throw new GentleAiDevBinaryOverrideError(source, origin, "declares an empty dev binary path");
121
+ if (!isAbsolute(path)) throw new GentleAiDevBinaryOverrideError(source, origin, `must name an absolute path, received "${path}"`);
122
+ let details ;
123
+ try {
124
+ details = lstatSync(path);
125
+ } catch {
126
+ throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which does not exist`);
127
+ }
128
+ if (!details.isFile() || details.isSymbolicLink()) throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which is not a regular non-symlink file`);
129
+ if (platform !== "win32" && (details.mode & 0o111) === 0) throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which is not a POSIX executable`);
130
+ let digest ;
131
+ try {
132
+ digest = sha256(readFileSync(path));
133
+ } catch {
134
+ throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which could not be read`);
135
+ }
136
+ return { source, origin, path, sha256: digest };
137
+ }
138
+
139
+ function readDevBinaryRegistration(registrationPath ) {
140
+ let contents ;
141
+ try {
142
+ contents = readFileSync(registrationPath, "utf8");
143
+ } catch {
144
+ throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "could not be read");
145
+ }
146
+ let parsed ;
147
+ try {
148
+ parsed = JSON.parse(contents);
149
+ } catch {
150
+ throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "is not valid JSON");
151
+ }
152
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "must be a JSON object");
153
+ const record = parsed ;
154
+ const keys = Object.keys(record).sort();
155
+ if (keys.length !== 2 || keys[0] !== "path" || keys[1] !== "schema") throw new GentleAiDevBinaryOverrideError("registration", registrationPath, `must carry exactly the keys "schema" and "path"`);
156
+ if (record.schema !== GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, `must declare schema ${GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA}`);
157
+ if (typeof record.path !== "string" || record.path.length === 0) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "must declare a non-empty string path");
158
+ return record.path;
159
+ }
160
+
161
+ /**
162
+ * Resolves the active dev-binary override, if any. Returns undefined only when
163
+ * neither activation path is present; a present-but-invalid override always
164
+ * throws a typed GentleAiDevBinaryOverrideError naming its origin.
165
+ */
166
+ export function resolveGentleAiDevBinaryOverride(
167
+ environment = ambientDevBinaryEnvironment(),
168
+ platform = process.platform,
169
+ ) {
170
+ const envValue = environment.env[GENTLE_AI_DEV_BINARY_ENV];
171
+ if (envValue !== undefined && envValue.length > 0) return validateDevBinary("env", GENTLE_AI_DEV_BINARY_ENV, envValue, platform);
172
+ const registrationPath = gentleAiDevBinaryRegistrationPath(environment);
173
+ if (!existsSync(registrationPath)) return undefined;
174
+ return validateDevBinary("registration", registrationPath, readDevBinaryRegistration(registrationPath), platform);
175
+ }
176
+
177
+ /**
178
+ * Cheap presence probe: is a dev-binary override declared at all? Used by the
179
+ * native CLI to select the unpinned version gate without hashing the binary.
180
+ * Declared-but-invalid still counts as configured — the resolution path will
181
+ * fail loudly with the typed error instead of quietly using the pin.
182
+ */
183
+ export function gentleAiDevBinaryOverrideConfigured(environment = ambientDevBinaryEnvironment()) {
184
+ const envValue = environment.env[GENTLE_AI_DEV_BINARY_ENV];
185
+ if (envValue !== undefined && envValue.length > 0) return true;
186
+ return existsSync(gentleAiDevBinaryRegistrationPath(environment));
187
+ }
188
+
189
+ /** Validates and persistently registers a dev binary; returns the fresh override. */
190
+ export function registerGentleAiDevBinary(
191
+ path ,
192
+ environment = ambientDevBinaryEnvironment(),
193
+ platform = process.platform,
194
+ ) {
195
+ const registrationPath = gentleAiDevBinaryRegistrationPath(environment);
196
+ const validated = validateDevBinary("registration", registrationPath, path, platform);
197
+ mkdirSync(dirname(registrationPath), { recursive: true });
198
+ writeFileSync(registrationPath, `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path })}\n`);
199
+ return { registrationPath, override: validated };
200
+ }
201
+
202
+ /** Deletes the persistent registration; returns whether one existed. */
203
+ export function unregisterGentleAiDevBinary(environment = ambientDevBinaryEnvironment()) {
204
+ const registrationPath = gentleAiDevBinaryRegistrationPath(environment);
205
+ if (!existsSync(registrationPath)) return false;
206
+ rmSync(registrationPath);
207
+ return true;
208
+ }
209
+
46
210
  function isConfined(path , directory ) {
47
211
  const relativePath = relative(directory, path);
48
212
  return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
@@ -107,7 +271,14 @@ export function resolveGentleAiBinary(
107
271
  packageRoot = dirname(dirname(fileURLToPath(import.meta.url))),
108
272
  platform = process.platform,
109
273
  readBinary = readFileSync,
274
+ environment = ambientDevBinaryEnvironment(),
110
275
  ) {
276
+ // The explicit dev-binary override wins over the pinned supply-chain path.
277
+ // Its typed errors propagate: a declared override never falls back to the
278
+ // pin. Without a declared override this call returns undefined and the
279
+ // pinned resolution below is byte-identical to the pre-override behavior.
280
+ const override = resolveGentleAiDevBinaryOverride(environment, platform);
281
+ if (override !== undefined) return override.path;
111
282
  const binaryPath = gentleAiBinaryPath(packageRoot, platform);
112
283
  const versionDirectory = dirname(binaryPath);
113
284
  const manifestPath = join(versionDirectory, "integrity.json");
@@ -181,13 +181,68 @@ function absoluteGitPath(cwd , name ) {
181
181
  return isAbsolute(value) ? value : resolve(cwd, value);
182
182
  }
183
183
 
184
+ // Runs a probe that may exit nonzero as an expected signal (absent ref, unborn
185
+ // HEAD). Returns the exit status and trimmed stdout. Timeout and I/O failures
186
+ // propagate instead of being masked as a status, so callers fail closed.
187
+ function probeGit(cwd , args ) {
188
+ try {
189
+ const stdout = execFileSync("git", args, {
190
+ cwd,
191
+ encoding: "utf8",
192
+ stdio: ["ignore", "pipe", "pipe"],
193
+ timeout: GIT_TIMEOUT_MS,
194
+ windowsHide: true,
195
+ });
196
+ return { status: 0, stdout: stdout.trim() };
197
+ } catch (error) {
198
+ const detail = error ;
199
+ if (detail.code === "ETIMEDOUT" || detail.killed === true) throw error;
200
+ if (typeof detail.status === "number") return { status: detail.status, stdout: typeof detail.stdout === "string" ? detail.stdout.trim() : "" };
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ // Resolves HEAD to a commit SHA, or undefined only for a valid unborn symbolic
206
+ // HEAD (symbolic HEAD pointing at a branch with no commits). Timeout, I/O,
207
+ // corruption, and all other failures propagate (fail closed on uncertain HEAD
208
+ // state). Classification uses status-based probes, not localized stderr text.
209
+ function resolveHead(cwd ) {
210
+ try {
211
+ return git(cwd, ["rev-parse", "--verify", "HEAD"]);
212
+ } catch (error) {
213
+ const detail = error ;
214
+ if (detail.code === "ETIMEDOUT" || detail.killed === true) throw error;
215
+ if (typeof detail.status !== "number") throw error;
216
+ const symbolic = probeGit(cwd, ["symbolic-ref", "--quiet", "HEAD"]);
217
+ if (symbolic.status !== 0) throw error;
218
+ // show-ref --verify --quiet distinguishes: status 1 = ref absent (valid
219
+ // unborn), status 0 = ref exists and valid (rethrow original HEAD error),
220
+ // any other status (128, etc.) = corruption/missing object (fail closed).
221
+ const refProbe = probeGit(cwd, ["show-ref", "--verify", "--quiet", symbolic.stdout]);
222
+ if (refProbe.status === 1) return undefined;
223
+ throw error;
224
+ }
225
+ }
226
+
184
227
  function repositoryBinding(cwd ) {
185
228
  const root = realpathSync(git(cwd, ["rev-parse", "--show-toplevel"]));
186
229
  const commonDirValue = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
187
230
  const gitDirValue = git(root, ["rev-parse", "--path-format=absolute", "--git-dir"]);
188
231
  const commonDir = realpathSync(commonDirValue);
189
232
  const gitDir = realpathSync(gitDirValue);
190
- const roots = git(root, ["rev-list", "--max-parents=0", "HEAD"]).split(/\r?\n/).filter(Boolean).sort();
233
+ // Preserve the durable repository identity across the unborn-handling
234
+ // upgrade: a born repository keeps the byte-for-byte previous formula
235
+ // `sha256(canonicalJson({ common_directory: commonDir, roots }))` with
236
+ // `roots` the sorted root commits reachable from HEAD. An unborn
237
+ // repository has no HEAD, so `rev-list HEAD` cannot run; resolveHead
238
+ // already classifies HEAD state and propagates timeout/corruption/I/O
239
+ // failures rather than masking them, so the unborn branch gets a
240
+ // deterministic safe roots representation (the empty set) without
241
+ // hiding real errors.
242
+ const head = resolveHead(root);
243
+ const roots = head === undefined
244
+ ? []
245
+ : git(root, ["rev-list", "--max-parents=0", "HEAD"]).split(/\r?\n/).filter(Boolean).sort();
191
246
  const repositoryId = sha256(canonicalJson({ common_directory: commonDir, roots }));
192
247
  const worktreeKey = sha256(gitDir).slice("sha256:".length, "sha256:".length + 24);
193
248
  const stateDir = join(commonDir, "gentle-pi", "commit-transactions", worktreeKey);
@@ -242,11 +297,11 @@ function decodeRecord(value ) {
242
297
  if (record.schema !== TRANSACTION_SCHEMA) throw new Error("commit transaction record schema is incompatible");
243
298
  for (const field of [
244
299
  "transaction_id", "repository_id", "repository_root", "common_directory", "git_directory",
245
- "command", "command_hash", "original_head", "original_head_tree", "original_index_tree",
300
+ "command", "command_hash", "original_index_tree",
246
301
  "original_index_hash", "authorized_pre_hook_tree", "state", "created_at", "updated_at", "record_hash",
247
302
  ]) if (typeof record[field] !== "string" || (record[field] ).length === 0) throw new Error(`commit transaction record ${field} is invalid`);
248
303
  if (!isStringArray(record.arguments) || !isStringArray(record.invocation_ids) || !isStringArray(record.lineage_history)) throw new Error("commit transaction record arrays are invalid");
249
- for (const field of ["post_hook_tree", "post_hook_index_hash", "authorized_tree", "authority_revision", "gate_context_hash", "committed_head", "committed_tree", "git_created_head", "git_created_tree", "error"] ) {
304
+ for (const field of ["original_head", "original_head_tree", "post_hook_tree", "post_hook_index_hash", "authorized_tree", "authority_revision", "gate_context_hash", "committed_head", "committed_tree", "git_created_head", "git_created_tree", "error"] ) {
250
305
  if (record[field] !== undefined && typeof record[field] !== "string") throw new Error(`commit transaction record ${field} is invalid`);
251
306
  }
252
307
  if (!Number.isSafeInteger(record.hook_runs) || (record.hook_runs ) < 0) throw new Error("commit transaction hook count is invalid");
@@ -438,7 +493,7 @@ export function prepareCommitTransactionInvocation(input
438
493
  ) {
439
494
  assertSafeCommitArguments(input.arguments);
440
495
  const binding = repositoryBinding(input.cwd);
441
- const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
496
+ const head = resolveHead(binding.root);
442
497
  const currentTree = git(binding.root, ["write-tree"]);
443
498
  if (currentTree !== input.authorization.intendedTree) throw new Error("commit transaction pre-hook index no longer matches its controller authorization");
444
499
  let transactionId = randomUUID();
@@ -541,7 +596,7 @@ function validateNativeTree(result , lineageId , tre
541
596
  }
542
597
 
543
598
  function createRecord(binding , invocation , now ) {
544
- const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
599
+ const head = resolveHead(binding.root);
545
600
  const tree = git(binding.root, ["write-tree"]);
546
601
  if (tree !== invocation.authorization.intendedTree) throw new Error("commit transaction index changed after controller authorization");
547
602
  const timestamp = now().toISOString();
@@ -556,7 +611,7 @@ function createRecord(binding , invocation
556
611
  command_hash: invocation.commandHash,
557
612
  arguments: [...invocation.arguments],
558
613
  original_head: head,
559
- original_head_tree: git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]),
614
+ original_head_tree: head === undefined ? undefined : git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]),
560
615
  original_index_tree: tree,
561
616
  original_index_hash: indexFingerprint(binding.root),
562
617
  authorized_pre_hook_tree: invocation.authorization.intendedTree,
@@ -572,13 +627,17 @@ function createRecord(binding , invocation
572
627
  function assertInvocationMatches(binding , record , invocation ) {
573
628
  if (record.repository_id !== binding.repositoryId || record.repository_root !== binding.root || record.common_directory !== binding.commonDir || record.git_directory !== binding.gitDir) throw new Error("commit transaction repository identity changed");
574
629
  if (record.transaction_id !== invocation.transactionId || record.command_hash !== invocation.commandHash || record.command !== invocation.command || canonicalJson(record.arguments) !== canonicalJson(invocation.arguments)) throw new Error("commit transaction exact retry does not match the durable command intent");
575
- if (git(binding.root, ["rev-parse", "--verify", "HEAD"]) !== record.original_head) throw new Error("commit transaction HEAD changed before reconciliation");
630
+ if (resolveHead(binding.root) !== record.original_head) throw new Error("commit transaction HEAD changed before reconciliation");
576
631
  }
577
632
 
578
633
  function recoverCompletedCommit(binding , record , now ) {
579
634
  if (record.state !== COMMIT_TRANSACTION_STATE.COMMIT_RUNNING && record.state !== COMMIT_TRANSACTION_STATE.COMMITTED) return undefined;
580
- const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
635
+ const head = resolveHead(binding.root);
581
636
  if (head === record.original_head) return undefined;
637
+ if (head === undefined) {
638
+ transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { error: "HEAD disappeared during commit transaction" }, now);
639
+ throw new Error("commit transaction incident: HEAD disappeared during commit; publication remains blocked");
640
+ }
582
641
  const tree = git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
583
642
  if (record.git_created_head === undefined || record.git_created_tree === undefined || head !== record.git_created_head || tree !== record.git_created_tree || tree !== record.authorized_tree) {
584
643
  transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { committed_head: head, committed_tree: tree, error: "HEAD identity differs from the exact Git-created authorized commit" }, now);
@@ -685,14 +744,15 @@ export async function runGitCommitTransaction(
685
744
  const commit = await runProcess("git", ["-c", `core.hooksPath=${proxy}`, "commit", ...invocation.arguments], binding.root);
686
745
  if (dependencies.failpoint === "after-commit-before-proof") throw new Error("commit transaction test interruption after Git returned");
687
746
  record = readRecord(binding.activePath) ?? record;
688
- const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
689
- const headTree = git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
690
- if (head !== record.original_head && head === record.git_created_head && headTree === record.git_created_tree && headTree === record.authorized_tree) {
747
+ const head = resolveHead(binding.root);
748
+ const headTree = head === undefined ? undefined : git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
749
+ const headChanged = head !== record.original_head;
750
+ if (headChanged && head === record.git_created_head && headTree === record.git_created_tree && headTree === record.authorized_tree) {
691
751
  const committed = transition(binding, record, COMMIT_TRANSACTION_STATE.COMMITTED, { committed_head: head, committed_tree: headTree, ...(commit.code === 0 && commit.signal === null ? {} : { error: `Git returned ${commit.signal ?? `exit ${commit.code}`} after creating the authorized commit` }) }, now);
692
752
  archive(binding, committed);
693
- return { transactionId: record.transaction_id, status: "committed", head, tree: headTree };
753
+ return { transactionId: record.transaction_id, status: "committed", head: head , tree: headTree };
694
754
  }
695
- if (head !== record.original_head) {
755
+ if (headChanged) {
696
756
  transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { committed_head: head, committed_tree: headTree, error: "HEAD identity differs from the exact Git-created authorized commit" }, now);
697
757
  throw new Error("commit transaction incident: HEAD identity changed after Git created the authorized commit; publication remains blocked");
698
758
  }
@@ -702,7 +762,7 @@ export async function runGitCommitTransaction(
702
762
  if (record !== undefined && dependencies.signal?.aborted === true && existsSync(binding.activePath)) {
703
763
  try {
704
764
  const active = readRecord(binding.activePath) ?? record;
705
- if (git(binding.root, ["rev-parse", "--verify", "HEAD"]) === active.original_head) transition(binding, active, COMMIT_TRANSACTION_STATE.INTERRUPTED, { error: "commit transaction was cancelled" }, now);
765
+ if (resolveHead(binding.root) === active.original_head) transition(binding, active, COMMIT_TRANSACTION_STATE.INTERRUPTED, { error: "commit transaction was cancelled" }, now);
706
766
  } catch { /* retain the earlier durable state */ }
707
767
  }
708
768
  throw error;
@@ -783,7 +843,7 @@ export function abandonCommitTransaction(cwd ) {
783
843
  try {
784
844
  const record = readRecord(binding.activePath);
785
845
  if (record === undefined) throw new Error("active commit transaction disappeared during recovery");
786
- if (git(binding.root, ["rev-parse", "--verify", "HEAD"]) !== record.original_head) throw new Error("cannot abandon a commit transaction after HEAD changed; reconcile the committed tree instead");
846
+ if (resolveHead(binding.root) !== record.original_head) throw new Error("cannot abandon a commit transaction after HEAD changed; reconcile the committed tree instead");
787
847
  const abandoned = transition(binding, record, COMMIT_TRANSACTION_STATE.ABANDONED, { error: "explicitly abandoned without changing HEAD or index" }, now);
788
848
  return archive(binding, abandoned);
789
849
  } finally { releaseLock(); }