gentle-pi 2.1.2 → 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 (228) hide show
  1. package/README.md +108 -79
  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 -3
  7. package/assets/agents/review-reliability.md +4 -3
  8. package/assets/agents/review-resilience.md +4 -3
  9. package/assets/agents/review-risk.md +4 -3
  10. package/assets/agents/sdd-apply.md +11 -4
  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 +8 -1
  17. package/assets/agents/sdd-research.md +54 -0
  18. package/assets/agents/sdd-spec.md +6 -1
  19. package/assets/agents/sdd-status.md +10 -5
  20. package/assets/agents/sdd-sync.md +6 -1
  21. package/assets/agents/sdd-tasks.md +8 -4
  22. package/assets/agents/sdd-verify.md +27 -2
  23. package/assets/chains/4r-review.chain.md +2 -0
  24. package/assets/chains/sdd-full.chain.md +2 -2
  25. package/assets/chains/sdd-plan.chain.md +1 -1
  26. package/assets/chains/sdd-verify.chain.md +2 -2
  27. package/assets/orchestrator-delegation.md +145 -160
  28. package/assets/orchestrator-memory.md +2 -0
  29. package/assets/orchestrator.md +25 -48
  30. package/assets/sdd-orchestrator-workflow.md +163 -25
  31. package/assets/support/sdd-status-contract.md +24 -6
  32. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  33. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  34. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  35. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  36. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  37. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  38. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  39. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  40. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  41. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  42. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  43. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  44. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  45. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  46. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  47. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  48. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  49. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  50. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  51. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  52. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  58. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  59. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  60. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  61. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  62. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  63. package/docs/native-authority-architecture.md +14 -16
  64. package/docs/review-integration.md +27 -353
  65. package/extensions/ask-user-choice.ts +151 -0
  66. package/extensions/gentle-ai.ts +2710 -3211
  67. package/extensions/quiet-tools.ts +515 -32
  68. package/extensions/sdd-init.ts +21 -12
  69. package/extensions/skill-registry.ts +10 -2
  70. package/extensions/startup-banner.ts +10 -4
  71. package/lib/gentle-ai-binary.ts +173 -2
  72. package/lib/gentle-ai-renderer.ts +70 -0
  73. package/lib/model-routing-authority.ts +133 -0
  74. package/lib/native-review-cli.ts +676 -781
  75. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  76. package/lib/provider-contract-bundle.ts +704 -0
  77. package/lib/review-candidate-view.ts +836 -118
  78. package/lib/review-compact-contract.ts +59 -248
  79. package/lib/review-host-relay.ts +578 -0
  80. package/lib/review-integration-v2.ts +1254 -221
  81. package/lib/review-last-event-controller.ts +35 -0
  82. package/lib/review-relay-contract.ts +16 -0
  83. package/lib/sdd-preflight.ts +230 -67
  84. package/lib/sdd-status.ts +66 -111
  85. package/lib/terminal-theme.ts +1 -1
  86. package/package.json +83 -79
  87. package/runtime/gentle-ai-binary.mjs +174 -3
  88. package/runtime/native-review-cli.mjs +610 -715
  89. package/runtime/review-integration-v2.mjs +1223 -190
  90. package/runtime/review-relay-contract.mjs +17 -0
  91. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
  92. package/scripts/check-provider-contract.mjs +138 -0
  93. package/scripts/gentle-ai-installer.mjs +85 -22
  94. package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
  95. package/scripts/mirror-provider-contract.mjs +143 -0
  96. package/scripts/test-packed-runner.mjs +20 -9
  97. package/scripts/verify-package-files.mjs +119 -43
  98. package/skills/_shared/review-ledger-contract.md +11 -19
  99. package/skills/chained-pr/SKILL.md +3 -0
  100. package/skills/cognitive-doc-design/SKILL.md +1 -1
  101. package/skills/comment-writer/SKILL.md +1 -1
  102. package/skills/gentle-ai/SKILL.md +7 -74
  103. package/skills/issue-creation/SKILL.md +94 -168
  104. package/skills/judgment-day/SKILL.md +9 -5
  105. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  106. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  107. package/skills/release/SKILL.md +3 -3
  108. package/skills/skill-registry/SKILL.md +1 -1
  109. package/skills/work-unit-commits/SKILL.md +3 -1
  110. package/tests/artifact-language.test.ts +24 -11
  111. package/tests/ask-user-choice.test.ts +264 -0
  112. package/tests/background-subagents.test.ts +771 -0
  113. package/tests/codegraph-tools.test.ts +3 -3
  114. package/tests/crosslane/cross-lane.mjs +16 -0
  115. package/tests/delegated-key-learnings-contract.test.ts +240 -0
  116. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  117. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  118. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  119. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  120. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  121. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  122. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  123. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  124. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  125. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  126. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  127. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  128. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  129. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  130. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  131. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  132. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  133. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  134. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  135. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  136. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  137. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  138. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  139. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  140. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  141. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  142. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  143. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  144. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  145. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  148. package/tests/gentle-ai-binary.test.ts +83 -13
  149. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  150. package/tests/gentle-ai-dev-binary.test.ts +159 -0
  151. package/tests/gentle-ai-installer.test.ts +138 -48
  152. package/tests/gentle-ai.test.ts +820 -131
  153. package/tests/gentle-theme.test.ts +133 -0
  154. package/tests/maintainer/provider-relay.maintest.ts +601 -0
  155. package/tests/model-routing-authority.test.ts +257 -0
  156. package/tests/native-review-capability-contract.test.ts +75 -2
  157. package/tests/native-review-cli.test.ts +588 -908
  158. package/tests/native-review-consent.test.ts +283 -38
  159. package/tests/native-review-parity-runtime.test.ts +108 -350
  160. package/tests/native-review-parity.test.ts +684 -732
  161. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  162. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  163. package/tests/orchestrator-budget.test.ts +158 -89
  164. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  165. package/tests/package-manifest.test.ts +128 -100
  166. package/tests/provider-contract-bundle.test.ts +385 -0
  167. package/tests/provider-contract-mirror.test.ts +206 -0
  168. package/tests/provider-defect-handoff.test.ts +252 -0
  169. package/tests/quiet-tool-rendering.test.ts +1055 -28
  170. package/tests/review-actor-tool-deny.test.ts +12 -13
  171. package/tests/review-authority-recovery-docs.test.ts +1 -2
  172. package/tests/review-candidate-view.test.ts +898 -12
  173. package/tests/review-compact-contract.test.ts +29 -122
  174. package/tests/review-controller-lock-status.test.ts +2 -2
  175. package/tests/review-controller-native-recovery.test.ts +366 -857
  176. package/tests/review-controller-native-routing.test.ts +1253 -4199
  177. package/tests/review-controller-retired-ops.test.ts +1 -1
  178. package/tests/review-controller-workspace-root.test.ts +236 -70
  179. package/tests/review-controller.test.ts +26 -816
  180. package/tests/review-corrected-finalize-binding.test.ts +134 -0
  181. package/tests/review-dispatch-hydration-gap.test.ts +145 -0
  182. package/tests/review-gate.test.ts +0 -45
  183. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  184. package/tests/review-host-relay-routing.test.ts +352 -0
  185. package/tests/review-host-relay.test.ts +754 -0
  186. package/tests/review-integration-v2-forward.test.ts +654 -0
  187. package/tests/review-integration-v2.test.ts +317 -129
  188. package/tests/review-last-event-closure.test.ts +408 -0
  189. package/tests/review-ledger-contract.test.ts +106 -60
  190. package/tests/review-recovered-lineage-routing.test.ts +199 -0
  191. package/tests/review-relay-transport-agent.test.ts +322 -0
  192. package/tests/review-snapshot.test.ts +3 -2
  193. package/tests/runtime-harness.mjs +573 -180
  194. package/tests/sdd-agent-tools.test.ts +53 -32
  195. package/tests/sdd-preflight.test.ts +81 -15
  196. package/tests/sdd-status.test.ts +109 -110
  197. package/tests/skill-collision-prefixes.test.ts +6 -8
  198. package/tests/skill-registry.test.ts +50 -1
  199. package/tests/verify-package-files.test.ts +62 -0
  200. package/tests/writer-edit-surface-scope.test.ts +230 -0
  201. package/themes/Gentleman-Cute.json +94 -0
  202. package/themes/Gentleman-Sexy.json +92 -0
  203. package/assets/agents/review-refuter.md +0 -40
  204. package/assets/agents/review-validator.md +0 -23
  205. package/lib/git-commit-transaction.ts +0 -801
  206. package/lib/native-review-remediation.ts +0 -49
  207. package/lib/review-compact.ts +0 -947
  208. package/lib/review-refuter-adapter.ts +0 -129
  209. package/lib/review-runtime-contract.ts +0 -68
  210. package/prompts/gcl.md +0 -54
  211. package/prompts/gis.md +0 -25
  212. package/prompts/gpr.md +0 -41
  213. package/prompts/gwr.md +0 -31
  214. package/runtime/git-commit-transaction.mjs +0 -802
  215. package/scripts/run-git-commit-transaction.mjs +0 -35
  216. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  217. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  218. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  219. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  220. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  221. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  222. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  223. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  224. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  225. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  226. package/tests/git-commit-transaction.test.ts +0 -302
  227. package/tests/review-compact.test.ts +0 -243
  228. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -771,33 +771,42 @@ function ensureOpenSpecDirs(cwd: string): void {
771
771
  }
772
772
 
773
773
  export default function (pi: ExtensionAPI) {
774
- pi.registerCommand("sdd-init", {
774
+ pi.registerCommand("gentle-sdd-init", {
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, {
779
- pi,
780
- installAssets: (cwd) => installSddAssets(cwd, false),
781
- applyModelConfig: () => applySavedModelConfig(ctx),
782
- });
778
+ const prefs = await ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installSddAssets(cwd, false), applyModelConfig: () => applySavedModelConfig(ctx) }, { promptFields: [] });
779
+
780
+ const detection = detectProject(ctx.cwd);
781
+ const testSummary = detection.testCommand
782
+ ? `strict TDD enabled with \`${detection.testCommand}\``
783
+ : "strict TDD disabled because no test runner was detected";
784
+ const layerSummary = `unit: ${detection.commands.unit.length}, integration: ${detection.commands.integration.length}, e2e: ${detection.commands.e2e.length}`;
785
+
786
+ const shouldCreateOpenSpec =
787
+ prefs.artifactStore === "openspec" ||
788
+ prefs.artifactStore === "hybrid";
789
+ if (!shouldCreateOpenSpec) {
790
+ ctx.ui.notify(
791
+ `SDD initialized for ${prefs.artifactStore}: detected ${detection.stack.join(", ") || "project"}; ${testSummary}; tests found: ${layerSummary}.`,
792
+ "info",
793
+ );
794
+ return;
795
+ }
796
+
783
797
  const configPath = join(ctx.cwd, CONFIG_REL_PATH);
784
798
  if (existsSync(configPath)) {
785
799
  ctx.ui.notify(
786
- `${CONFIG_REL_PATH} already exists. Edit it manually or remove it before re-running /sdd-init.`,
800
+ `${CONFIG_REL_PATH} already exists. Edit it manually or remove it before re-running /gentle-sdd-init.`,
787
801
  "warning",
788
802
  );
789
803
  return;
790
804
  }
791
805
 
792
- const detection = detectProject(ctx.cwd);
793
806
  ensureOpenSpecDirs(ctx.cwd);
794
807
  mkdirSync(dirname(configPath), { recursive: true });
795
808
  writeFileSync(configPath, renderConfig(detection));
796
809
 
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
810
  ctx.ui.notify(
802
811
  `Wrote ${CONFIG_REL_PATH}: detected ${detection.stack.join(", ") || "project"}; ${testSummary}; tests found: ${layerSummary}.`,
803
812
  "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");
@@ -0,0 +1,70 @@
1
+ import { keyHint, type AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { sanitizeTerminalText } from "./terminal-theme.ts";
4
+
5
+ type GentleAiLifecycleColor = "warning" | "success" | "error" | "dim";
6
+
7
+ export interface GentleAiRenderTheme {
8
+ bold(value: string): string;
9
+ fg(color: GentleAiLifecycleColor, value: string): string;
10
+ }
11
+
12
+ export interface GentleAiRenderState {
13
+ lifecycleComponent?: boolean; genericLocked?: boolean;
14
+ }
15
+
16
+ export interface GentleAiRenderContext {
17
+ argsComplete?: boolean;
18
+ executionStarted?: boolean;
19
+ isPartial?: boolean;
20
+ isError?: boolean;
21
+ lastComponent?: unknown;
22
+ state?: unknown;
23
+ }
24
+
25
+ export function getGentleAiRenderState(state: unknown): GentleAiRenderState | undefined {
26
+ if (!state || typeof state !== "object" || Array.isArray(state)) return undefined;
27
+ const rowState = state as Record<string, unknown>, existing = rowState.gentleAiRender;
28
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) return existing as GentleAiRenderState;
29
+ return (rowState.gentleAiRender = {} as GentleAiRenderState);
30
+ }
31
+
32
+ export interface GentleAiResultRenderOptions {
33
+ expanded?: boolean;
34
+ }
35
+
36
+ export function renderGentleAiResult(
37
+ result: AgentToolResult<unknown>,
38
+ options: GentleAiResultRenderOptions,
39
+ ): Text {
40
+ const textItems = result.content.flatMap((content) => (content.type === "text" ? [sanitizeTerminalText(content.text)] : []));
41
+ const text = textItems.join("\n");
42
+ const hasExpandableText = textItems.some((item) => item.length > 0);
43
+ const output = !hasExpandableText ? "" : options.expanded ? text : keyHint("app.tools.expand", "to expand");
44
+ return new Text(output, 0, 0);
45
+ }
46
+
47
+ export function renderGentleAiLifecycleCall(
48
+ operationPath: string,
49
+ theme: GentleAiRenderTheme,
50
+ context?: GentleAiRenderContext,
51
+ detail?: string,
52
+ ): Text {
53
+ const status = context?.isError
54
+ ? "failed"
55
+ : context?.argsComplete === false
56
+ ? "preparing"
57
+ : !context?.executionStarted || context.isPartial
58
+ ? "running"
59
+ : "completed";
60
+ const color = status === "preparing" || status === "running" ? "warning" : status === "completed" ? "success" : "error";
61
+ const lines = [theme.fg(color, theme.bold(`🌹︎ Gentle AI · ${status} · ${operationPath}`))];
62
+ if (detail) lines.push(theme.fg("dim", sanitizeTerminalText(detail)));
63
+ const state = getGentleAiRenderState(context?.state);
64
+ const component = context?.lastComponent instanceof Text && (!state || state.lifecycleComponent === true)
65
+ ? context.lastComponent
66
+ : new Text("", 0, 0);
67
+ if (state) state.lifecycleComponent = true;
68
+ component.setText(lines.join("\n"));
69
+ return component;
70
+ }
@@ -0,0 +1,133 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { access, readFile } from "node:fs/promises";
3
+
4
+ export const THINKING_LEVELS = [
5
+ "off",
6
+ "minimal",
7
+ "low",
8
+ "medium",
9
+ "high",
10
+ "xhigh",
11
+ "max",
12
+ ] as const;
13
+
14
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
15
+
16
+ export interface AgentRoutingEntry {
17
+ model?: string;
18
+ thinking?: ThinkingLevel;
19
+ }
20
+
21
+ export type AgentModelConfig = Record<string, AgentRoutingEntry>;
22
+
23
+ export type ModelConfigFileResult =
24
+ | { status: "missing" }
25
+ | { status: "invalid"; path: string }
26
+ | { status: "valid"; config: AgentModelConfig };
27
+
28
+ const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/;
29
+ const SAFE_AGENT_NAME_PATTERN = /^[A-Za-z0-9._:@/+%-]+$/;
30
+
31
+ function isRecord(value: unknown): value is Record<string, unknown> {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ async function pathExists(path: string): Promise<boolean> {
36
+ try {
37
+ await access(path);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ export function isThinkingLevel(value: unknown): value is ThinkingLevel {
45
+ return (
46
+ typeof value === "string" &&
47
+ (THINKING_LEVELS as readonly string[]).includes(value)
48
+ );
49
+ }
50
+
51
+ export function normalizeModelId(value: unknown): string | undefined {
52
+ if (typeof value !== "string") return undefined;
53
+ const model = value.trim();
54
+ if (model.length === 0) return undefined;
55
+ if (!SAFE_MODEL_ID_PATTERN.test(model)) return undefined;
56
+ return model;
57
+ }
58
+
59
+ export function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
60
+ if (typeof value === "string") {
61
+ const model = normalizeModelId(value);
62
+ return model ? { model } : undefined;
63
+ }
64
+ if (!isRecord(value)) return undefined;
65
+ const model = normalizeModelId(value.model);
66
+ const thinking = isThinkingLevel(value.thinking) ? value.thinking : undefined;
67
+ if (!model && !thinking) {
68
+ return Object.keys(value).length === 0 ? {} : undefined;
69
+ }
70
+ return { model, thinking };
71
+ }
72
+
73
+ export function normalizeModelConfig(value: unknown): AgentModelConfig | undefined {
74
+ if (!isRecord(value)) return undefined;
75
+ const cleaned: AgentModelConfig = {};
76
+ for (const [name, entryValue] of Object.entries(value)) {
77
+ if (!SAFE_AGENT_NAME_PATTERN.test(name)) continue;
78
+ const entry = normalizeRoutingEntry(entryValue);
79
+ if (entry) cleaned[name] = entry;
80
+ }
81
+ return cleaned;
82
+ }
83
+
84
+ function parseModelConfigFileValue(value: Record<string, unknown>): AgentModelConfig {
85
+ const config: AgentModelConfig = {};
86
+ for (const [name, entryValue] of Object.entries(value)) {
87
+ const entry = normalizeRoutingEntry(entryValue);
88
+ if (entry) config[name] = entry;
89
+ }
90
+ return config;
91
+ }
92
+
93
+ export function readModelConfigFile(path: string): ModelConfigFileResult {
94
+ if (!existsSync(path)) return { status: "missing" };
95
+ try {
96
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
97
+ if (!isRecord(parsed)) return { status: "invalid", path };
98
+ return { status: "valid", config: parseModelConfigFileValue(parsed) };
99
+ } catch {
100
+ return { status: "invalid", path };
101
+ }
102
+ }
103
+
104
+ export async function readModelConfigFileAsync(
105
+ path: string,
106
+ ): Promise<ModelConfigFileResult> {
107
+ if (!(await pathExists(path))) return { status: "missing" };
108
+ try {
109
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
110
+ if (!isRecord(parsed)) return { status: "invalid", path };
111
+ return { status: "valid", config: parseModelConfigFileValue(parsed) };
112
+ } catch {
113
+ return { status: "invalid", path };
114
+ }
115
+ }
116
+
117
+ export function readSavedModelConfig(
118
+ globalPath: string,
119
+ projectPath: string,
120
+ ): ModelConfigFileResult {
121
+ const globalResult = readModelConfigFile(globalPath);
122
+ if (globalResult.status !== "missing") return globalResult;
123
+ return readModelConfigFile(projectPath);
124
+ }
125
+
126
+ export async function readSavedModelConfigAsync(
127
+ globalPath: string,
128
+ projectPath: string,
129
+ ): Promise<ModelConfigFileResult> {
130
+ const globalResult = await readModelConfigFileAsync(globalPath);
131
+ if (globalResult.status !== "missing") return globalResult;
132
+ return readModelConfigFileAsync(projectPath);
133
+ }