gentle-pi 3.7.0 → 4.0.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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -0,0 +1,142 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import promptHistoryExtension from "../extensions/history/index.ts";
7
+ import { projectHash } from "../extensions/history/store.ts";
8
+
9
+ // Home/End routing in the history selector: with text in the search box
10
+ // they move the search caret (End never jumps or loads the list); with an
11
+ // empty search box they keep the documented list jumps (§B2/§D7). The real
12
+ // selector is driven through the history command with a fake overlay host;
13
+ // every fixture lives under os.tmpdir(), never the user's real ~/.pi.
14
+
15
+ const CWD = "/pi-history-fixtures/project-caret-keys";
16
+ const HOME = "\x1b[H";
17
+ const END = "\x1b[F";
18
+ const LEFT = "\x1b[D";
19
+ const ENTER = "\r";
20
+ // 15 prompts, oldest p00 .. newest p14: more than the initial 10-row window.
21
+ const PROMPT_COUNT = 15;
22
+
23
+ interface Selector {
24
+ handleInput(data: string): void;
25
+ }
26
+
27
+ function makeRoot(): string {
28
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-caret-"));
29
+ const dir = path.join(root, "projects", projectHash(CWD));
30
+ fs.mkdirSync(dir, { recursive: true });
31
+ const lines = Array.from({ length: PROMPT_COUNT }, (_, i) =>
32
+ JSON.stringify({
33
+ v: 1,
34
+ text: `p${String(i).padStart(2, "0")}`,
35
+ ts: 1_700_000_000_000 + i,
36
+ }),
37
+ );
38
+ fs.writeFileSync(path.join(dir, "store.jsonl"), `${lines.join("\n")}\n`);
39
+ return root;
40
+ }
41
+
42
+ /**
43
+ * Open the selector, feed `keys` one at a time, and return the prompt the
44
+ * selector pasted into the editor (null when it pasted nothing).
45
+ */
46
+ async function selectAfter(keys: string[]): Promise<string | null> {
47
+ const root = makeRoot();
48
+ const commands: Array<[string, { handler: unknown }]> = [];
49
+ promptHistoryExtension(
50
+ {
51
+ on: () => {},
52
+ registerShortcut: () => {},
53
+ registerCommand: (name: string, def: { handler: unknown }) => {
54
+ commands.push([name, def]);
55
+ },
56
+ } as never,
57
+ {
58
+ env: { GENTLE_PI_HISTORY_CAPTURE: "1" },
59
+ gentlePiConfigHome: fs.mkdtempSync(
60
+ path.join(os.tmpdir(), "pi-history-caret-config-"),
61
+ ),
62
+ root,
63
+ cwd: CWD,
64
+ instanceId: "inst-caret",
65
+ agentDir: path.join(root, "agent"),
66
+ sessionsRoot: path.join(root, "sessions"),
67
+ },
68
+ );
69
+ const command = commands.find(([name]) => name === "history");
70
+ assert.ok(command, "the history command must be registered");
71
+ const handler = command[1].handler as (
72
+ args: unknown,
73
+ ctx: unknown,
74
+ ) => Promise<void>;
75
+
76
+ let pasted: string | null = null;
77
+ const plain = (_color: string, text: string) => text;
78
+ const ctx = {
79
+ ui: {
80
+ notify: (message: string) => {
81
+ assert.fail(`unexpected notification: ${message}`);
82
+ },
83
+ pasteToEditor: (text: string) => {
84
+ pasted = text;
85
+ },
86
+ custom: (
87
+ factory: (
88
+ tui: unknown,
89
+ theme: unknown,
90
+ keybindings: unknown,
91
+ done: (result: unknown) => void,
92
+ ) => Selector,
93
+ ) =>
94
+ new Promise((resolve) => {
95
+ const selector = factory(
96
+ { requestRender: () => {} },
97
+ { fg: plain, bg: plain, bold: (text: string) => text },
98
+ undefined,
99
+ resolve,
100
+ );
101
+ for (const key of keys) selector.handleInput(key);
102
+ // Enter selects the highlighted row; with no match it resolves
103
+ // nothing, so close explicitly to finish the command.
104
+ selector.handleInput(ENTER);
105
+ resolve(null);
106
+ }),
107
+ },
108
+ };
109
+ await handler([], ctx);
110
+ // The paste path schedules one render tick; let it drain.
111
+ await new Promise((resolve) => setTimeout(resolve, 0));
112
+ return pasted;
113
+ }
114
+
115
+ test("with a query, Home moves the search caret instead of the list", async () => {
116
+ // Typed "1", caret to the start, then "p": the query is "p1", whose
117
+ // newest match is p14. A list jump would leave the query as "1p".
118
+ assert.equal(await selectAfter(["1", HOME, "p"]), "p14");
119
+ });
120
+
121
+ test("with a query, End moves the search caret instead of the list", async () => {
122
+ // Caret moved to the start, End brings it back: "p1" + "3" = "p13".
123
+ // A list jump would leave the caret at 0 and type "3p1".
124
+ assert.equal(await selectAfter(["p", "1", LEFT, LEFT, END, "3"]), "p13");
125
+ });
126
+
127
+ test("with a query, End neither jumps to the last match nor loads the list", async () => {
128
+ // A real query: the highlighted row stays the newest match, not p00.
129
+ assert.equal(await selectAfter(["p", END]), "p14");
130
+ // A whitespace-only query filters nothing and keeps the 10-row window:
131
+ // End must not load every record and select the oldest one.
132
+ assert.equal(await selectAfter([" ", END]), "p14");
133
+ });
134
+
135
+ test("with an empty query, Home and End keep the list jumps", async () => {
136
+ // End loads every record and selects the oldest one (§D7).
137
+ assert.equal(await selectAfter([END]), "p00");
138
+ // Home jumps back to the newest.
139
+ assert.equal(await selectAfter([END, HOME]), "p14");
140
+ // Emptying the query hands Home/End back to the list.
141
+ assert.equal(await selectAfter(["p", "\x7f", END]), "p00");
142
+ });
@@ -0,0 +1,170 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import {
7
+ bootstrapProjectSeed,
8
+ projectHash,
9
+ seedFilePath,
10
+ } from "../extensions/history/store.ts";
11
+
12
+ // Fake project cwd (never created on disk): projectHash falls back to
13
+ // raw-string hashing for nonexistent paths, and the transcript dirName
14
+ // encoding derives from the same string.
15
+ const CWD = "/pi-history-test/seed-project";
16
+ const DIR = "--pi-history-test-seed-project--";
17
+
18
+ function makeDirs(): { root: string; sessionsRoot: string } {
19
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-seed-"));
20
+ return {
21
+ root: path.join(base, "pi-history"),
22
+ sessionsRoot: path.join(base, "sessions"),
23
+ };
24
+ }
25
+
26
+ function writeSession(
27
+ sessionsRoot: string,
28
+ dirName: string,
29
+ fileName: string,
30
+ userTexts: string[],
31
+ mtimeMs?: number,
32
+ ): string {
33
+ const dir = path.join(sessionsRoot, dirName);
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ const file = path.join(dir, fileName);
36
+ const lines: string[] = [
37
+ JSON.stringify({
38
+ type: "session",
39
+ version: 1,
40
+ timestamp: "2026-01-01T00:00:00.000Z",
41
+ }),
42
+ ];
43
+ let ms = 1700000000000;
44
+ for (const text of userTexts) {
45
+ lines.push(
46
+ JSON.stringify({
47
+ type: "message",
48
+ timestamp: "2026-01-01T00:00:00.000Z",
49
+ message: { role: "user", content: text, timestamp: ms },
50
+ }),
51
+ );
52
+ ms += 1;
53
+ }
54
+ fs.writeFileSync(file, `${lines.join("\n")}\n`, "utf8");
55
+ if (mtimeMs !== undefined) {
56
+ fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs));
57
+ }
58
+ return file;
59
+ }
60
+
61
+ function seedTexts(root: string): string[] {
62
+ const file = seedFilePath(root, CWD);
63
+ if (!fs.existsSync(file)) return [];
64
+ return fs
65
+ .readFileSync(file, "utf8")
66
+ .trim()
67
+ .split("\n")
68
+ .filter((l) => l.length > 0)
69
+ .map((l) => (JSON.parse(l) as { text: string }).text);
70
+ }
71
+
72
+ test("no sessions and no project dir: bootstrap seeds nothing", () => {
73
+ const { root, sessionsRoot } = makeDirs();
74
+ const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
75
+ assert.deepEqual(result, { seeded: 0, ran: false });
76
+ assert.equal(fs.existsSync(seedFilePath(root, CWD)), false);
77
+ });
78
+
79
+ test("empty project dir bootstraps from the project's transcripts", () => {
80
+ const { root, sessionsRoot } = makeDirs();
81
+ writeSession(sessionsRoot, DIR, "s1.jsonl", [
82
+ "real prompt",
83
+ "/compact",
84
+ " ",
85
+ "second prompt",
86
+ ]);
87
+ const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
88
+ assert.deepEqual(result, { seeded: 2, ran: true });
89
+ // Chronological order (oldest first) in the seed file.
90
+ assert.deepEqual(seedTexts(root), ["real prompt", "second prompt"]);
91
+ });
92
+
93
+ test("only the project's own session dir is scanned", () => {
94
+ const { root, sessionsRoot } = makeDirs();
95
+ writeSession(sessionsRoot, DIR, "s1.jsonl", ["mine"]);
96
+ writeSession(sessionsRoot, "--Other--", "s2.jsonl", ["not mine"]);
97
+ bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
98
+ assert.deepEqual(seedTexts(root), ["mine"]);
99
+ });
100
+
101
+ test("caps at the target keeping the newest", () => {
102
+ const { root, sessionsRoot } = makeDirs();
103
+ const texts: string[] = [];
104
+ for (let i = 1; i <= 600; i++) texts.push(`p${i}`);
105
+ writeSession(sessionsRoot, DIR, "big.jsonl", texts);
106
+ const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
107
+ assert.deepEqual(result, { seeded: 500, ran: true });
108
+ const all = seedTexts(root);
109
+ assert.equal(all.length, 500);
110
+ assert.equal(all[0], "p101"); // oldest kept
111
+ assert.equal(all[499], "p600"); // newest
112
+ });
113
+
114
+ test("project dir already populated above target: no scan, seed untouched", () => {
115
+ const { root, sessionsRoot } = makeDirs();
116
+ const dir = path.join(root, "projects", projectHash(CWD));
117
+ fs.mkdirSync(dir, { recursive: true });
118
+ const existing = path.join(dir, "existing.jsonl");
119
+ fs.writeFileSync(
120
+ existing,
121
+ `${Array.from({ length: 500 }, (_, i) =>
122
+ JSON.stringify({ v: 1, text: `e${i}` }),
123
+ ).join("\n")}\n`,
124
+ "utf8",
125
+ );
126
+ const marker = writeSession(sessionsRoot, DIR, "s.jsonl", ["marker"]);
127
+ fs.utimesSync(
128
+ marker,
129
+ new Date(Date.now() + 5000),
130
+ new Date(Date.now() + 5000),
131
+ );
132
+ const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
133
+ assert.deepEqual(result, { seeded: 0, ran: false });
134
+ assert.deepEqual(seedTexts(root), []);
135
+ assert.equal(fs.readFileSync(existing, "utf8").includes("marker"), false);
136
+ });
137
+
138
+ // node:test has no test.skipIf (Bun-ism): root skips via the options
139
+ // object — chmod 000 is invisible to the superuser.
140
+ const sealedStoreTest = (name: string, fn: () => void) =>
141
+ test(
142
+ name,
143
+ { skip: process.getuid?.() === 0 ? "requires non-root" : false },
144
+ fn,
145
+ );
146
+ sealedStoreTest(
147
+ "an unreadable existing store file is skipped during counting; seeding still runs from transcripts",
148
+ () => {
149
+ const { root, sessionsRoot } = makeDirs();
150
+ const dir = path.join(root, "projects", projectHash(CWD));
151
+ fs.mkdirSync(dir, { recursive: true });
152
+ const sealed = path.join(dir, "sealed.jsonl");
153
+ fs.writeFileSync(
154
+ sealed,
155
+ `${JSON.stringify({ v: 1, text: "sealed-entry" })}\n`,
156
+ "utf8",
157
+ );
158
+ fs.chmodSync(sealed, 0o000);
159
+ writeSession(sessionsRoot, DIR, "s1.jsonl", ["from transcript"]);
160
+ try {
161
+ // The unreadable file contributes zero to existingCount, so the count
162
+ // stays under target and the transcript scan still runs. No throw.
163
+ const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
164
+ assert.deepEqual(result, { seeded: 1, ran: true });
165
+ assert.deepEqual(seedTexts(root), ["from transcript"]);
166
+ } finally {
167
+ fs.chmodSync(sealed, 0o644); // restore before cleanup
168
+ }
169
+ },
170
+ );
@@ -0,0 +1,129 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { bootstrapProjectSeed, seedFilePath } from "../extensions/history/store.ts";
7
+
8
+ // Fake project cwd (never created on disk): projectHash falls back to
9
+ // raw-string hashing for nonexistent paths, and the transcript dirName
10
+ // encoding derives from the same string.
11
+ const CWD = "/pi-history-test/seed-regen-project";
12
+ const DIR = "--pi-history-test-seed-regen-project--";
13
+
14
+ function setup() {
15
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "seed2-"));
16
+ return {
17
+ root: path.join(base, "h"),
18
+ sessionsRoot: path.join(base, "sessions"),
19
+ stateDir: path.join(base, "state"),
20
+ };
21
+ }
22
+
23
+ function writeSession(sessionsRoot: string, texts: string[]): void {
24
+ const dir = path.join(sessionsRoot, DIR);
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ const lines = [
27
+ JSON.stringify({
28
+ type: "session",
29
+ version: 1,
30
+ timestamp: "2026-01-01T00:00:00.000Z",
31
+ }),
32
+ ];
33
+ let ms = 1700000000000;
34
+ for (const text of texts) {
35
+ lines.push(
36
+ JSON.stringify({
37
+ type: "message",
38
+ timestamp: "2026-01-01T00:00:00.000Z",
39
+ message: { role: "user", content: text, timestamp: ms++ },
40
+ }),
41
+ );
42
+ }
43
+ fs.writeFileSync(path.join(dir, "s1.jsonl"), `${lines.join("\n")}\n`, "utf8");
44
+ }
45
+
46
+ test("an existing seed is never regenerated (deleted prompts stay gone)", () => {
47
+ const { root, sessionsRoot } = setup();
48
+ writeSession(sessionsRoot, ["keep", "delete-me"]);
49
+ bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
50
+ // User deletes "delete-me" from the seed file (scope delete).
51
+ const seed = seedFilePath(root, CWD);
52
+ const kept = fs
53
+ .readFileSync(seed, "utf8")
54
+ .split("\n")
55
+ .filter((l) => !l.includes("delete-me"))
56
+ .join("\n");
57
+ fs.writeFileSync(seed, kept, "utf8");
58
+ // A NEW session bootstraps again -> must not resurrect from transcripts.
59
+ const again = bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
60
+ assert.deepEqual(again, { seeded: 0, ran: false });
61
+ const texts = fs
62
+ .readFileSync(seed, "utf8")
63
+ .trim()
64
+ .split("\n")
65
+ .map((l) => (JSON.parse(l) as { text: string }).text);
66
+ assert.deepEqual(texts, ["keep"]);
67
+ });
68
+
69
+ test("untrusted tombstones defer seed until repaired, then suppress deleted prompts", () => {
70
+ const { root, sessionsRoot, stateDir } = setup();
71
+ writeSession(sessionsRoot, ["visible", "hidden-prompt"]);
72
+ fs.mkdirSync(stateDir, { recursive: true });
73
+ const hiddenFile = path.join(stateDir, "hidden.json");
74
+ fs.writeFileSync(hiddenFile, "{broken");
75
+ assert.deepEqual(bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), {
76
+ seeded: 0, ran: false,
77
+ });
78
+ assert.equal(fs.existsSync(seedFilePath(root, CWD)), false);
79
+ fs.writeFileSync(hiddenFile, JSON.stringify(["hidden-prompt"]));
80
+ assert.deepEqual(bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), {
81
+ seeded: 1, ran: true,
82
+ });
83
+ assert.deepEqual(
84
+ fs.readFileSync(seedFilePath(root, CWD), "utf8").trim().split("\n")
85
+ .map((line) => (JSON.parse(line) as { text: string }).text),
86
+ ["visible"],
87
+ );
88
+ });
89
+
90
+ test("a failed seed rename leaves no gate and retries with all prompts", () => {
91
+ const { root, sessionsRoot, stateDir } = setup();
92
+ writeSession(sessionsRoot, ["visible", "hidden-prompt"]);
93
+ fs.mkdirSync(stateDir, { recursive: true });
94
+ fs.writeFileSync(path.join(stateDir, "hidden.json"), JSON.stringify(["hidden-prompt"]));
95
+ const seed = seedFilePath(root, CWD);
96
+ const original = fs.renameSync;
97
+ fs.renameSync = ((from: fs.PathLike, to: fs.PathLike) => {
98
+ if (to === seed) throw new Error("injected seed rename failure");
99
+ return original(from, to);
100
+ }) as typeof fs.renameSync;
101
+ try {
102
+ assert.throws(() => bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), /injected/);
103
+ assert.equal(fs.existsSync(seed), false);
104
+ } finally {
105
+ fs.renameSync = original;
106
+ }
107
+ assert.deepEqual(bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), {
108
+ seeded: 1, ran: true,
109
+ });
110
+ });
111
+
112
+ test("tombstoned prompts are not seeded from transcripts", () => {
113
+ const { root, sessionsRoot, stateDir } = setup();
114
+ writeSession(sessionsRoot, ["visible", "hidden-prompt"]);
115
+ // Tombstone "hidden-prompt" (same key shape hide-prompts writes).
116
+ fs.mkdirSync(stateDir, { recursive: true });
117
+ fs.writeFileSync(
118
+ path.join(stateDir, "hidden.json"),
119
+ JSON.stringify(["hidden-prompt"]),
120
+ "utf8",
121
+ );
122
+ bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir);
123
+ const texts = fs
124
+ .readFileSync(seedFilePath(root, CWD), "utf8")
125
+ .trim()
126
+ .split("\n")
127
+ .map((l) => (JSON.parse(l) as { text: string }).text);
128
+ assert.deepEqual(texts, ["visible"]);
129
+ });
@@ -0,0 +1,94 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ buildPromptRecords,
5
+ clampPreviewOffset,
6
+ clampSelectedIndex,
7
+ computeVisibleRange,
8
+ getVisiblePromptRecords,
9
+ moveSelectedIndex,
10
+ pageSelectedIndex,
11
+ } from "../extensions/history/selector-helpers.ts";
12
+
13
+ test("buildPromptRecords lowercases search text", () => {
14
+ assert.deepEqual(buildPromptRecords(["Hello"]), [
15
+ { text: "Hello", searchText: "hello" },
16
+ ]);
17
+ });
18
+
19
+ test("computeVisibleRange centers when possible", () => {
20
+ assert.deepEqual(computeVisibleRange(8, 30, 10), { start: 3, end: 13 });
21
+ });
22
+
23
+ test("computeVisibleRange pins near end", () => {
24
+ assert.deepEqual(computeVisibleRange(28, 30, 10), { start: 20, end: 30 });
25
+ });
26
+
27
+ test("computeVisibleRange handles small lists", () => {
28
+ assert.deepEqual(computeVisibleRange(1, 3, 10), { start: 0, end: 3 });
29
+ });
30
+
31
+ test("clampSelectedIndex stays within filtered record bounds", () => {
32
+ assert.equal(clampSelectedIndex(8, 3), 2);
33
+ assert.equal(clampSelectedIndex(1, 0), 0);
34
+ });
35
+
36
+ test("clampPreviewOffset clamps offsets past the last page", () => {
37
+ assert.equal(clampPreviewOffset(50, 47, 10), 37);
38
+ assert.equal(clampPreviewOffset(5, 47, 10), 5);
39
+ });
40
+
41
+ test("clampPreviewOffset pins to zero when content fits the viewport", () => {
42
+ assert.equal(clampPreviewOffset(3, 8, 10), 0);
43
+ assert.equal(clampPreviewOffset(7, 0, 10), 0);
44
+ });
45
+
46
+ test("moveSelectedIndex wraps around the list", () => {
47
+ assert.equal(moveSelectedIndex(0, 3, -1), 2);
48
+ assert.equal(moveSelectedIndex(2, 3, 1), 0);
49
+ assert.equal(moveSelectedIndex(0, 0, 1), 0);
50
+ });
51
+
52
+ test("pageSelectedIndex clamps within the list", () => {
53
+ assert.equal(pageSelectedIndex(8, 30, -10), 0);
54
+ assert.equal(pageSelectedIndex(2, 3, 10), 2);
55
+ assert.equal(pageSelectedIndex(0, 0, 10), 0);
56
+ });
57
+
58
+ test("pageSelectedIndex end-clamps a downward page at the last entry (AC-P2-1.1)", () => {
59
+ assert.equal(pageSelectedIndex(28, 30, 10), 29);
60
+ assert.equal(pageSelectedIndex(25, 30, 10), 29);
61
+ });
62
+
63
+ test("pageSelectedIndex clamps |pageSize| greater than total in both directions (AC-P2-1.2)", () => {
64
+ assert.equal(pageSelectedIndex(0, 3, 10), 2);
65
+ assert.equal(pageSelectedIndex(2, 3, -10), 0);
66
+ assert.equal(pageSelectedIndex(0, 30, -50), 0);
67
+ });
68
+
69
+ test("pageSelectedIndex never wraps past the ends (AC-P2-1.2)", () => {
70
+ assert.equal(pageSelectedIndex(29, 30, 10), 29);
71
+ assert.equal(pageSelectedIndex(0, 30, -10), 0);
72
+ });
73
+
74
+ test("getVisiblePromptRecords returns visible records with selection state", () => {
75
+ assert.deepEqual(
76
+ getVisiblePromptRecords(
77
+ buildPromptRecords(["one", "two", "three", "four"]),
78
+ 2,
79
+ 2,
80
+ ),
81
+ [
82
+ {
83
+ index: 1,
84
+ record: { text: "two", searchText: "two" },
85
+ isSelected: false,
86
+ },
87
+ {
88
+ index: 2,
89
+ record: { text: "three", searchText: "three" },
90
+ isSelected: true,
91
+ },
92
+ ],
93
+ );
94
+ });
@@ -0,0 +1,87 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { listSessionFiles } from "../extensions/history/session-scan.ts";
7
+
8
+ /**
9
+ * WU1b-carried T7 (AC-S1-7): the one-level directory exclusion matrix. The
10
+ * fixture tree mirrors the pi sessions root — encoded-cwd directories with
11
+ * top-level jsonl session files, nested run-N/session.jsonl subagent
12
+ * payloads, a subagent-artifacts subtree, and a stray root-level file.
13
+ * Fixture files carry garbage content: the scanner must LIST paths only, so
14
+ * exact path equality proves nested payloads are never ingested (a
15
+ * recursive scanner would emit them).
16
+ */
17
+ function makeSessionsRoot(): string {
18
+ return fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-dirs-"));
19
+ }
20
+
21
+ function writeFileAt(filePath: string): void {
22
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
23
+ fs.writeFileSync(filePath, "garbage-not-json\n", "utf8");
24
+ }
25
+
26
+ test("one-level scan rule: only top-level jsonl of cwd dirs; nested payloads, subagent-artifacts, and stray root files never ingested (AC-S1-7)", () => {
27
+ const root = makeSessionsRoot();
28
+
29
+ // two cwd directories, each holding top-level jsonl session files
30
+ const cwdA = path.join(root, "--home-user-project-a--");
31
+ const cwdB = path.join(root, "--home-user-project-b--");
32
+ writeFileAt(path.join(cwdA, "2026-01-01t10-00-00aaa.jsonl"));
33
+ writeFileAt(path.join(cwdA, "2026-01-02t11-00-00bbb.jsonl"));
34
+ writeFileAt(path.join(cwdB, "2026-01-03t12-00-00ccc.jsonl"));
35
+
36
+ // nested run-N/session.jsonl subagent payloads — never descended
37
+ writeFileAt(path.join(cwdA, "run-1", "session.jsonl"));
38
+ writeFileAt(path.join(cwdB, "run-2", "session.jsonl"));
39
+
40
+ // a subagent-artifacts subtree holding a jsonl file — never descended
41
+ writeFileAt(path.join(cwdA, "subagent-artifacts", "artifact.jsonl"));
42
+
43
+ // one stray root-level FILE (not a directory) — skipped
44
+ writeFileAt(path.join(root, "stray.jsonl"));
45
+
46
+ const files = listSessionFiles(root);
47
+
48
+ // exactly the three top-level jsonl paths of the cwd directories,
49
+ // sorted, absolute
50
+ assert.deepEqual(files, [
51
+ path.join(cwdA, "2026-01-01t10-00-00aaa.jsonl"),
52
+ path.join(cwdA, "2026-01-02t11-00-00bbb.jsonl"),
53
+ path.join(cwdB, "2026-01-03t12-00-00ccc.jsonl"),
54
+ ]);
55
+ for (const file of files) {
56
+ assert.equal(path.isAbsolute(file), true);
57
+ }
58
+ });
59
+
60
+ // node:test has no test.skipIf (Bun-ism): root skips via the options
61
+ // object — chmod 000 is invisible to the superuser.
62
+ const sealedDirTest = (name: string, fn: () => void) =>
63
+ test(
64
+ name,
65
+ { skip: process.getuid?.() === 0 ? "requires non-root" : false },
66
+ fn,
67
+ );
68
+ sealedDirTest(
69
+ "an unreadable child dir (chmod 000) is skipped; sibling dirs still list",
70
+ () => {
71
+ const root = makeSessionsRoot();
72
+ const sealed = path.join(root, "--sealed--");
73
+ const open = path.join(root, "--open--");
74
+ writeFileAt(path.join(sealed, "hidden-session.jsonl"));
75
+ writeFileAt(path.join(open, "visible-session.jsonl"));
76
+ fs.chmodSync(sealed, 0o000);
77
+ try {
78
+ // One directory whose readdir fails skips itself — never fatal — and
79
+ // the sibling directories still contribute their files.
80
+ assert.deepEqual(listSessionFiles(root), [
81
+ path.join(open, "visible-session.jsonl"),
82
+ ]);
83
+ } finally {
84
+ fs.chmodSync(sealed, 0o755); // restore before cleanup
85
+ }
86
+ },
87
+ );