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.
- package/README.md +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +92 -28
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +112 -206
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +429 -262
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +96 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +49 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +49 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +9 -15
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +378 -89
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1047 -252
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
|
@@ -0,0 +1,265 @@
|
|
|
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 {
|
|
8
|
+
planHeaderLayout,
|
|
9
|
+
SCOPE_RADIO_COMPACT_GLOBAL,
|
|
10
|
+
SCOPE_RADIO_COMPACT_PROJECT,
|
|
11
|
+
SCOPE_RADIO_FULL_GLOBAL,
|
|
12
|
+
SCOPE_RADIO_FULL_PROJECT,
|
|
13
|
+
scopeRadioText,
|
|
14
|
+
} from "../extensions/history/selector-helpers.ts";
|
|
15
|
+
import { projectHash } from "../extensions/history/store.ts";
|
|
16
|
+
|
|
17
|
+
// Responsive selector header (restored from Carolina's #1394 work,
|
|
18
|
+
// e2cca1f9b^): the header is fit-driven — inline, stacked (tablet), or
|
|
19
|
+
// compact (mobile) — while the overlay stays exactly 30 rows.
|
|
20
|
+
|
|
21
|
+
const LEFT =
|
|
22
|
+
" History Search ".length +
|
|
23
|
+
" · 1 of 10 ".length +
|
|
24
|
+
" · loaded 10 of 27 ".length;
|
|
25
|
+
const RADIO = SCOPE_RADIO_FULL_PROJECT.length;
|
|
26
|
+
const GAP = 4;
|
|
27
|
+
|
|
28
|
+
test("inline while counts plus radio plus minimum gap fit the width", () => {
|
|
29
|
+
assert.equal(
|
|
30
|
+
planHeaderLayout(LEFT + GAP + RADIO, LEFT, RADIO, GAP),
|
|
31
|
+
"inline",
|
|
32
|
+
);
|
|
33
|
+
assert.equal(planHeaderLayout(200, LEFT, RADIO, GAP), "inline");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("stacked (tablet) once the spacer would drop below the minimum gap", () => {
|
|
37
|
+
assert.equal(
|
|
38
|
+
planHeaderLayout(LEFT + GAP + RADIO - 1, LEFT, RADIO, GAP),
|
|
39
|
+
"stacked",
|
|
40
|
+
);
|
|
41
|
+
assert.equal(planHeaderLayout(LEFT, LEFT, RADIO, GAP), "stacked");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("compact (mobile) when even the counts line no longer fits", () => {
|
|
45
|
+
assert.equal(planHeaderLayout(LEFT - 1, LEFT, RADIO, GAP), "compact");
|
|
46
|
+
assert.equal(planHeaderLayout(30, LEFT, RADIO, GAP), "compact");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("radio pins the user-directed labels", () => {
|
|
50
|
+
assert.equal(SCOPE_RADIO_FULL_PROJECT, "◉ Current project | ○ All projects");
|
|
51
|
+
assert.equal(SCOPE_RADIO_FULL_GLOBAL, "○ Current project | ◉ All projects");
|
|
52
|
+
assert.equal(SCOPE_RADIO_COMPACT_PROJECT, "◉ Current project | ○ All");
|
|
53
|
+
assert.equal(SCOPE_RADIO_COMPACT_GLOBAL, "○ Current | ◉ All projects");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("scopeRadioText abbreviates only in compact mode", () => {
|
|
57
|
+
assert.equal(scopeRadioText("project", false), SCOPE_RADIO_FULL_PROJECT);
|
|
58
|
+
assert.equal(scopeRadioText("global", false), SCOPE_RADIO_FULL_GLOBAL);
|
|
59
|
+
assert.equal(scopeRadioText("project", true), SCOPE_RADIO_COMPACT_PROJECT);
|
|
60
|
+
assert.equal(scopeRadioText("global", true), SCOPE_RADIO_COMPACT_GLOBAL);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Rendered selector: the real overlay component driven through the history
|
|
65
|
+
// command with a fake overlay host. Fixtures live under os.tmpdir().
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const CWD = "/pi-history-fixtures/project-header-layout";
|
|
69
|
+
const ENTER = "\r";
|
|
70
|
+
// 15 prompts, oldest p00 .. newest p14: the header reads
|
|
71
|
+
// " History Search · 1 of 10 · loaded 10 of 15 ".
|
|
72
|
+
const PROMPT_COUNT = 15;
|
|
73
|
+
const RENDERED_LEFT =
|
|
74
|
+
" History Search ".length +
|
|
75
|
+
" · 1 of 10 ".length +
|
|
76
|
+
" · loaded 10 of 15 ".length;
|
|
77
|
+
const INLINE_WIDTH = RENDERED_LEFT + GAP + RADIO;
|
|
78
|
+
const STACKED_WIDTH = INLINE_WIDTH - 1;
|
|
79
|
+
const COMPACT_WIDTH = RENDERED_LEFT - 1;
|
|
80
|
+
const HINT = "Type to filter (multi-word AND substring, case-insensitive)";
|
|
81
|
+
|
|
82
|
+
interface Selector {
|
|
83
|
+
handleInput(data: string): void;
|
|
84
|
+
handleMouse(event: unknown): unknown;
|
|
85
|
+
render(width: number): string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeRoot(): string {
|
|
89
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-header-"));
|
|
90
|
+
const dir = path.join(root, "projects", projectHash(CWD));
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
92
|
+
const lines = Array.from({ length: PROMPT_COUNT }, (_, i) =>
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
v: 1,
|
|
95
|
+
text: `p${String(i).padStart(2, "0")}`,
|
|
96
|
+
ts: 1_700_000_000_000 + i,
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
fs.writeFileSync(path.join(dir, "store.jsonl"), `${lines.join("\n")}\n`);
|
|
100
|
+
return root;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Open the selector through the history command, run `drive` against the
|
|
105
|
+
* mounted component, press Enter, and return the pasted prompt.
|
|
106
|
+
*/
|
|
107
|
+
async function withSelector(
|
|
108
|
+
drive: (selector: Selector) => void,
|
|
109
|
+
): Promise<string | null> {
|
|
110
|
+
const root = makeRoot();
|
|
111
|
+
const commands: Array<[string, { handler: unknown }]> = [];
|
|
112
|
+
promptHistoryExtension(
|
|
113
|
+
{
|
|
114
|
+
on: () => {},
|
|
115
|
+
registerShortcut: () => {},
|
|
116
|
+
registerCommand: (name: string, def: { handler: unknown }) => {
|
|
117
|
+
commands.push([name, def]);
|
|
118
|
+
},
|
|
119
|
+
} as never,
|
|
120
|
+
{
|
|
121
|
+
env: { GENTLE_PI_HISTORY_CAPTURE: "1" },
|
|
122
|
+
gentlePiConfigHome: fs.mkdtempSync(
|
|
123
|
+
path.join(os.tmpdir(), "pi-history-header-config-"),
|
|
124
|
+
),
|
|
125
|
+
root,
|
|
126
|
+
cwd: CWD,
|
|
127
|
+
instanceId: "inst-header",
|
|
128
|
+
agentDir: path.join(root, "agent"),
|
|
129
|
+
sessionsRoot: path.join(root, "sessions"),
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
const command = commands.find(([name]) => name === "history");
|
|
133
|
+
assert.ok(command, "the history command must be registered");
|
|
134
|
+
const handler = command[1].handler as (
|
|
135
|
+
args: unknown,
|
|
136
|
+
ctx: unknown,
|
|
137
|
+
) => Promise<void>;
|
|
138
|
+
|
|
139
|
+
let pasted: string | null = null;
|
|
140
|
+
const plain = (_color: string, text: string) => text;
|
|
141
|
+
const ctx = {
|
|
142
|
+
ui: {
|
|
143
|
+
notify: (message: string) => {
|
|
144
|
+
assert.fail(`unexpected notification: ${message}`);
|
|
145
|
+
},
|
|
146
|
+
pasteToEditor: (text: string) => {
|
|
147
|
+
pasted = text;
|
|
148
|
+
},
|
|
149
|
+
custom: (
|
|
150
|
+
factory: (
|
|
151
|
+
tui: unknown,
|
|
152
|
+
theme: unknown,
|
|
153
|
+
keybindings: unknown,
|
|
154
|
+
done: (result: unknown) => void,
|
|
155
|
+
) => Selector,
|
|
156
|
+
) =>
|
|
157
|
+
new Promise((resolve) => {
|
|
158
|
+
const selector = factory(
|
|
159
|
+
{ requestRender: () => {} },
|
|
160
|
+
{ fg: plain, bg: plain, bold: (text: string) => text },
|
|
161
|
+
undefined,
|
|
162
|
+
resolve,
|
|
163
|
+
);
|
|
164
|
+
drive(selector);
|
|
165
|
+
selector.handleInput(ENTER);
|
|
166
|
+
resolve(null);
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
await handler([], ctx);
|
|
171
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
172
|
+
return pasted;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function renderAt(width: number): Promise<string[]> {
|
|
176
|
+
let lines: string[] = [];
|
|
177
|
+
await withSelector((selector) => {
|
|
178
|
+
lines = selector.render(width);
|
|
179
|
+
});
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function wheelAt(y: number, width: number): Promise<string | null> {
|
|
184
|
+
return withSelector((selector) => {
|
|
185
|
+
selector.render(width);
|
|
186
|
+
selector.handleMouse({
|
|
187
|
+
type: "wheel",
|
|
188
|
+
wheelDelta: 1,
|
|
189
|
+
x: 1,
|
|
190
|
+
y,
|
|
191
|
+
screenX: 1,
|
|
192
|
+
screenY: y,
|
|
193
|
+
width,
|
|
194
|
+
height: 30,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
test("inline: title, counts and a right-flushed radio share one row above the hint", async () => {
|
|
200
|
+
const lines = await renderAt(INLINE_WIDTH);
|
|
201
|
+
assert.equal(lines.length, 30, "the overlay stays exactly 30 rows");
|
|
202
|
+
assert.ok(lines[1]!.startsWith(" History Search · 1 of 10 · loaded 10 of 15 "));
|
|
203
|
+
assert.ok(
|
|
204
|
+
lines[1]!.endsWith(SCOPE_RADIO_FULL_PROJECT),
|
|
205
|
+
"the radio ends flush at the header's last column",
|
|
206
|
+
);
|
|
207
|
+
assert.equal(lines[2]!.trimEnd(), HINT);
|
|
208
|
+
assert.ok(lines[5]!.startsWith("→ p14"), "the list starts at row 5");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("stacked (tablet): the radio wraps under the counts and the hint row is reclaimed", async () => {
|
|
212
|
+
const lines = await renderAt(STACKED_WIDTH);
|
|
213
|
+
assert.equal(lines.length, 30, "the overlay stays exactly 30 rows");
|
|
214
|
+
assert.equal(
|
|
215
|
+
lines[1]!.trimEnd(),
|
|
216
|
+
" History Search · 1 of 10 · loaded 10 of 15",
|
|
217
|
+
);
|
|
218
|
+
assert.equal(lines[2]!.trimEnd(), ` ${SCOPE_RADIO_FULL_PROJECT}`);
|
|
219
|
+
assert.ok(
|
|
220
|
+
!lines.some((line) => line.includes(HINT)),
|
|
221
|
+
"the hint row gives its row to the radio",
|
|
222
|
+
);
|
|
223
|
+
assert.ok(lines[5]!.startsWith("→ p14"), "the list keeps rows 5-14");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("compact (mobile): counts split across three rows and the list gives up one row", async () => {
|
|
227
|
+
const lines = await renderAt(COMPACT_WIDTH);
|
|
228
|
+
assert.equal(lines.length, 30, "the overlay stays exactly 30 rows");
|
|
229
|
+
assert.equal(lines[1]!.trimEnd(), " History Search · 1 of 10");
|
|
230
|
+
assert.equal(lines[2]!.trimEnd(), " loaded 10 of 15");
|
|
231
|
+
assert.equal(lines[3]!.trimEnd(), ` ${SCOPE_RADIO_FULL_PROJECT}`);
|
|
232
|
+
assert.ok(lines[6]!.startsWith("→ p14"), "the list starts one row lower");
|
|
233
|
+
assert.ok(
|
|
234
|
+
lines[14]!.startsWith(" p06"),
|
|
235
|
+
"compact paints nine list rows (p14..p06)",
|
|
236
|
+
);
|
|
237
|
+
assert.ok(lines[16]!.includes("Preview"), "the preview block keeps its rows");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("compact abbreviates the radio only when the full radio cannot fit", async () => {
|
|
241
|
+
const lines = await renderAt(RADIO - 1);
|
|
242
|
+
assert.equal(lines.length, 30, "the overlay stays exactly 30 rows");
|
|
243
|
+
assert.equal(lines[3]!.trimEnd(), ` ${SCOPE_RADIO_COMPACT_PROJECT}`);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("the full radio shows exactly when its row, leading space included, fits", async () => {
|
|
247
|
+
// Stacked and compact rows print the radio after one leading space, so
|
|
248
|
+
// the full radio needs RADIO + 1 columns (review advisory A5).
|
|
249
|
+
const fits = await renderAt(RADIO + 1);
|
|
250
|
+
assert.equal(fits[3], ` ${SCOPE_RADIO_FULL_PROJECT}`);
|
|
251
|
+
const tight = await renderAt(RADIO);
|
|
252
|
+
assert.equal(tight[3]!.trimEnd(), ` ${SCOPE_RADIO_COMPACT_PROJECT}`);
|
|
253
|
+
assert.ok(
|
|
254
|
+
tight.slice(1, 4).every((line) => !line.includes("…")),
|
|
255
|
+
"no header row is truncated at the boundary width",
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("the list wheel band follows the header mode", async () => {
|
|
260
|
+
// Inline: row 5 is the first list row — wheel down selects p13.
|
|
261
|
+
assert.equal(await wheelAt(5, INLINE_WIDTH), "p13");
|
|
262
|
+
// Compact: row 5 is the search border — a consumed no-op.
|
|
263
|
+
assert.equal(await wheelAt(5, COMPACT_WIDTH), "p14");
|
|
264
|
+
assert.equal(await wheelAt(6, COMPACT_WIDTH), "p13");
|
|
265
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
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
|
+
hidePrompt,
|
|
8
|
+
readHiddenPrompts,
|
|
9
|
+
tombstoneKey,
|
|
10
|
+
} from "../extensions/history/hide-prompts.ts";
|
|
11
|
+
|
|
12
|
+
// Unit WU4 — tombstone write half + read half (spec C4, design §D6; slice-05
|
|
13
|
+
// D5 recency cap). fs-only coverage. The READ half FAILS CLOSED for history:
|
|
14
|
+
// a file that exists but cannot be trusted (unreadable, corrupt, wrong
|
|
15
|
+
// shape) reads `untrusted` with a recovery warning instead of an empty
|
|
16
|
+
// tombstone set, and the WRITE half refuses without a silent rewrite. The
|
|
17
|
+
// WRITE half persists keys in RECENCY order (oldest first, newest last,
|
|
18
|
+
// never sorted) capped at HIDE_FILE_MAX_ENTRIES (1000, oldest dropped).
|
|
19
|
+
// The dev suite's deleteCurrent source-parse pins (T27/T28) and the
|
|
20
|
+
// deletionActionsFor planner pins cover the slice-3 selector branch and
|
|
21
|
+
// the slice-5 delete flow; they port with those slices.
|
|
22
|
+
|
|
23
|
+
function makeStateDir(name: string): string {
|
|
24
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), `hide-prompts-${name}-`));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readHideFile(stateDir: string) {
|
|
28
|
+
return JSON.parse(
|
|
29
|
+
fs.readFileSync(path.join(stateDir, "hidden.json"), "utf8"),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Assert an untrusted read of the expected reason; returns its message. */
|
|
34
|
+
function assertUntrusted(
|
|
35
|
+
stateDir: string,
|
|
36
|
+
reason: "unreadable" | "corrupt" | "malformed",
|
|
37
|
+
): string {
|
|
38
|
+
const read = readHiddenPrompts(stateDir);
|
|
39
|
+
assert.equal(read.status, "untrusted");
|
|
40
|
+
if (read.status !== "untrusted") throw new Error("unreachable");
|
|
41
|
+
assert.equal(read.reason, reason);
|
|
42
|
+
// The recovery warning names the file and offers restore-or-delete.
|
|
43
|
+
assert.ok(read.message.includes("hidden.json"));
|
|
44
|
+
assert.ok(/restore|delete/.test(read.message));
|
|
45
|
+
return read.message;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Assert a trusted read and return its key set. */
|
|
49
|
+
function trustedKeys(stateDir: string): Set<string> {
|
|
50
|
+
const read = readHiddenPrompts(stateDir);
|
|
51
|
+
assert.equal(read.status, "trusted");
|
|
52
|
+
if (read.status !== "trusted") throw new Error("unreachable");
|
|
53
|
+
return read.keys;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// T24 — AC-S4-1: hide-key fidelity. Tombstone keys must byte-match the
|
|
57
|
+
// shared tombstoneKey helper for the same text — never a re-implementation:
|
|
58
|
+
// the stored file content is compared against tombstoneKey's own output
|
|
59
|
+
// with strict equality.
|
|
60
|
+
test("T24 (AC-S4-1): hide keys byte-match tombstoneKey across whitespace, case, and >120-char groups", () => {
|
|
61
|
+
const stateDir = makeStateDir("t24");
|
|
62
|
+
// Three normalization groups: internal whitespace runs (space + tab),
|
|
63
|
+
// letter case, and a text longer than the 120-char key prefix.
|
|
64
|
+
const texts = [
|
|
65
|
+
"fix\t the build",
|
|
66
|
+
"Deploy THE api",
|
|
67
|
+
`${"pad ".repeat(40)}tail beyond one hundred twenty chars`,
|
|
68
|
+
];
|
|
69
|
+
for (const text of texts) {
|
|
70
|
+
assert.deepEqual(hidePrompt(stateDir, text), { status: "written" });
|
|
71
|
+
}
|
|
72
|
+
const stored = readHideFile(stateDir);
|
|
73
|
+
assert.ok(Array.isArray(stored), "hidden.json must hold a JSON array");
|
|
74
|
+
// Byte-match: the file holds EXACTLY the shared helper's output, in
|
|
75
|
+
// insertion (recency) order — the write half never sorts.
|
|
76
|
+
assert.deepEqual(stored, texts.map((text) => tombstoneKey(text)));
|
|
77
|
+
// The read half agrees.
|
|
78
|
+
const keys = trustedKeys(stateDir);
|
|
79
|
+
assert.equal(keys.size, stored.length);
|
|
80
|
+
for (const key of stored) {
|
|
81
|
+
assert.ok(keys.has(key));
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// T25 — AC-S4-2: hide persistence and tolerance. Two deletes of the same
|
|
86
|
+
// text compact to ONE key; a MISSING hide file (before any deletion) is the
|
|
87
|
+
// safe empty case — trusted with no keys; reads never throw.
|
|
88
|
+
test("T25 (AC-S4-2): duplicate hides compact to one key; a missing file reads trusted-empty; reads never throw", () => {
|
|
89
|
+
const stateDir = makeStateDir("t25");
|
|
90
|
+
// Missing file: trusted empty tombstones (before any write exists).
|
|
91
|
+
assert.equal(trustedKeys(stateDir).size, 0);
|
|
92
|
+
// Two deletes of the same text — variants differing by case + whitespace
|
|
93
|
+
// runs normalize onto the same key.
|
|
94
|
+
assert.deepEqual(hidePrompt(stateDir, "Same Text"), { status: "written" });
|
|
95
|
+
assert.deepEqual(hidePrompt(stateDir, "same text"), { status: "written" });
|
|
96
|
+
const stored = readHideFile(stateDir);
|
|
97
|
+
assert.deepEqual(stored, [tombstoneKey("same text")]);
|
|
98
|
+
const keys = trustedKeys(stateDir);
|
|
99
|
+
assert.equal(keys.size, 1);
|
|
100
|
+
assert.ok(keys.has(tombstoneKey("same text")));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// D5 — recency order: re-hiding an existing key REFRESHES it to the end
|
|
104
|
+
// (newest); the file array is oldest-first, newest-appended-last — the
|
|
105
|
+
// write half never sorts.
|
|
106
|
+
test("re-hiding an existing key refreshes it to the end (recency order, no sort)", () => {
|
|
107
|
+
const stateDir = makeStateDir("recency");
|
|
108
|
+
const keysOf = (texts: string[]) => texts.map((text) => tombstoneKey(text));
|
|
109
|
+
for (const text of ["alpha prompt", "beta prompt", "gamma prompt"]) {
|
|
110
|
+
assert.deepEqual(hidePrompt(stateDir, text), { status: "written" });
|
|
111
|
+
}
|
|
112
|
+
assert.deepEqual(readHideFile(stateDir), keysOf([
|
|
113
|
+
"alpha prompt",
|
|
114
|
+
"beta prompt",
|
|
115
|
+
"gamma prompt",
|
|
116
|
+
]));
|
|
117
|
+
// Re-hide the oldest key: it moves to the END; the others keep order.
|
|
118
|
+
assert.deepEqual(hidePrompt(stateDir, "alpha prompt"), {
|
|
119
|
+
status: "written",
|
|
120
|
+
});
|
|
121
|
+
assert.deepEqual(readHideFile(stateDir), keysOf([
|
|
122
|
+
"beta prompt",
|
|
123
|
+
"gamma prompt",
|
|
124
|
+
"alpha prompt",
|
|
125
|
+
]));
|
|
126
|
+
assert.equal(trustedKeys(stateDir).size, 3);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// D5 — cap: hidden.json keeps at most 1000 keys in recency order; the
|
|
130
|
+
// 1001st distinct prompt drops the OLDEST key from the front. The file is
|
|
131
|
+
// a rebuildable cache, not a retention guarantee — a dropped prompt may
|
|
132
|
+
// reappear and be deleted again.
|
|
133
|
+
test("the 1001st distinct prompt drops the oldest key — the file keeps exactly 1000", () => {
|
|
134
|
+
const stateDir = makeStateDir("cap");
|
|
135
|
+
const oldest = "oldest prompt";
|
|
136
|
+
assert.deepEqual(hidePrompt(stateDir, oldest), { status: "written" });
|
|
137
|
+
for (let i = 1; i <= 999; i++) {
|
|
138
|
+
assert.deepEqual(hidePrompt(stateDir, `prompt number ${i}`), {
|
|
139
|
+
status: "written",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// Exactly at the cap: 1000 keys, oldest first, newest last.
|
|
143
|
+
const atCap = readHideFile(stateDir);
|
|
144
|
+
assert.equal(atCap.length, 1000);
|
|
145
|
+
assert.equal(atCap[0], tombstoneKey(oldest));
|
|
146
|
+
assert.equal(atCap[atCap.length - 1], tombstoneKey("prompt number 999"));
|
|
147
|
+
// The 1001st distinct prompt: the front (oldest) drops, the newest lands.
|
|
148
|
+
assert.deepEqual(hidePrompt(stateDir, "prompt number 1000"), {
|
|
149
|
+
status: "written",
|
|
150
|
+
});
|
|
151
|
+
const after = readHideFile(stateDir);
|
|
152
|
+
assert.equal(after.length, 1000, "the cap holds the file at exactly 1000");
|
|
153
|
+
assert.equal(
|
|
154
|
+
after.includes(tombstoneKey(oldest)),
|
|
155
|
+
false,
|
|
156
|
+
"the oldest key must be dropped from the front",
|
|
157
|
+
);
|
|
158
|
+
assert.equal(
|
|
159
|
+
after[after.length - 1],
|
|
160
|
+
tombstoneKey("prompt number 1000"),
|
|
161
|
+
"the newest key must sit at the end",
|
|
162
|
+
);
|
|
163
|
+
// The read half agrees with the capped file.
|
|
164
|
+
assert.equal(trustedKeys(stateDir).size, 1000);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// T26 — AC-S4-5: corrupt hidden.json FAILS CLOSED for history reads. The
|
|
168
|
+
// READ half reports untrusted (corrupt) so callers block history instead of
|
|
169
|
+
// resurfacing hidden prompts; the WRITE half refuses WITHOUT a silent clean
|
|
170
|
+
// rewrite (the old fail-open behavior cleared the blocked state one hide
|
|
171
|
+
// later). Recovery is manual — restore or delete the file; after deletion
|
|
172
|
+
// the next hide succeeds and reads are trusted again.
|
|
173
|
+
test("T26 (AC-S4-5): corrupt hidden.json reads untrusted; hide refuses without rewriting; deleting the file recovers", () => {
|
|
174
|
+
const stateDir = makeStateDir("t26");
|
|
175
|
+
const hidePath = path.join(stateDir, "hidden.json");
|
|
176
|
+
fs.writeFileSync(hidePath, "{corrupt bytes", "utf8");
|
|
177
|
+
// READ half: untrusted/corrupt — never an empty trusted set.
|
|
178
|
+
const message = assertUntrusted(stateDir, "corrupt");
|
|
179
|
+
// WRITE half: refused, and the corrupt bytes are UNCHANGED — the blocked
|
|
180
|
+
// state is never silently reset (no-silent-rewrite pin).
|
|
181
|
+
const before = fs.readFileSync(hidePath, "utf8");
|
|
182
|
+
assert.deepEqual(hidePrompt(stateDir, "beta prompt"), {
|
|
183
|
+
status: "error",
|
|
184
|
+
message,
|
|
185
|
+
});
|
|
186
|
+
assert.equal(fs.readFileSync(hidePath, "utf8"), before);
|
|
187
|
+
// Manual recovery: delete the file; the next hide succeeds and reads are
|
|
188
|
+
// trusted with exactly the new key.
|
|
189
|
+
fs.unlinkSync(hidePath);
|
|
190
|
+
assert.deepEqual(hidePrompt(stateDir, "beta prompt"), { status: "written" });
|
|
191
|
+
const keys = trustedKeys(stateDir);
|
|
192
|
+
assert.equal(keys.size, 1);
|
|
193
|
+
assert.ok(keys.has(tombstoneKey("beta prompt")));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Wrong-shaped file: valid JSON that is not an array fails closed too (both
|
|
197
|
+
// halves), while junk items inside a VALID array are ignored and the real
|
|
198
|
+
// keys stay trusted.
|
|
199
|
+
test("malformed hidden.json fails closed for reads and writes; junk items in a valid array are ignored", () => {
|
|
200
|
+
const malformed = makeStateDir("malformed");
|
|
201
|
+
const hidePath = path.join(malformed, "hidden.json");
|
|
202
|
+
for (const shape of ["{}", JSON.stringify({ keys: [] })]) {
|
|
203
|
+
fs.writeFileSync(hidePath, shape, "utf8");
|
|
204
|
+
assertUntrusted(malformed, "malformed");
|
|
205
|
+
}
|
|
206
|
+
// The write half refuses the malformed file as well.
|
|
207
|
+
const message = assertUntrusted(malformed, "malformed");
|
|
208
|
+
assert.deepEqual(hidePrompt(malformed, "kept prompt"), {
|
|
209
|
+
status: "error",
|
|
210
|
+
message,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Junk items are ignored, never trusted; real keys survive.
|
|
214
|
+
const junk = makeStateDir("junk");
|
|
215
|
+
fs.writeFileSync(
|
|
216
|
+
path.join(junk, "hidden.json"),
|
|
217
|
+
JSON.stringify([42, "", "real-key", null]),
|
|
218
|
+
"utf8",
|
|
219
|
+
);
|
|
220
|
+
const keys = trustedKeys(junk);
|
|
221
|
+
assert.equal(keys.size, 1);
|
|
222
|
+
assert.ok(keys.has("real-key"));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Unreadable file: a hidden.json that cannot be read at all fails closed
|
|
226
|
+
// for reads, and the write half refuses too. Skipped as root, where chmod
|
|
227
|
+
// 000 does not block reads; permissions are restored in finally.
|
|
228
|
+
test(
|
|
229
|
+
"an unreadable hidden.json fails closed for reads and refuses writes",
|
|
230
|
+
{ skip: process.getuid?.() === 0 },
|
|
231
|
+
() => {
|
|
232
|
+
const stateDir = makeStateDir("sealed");
|
|
233
|
+
const hidePath = path.join(stateDir, "hidden.json");
|
|
234
|
+
fs.writeFileSync(hidePath, '["kept-key"]', "utf8");
|
|
235
|
+
fs.chmodSync(hidePath, 0o000);
|
|
236
|
+
try {
|
|
237
|
+
const message = assertUntrusted(stateDir, "unreadable");
|
|
238
|
+
assert.deepEqual(hidePrompt(stateDir, "beta prompt"), {
|
|
239
|
+
status: "error",
|
|
240
|
+
message,
|
|
241
|
+
});
|
|
242
|
+
} finally {
|
|
243
|
+
fs.chmodSync(hidePath, 0o644); // restore before cleanup
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// WU4c — write-failure path (AC-S4-2 triangulation): a trusted read whose
|
|
249
|
+
// atomic write fails makes hidePrompt return the toast-suitable error
|
|
250
|
+
// object — never a throw. The state dir is made non-writable while the
|
|
251
|
+
// existing hidden.json stays readable (a state dir whose PATH is blocked
|
|
252
|
+
// by a regular file is now the untrusted-refusal case instead — the READ
|
|
253
|
+
// half fails closed before any write). Skipped as root, where chmod-based
|
|
254
|
+
// write blocking does not apply; permissions are restored in finally.
|
|
255
|
+
test(
|
|
256
|
+
"hide write failure returns the exact error shape for the delete-flow toast",
|
|
257
|
+
{ skip: process.getuid?.() === 0 },
|
|
258
|
+
() => {
|
|
259
|
+
const stateDir = makeStateDir("fail");
|
|
260
|
+
fs.writeFileSync(
|
|
261
|
+
path.join(stateDir, "hidden.json"),
|
|
262
|
+
'["kept-key"]',
|
|
263
|
+
"utf8",
|
|
264
|
+
);
|
|
265
|
+
fs.chmodSync(stateDir, 0o555); // read+execute, no write → EACCES on tmp
|
|
266
|
+
try {
|
|
267
|
+
assert.deepEqual(hidePrompt(stateDir, "kept prompt"), {
|
|
268
|
+
status: "error",
|
|
269
|
+
message: "Could not write the hide file; the prompt may reappear.",
|
|
270
|
+
});
|
|
271
|
+
} finally {
|
|
272
|
+
fs.chmodSync(stateDir, 0o700); // restore before cleanup
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
);
|