gentle-pi 4.2.0 → 4.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 (202) hide show
  1. package/README.md +336 -137
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/gentle-shell.mjs +170 -22
  12. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  13. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  15. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  16. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  18. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  19. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  20. package/docs/assets/diagrams/measured-routing.svg +49 -0
  21. package/docs/assets/diagrams/native-review.svg +52 -12
  22. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  23. package/docs/assets/diagrams/permission-modes.svg +51 -0
  24. package/docs/assets/features/audio-notifications.png +0 -0
  25. package/docs/assets/features/command-palette.png +0 -0
  26. package/docs/bundled-install.md +33 -3
  27. package/docs/delegated-verification.md +10 -8
  28. package/docs/gentle-agents-activity.md +11 -5
  29. package/docs/gentle-shell-uninstall.md +8 -1
  30. package/docs/gentle-shell.md +5 -4
  31. package/docs/install-wizard.md +8 -1
  32. package/docs/permission-modes.md +1 -1
  33. package/docs/readme-reference.md +107 -19
  34. package/docs/yolo-mode.md +21 -8
  35. package/extensions/codegraph-tools.ts +68 -6
  36. package/extensions/gentle-agents.ts +99 -12
  37. package/extensions/gentle-ai.ts +284 -52
  38. package/extensions/gentle-herdr-activity.ts +2 -3
  39. package/extensions/gentle-shell.ts +27 -12
  40. package/extensions/gentle-todo.ts +43 -9
  41. package/extensions/quiet-tools.ts +1 -1
  42. package/extensions/session-scratch.ts +365 -0
  43. package/extensions/startup-banner.ts +2 -3
  44. package/lib/agent-home.ts +8 -3
  45. package/lib/agents-protocol.ts +3 -0
  46. package/lib/agents-runner.ts +55 -6
  47. package/lib/agents-view.ts +3 -1
  48. package/lib/bounded-writer-admission.ts +51 -0
  49. package/lib/command-palette-catalog.ts +0 -4
  50. package/lib/commit-slice-signal.ts +100 -0
  51. package/lib/context-backstop.ts +84 -0
  52. package/lib/delivered-test-lint.ts +80 -0
  53. package/lib/destructive-command-guard.ts +120 -9
  54. package/lib/gauge-theme.ts +146 -0
  55. package/lib/gentle-ai-binary.ts +28 -7
  56. package/lib/gentle-shell-bundled.ts +39 -3
  57. package/lib/gentle-shell-import-pi.ts +233 -0
  58. package/lib/gentle-shell-launcher.ts +145 -11
  59. package/lib/gentle-shell-uninstall.ts +18 -1
  60. package/lib/herdr-activity.ts +24 -0
  61. package/lib/native-review-cli.ts +9 -1
  62. package/lib/orchestrator-discovery.ts +5 -1
  63. package/lib/orchestrator-presence.ts +9 -0
  64. package/lib/orchestrator-scope.ts +21 -3
  65. package/lib/provider-contract-bundle.ts +99 -53
  66. package/lib/review-candidate-view-owner.ts +29 -19
  67. package/lib/review-candidate-view.ts +36 -2
  68. package/lib/review-risk-assessment.ts +43 -38
  69. package/lib/review-session-standing-permission.ts +3 -1
  70. package/lib/session-profile-freeze.ts +30 -1
  71. package/lib/session-scratch.ts +474 -0
  72. package/lib/shell-bar.ts +25 -25
  73. package/lib/shell-changes-view.ts +80 -13
  74. package/lib/shell-gauge.ts +49 -6
  75. package/lib/shell-todo.ts +23 -4
  76. package/lib/shell-usage-view.ts +5 -1
  77. package/lib/shell-usage.ts +7 -7
  78. package/lib/verifier-admission.ts +29 -0
  79. package/lib/yolo-temp-scratch.ts +69 -0
  80. package/package.json +1 -2
  81. package/runtime/gentle-ai-binary.mjs +28 -7
  82. package/runtime/gentle-shell-bundled.mjs +38 -2
  83. package/runtime/gentle-shell-import-pi.mjs +234 -0
  84. package/runtime/gentle-shell-launcher.mjs +143 -9
  85. package/runtime/gentle-shell-uninstall.mjs +18 -1
  86. package/runtime/native-review-cli.mjs +8 -0
  87. package/runtime/review-risk-assessment.mjs +43 -38
  88. package/scripts/build-runtime-modules.mjs +1 -0
  89. package/scripts/bundled-engram-migration.mjs +125 -0
  90. package/scripts/gentle-ai-installer.mjs +10 -10
  91. package/scripts/installer-server.mjs +14 -4
  92. package/scripts/main-channel.mjs +3 -2
  93. package/scripts/run-test-suite.mjs +5 -0
  94. package/scripts/verify-package-files.mjs +19 -13
  95. package/skills/gentle-ai/SKILL.md +3 -3
  96. package/skills/work-unit-commits/SKILL.md +5 -3
  97. package/tests/agent-risk-escalation.test.ts +37 -10
  98. package/tests/agents-runner.test.ts +260 -3
  99. package/tests/autonomous-guard.test.ts +122 -0
  100. package/tests/bounded-writer-admission.test.ts +11 -3
  101. package/tests/card-content-policy.test.ts +19 -9
  102. package/tests/child-safety.test.ts +39 -0
  103. package/tests/codegraph-tools.test.ts +123 -2
  104. package/tests/command-palette.test.ts +14 -4
  105. package/tests/commit-slice-signal.test.ts +162 -0
  106. package/tests/context-backstop.test.ts +159 -0
  107. package/tests/delegate-for-reason-contract.test.ts +91 -24
  108. package/tests/delivered-test-lint.test.ts +105 -0
  109. package/tests/destructive-command-guard.test.ts +85 -1
  110. package/tests/engram-safety.test.ts +1 -1
  111. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  115. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  116. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  117. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  118. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  119. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  120. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  121. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  122. package/tests/gauge-theme.test.ts +120 -0
  123. package/tests/gentle-agents.test.ts +282 -3
  124. package/tests/gentle-ai-binary.test.ts +1 -1
  125. package/tests/gentle-ai-child-guards.test.ts +61 -0
  126. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  127. package/tests/gentle-ai-installer.test.ts +51 -51
  128. package/tests/gentle-shell-bin.test.ts +163 -0
  129. package/tests/gentle-shell-bundled.test.ts +169 -6
  130. package/tests/gentle-shell-import-pi.test.ts +262 -0
  131. package/tests/gentle-shell-launcher.test.ts +79 -0
  132. package/tests/gentle-shell-uninstall.test.ts +30 -0
  133. package/tests/gentle-shell.test.ts +42 -9
  134. package/tests/gentle-todo.test.ts +179 -0
  135. package/tests/headless-delivery-question.test.ts +58 -0
  136. package/tests/herdr-activity.test.ts +34 -1
  137. package/tests/installer-server.test.ts +10 -2
  138. package/tests/installer-windows-bootstrap.test.ts +33 -4
  139. package/tests/native-review-capability-contract.test.ts +7 -1
  140. package/tests/native-review-parity.test.ts +22 -1
  141. package/tests/no-self-pause-contract.test.ts +5 -2
  142. package/tests/odd-integration.test.ts +5 -1
  143. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  144. package/tests/odd-routing-contract.test.ts +115 -30
  145. package/tests/orchestrator-budget.test.ts +19 -8
  146. package/tests/orchestrator-discovery.test.ts +29 -0
  147. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  148. package/tests/orchestrator-scope.test.ts +66 -0
  149. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  150. package/tests/package-manifest.test.ts +24 -20
  151. package/tests/pi-update-launcher.test.ts +21 -2
  152. package/tests/primary-session-prompt.test.ts +101 -0
  153. package/tests/provider-contract-bundle.test.ts +118 -0
  154. package/tests/provider-contract-mirror.test.ts +24 -0
  155. package/tests/quiet-tool-rendering.test.ts +23 -0
  156. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  157. package/tests/rdd-status-line.test.ts +79 -0
  158. package/tests/review-agent-end-preflight.test.ts +6 -0
  159. package/tests/review-candidate-view.test.ts +49 -0
  160. package/tests/review-contract-prompt.test.ts +5 -2
  161. package/tests/review-controller-native-routing.test.ts +14 -1
  162. package/tests/review-controller.test.ts +1 -1
  163. package/tests/review-host-relay-routing.test.ts +59 -2
  164. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  165. package/tests/review-owner-identity.test.ts +132 -0
  166. package/tests/review-risk-assessment.test.ts +163 -76
  167. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  168. package/tests/review-session-standing-permission.test.ts +27 -1
  169. package/tests/run-test-suite.test.ts +15 -0
  170. package/tests/runtime-harness.mjs +16 -6
  171. package/tests/session-changes-shell.test.ts +1 -1
  172. package/tests/session-profile-freeze.test.ts +23 -0
  173. package/tests/session-scratch.test.ts +945 -0
  174. package/tests/session-subject-routing-contract.test.ts +2 -1
  175. package/tests/shell-bar.test.ts +7 -0
  176. package/tests/shell-changes-view.test.ts +137 -1
  177. package/tests/shell-gauge.test.ts +42 -0
  178. package/tests/small-task-inline-contract.test.ts +37 -0
  179. package/tests/standalone-config-home.test.ts +324 -0
  180. package/tests/startup-banner.test.ts +5 -4
  181. package/tests/startup-home-repo.test.ts +34 -0
  182. package/tests/support/orchestrator-modules.ts +1 -0
  183. package/tests/task-size-routing-contract.test.ts +69 -9
  184. package/tests/test-scratch-contract.test.ts +102 -0
  185. package/tests/test-strength-contract.test.ts +36 -1
  186. package/tests/tool-batching-contract.test.ts +64 -0
  187. package/tests/verification-proportionality.test.ts +46 -0
  188. package/tests/verifier-admission.test.ts +137 -0
  189. package/tests/verify-agent-contract.test.ts +8 -8
  190. package/tests/windows-hidden-processes.test.ts +5 -0
  191. package/tests/writer-edit-surface-scope.test.ts +3 -0
  192. package/tests/writer-feature-document-admission.test.ts +156 -0
  193. package/tests/yolo-mode.test.ts +18 -0
  194. package/tests/yolo-temp-scratch.test.ts +71 -0
  195. package/scripts/mirror-odd-routing.mjs +0 -242
  196. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  197. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  198. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  199. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  200. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  201. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  202. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -67,12 +67,15 @@ export function colorDiff(text: string, theme: ChangesViewTheme): string[] {
67
67
  return lines;
68
68
  }
69
69
 
70
+ // New files are two distinct states: `A` is tracked and staged, `U` is still
71
+ // untracked. Git's own `??` reads as a broken value in a status column, so the
72
+ // list names the state instead of echoing the plumbing code.
70
73
  const FILE_STATUS = {
71
74
  [CHANGE_STATUS.MODIFIED]: "M",
72
75
  [CHANGE_STATUS.ADDED]: "A",
73
76
  [CHANGE_STATUS.DELETED]: "D",
74
77
  [CHANGE_STATUS.RENAMED]: "R",
75
- [CHANGE_STATUS.UNTRACKED]: "??",
78
+ [CHANGE_STATUS.UNTRACKED]: "U",
76
79
  } as const;
77
80
 
78
81
  function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
@@ -113,18 +116,41 @@ function fit(text: string, width: number): string {
113
116
  return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
114
117
  }
115
118
 
116
- // Both standalone files and the worktree accordion use the original frame.
117
- function renderPanes(width: number, rows: number, theme: ChangesViewTheme, title: string, leftLine: (row: number) => string, diffLines: string[], keys: string, headerFile?: ChangedFile): string[] {
119
+ // One geometry feeds both rendering and pointer hit-testing, so clicks stay
120
+ // aligned with what is painted. Diff-only drops the list column and its
121
+ // separator; its empty list zone receives no pointer events.
122
+ interface PaneLayout {
123
+ inner: number;
124
+ listWidth: number;
125
+ diffWidth: number;
126
+ listStart: number;
127
+ listEnd: number;
128
+ diffStart: number;
129
+ diffEnd: number;
130
+ }
131
+
132
+ function paneLayout(width: number, diffOnly: boolean): PaneLayout {
118
133
  const inner = Math.max(8, width - 2);
134
+ if (diffOnly) return { inner, listWidth: 0, diffWidth: inner - 2, listStart: 0, listEnd: 0, diffStart: 2, diffEnd: inner };
119
135
  const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
120
136
  const diffWidth = inner - listWidth - 4;
137
+ return { inner, listWidth, diffWidth, listStart: 2, listEnd: 2 + listWidth, diffStart: 5 + listWidth, diffEnd: 5 + listWidth + diffWidth };
138
+ }
139
+
140
+ // Both standalone files and the worktree accordion use the original frame.
141
+ function renderPanes(width: number, rows: number, theme: ChangesViewTheme, title: string, leftLine: (row: number) => string, diffLines: string[], keys: string, headerFile?: ChangedFile, diffOnly = false): string[] {
142
+ const { inner, listWidth, diffWidth } = paneLayout(width, diffOnly);
121
143
  const rightLines = headerFile ? [pathHeader(headerFile, theme, diffWidth), ...diffLines] : diffLines;
122
144
  const titleText = truncateToWidth(title, inner - 4, "…");
123
145
  const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, titleText) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(titleText) - 3)}╮`);
124
146
  const body: string[] = [];
125
147
  for (let row = 0; row < rows; row += 1) {
126
- const left = fit(leftLine(row), listWidth);
127
148
  const right = fit(rightLines[row] ?? "", diffWidth);
149
+ if (diffOnly) {
150
+ body.push(`${theme.fg(ROLE.FRAME, "│")} ${right} ${theme.fg(ROLE.FRAME, "│")}`);
151
+ continue;
152
+ }
153
+ const left = fit(leftLine(row), listWidth);
128
154
  body.push(`${theme.fg(ROLE.FRAME, "│")} ${left} ${theme.fg(ROLE.FRAME, "│")} ${right}${theme.fg(ROLE.FRAME, "│")}`);
129
155
  }
130
156
  const keysLine = `${theme.fg(ROLE.FRAME, "│")} ${fit(keys, inner - 2)} ${theme.fg(ROLE.FRAME, "│")}`;
@@ -142,11 +168,9 @@ interface PointerLayout {
142
168
  diffEnd: number;
143
169
  }
144
170
 
145
- function pointerLayout(width: number, bodyRows: number): PointerLayout {
146
- const inner = Math.max(8, width - 2);
147
- const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
148
- const diffWidth = inner - listWidth - 4;
149
- return { width, height: bodyRows + CHROME_ROWS, bodyRows, listStart: 2, listEnd: 2 + listWidth, diffStart: 5 + listWidth, diffEnd: 5 + listWidth + diffWidth };
171
+ function pointerLayout(width: number, bodyRows: number, diffOnly = false): PointerLayout {
172
+ const { listStart, listEnd, diffStart, diffEnd } = paneLayout(width, diffOnly);
173
+ return { width, height: bodyRows + CHROME_ROWS, bodyRows, listStart, listEnd, diffStart, diffEnd };
150
174
  }
151
175
 
152
176
  function displayText(text: string): string {
@@ -186,6 +210,8 @@ export class WorktreeChangesView {
186
210
  private disposed = false;
187
211
  private leftPressActive = false;
188
212
  private hoveredIndex: number | undefined;
213
+ // Not persisted: every opening starts with the file list visible.
214
+ private diffOnly = false;
189
215
  private readonly expanded = new Set<string>();
190
216
  private readonly previews = new Map<string, { fingerprint: string; view: ChangesView }>();
191
217
 
@@ -224,13 +250,27 @@ export class WorktreeChangesView {
224
250
  handleInput(data: string): void {
225
251
  if (this.disposed) return;
226
252
  if (data === "r") return this.deps.onRefresh();
227
- if (matchesKey(data, Key.escape) || data === "q") return this.deps.onClose();
253
+ if (data === "q") return this.deps.onClose();
254
+ if (matchesKey(data, Key.escape)) {
255
+ if (!this.diffOnly) return this.deps.onClose();
256
+ return this.setDiffOnly(false);
257
+ }
258
+ if (data === "F") return this.setDiffOnly(!this.diffOnly);
228
259
  const rows = this.visibleRows();
229
260
  const row = rows[this.selected];
230
261
  if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("j")) || matchesKey(data, Key.ctrl("k"))) {
231
262
  if (row?.file) this.previews.get(rowKey(row))?.view.handleInput(data);
232
263
  return;
233
264
  }
265
+ if (this.diffOnly) {
266
+ // Only files have a diff: j/k walk every file, crossing groups.
267
+ if (data === "j" || matchesKey(data, Key.down)) this.stepFile(1);
268
+ else if (data === "k" || matchesKey(data, Key.up)) this.stepFile(-1);
269
+ else if (row?.file && (data === "o" || matchesKey(data, Key.enter))) return this.deps.onOpen(row.tree.root, row.file);
270
+ else return;
271
+ this.deps.requestRender();
272
+ return;
273
+ }
234
274
  if (data === "j" || matchesKey(data, Key.down)) this.selected = Math.max(0, Math.min(rows.length - 1, this.selected + 1));
235
275
  else if (data === "k" || matchesKey(data, Key.up)) this.selected = Math.max(0, this.selected - 1);
236
276
  else if (row && (matchesKey(data, Key.left) || matchesKey(data, Key.backspace))) {
@@ -315,9 +355,11 @@ export class WorktreeChangesView {
315
355
  : paintHoverable(theme, `${this.expanded.has(row.tree.root) ? "▾" : "▸"} ${displayText(row.tree.branch ?? "detached")} · ${displayText(basename(row.tree.root))}`, hovered, idleRole);
316
356
  return `${marker} ${text}`;
317
357
  };
318
- const keys = theme.fg(ROLE.KEY_TEXT, "j/k select enter toggle/open ← parent/fold ctrl+j/k scroll r refresh esc close");
319
- this.pointerLayout = pointerLayout(width, height);
320
- return renderPanes(width, height, theme, `✎ Changes · ${this.trees.length} worktrees`, leftLine, preview, keys, selected?.file);
358
+ const keys = theme.fg(ROLE.KEY_TEXT, this.diffOnly
359
+ ? "j/k file ctrl+j/k scroll o open r refresh F files esc files"
360
+ : "j/k select enter toggle/open ← fold ctrl+j/k scroll r refresh F diff only esc close");
361
+ this.pointerLayout = pointerLayout(width, height, this.diffOnly);
362
+ return renderPanes(width, height, theme, `✎ Changes · ${this.trees.length} worktrees`, leftLine, preview, keys, selected?.file, this.diffOnly);
321
363
  }
322
364
 
323
365
  invalidate(): void {
@@ -341,6 +383,31 @@ export class WorktreeChangesView {
341
383
  return Math.max(MIN_BODY_ROWS, rows - CHROME_ROWS);
342
384
  }
343
385
 
386
+ private setDiffOnly(on: boolean): void {
387
+ this.diffOnly = on;
388
+ this.hoveredIndex = undefined;
389
+ this.pointerLayout = undefined;
390
+ // A group header has no diff; diff-only starts on the next file.
391
+ if (on && !this.visibleRows()[this.selected]?.file) this.stepFile(1);
392
+ this.deps.requestRender();
393
+ }
394
+
395
+ // Move to the next file in the fully expanded order, expanding its group
396
+ // so the file list shows the same selection after diff-only mode.
397
+ private stepFile(direction: 1 | -1): void {
398
+ const all: WorktreeRow[] = this.trees.flatMap((tree) => [{ tree }, ...tree.model.files.map((file) => ({ tree, file }))]);
399
+ const current = this.visibleRows()[this.selected];
400
+ let index = current ? all.findIndex((row) => rowKey(row) === rowKey(current)) : -1;
401
+ do index += direction;
402
+ while (index >= 0 && index < all.length && !all[index]!.file);
403
+ const target = all[index];
404
+ if (!target) return;
405
+ this.expanded.add(target.tree.root);
406
+ this.selected = this.visibleRows().findIndex((row) => rowKey(row) === rowKey(target));
407
+ this.ensureSelectedVisible();
408
+ this.ensurePreview();
409
+ }
410
+
344
411
  private scrollList(delta: number, rows: number): boolean {
345
412
  const next = Math.max(0, Math.min(Math.max(0, this.visibleRows().length - rows), this.listOffset + Math.trunc(delta)));
346
413
  if (next === this.listOffset) return false;
@@ -20,10 +20,38 @@ const GAUGE_EMPTY_ROLE = "border";
20
20
  const WARNING_THRESHOLD = 80;
21
21
  const ERROR_THRESHOLD = 95;
22
22
 
23
- export function renderGauge(percent: number | null, cells: number = GAUGE_CELLS): string {
23
+ // Gauge-specific theme roles (issue #2222). Every field is optional and falls
24
+ // back to the shared role the gauge painted with before gauge roles existed,
25
+ // so themes without a gauge block render byte-identically to today.
26
+ export interface GaugePaint {
27
+ /** Filled cells below the warning threshold; falls back to `accent`. */
28
+ ok?: string;
29
+ /** Filled cells from 80%; falls back to `warning`. */
30
+ warning?: string;
31
+ /** Filled cells from 95%; falls back to `error`. */
32
+ error?: string;
33
+ /** Empty cells; falls back to `border`. */
34
+ empty?: string;
35
+ /** Filled-cell glyph; falls back to `▰`. */
36
+ filled?: string;
37
+ /** Empty-cell glyph; falls back to `▱`. */
38
+ emptyGlyph?: string;
39
+ }
40
+
41
+ export interface GaugeGlyphs {
42
+ filled: string;
43
+ empty: string;
44
+ }
45
+
46
+ export function gaugeGlyphs(glyphs?: Partial<GaugeGlyphs>): GaugeGlyphs {
47
+ return { filled: glyphs?.filled ?? GAUGE_FILLED, empty: glyphs?.empty ?? GAUGE_EMPTY };
48
+ }
49
+
50
+ export function renderGauge(percent: number | null, cells: number = GAUGE_CELLS, glyphs?: Partial<GaugeGlyphs>): string {
24
51
  const clamped = Math.max(0, Math.min(100, percent ?? 0));
25
52
  const filled = Math.round((clamped / 100) * cells);
26
- return GAUGE_FILLED.repeat(filled) + GAUGE_EMPTY.repeat(cells - filled);
53
+ const [filledGlyph, emptyGlyph] = [glyphs?.filled ?? GAUGE_FILLED, glyphs?.empty ?? GAUGE_EMPTY];
54
+ return filledGlyph.repeat(filled) + emptyGlyph.repeat(cells - filled);
27
55
  }
28
56
 
29
57
  export function gaugeTone(percent: number | null): GaugeTone {
@@ -33,8 +61,23 @@ export function gaugeTone(percent: number | null): GaugeTone {
33
61
  return GAUGE_TONE.ACCENT;
34
62
  }
35
63
 
36
- export function paintGauge(percent: number | null, theme: GaugeTheme, cells: number = GAUGE_CELLS): string {
37
- const gauge = renderGauge(percent, cells);
38
- const filled = gauge.replace(new RegExp(`${GAUGE_EMPTY}+$`), "");
39
- return theme.fg(gaugeTone(percent), filled) + theme.fg(GAUGE_EMPTY_ROLE, gauge.slice(filled.length));
64
+ export function paintGauge(percent: number | null, theme: GaugeTheme, cells: number = GAUGE_CELLS, paint?: GaugePaint): string {
65
+ const glyphs = gaugeGlyphs(paint && { filled: paint.filled, empty: paint.emptyGlyph });
66
+ const gauge = renderGauge(percent, cells, glyphs);
67
+ const filled = stripTrailing(gauge, glyphs.empty);
68
+ // Token names go through theme.fg; raw ANSI prefixes (resolved from direct
69
+ // colors by gauge-theme.ts) paint directly, so the gauges alone recolor.
70
+ const isAnsi = (role: string | undefined): role is string => typeof role === "string" && role.startsWith("\x1b[");
71
+ const paintFg = (role: string | undefined, fallback: string, text: string): string => (isAnsi(role) ? `${role}${text}\x1b[39m` : theme.fg(role ?? fallback, text));
72
+ const tone = gaugeTone(percent);
73
+ const filledRole = tone === GAUGE_TONE.ACCENT ? paint?.ok : tone === GAUGE_TONE.WARNING ? paint?.warning : tone === GAUGE_TONE.ERROR ? paint?.error : undefined;
74
+ return paintFg(filledRole, tone, filled) + paintFg(paint?.empty, GAUGE_EMPTY_ROLE, gauge.slice(filled.length));
75
+ }
76
+
77
+ // Strips the maximal run of trailing empty glyphs without building a RegExp
78
+ // from theme-authored strings, which could contain regex metacharacters.
79
+ function stripTrailing(gauge: string, empty: string): string {
80
+ let end = gauge.length;
81
+ while (end >= empty.length && end > 0 && gauge.endsWith(empty, end)) end -= empty.length;
82
+ return gauge.slice(0, end);
40
83
  }
package/lib/shell-todo.ts CHANGED
@@ -90,6 +90,21 @@ export interface TodoRenderOptions {
90
90
  export const TODO_DETAILS_KEY = "gentleTodo";
91
91
  export const TODO_TOOL_NAME = "todo";
92
92
  export const TODO_GLYPH = "❀";
93
+ /** Emitted on pi.events after each successful todo write; gentle-agents publishes it as session progress. */
94
+ export const TODO_PROGRESS_EVENT = "gentle-pi:todo:progress";
95
+
96
+ export interface TodoProgress {
97
+ sessionId: string;
98
+ done: number;
99
+ total: number;
100
+ /** Title of the first in-progress task, or null when none is in progress. */
101
+ current: string | null;
102
+ }
103
+
104
+ export function todoProgress(state: TodoState, sessionId: string): TodoProgress {
105
+ const current = state.tasks.find((task) => task.status === TODO_STATUS.IN_PROGRESS)?.title ?? null;
106
+ return { sessionId, done: countStatus(state, TODO_STATUS.DONE), total: state.tasks.length, current };
107
+ }
93
108
  const STATUS_ALIASES: Record<string, TodoStatus> = {
94
109
  completed: TODO_STATUS.DONE,
95
110
  complete: TODO_STATUS.DONE,
@@ -261,13 +276,14 @@ export function replayTodo(entries: readonly unknown[]): TodoState {
261
276
  // gentle-shell#1367, #1662: the system prompt is a cached prefix for every
262
277
  // provider, so the block it carries is byte-stable for the whole session:
263
278
  // rules only, no task content and no counters. The current list travels as a
264
- // request-local message at the end of the conversation (todoStateMessage),
265
- // where a change costs only the tail of the request, never the prefix.
279
+ // hidden transcript message appended when it changes (todoStateMessage,
280
+ // gentle-shell#2195), so requests stay append-only and a change costs only
281
+ // the tail of the conversation, never the prefix.
266
282
  const TODO_PROMPT_BLOCK = [
267
283
  "## Todo list",
268
284
  "Keep it current with the `todo` tool: mark a task in_progress before starting it, done right after finishing it, and rewrite the whole list with `write` whenever the plan changes. Update it before you end the turn.",
269
285
  "A blocked task waits on something outside the list: do not work on it until its condition holds. When the plan changes, mark the tasks it made obsolete dropped instead of leaving them pending or marking them done.",
270
- "While tasks stay open, the current list arrives as the last message of each request; it is not repeated here.",
286
+ "While tasks stay open, the current list arrives as a hidden conversation message whenever it changes; the latest one is current, and it is not repeated here.",
271
287
  ].join("\n");
272
288
 
273
289
  /** The byte-stable rules block appended to every system prompt. */
@@ -275,7 +291,10 @@ export function todoPromptBlock(): string {
275
291
  return TODO_PROMPT_BLOCK;
276
292
  }
277
293
 
278
- /** The open-task snapshot delivered as a request-local message; undefined when nothing is open. */
294
+ /** Persisted once an open list closes, so an earlier snapshot never reads as current work (#2195). */
295
+ export const TODO_CLOSED_STATE = "Current todo list: no open tasks.";
296
+
297
+ /** The open-task snapshot delivered as a hidden transcript message; undefined when nothing is open. */
279
298
  export function todoStateMessage(state: TodoState): string | undefined {
280
299
  if (todoSummary(state).open === 0) return undefined;
281
300
  const lines = state.tasks.map((task, index) => `${index + 1}. [${task.status}] ${task.title}${task.note ? ` — ${task.note}` : ""}`);
@@ -1,5 +1,6 @@
1
1
  import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
2
  import { renderUsagePanel, type ActiveProvider, type UsagePanelScope, type UsageSourceRegistry, type UsageStore, type UsageTheme } from "./shell-usage.ts";
3
+ import type { GaugePaint } from "./shell-gauge.ts";
3
4
  import { paintHoverable } from "./shell-hover.ts";
4
5
 
5
6
  // Gentle Shell subscriptions overlay: a framed panel over the usage store.
@@ -16,6 +17,9 @@ export interface UsageViewDeps {
16
17
  // provider the store holds; with it, exactly the targeted providers, in
17
18
  // scope order, explaining the ones without data.
18
19
  scope?(): UsagePanelScope | undefined;
20
+ // Optional: gauge-specific theme roles resolved from the installed theme
21
+ // source; without it the panel paints with the shared roles.
22
+ gauge?(): GaugePaint | undefined;
19
23
  onRefresh(): Promise<void>;
20
24
  onClose(): void;
21
25
  requestRender(): void;
@@ -106,7 +110,7 @@ export class UsageView {
106
110
  const inner = width - 2;
107
111
  const title = this.refreshing ? REFRESHING : TITLE;
108
112
  const top = theme.fg(FRAME_ROLE, "╭─ ") + theme.fg(TITLE_ROLE, title) + theme.fg(FRAME_ROLE, ` ${rule(inner - visibleWidth(title) - 3)}╮`);
109
- const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active(), this.deps.registry?.(), this.deps.scope?.()).map(
113
+ const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active(), this.deps.registry?.(), this.deps.scope?.(), this.deps.gauge?.()).map(
110
114
  (line) => `${theme.fg(FRAME_ROLE, "│")} ${fit(line, inner - 2)} ${theme.fg(FRAME_ROLE, "│")}`,
111
115
  );
112
116
  const hints = KEYS.map(([key, label]) => ({ key, label, text: `${key} ${label}`, action: (key === "r" ? "refresh" : "close") as HintAction }));
@@ -1,6 +1,6 @@
1
1
  import { truncateToWidth } from "@earendil-works/pi-tui";
2
2
  import { parseModelRef } from "./agents-config.ts";
3
- import { paintGauge } from "./shell-gauge.ts";
3
+ import { paintGauge, type GaugePaint } from "./shell-gauge.ts";
4
4
 
5
5
  // Gentle Shell subscription usage: the rate-limit windows each connected
6
6
  // provider reports. Codex sends them as SSE headers and through its usage
@@ -514,16 +514,16 @@ export function accountIdFromToken(token: string): string | undefined {
514
514
  }
515
515
  }
516
516
 
517
- function paintMeter(percent: number, cells: number, theme: UsageTheme): string {
518
- return paintGauge(percent, theme, cells);
517
+ function paintMeter(percent: number, cells: number, theme: UsageTheme, paint?: GaugePaint): string {
518
+ return paintGauge(percent, theme, cells, paint);
519
519
  }
520
520
 
521
- export function renderUsageBar(usage: ProviderUsage, theme: UsageTheme, activeModelId?: string): string | undefined {
521
+ export function renderUsageBar(usage: ProviderUsage, theme: UsageTheme, activeModelId?: string, paint?: GaugePaint): string | undefined {
522
522
  const main = selectUsageLimit(usage, activeModelId);
523
523
  const [first, ...rest] = main?.windows ?? [];
524
524
  if (!first) return undefined;
525
525
  // An unlabeled window prints as the name, the meter and the percentage.
526
- const head = [theme.fg(ROLE.LABEL, main.name), ...(first.label.length === 0 ? [] : [theme.fg(ROLE.LABEL, first.label)]), paintMeter(first.usedPercent, 8, theme), theme.fg(ROLE.PERCENT, `${Math.round(first.usedPercent)}%`)].join(" ");
526
+ const head = [theme.fg(ROLE.LABEL, main.name), ...(first.label.length === 0 ? [] : [theme.fg(ROLE.LABEL, first.label)]), paintMeter(first.usedPercent, 8, theme, paint), theme.fg(ROLE.PERCENT, `${Math.round(first.usedPercent)}%`)].join(" ");
527
527
  const tail = rest.map((window) => `${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.LABEL, window.label)} ${theme.fg(ROLE.PERCENT, `${Math.round(window.usedPercent)}%`)}`);
528
528
  return [head, ...tail].join(" ");
529
529
  }
@@ -574,7 +574,7 @@ export interface UsagePanelScope {
574
574
  // snapshot keeps that snapshot and says so beside it, and a provider recorded
575
575
  // under a previous profile's routing is never presented as current scope after
576
576
  // a profile switch.
577
- export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, width: number, now: number, active?: ActiveProvider, registry?: UsageSourceRegistry, scope?: UsagePanelScope): string[] {
577
+ export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, width: number, now: number, active?: ActiveProvider, registry?: UsageSourceRegistry, scope?: UsagePanelScope, paint?: GaugePaint): string[] {
578
578
  const activeUsage = active ? usages.find((usage) => usage.provider === active.provider) : undefined;
579
579
  const byProvider = new Map(usages.map((usage) => [usage.provider, usage] as const));
580
580
  const rows = scope
@@ -607,7 +607,7 @@ export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, wid
607
607
  const percent = `${Math.round(row.window.usedPercent)}%`.padStart(4);
608
608
  const reset = formatReset(row.window.resetAt, now);
609
609
  const tail = reset.length > 0 ? ` ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, reset)}` : "";
610
- lines.push(` ${theme.fg(ROLE.LABEL, row.name.padEnd(nameWidth))} ${paintMeter(row.window.usedPercent, PANEL_METER_CELLS, theme)} ${theme.fg(ROLE.PERCENT, percent)}${tail}`);
610
+ lines.push(` ${theme.fg(ROLE.LABEL, row.name.padEnd(nameWidth))} ${paintMeter(row.window.usedPercent, PANEL_METER_CELLS, theme, paint)} ${theme.fg(ROLE.PERCENT, percent)}${tail}`);
611
611
  }
612
612
  // A failure after a good snapshot never hides either fact: the last good
613
613
  // snapshot stays on its rows and the failed refresh says so beside it.
@@ -0,0 +1,29 @@
1
+ import { namesExistingFeatureDocumentTask } from "./bounded-writer-admission.ts";
2
+ import { contextBackstopReached, type MeasuredContext } from "./context-backstop.ts";
3
+
4
+ // odd/tasks/verifier-admission.md: the Verification rule reserves the independent verifier for a change that names a
5
+ // Task Size high-risk item, yet a bench run still launched it on a small, not high-risk task. In the primary session a
6
+ // verifier launch is admitted only when its task or context declares a named high-risk item, carries a task of an
7
+ // existing feature document (the same lines writer admission accepts), or the parent's measured context reaches the
8
+ // context backstop. The agent catalog has no generic verifier alias, so only `gentle-ai-verify` is gated.
9
+ const VERIFIER_NAMES = ["gentle-ai-verify"];
10
+ // A line that starts with `Risk:` (optionally as a list item) whose first entry is `item N` with N in 1-6, for
11
+ // example `Risk: item 3 (shared parser)` or `- Risk: item 2, item 3 (reason)`. `Risk: none` and prose mentions
12
+ // do not start such a line.
13
+ const HIGH_RISK_ITEM_LINE = /^[ \t]*(?:[-*+][ \t]+)?Risk:[ \t]*item[ \t]+[1-6](?!\d)/m;
14
+ export const VERIFIER_RISK_REJECTION =
15
+ "An independent verifier runs only for a change that declares a named high-risk item (`Risk: item N` from the Task Size list in the task). This change declares none: run its focused test and suite inline yourself now; do not retry the launch, and never add a Risk line or create a feature document only to get a verifier admitted.";
16
+
17
+ export function rejectUnriskedVerifierLaunch(
18
+ input: unknown,
19
+ options: { root: string; contextUsage: MeasuredContext | undefined; env?: Readonly<Record<string, string | undefined>> },
20
+ ): { block: true; reason: string } | undefined {
21
+ if (!input || typeof input !== "object" || Array.isArray(input)) return undefined;
22
+ const record = input as Record<string, unknown>;
23
+ if (typeof record.agent !== "string" || !VERIFIER_NAMES.includes(record.agent)) return undefined;
24
+ const values = [record.task, record.context];
25
+ if (values.some(value => typeof value === "string" && HIGH_RISK_ITEM_LINE.test(value))) return undefined;
26
+ if (namesExistingFeatureDocumentTask(values, options.root)) return undefined;
27
+ if (contextBackstopReached(options.contextUsage, options.env)) return undefined;
28
+ return { block: true, reason: VERIFIER_RISK_REJECTION };
29
+ }
@@ -0,0 +1,69 @@
1
+ // YOLO temp-scratch waiver: a recursive `rm` whose every literal target is the
2
+ // session's own scratch under a temp root needs no data-loss confirmation.
3
+ // Pure over an injectable filesystem view so tests never touch real paths.
4
+ // Callers own the policy gate: primary session with active YOLO only.
5
+ import { lstatSync, realpathSync } from "node:fs";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { posix } from "node:path";
8
+ import type { DestructiveCommandMatch } from "./destructive-command-guard.ts";
9
+
10
+ export interface TempScratchStat {
11
+ uid: number;
12
+ isDirectory(): boolean;
13
+ isFile(): boolean;
14
+ isSymbolicLink(): boolean;
15
+ }
16
+
17
+ export interface TempScratchFs {
18
+ /** Throws an error with code `ENOENT` when the path is missing. */
19
+ lstat(path: string): TempScratchStat;
20
+ /** Current process uid; undefined where POSIX ownership is unavailable. */
21
+ uid: number | undefined;
22
+ /** Resolved `os.tmpdir()`, when resolvable. */
23
+ tmpdir: string | undefined;
24
+ home: string;
25
+ }
26
+
27
+ const FIXED_TEMP_ROOTS = ["/tmp", "/var/tmp"];
28
+ const MKTEMP_NAME_RE = /^tmp\.[A-Za-z0-9]{6,}$/;
29
+ // A closed character set: expansion, quoting, glob, brace and redirection
30
+ // characters (`$`, backtick, `~`, `*?[{`, quotes, `<>`) make the shell
31
+ // target differ from the text or add a write elsewhere.
32
+ const LITERAL_PATH_RE = /^\/[A-Za-z0-9._@%+=:,/-]*$/;
33
+
34
+ export function defaultTempScratchFs(): TempScratchFs {
35
+ let resolvedTmpdir: string | undefined;
36
+ try { resolvedTmpdir = realpathSync(tmpdir()); } catch { resolvedTmpdir = undefined; }
37
+ return { lstat: (path) => lstatSync(path), uid: process.getuid?.(), tmpdir: resolvedTmpdir, home: homedir() };
38
+ }
39
+
40
+ /** A temp root equal to, above or below home would expose home entries as scratch. */
41
+ function tempRoots(fs: TempScratchFs): string[] {
42
+ const home = posix.normalize(fs.home);
43
+ return [...FIXED_TEMP_ROOTS, fs.tmpdir].filter((root): root is string =>
44
+ root !== undefined && posix.isAbsolute(root) && posix.normalize(root) === root && root !== "/" &&
45
+ root !== home && !home.startsWith(`${root}/`) && !root.startsWith(`${home}/`));
46
+ }
47
+
48
+ /** One literal path component directly under a temp root, proven own scratch by an admission-time lstat. */
49
+ export function isTempScratchTarget(target: string, fs: TempScratchFs): boolean {
50
+ if (fs.uid === undefined || !LITERAL_PATH_RE.test(target)) return false;
51
+ if (target.endsWith("/") || posix.normalize(target) !== target) return false;
52
+ const name = posix.basename(target);
53
+ if (name === "" || name === "." || name === ".." || !tempRoots(fs).includes(posix.dirname(target))) return false;
54
+ let stat: TempScratchStat;
55
+ try {
56
+ stat = fs.lstat(target);
57
+ } catch (error) {
58
+ return (error as { code?: unknown }).code === "ENOENT";
59
+ }
60
+ if (stat.isSymbolicLink()) return true;
61
+ if (stat.uid !== fs.uid) return false;
62
+ return stat.isFile() || (stat.isDirectory() && MKTEMP_NAME_RE.test(name));
63
+ }
64
+
65
+ /** A recognized effect is waivable temp scratch only when it is a direct recursive `rm` of temp scratch alone. */
66
+ export function isTempScratchRemoval(match: DestructiveCommandMatch, fs: TempScratchFs): boolean {
67
+ if (match.hardDeny || match.kind !== "filesystem" || match.removalTargets === undefined) return false;
68
+ return match.removalTargets.every((target) => isTempScratchTarget(target, fs));
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "Turn Pi into el Gentleman: an ODD development harness with focused subagents, configured TDD evidence, native review, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,7 +42,6 @@
42
42
  ],
43
43
  "scripts": {
44
44
  "check:provider-contract": "node scripts/check-provider-contract.mjs",
45
- "mirror:odd-routing": "node scripts/mirror-odd-routing.mjs",
46
45
  "postinstall": "node scripts/install-gentle-ai.mjs",
47
46
  "test": "node scripts/run-test-suite.mjs",
48
47
  "test:harness": "node --experimental-strip-types tests/runtime-harness.mjs",
@@ -15,7 +15,7 @@ import {
15
15
  resolveGentleAiReleaseAsset,
16
16
  } from "../scripts/gentle-ai-installer.mjs";
17
17
  import { engramPinVersion } from "../scripts/installer-downloads.mjs";
18
- import { bundledGentlePiConfigHome, bundledPiConfigDir, bundledPiConfigParent, runningBundledPrefix } from "./gentle-shell-bundled.mjs";
18
+ import { bundledPiConfigDir, bundledPiConfigParent, GENTLE_SHELL_CONFIG_HOME_ENV, resolveGentlePiConfigHome, runningBundledPrefix } from "./gentle-shell-bundled.mjs";
19
19
  import { fileURLToPath } from "node:url";
20
20
 
21
21
  export const GENTLE_AI_BINARY_MISSING_CODE = "package-local-binary-missing";
@@ -87,9 +87,11 @@ export function bundledGentleAiPaths(prefix , platform = process.
87
87
  * (only when the pinned Engram is published), GENTLE_AI_NO_SELF_UPDATE=1 and
88
88
  * GENTLE_PI_CONFIG_HOME (the parent of GENTLE_AI_PI_CONFIG_DIR)
89
89
  * when this gentle-pi runs from the valid bundled install GENTLE_SHELL_PREFIX
90
- * names (runningBundledPrefix). Otherwise `env` itself, unchanged: a gentle-pi
91
- * installed elsewhere (the user's own, started from the agent's shell, which
92
- * inherits the variable) keeps its own Gentle AI.
90
+ * names (runningBundledPrefix). In the standalone launcher's isolated home
91
+ * (#2201) only the config pair, for the folder its marker names. Otherwise
92
+ * `env` itself, unchanged: a gentle-pi installed elsewhere (the user's own,
93
+ * started from the agent's shell, which inherits the variable) keeps its own
94
+ * Gentle AI.
93
95
  */
94
96
  export function gentleAiIsolationEnv(
95
97
  env ,
@@ -97,7 +99,7 @@ export function gentleAiIsolationEnv(
97
99
  ) {
98
100
  const platform = options.platform ?? process.platform;
99
101
  const prefix = runningBundledPrefix(env, { packageRoot: options.packageRoot, platform });
100
- if (prefix === undefined) return env;
102
+ if (prefix === undefined) return standaloneGentleAiEnv(env, platform);
101
103
  const paths = bundledGentleAiPaths(prefix, platform);
102
104
  let engram = false;
103
105
  try {
@@ -126,6 +128,23 @@ export function gentleAiIsolationEnv(
126
128
  };
127
129
  }
128
130
 
131
+ // #2201: the same config pair as a bundled install, for the folder the
132
+ // standalone launcher's marker names, when it decides the config home
133
+ // (resolveGentlePiConfigHome; an explicit GENTLE_PI_CONFIG_HOME keeps its
134
+ // existing meaning for Gentle AI). Gentle AI's own state stays where it was.
135
+ // The parent is given only for a folder named gentle-ai, the one Gentle AI
136
+ // 4.0.0 appends to it.
137
+ function standaloneGentleAiEnv(env , platform ) {
138
+ const marker = env[GENTLE_SHELL_CONFIG_HOME_ENV];
139
+ if (marker === undefined || resolveGentlePiConfigHome(env, homedir(), { platform }) !== marker) return env;
140
+ const path = platform === "win32" ? win32 : posix;
141
+ return {
142
+ ...env,
143
+ GENTLE_AI_PI_CONFIG_DIR: marker,
144
+ ...(path.basename(marker) === "gentle-ai" ? { GENTLE_PI_CONFIG_HOME: path.dirname(marker) } : {}),
145
+ };
146
+ }
147
+
129
148
  function sha256(value ) {
130
149
  return createHash("sha256").update(value).digest("hex");
131
150
  }
@@ -137,7 +156,9 @@ function sha256(value ) {
137
156
  // 1. GENTLE_PI_GENTLE_AI_DEV_BINARY (session override, absolute path), then
138
157
  // 2. the persistent registration file at
139
158
  // <config home>/dev-binary.json (a bundled prefix's gentle-pi, else
140
- // GENTLE_PI_CONFIG_HOME, else ~/.pi/gentle-ai) with the strict
159
+ // GENTLE_PI_CONFIG_HOME, else ~/.pi/gentle-ai, never the standalone
160
+ // launcher's marker: the main channel writes it outside any session,
161
+ // #2201) with the strict
141
162
  // shape {"schema":"gentle-pi.dev-binary/v1","path":"<absolute path>"}.
142
163
  //
143
164
  // The registration deliberately pins no digest: it is the unpinned field-test
@@ -195,7 +216,7 @@ function ambientDevBinaryEnvironment() {
195
216
  }
196
217
 
197
218
  export function gentleAiDevBinaryRegistrationPath(environment = ambientDevBinaryEnvironment()) {
198
- const configHome = bundledGentlePiConfigHome(environment.env) ?? environment.env.GENTLE_PI_CONFIG_HOME ?? join(environment.home, ".pi", "gentle-ai");
219
+ const configHome = resolveGentlePiConfigHome(environment.env, environment.home, { launcherMarker: false });
199
220
  return join(configHome, "dev-binary.json");
200
221
  }
201
222
 
@@ -176,6 +176,29 @@ export function bundledGentlePiConfigHome(env ) {
176
176
  return prefix === undefined ? undefined : bundledPiConfigDir(prefix);
177
177
  }
178
178
 
179
+ // #2201: the standalone launcher gives Pi this variable in its isolated home
180
+ // only, naming the config folder next to the agent dir (standaloneConfigHome:
181
+ // ~/.gentle-shell/gentle-ai by default). It is launcher-owned, never
182
+ // GENTLE_PI_CONFIG_HOME: Gentle AI 4.0.0 reads that one as the parent of its
183
+ // own gentle-ai folder, and the agent's shell would pass it to any gentle-ai.
184
+ export const GENTLE_SHELL_CONFIG_HOME_ENV = "GENTLE_SHELL_CONFIG_HOME";
185
+
186
+ // gentle-pi's config home, the one rule every reader shares: a bundled
187
+ // prefix's folder, then an explicit GENTLE_PI_CONFIG_HOME, then the launcher's
188
+ // marker (absolute only), then ~/.pi/gentle-ai. Empty values count as unset.
189
+ // `launcherMarker: false` is for install-level state written outside a session
190
+ // (the main channel's channel.json, builds and dev-binary.json): the installer
191
+ // and `gentle-shell upgrade` never see the marker, so their readers ignore it.
192
+ export function resolveGentlePiConfigHome(env , home , options = {}) {
193
+ const path = pathFor(options.platform ?? process.platform);
194
+ const bundled = bundledGentlePiConfigHome(env);
195
+ if (bundled !== undefined) return bundled;
196
+ if (env.GENTLE_PI_CONFIG_HOME) return env.GENTLE_PI_CONFIG_HOME;
197
+ const marker = env[GENTLE_SHELL_CONFIG_HOME_ENV];
198
+ if (options.launcherMarker !== false && marker && path.isAbsolute(marker)) return marker;
199
+ return path.join(home, ".pi", "gentle-ai");
200
+ }
201
+
179
202
  // The exact Pi of the version (S8): the Pi the version's package.json pins,
180
203
  // installed inside the version folder with Pi's own bin entry. Never another Pi.
181
204
  export function bundledPi(install , input ) {
@@ -214,6 +237,8 @@ export function bundledPi(install , input
214
237
  // memory is allowed locally; otherwise the Shell-owned guard, so no provider,
215
238
  // whatever loaded it, runs an Engram binary against an unverified store.
216
239
  export const ENGRAM_COMPANION_SOURCE = "npm:gentle-engram@0.4.2";
240
+ export const ENGRAM_PRIVATE_REQUIRED_NOTICE = "Engram memory disabled: session-owned servers require a supporting published bundled core and companion. This bundle does not establish both capabilities; it will not fall back to a detached shared server. Install a supporting published bundle and restart Shell. Existing shared services are not stopped or adopted as session-owned.";
241
+ export const ENGRAM_CORE_CHANGED_NOTICE = "Engram memory disabled: the bundled core changed since Shell recorded this store. Install a published bundle supporting migrate-copy, stop every Engram client, retain a complete backup, then run gentle-shell memory migrate --confirm-quiescent. The command verifies and migrates a copy before updating provenance; older bundles refuse safely. Do not edit coreSha256 or delete the original store. Shell remains usable without memory.";
217
242
  export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe store or server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server with verified /compatibility evidence or matching Shell-provisioned store identity. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
218
243
  // `gentle-shell setup` publishes the verified core (ensureEngram) on every run.
219
244
  export const ENGRAM_PROVISION_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not provision a new Engram store identity, so it cannot vouch for that store. Restart Shell to retry. If Shell keeps reporting unknown compatibility and ~/.engram holds only .instance-id files, back it up and remove it, then restart Shell. Shell remains usable without memory.";
@@ -237,7 +262,7 @@ function outdatedServerVersion(version )
237
262
  // The default endpoint gentle-engram uses without ENGRAM_URL or ENGRAM_PORT.
238
263
  export const ENGRAM_DEFAULT_PORT = 7437;
239
264
  export const ENGRAM_DEFAULT_URL = `http://127.0.0.1:${ENGRAM_DEFAULT_PORT}`;
240
- const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_DATABASE_URL"];
265
+ const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_SOCKET", "ENGRAM_DATABASE_URL"];
241
266
  const HEX32 = /^[a-f0-9]{32}$/;
242
267
 
243
268
 
@@ -271,7 +296,7 @@ export function classifyEngramStartup(input
271
296
  // Shell's own store, created by this exact core: offline, the provider starts
272
297
  // that core; a running server must be that store and that exact core.
273
298
  if (endpoint.kind === "refused") return { policy: "allow" };
274
- if (endpoint.kind === "listening" && endpoint.healthInstanceId === own.instanceId && endpoint.healthVersion === input.coreVersion) return { policy: "allow" };
299
+ if (endpoint.kind === "listening" && endpoint.healthInstanceId === own.instanceId && endpoint.healthVersion === input.coreVersion) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
275
300
  return unknown;
276
301
  }
277
302
 
@@ -402,6 +427,11 @@ export function supportsEngramStartupPolicy(metadata )
402
427
  return pkg?.name === "gentle-engram" && pkg.engram?.startupPolicy === 1;
403
428
  }
404
429
 
430
+ export function supportsEngramSessionOwned(metadata ) {
431
+ const pkg = parseJson(metadata);
432
+ return supportsEngramStartupPolicy(metadata) && pkg?.engram?.sessionOwned === 1;
433
+ }
434
+
405
435
  export function bundledChildEnv(base , input ) {
406
436
  const env = { ...base, PI_SKIP_VERSION_CHECK: "1", [GENTLE_SHELL_PREFIX_ENV]: input.prefix };
407
437
  delete env.GENTLE_SHELL_PI;
@@ -426,6 +456,12 @@ export function bundledChildEnv(base , input
426
456
  if (memory.url !== undefined) env.ENGRAM_URL = memory.url;
427
457
  const local = memory.policy === "allow" && memory.url === undefined ? input.engram : undefined;
428
458
  env.ENGRAM_BIN = local ?? input.guard;
459
+ if (local && memory.owned) {
460
+ env.ENGRAM_SERVER_MODE = "session-owned";
461
+ delete env.ENGRAM_URL;
462
+ delete env.ENGRAM_PORT;
463
+ delete env.ENGRAM_SOCKET;
464
+ }
429
465
  return env;
430
466
  }
431
467