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
@@ -775,11 +775,29 @@ export default function (pi: ExtensionAPI) {
775
775
  description:
776
776
  "Auto-detect project stack and bootstrap openspec/config.yaml for SDD.",
777
777
  handler: async (_args: unknown, ctx: any) => {
778
- await ensureSddPreflight(ctx, {
778
+ const prefs = await ensureSddPreflight(ctx, {
779
779
  pi,
780
780
  installAssets: (cwd) => installSddAssets(cwd, false),
781
781
  applyModelConfig: () => applySavedModelConfig(ctx),
782
782
  });
783
+
784
+ const detection = detectProject(ctx.cwd);
785
+ const testSummary = detection.testCommand
786
+ ? `strict TDD enabled with \`${detection.testCommand}\``
787
+ : "strict TDD disabled because no test runner was detected";
788
+ const layerSummary = `unit: ${detection.commands.unit.length}, integration: ${detection.commands.integration.length}, e2e: ${detection.commands.e2e.length}`;
789
+
790
+ const shouldCreateOpenSpec =
791
+ prefs.artifactStore === "openspec" ||
792
+ prefs.artifactStore === "both";
793
+ if (!shouldCreateOpenSpec) {
794
+ ctx.ui.notify(
795
+ `SDD initialized for ${prefs.artifactStore}: detected ${detection.stack.join(", ") || "project"}; ${testSummary}; tests found: ${layerSummary}.`,
796
+ "info",
797
+ );
798
+ return;
799
+ }
800
+
783
801
  const configPath = join(ctx.cwd, CONFIG_REL_PATH);
784
802
  if (existsSync(configPath)) {
785
803
  ctx.ui.notify(
@@ -789,15 +807,10 @@ export default function (pi: ExtensionAPI) {
789
807
  return;
790
808
  }
791
809
 
792
- const detection = detectProject(ctx.cwd);
793
810
  ensureOpenSpecDirs(ctx.cwd);
794
811
  mkdirSync(dirname(configPath), { recursive: true });
795
812
  writeFileSync(configPath, renderConfig(detection));
796
813
 
797
- const testSummary = detection.testCommand
798
- ? `strict TDD enabled with \`${detection.testCommand}\``
799
- : "strict TDD disabled because no test runner was detected";
800
- const layerSummary = `unit: ${detection.commands.unit.length}, integration: ${detection.commands.integration.length}, e2e: ${detection.commands.e2e.length}`;
801
814
  ctx.ui.notify(
802
815
  `Wrote ${CONFIG_REL_PATH}: detected ${detection.stack.join(", ") || "project"}; ${testSummary}; tests found: ${layerSummary}.`,
803
816
  "info",
@@ -21,7 +21,7 @@ const EXCLUDE_NAMES = new Set(["_shared", "skill-registry"]);
21
21
  const EXCLUDE_PREFIXES = ["sdd-"];
22
22
  const ATL_IGNORE_ENTRY = ".atl/";
23
23
  const WATCH_DEBOUNCE_MS = 500;
24
- const REGISTRY_SCHEMA_VERSION = 6;
24
+ const REGISTRY_SCHEMA_VERSION = 7;
25
25
  const NO_SKILL_REGISTRY_FLAG = "no-skill-registry";
26
26
  const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
27
27
  const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
@@ -253,7 +253,14 @@ async function fingerprint(files: string[]): Promise<string> {
253
253
  for (const file of files) {
254
254
  try {
255
255
  const info = await stat(file);
256
- lines.push(`${file}:${info.mtimeMs}:${info.size}`);
256
+ let contentHash: string;
257
+ try {
258
+ contentHash = createHash("sha1").update(await readFile(file)).digest("hex");
259
+ } catch {
260
+ lines.push(`${file}:unreadable`);
261
+ continue;
262
+ }
263
+ lines.push(`${file}:${info.mtimeMs}:${info.size}:${contentHash}`);
257
264
  } catch {
258
265
  lines.push(`${file}:missing`);
259
266
  }
@@ -526,6 +533,7 @@ export const __testing = {
526
533
  normalizeSkillDescription,
527
534
  parseFrontmatter,
528
535
  renderRegistry,
536
+ regenerateRegistry,
529
537
  shouldSkipSkillRegistryStartup,
530
538
  shouldSkipDuplicateExtensionLoad,
531
539
  startSkillRegistryWatcher,
@@ -553,7 +553,9 @@ export default function (pi: ExtensionAPI) {
553
553
  if (selected.startsWith("Rose:")) config.showRose = !config.showRose;
554
554
  else if (selected.startsWith("Text logo:")) config.showTextLogo = !config.showTextLogo;
555
555
  else if (selected.startsWith("Color:")) {
556
- config.color = await ctx.ui.select("Startup banner color", [...BANNER_COLORS]) as BannerColor;
556
+ const color = await ctx.ui.select("Startup banner color", [...BANNER_COLORS]);
557
+ if (!color) return;
558
+ config.color = color as BannerColor;
557
559
  }
558
560
  await writeBannerConfig(config);
559
561
  notifyBannerConfig(ctx, config);
@@ -577,9 +579,13 @@ export default function (pi: ExtensionAPI) {
577
579
  handler: async (args, ctx) => {
578
580
  const config = await readBannerConfig();
579
581
  const requested = String(args ?? "").trim() as BannerColor;
580
- config.color = BANNER_COLORS.includes(requested)
581
- ? requested
582
- : await ctx.ui.select("Startup banner color", [...BANNER_COLORS]) as BannerColor;
582
+ if (BANNER_COLORS.includes(requested)) {
583
+ config.color = requested;
584
+ } else {
585
+ const selected = await ctx.ui.select("Startup banner color", [...BANNER_COLORS]);
586
+ if (!selected) return;
587
+ config.color = selected as BannerColor;
588
+ }
583
589
  await writeBannerConfig(config);
584
590
  notifyBannerConfig(ctx, config);
585
591
  },
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, lstatSync, readFileSync } from "node:fs";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
3
4
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
5
  import {
5
6
  GENTLE_AI_INSTALL_METHOD,
@@ -7,6 +8,7 @@ import {
7
8
  GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
8
9
  GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH,
9
10
  GENTLE_AI_WINDOWS_SOURCE_TAG,
11
+ INSTALLER_VERSION,
10
12
  isGentleAiWindowsGoVersionSupported,
11
13
  isWindowsGoSumdbSourceTarget,
12
14
  resolveGentleAiReleaseAsset,
@@ -14,7 +16,10 @@ import {
14
16
  import { fileURLToPath } from "node:url";
15
17
 
16
18
  export const GENTLE_AI_BINARY_MISSING_CODE = "package-local-binary-missing";
17
- export const GENTLE_AI_VERSION = "2.2.2";
19
+ // Derived from the one authoritative pinned version in
20
+ // scripts/gentle-ai-installer.mjs rather than repeating the literal here, so
21
+ // the two can never independently drift apart the way they once did.
22
+ export const GENTLE_AI_VERSION = INSTALLER_VERSION;
18
23
 
19
24
  export class PackageLocalGentleAiBinaryMissingError extends Error {
20
25
  readonly code = GENTLE_AI_BINARY_MISSING_CODE;
@@ -42,6 +47,165 @@ function sha256(value: Buffer): string {
42
47
  return createHash("sha256").update(value).digest("hex");
43
48
  }
44
49
 
50
+ // ---------------------------------------------------------------------------
51
+ // Dev-binary override — the maintainer field-test lane.
52
+ //
53
+ // Two explicit activation paths, in precedence order:
54
+ // 1. GENTLE_PI_GENTLE_AI_DEV_BINARY (session override, absolute path), then
55
+ // 2. the persistent registration file at
56
+ // <GENTLE_PI_CONFIG_HOME|~/.pi/gentle-ai>/dev-binary.json with the strict
57
+ // shape {"schema":"gentle-pi.dev-binary/v1","path":"<absolute path>"}.
58
+ //
59
+ // The registration deliberately pins no digest: it is the unpinned field-test
60
+ // mode, and the binary at that path changes on every rebuild. Every resolution
61
+ // re-validates the file and recomputes the sha256, so a rebuilt binary is
62
+ // followed automatically with a fresh digest and no re-registration.
63
+ //
64
+ // Guardrails per resolution: absolute path, regular non-symlink file, POSIX
65
+ // executable. Any failure — including a malformed registration document or a
66
+ // registered-but-missing binary — is a typed error naming its origin, never a
67
+ // silent fallback to the pinned binary: silently running the pin while the
68
+ // maintainer believes he is field-testing main is the worst possible outcome.
69
+ // With neither activation path present, the pinned supply-chain resolution
70
+ // below stays byte-identical.
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export const GENTLE_AI_DEV_BINARY_ENV = "GENTLE_PI_GENTLE_AI_DEV_BINARY";
74
+ export const GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA = "gentle-pi.dev-binary/v1";
75
+ export const GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE = "dev-binary-override-invalid";
76
+
77
+ export interface GentleAiDevBinaryEnvironment {
78
+ env: Record<string, string | undefined>;
79
+ home: string;
80
+ }
81
+
82
+ export interface GentleAiDevBinaryOverride {
83
+ source: "env" | "registration";
84
+ /** The env var name or registration file path that selected this binary. */
85
+ origin: string;
86
+ path: string;
87
+ sha256: string;
88
+ }
89
+
90
+ export class GentleAiDevBinaryOverrideError extends Error {
91
+ readonly code = GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE;
92
+ readonly source: "env" | "registration";
93
+ readonly origin: string;
94
+ constructor(source: "env" | "registration", origin: string, reason: string) {
95
+ 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.`);
96
+ this.name = "GentleAiDevBinaryOverrideError";
97
+ this.source = source;
98
+ this.origin = origin;
99
+ }
100
+ }
101
+
102
+ let devBinaryEnvironmentTestingOverlay: GentleAiDevBinaryEnvironment | undefined;
103
+
104
+ /** Testing-only environment overlay; production code never calls this. */
105
+ export function setGentleAiDevBinaryEnvironmentForTesting(environment: GentleAiDevBinaryEnvironment | undefined): void {
106
+ devBinaryEnvironmentTestingOverlay = environment;
107
+ }
108
+
109
+ function ambientDevBinaryEnvironment(): GentleAiDevBinaryEnvironment {
110
+ return devBinaryEnvironmentTestingOverlay ?? { env: process.env, home: homedir() };
111
+ }
112
+
113
+ export function gentleAiDevBinaryRegistrationPath(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): string {
114
+ const configHome = environment.env.GENTLE_PI_CONFIG_HOME ?? join(environment.home, ".pi", "gentle-ai");
115
+ return join(configHome, "dev-binary.json");
116
+ }
117
+
118
+ function validateDevBinary(source: "env" | "registration", origin: string, path: string, platform: string): GentleAiDevBinaryOverride {
119
+ if (typeof path !== "string" || path.length === 0) throw new GentleAiDevBinaryOverrideError(source, origin, "declares an empty dev binary path");
120
+ if (!isAbsolute(path)) throw new GentleAiDevBinaryOverrideError(source, origin, `must name an absolute path, received "${path}"`);
121
+ let details: ReturnType<typeof lstatSync>;
122
+ try {
123
+ details = lstatSync(path);
124
+ } catch {
125
+ throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which does not exist`);
126
+ }
127
+ if (!details.isFile() || details.isSymbolicLink()) throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which is not a regular non-symlink file`);
128
+ if (platform !== "win32" && (details.mode & 0o111) === 0) throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which is not a POSIX executable`);
129
+ let digest: string;
130
+ try {
131
+ digest = sha256(readFileSync(path));
132
+ } catch {
133
+ throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which could not be read`);
134
+ }
135
+ return { source, origin, path, sha256: digest };
136
+ }
137
+
138
+ function readDevBinaryRegistration(registrationPath: string): string {
139
+ let contents: string;
140
+ try {
141
+ contents = readFileSync(registrationPath, "utf8");
142
+ } catch {
143
+ throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "could not be read");
144
+ }
145
+ let parsed: unknown;
146
+ try {
147
+ parsed = JSON.parse(contents);
148
+ } catch {
149
+ throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "is not valid JSON");
150
+ }
151
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "must be a JSON object");
152
+ const record = parsed as Record<string, unknown>;
153
+ const keys = Object.keys(record).sort();
154
+ if (keys.length !== 2 || keys[0] !== "path" || keys[1] !== "schema") throw new GentleAiDevBinaryOverrideError("registration", registrationPath, `must carry exactly the keys "schema" and "path"`);
155
+ if (record.schema !== GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, `must declare schema ${GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA}`);
156
+ if (typeof record.path !== "string" || record.path.length === 0) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "must declare a non-empty string path");
157
+ return record.path;
158
+ }
159
+
160
+ /**
161
+ * Resolves the active dev-binary override, if any. Returns undefined only when
162
+ * neither activation path is present; a present-but-invalid override always
163
+ * throws a typed GentleAiDevBinaryOverrideError naming its origin.
164
+ */
165
+ export function resolveGentleAiDevBinaryOverride(
166
+ environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment(),
167
+ platform = process.platform,
168
+ ): GentleAiDevBinaryOverride | undefined {
169
+ const envValue = environment.env[GENTLE_AI_DEV_BINARY_ENV];
170
+ if (envValue !== undefined && envValue.length > 0) return validateDevBinary("env", GENTLE_AI_DEV_BINARY_ENV, envValue, platform);
171
+ const registrationPath = gentleAiDevBinaryRegistrationPath(environment);
172
+ if (!existsSync(registrationPath)) return undefined;
173
+ return validateDevBinary("registration", registrationPath, readDevBinaryRegistration(registrationPath), platform);
174
+ }
175
+
176
+ /**
177
+ * Cheap presence probe: is a dev-binary override declared at all? Used by the
178
+ * native CLI to select the unpinned version gate without hashing the binary.
179
+ * Declared-but-invalid still counts as configured — the resolution path will
180
+ * fail loudly with the typed error instead of quietly using the pin.
181
+ */
182
+ export function gentleAiDevBinaryOverrideConfigured(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): boolean {
183
+ const envValue = environment.env[GENTLE_AI_DEV_BINARY_ENV];
184
+ if (envValue !== undefined && envValue.length > 0) return true;
185
+ return existsSync(gentleAiDevBinaryRegistrationPath(environment));
186
+ }
187
+
188
+ /** Validates and persistently registers a dev binary; returns the fresh override. */
189
+ export function registerGentleAiDevBinary(
190
+ path: string,
191
+ environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment(),
192
+ platform = process.platform,
193
+ ): { registrationPath: string; override: GentleAiDevBinaryOverride } {
194
+ const registrationPath = gentleAiDevBinaryRegistrationPath(environment);
195
+ const validated = validateDevBinary("registration", registrationPath, path, platform);
196
+ mkdirSync(dirname(registrationPath), { recursive: true });
197
+ writeFileSync(registrationPath, `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path })}\n`);
198
+ return { registrationPath, override: validated };
199
+ }
200
+
201
+ /** Deletes the persistent registration; returns whether one existed. */
202
+ export function unregisterGentleAiDevBinary(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): boolean {
203
+ const registrationPath = gentleAiDevBinaryRegistrationPath(environment);
204
+ if (!existsSync(registrationPath)) return false;
205
+ rmSync(registrationPath);
206
+ return true;
207
+ }
208
+
45
209
  function isConfined(path: string, directory: string): boolean {
46
210
  const relativePath = relative(directory, path);
47
211
  return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
@@ -106,7 +270,14 @@ export function resolveGentleAiBinary(
106
270
  packageRoot = dirname(dirname(fileURLToPath(import.meta.url))),
107
271
  platform = process.platform,
108
272
  readBinary: (path: string) => Buffer = readFileSync,
273
+ environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment(),
109
274
  ): string {
275
+ // The explicit dev-binary override wins over the pinned supply-chain path.
276
+ // Its typed errors propagate: a declared override never falls back to the
277
+ // pin. Without a declared override this call returns undefined and the
278
+ // pinned resolution below is byte-identical to the pre-override behavior.
279
+ const override = resolveGentleAiDevBinaryOverride(environment, platform);
280
+ if (override !== undefined) return override.path;
110
281
  const binaryPath = gentleAiBinaryPath(packageRoot, platform);
111
282
  const versionDirectory = dirname(binaryPath);
112
283
  const manifestPath = join(versionDirectory, "integrity.json");
@@ -77,8 +77,8 @@ interface CommitTransactionRecordBody {
77
77
  command: string;
78
78
  command_hash: string;
79
79
  arguments: readonly string[];
80
- original_head: string;
81
- original_head_tree: string;
80
+ original_head?: string;
81
+ original_head_tree?: string;
82
82
  original_index_tree: string;
83
83
  original_index_hash: string;
84
84
  authorized_pre_hook_tree: string;
@@ -180,13 +180,68 @@ function absoluteGitPath(cwd: string, name: string): string {
180
180
  return isAbsolute(value) ? value : resolve(cwd, value);
181
181
  }
182
182
 
183
+ // Runs a probe that may exit nonzero as an expected signal (absent ref, unborn
184
+ // HEAD). Returns the exit status and trimmed stdout. Timeout and I/O failures
185
+ // propagate instead of being masked as a status, so callers fail closed.
186
+ function probeGit(cwd: string, args: readonly string[]): { status: number; stdout: string } {
187
+ try {
188
+ const stdout = execFileSync("git", args, {
189
+ cwd,
190
+ encoding: "utf8",
191
+ stdio: ["ignore", "pipe", "pipe"],
192
+ timeout: GIT_TIMEOUT_MS,
193
+ windowsHide: true,
194
+ });
195
+ return { status: 0, stdout: stdout.trim() };
196
+ } catch (error) {
197
+ const detail = error as NodeJS.ErrnoException & { killed?: boolean; status?: number; stdout?: string | Buffer };
198
+ if (detail.code === "ETIMEDOUT" || detail.killed === true) throw error;
199
+ if (typeof detail.status === "number") return { status: detail.status, stdout: typeof detail.stdout === "string" ? detail.stdout.trim() : "" };
200
+ throw error;
201
+ }
202
+ }
203
+
204
+ // Resolves HEAD to a commit SHA, or undefined only for a valid unborn symbolic
205
+ // HEAD (symbolic HEAD pointing at a branch with no commits). Timeout, I/O,
206
+ // corruption, and all other failures propagate (fail closed on uncertain HEAD
207
+ // state). Classification uses status-based probes, not localized stderr text.
208
+ function resolveHead(cwd: string): string | undefined {
209
+ try {
210
+ return git(cwd, ["rev-parse", "--verify", "HEAD"]);
211
+ } catch (error) {
212
+ const detail = error as NodeJS.ErrnoException & { killed?: boolean };
213
+ if (detail.code === "ETIMEDOUT" || detail.killed === true) throw error;
214
+ if (typeof detail.status !== "number") throw error;
215
+ const symbolic = probeGit(cwd, ["symbolic-ref", "--quiet", "HEAD"]);
216
+ if (symbolic.status !== 0) throw error;
217
+ // show-ref --verify --quiet distinguishes: status 1 = ref absent (valid
218
+ // unborn), status 0 = ref exists and valid (rethrow original HEAD error),
219
+ // any other status (128, etc.) = corruption/missing object (fail closed).
220
+ const refProbe = probeGit(cwd, ["show-ref", "--verify", "--quiet", symbolic.stdout]);
221
+ if (refProbe.status === 1) return undefined;
222
+ throw error;
223
+ }
224
+ }
225
+
183
226
  function repositoryBinding(cwd: string): RepositoryBinding {
184
227
  const root = realpathSync(git(cwd, ["rev-parse", "--show-toplevel"]));
185
228
  const commonDirValue = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
186
229
  const gitDirValue = git(root, ["rev-parse", "--path-format=absolute", "--git-dir"]);
187
230
  const commonDir = realpathSync(commonDirValue);
188
231
  const gitDir = realpathSync(gitDirValue);
189
- const roots = git(root, ["rev-list", "--max-parents=0", "HEAD"]).split(/\r?\n/).filter(Boolean).sort();
232
+ // Preserve the durable repository identity across the unborn-handling
233
+ // upgrade: a born repository keeps the byte-for-byte previous formula
234
+ // `sha256(canonicalJson({ common_directory: commonDir, roots }))` with
235
+ // `roots` the sorted root commits reachable from HEAD. An unborn
236
+ // repository has no HEAD, so `rev-list HEAD` cannot run; resolveHead
237
+ // already classifies HEAD state and propagates timeout/corruption/I/O
238
+ // failures rather than masking them, so the unborn branch gets a
239
+ // deterministic safe roots representation (the empty set) without
240
+ // hiding real errors.
241
+ const head = resolveHead(root);
242
+ const roots = head === undefined
243
+ ? []
244
+ : git(root, ["rev-list", "--max-parents=0", "HEAD"]).split(/\r?\n/).filter(Boolean).sort();
190
245
  const repositoryId = sha256(canonicalJson({ common_directory: commonDir, roots }));
191
246
  const worktreeKey = sha256(gitDir).slice("sha256:".length, "sha256:".length + 24);
192
247
  const stateDir = join(commonDir, "gentle-pi", "commit-transactions", worktreeKey);
@@ -241,11 +296,11 @@ function decodeRecord(value: unknown): CommitTransactionRecord {
241
296
  if (record.schema !== TRANSACTION_SCHEMA) throw new Error("commit transaction record schema is incompatible");
242
297
  for (const field of [
243
298
  "transaction_id", "repository_id", "repository_root", "common_directory", "git_directory",
244
- "command", "command_hash", "original_head", "original_head_tree", "original_index_tree",
299
+ "command", "command_hash", "original_index_tree",
245
300
  "original_index_hash", "authorized_pre_hook_tree", "state", "created_at", "updated_at", "record_hash",
246
301
  ]) if (typeof record[field] !== "string" || (record[field] as string).length === 0) throw new Error(`commit transaction record ${field} is invalid`);
247
302
  if (!isStringArray(record.arguments) || !isStringArray(record.invocation_ids) || !isStringArray(record.lineage_history)) throw new Error("commit transaction record arrays are invalid");
248
- 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"] as const) {
303
+ 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"] as const) {
249
304
  if (record[field] !== undefined && typeof record[field] !== "string") throw new Error(`commit transaction record ${field} is invalid`);
250
305
  }
251
306
  if (!Number.isSafeInteger(record.hook_runs) || (record.hook_runs as number) < 0) throw new Error("commit transaction hook count is invalid");
@@ -437,7 +492,7 @@ export function prepareCommitTransactionInvocation(input: {
437
492
  }): CommitTransactionInvocation {
438
493
  assertSafeCommitArguments(input.arguments);
439
494
  const binding = repositoryBinding(input.cwd);
440
- const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
495
+ const head = resolveHead(binding.root);
441
496
  const currentTree = git(binding.root, ["write-tree"]);
442
497
  if (currentTree !== input.authorization.intendedTree) throw new Error("commit transaction pre-hook index no longer matches its controller authorization");
443
498
  let transactionId = randomUUID();
@@ -540,7 +595,7 @@ function validateNativeTree(result: NativeValidateResult, lineageId: string, tre
540
595
  }
541
596
 
542
597
  function createRecord(binding: RepositoryBinding, invocation: CommitTransactionInvocation, now: () => Date): CommitTransactionRecord {
543
- const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
598
+ const head = resolveHead(binding.root);
544
599
  const tree = git(binding.root, ["write-tree"]);
545
600
  if (tree !== invocation.authorization.intendedTree) throw new Error("commit transaction index changed after controller authorization");
546
601
  const timestamp = now().toISOString();
@@ -555,7 +610,7 @@ function createRecord(binding: RepositoryBinding, invocation: CommitTransactionI
555
610
  command_hash: invocation.commandHash,
556
611
  arguments: [...invocation.arguments],
557
612
  original_head: head,
558
- original_head_tree: git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]),
613
+ original_head_tree: head === undefined ? undefined : git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]),
559
614
  original_index_tree: tree,
560
615
  original_index_hash: indexFingerprint(binding.root),
561
616
  authorized_pre_hook_tree: invocation.authorization.intendedTree,
@@ -571,13 +626,17 @@ function createRecord(binding: RepositoryBinding, invocation: CommitTransactionI
571
626
  function assertInvocationMatches(binding: RepositoryBinding, record: CommitTransactionRecord, invocation: CommitTransactionInvocation): void {
572
627
  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");
573
628
  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");
574
- if (git(binding.root, ["rev-parse", "--verify", "HEAD"]) !== record.original_head) throw new Error("commit transaction HEAD changed before reconciliation");
629
+ if (resolveHead(binding.root) !== record.original_head) throw new Error("commit transaction HEAD changed before reconciliation");
575
630
  }
576
631
 
577
632
  function recoverCompletedCommit(binding: RepositoryBinding, record: CommitTransactionRecord, now: () => Date): CommitTransactionResult | undefined {
578
633
  if (record.state !== COMMIT_TRANSACTION_STATE.COMMIT_RUNNING && record.state !== COMMIT_TRANSACTION_STATE.COMMITTED) return undefined;
579
- const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
634
+ const head = resolveHead(binding.root);
580
635
  if (head === record.original_head) return undefined;
636
+ if (head === undefined) {
637
+ transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { error: "HEAD disappeared during commit transaction" }, now);
638
+ throw new Error("commit transaction incident: HEAD disappeared during commit; publication remains blocked");
639
+ }
581
640
  const tree = git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
582
641
  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) {
583
642
  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);
@@ -684,14 +743,15 @@ export async function runGitCommitTransaction(
684
743
  const commit = await runProcess("git", ["-c", `core.hooksPath=${proxy}`, "commit", ...invocation.arguments], binding.root);
685
744
  if (dependencies.failpoint === "after-commit-before-proof") throw new Error("commit transaction test interruption after Git returned");
686
745
  record = readRecord(binding.activePath) ?? record;
687
- const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
688
- const headTree = git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
689
- if (head !== record.original_head && head === record.git_created_head && headTree === record.git_created_tree && headTree === record.authorized_tree) {
746
+ const head = resolveHead(binding.root);
747
+ const headTree = head === undefined ? undefined : git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
748
+ const headChanged = head !== record.original_head;
749
+ if (headChanged && head === record.git_created_head && headTree === record.git_created_tree && headTree === record.authorized_tree) {
690
750
  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);
691
751
  archive(binding, committed);
692
- return { transactionId: record.transaction_id, status: "committed", head, tree: headTree };
752
+ return { transactionId: record.transaction_id, status: "committed", head: head!, tree: headTree! };
693
753
  }
694
- if (head !== record.original_head) {
754
+ if (headChanged) {
695
755
  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);
696
756
  throw new Error("commit transaction incident: HEAD identity changed after Git created the authorized commit; publication remains blocked");
697
757
  }
@@ -701,7 +761,7 @@ export async function runGitCommitTransaction(
701
761
  if (record !== undefined && dependencies.signal?.aborted === true && existsSync(binding.activePath)) {
702
762
  try {
703
763
  const active = readRecord(binding.activePath) ?? record;
704
- if (git(binding.root, ["rev-parse", "--verify", "HEAD"]) === active.original_head) transition(binding, active, COMMIT_TRANSACTION_STATE.INTERRUPTED, { error: "commit transaction was cancelled" }, now);
764
+ if (resolveHead(binding.root) === active.original_head) transition(binding, active, COMMIT_TRANSACTION_STATE.INTERRUPTED, { error: "commit transaction was cancelled" }, now);
705
765
  } catch { /* retain the earlier durable state */ }
706
766
  }
707
767
  throw error;
@@ -782,7 +842,7 @@ export function abandonCommitTransaction(cwd: string): CommitTransactionRecord {
782
842
  try {
783
843
  const record = readRecord(binding.activePath);
784
844
  if (record === undefined) throw new Error("active commit transaction disappeared during recovery");
785
- 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");
845
+ if (resolveHead(binding.root) !== record.original_head) throw new Error("cannot abandon a commit transaction after HEAD changed; reconcile the committed tree instead");
786
846
  const abandoned = transition(binding, record, COMMIT_TRANSACTION_STATE.ABANDONED, { error: "explicitly abandoned without changing HEAD or index" }, now);
787
847
  return archive(binding, abandoned);
788
848
  } finally { releaseLock(); }