@ilya-lesikov/pi-pi 0.1.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/3p/pi-ask-user/index.ts +1835 -0
- package/3p/pi-ask-user/package.json +50 -0
- package/3p/pi-ask-user/single-select-layout.ts +203 -0
- package/3p/pi-lsp/extensions/lsp/client.ts +542 -0
- package/3p/pi-lsp/extensions/lsp/config.ts +135 -0
- package/3p/pi-lsp/extensions/lsp/effects/command.ts +42 -0
- package/3p/pi-lsp/extensions/lsp/effects/filesystem.ts +50 -0
- package/3p/pi-lsp/extensions/lsp/effects/runtime.ts +21 -0
- package/3p/pi-lsp/extensions/lsp/errors.ts +113 -0
- package/3p/pi-lsp/extensions/lsp/formatting.ts +315 -0
- package/3p/pi-lsp/extensions/lsp/index.ts +231 -0
- package/3p/pi-lsp/extensions/lsp/protocol.ts +209 -0
- package/3p/pi-lsp/extensions/lsp/retry.ts +39 -0
- package/3p/pi-lsp/extensions/lsp/tools/programs.ts +293 -0
- package/3p/pi-lsp/extensions/lsp/tools.ts +89 -0
- package/3p/pi-lsp/extensions/lsp/types.ts +243 -0
- package/3p/pi-lsp/package.json +54 -0
- package/3p/pi-plannotator/AGENTS.md +583 -0
- package/3p/pi-plannotator/apps/pi-extension/README.md +228 -0
- package/3p/pi-plannotator/apps/pi-extension/assistant-message.ts +128 -0
- package/3p/pi-plannotator/apps/pi-extension/config.test.ts +166 -0
- package/3p/pi-plannotator/apps/pi-extension/config.ts +318 -0
- package/3p/pi-plannotator/apps/pi-extension/current-pi-session.ts +147 -0
- package/3p/pi-plannotator/apps/pi-extension/index.ts +1279 -0
- package/3p/pi-plannotator/apps/pi-extension/package.json +56 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.test.ts +13 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +616 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +342 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator.json +12 -0
- package/3p/pi-plannotator/apps/pi-extension/server/agent-jobs.ts +515 -0
- package/3p/pi-plannotator/apps/pi-extension/server/ai-runtime.ts +169 -0
- package/3p/pi-plannotator/apps/pi-extension/server/annotations.ts +85 -0
- package/3p/pi-plannotator/apps/pi-extension/server/external-annotations.ts +189 -0
- package/3p/pi-plannotator/apps/pi-extension/server/handlers.ts +210 -0
- package/3p/pi-plannotator/apps/pi-extension/server/helpers.ts +78 -0
- package/3p/pi-plannotator/apps/pi-extension/server/ide.ts +46 -0
- package/3p/pi-plannotator/apps/pi-extension/server/integrations.ts +195 -0
- package/3p/pi-plannotator/apps/pi-extension/server/network.test.ts +158 -0
- package/3p/pi-plannotator/apps/pi-extension/server/network.ts +268 -0
- package/3p/pi-plannotator/apps/pi-extension/server/pr.ts +126 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +64 -0
- package/3p/pi-plannotator/apps/pi-extension/server/reference.ts +362 -0
- package/3p/pi-plannotator/apps/pi-extension/server/serverAnnotate.ts +199 -0
- package/3p/pi-plannotator/apps/pi-extension/server/serverPlan.ts +505 -0
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +1229 -0
- package/3p/pi-plannotator/apps/pi-extension/server/vcs.ts +121 -0
- package/3p/pi-plannotator/apps/pi-extension/server.test.ts +966 -0
- package/3p/pi-plannotator/apps/pi-extension/server.ts +44 -0
- package/3p/pi-plannotator/apps/pi-extension/tool-scope.test.ts +88 -0
- package/3p/pi-plannotator/apps/pi-extension/tool-scope.ts +39 -0
- package/3p/pi-plannotator/apps/pi-extension/tsconfig.json +16 -0
- package/3p/pi-plannotator/apps/pi-extension/vendor.sh +48 -0
- package/3p/pi-plannotator/package.json +53 -0
- package/3p/pi-plannotator/packages/ai/ai.test.ts +1407 -0
- package/3p/pi-plannotator/packages/ai/base-session.ts +94 -0
- package/3p/pi-plannotator/packages/ai/context.ts +250 -0
- package/3p/pi-plannotator/packages/ai/endpoints.ts +326 -0
- package/3p/pi-plannotator/packages/ai/index.ts +105 -0
- package/3p/pi-plannotator/packages/ai/package.json +21 -0
- package/3p/pi-plannotator/packages/ai/provider.ts +103 -0
- package/3p/pi-plannotator/packages/ai/providers/claude-agent-sdk.ts +447 -0
- package/3p/pi-plannotator/packages/ai/providers/codex-sdk.ts +430 -0
- package/3p/pi-plannotator/packages/ai/providers/command-path.ts +115 -0
- package/3p/pi-plannotator/packages/ai/providers/opencode-sdk.ts +546 -0
- package/3p/pi-plannotator/packages/ai/providers/pi-events.ts +110 -0
- package/3p/pi-plannotator/packages/ai/providers/pi-sdk-node.ts +425 -0
- package/3p/pi-plannotator/packages/ai/providers/pi-sdk.ts +469 -0
- package/3p/pi-plannotator/packages/ai/session-manager.ts +195 -0
- package/3p/pi-plannotator/packages/ai/tsconfig.json +15 -0
- package/3p/pi-plannotator/packages/ai/types.ts +379 -0
- package/3p/pi-plannotator/packages/server/agent-jobs.ts +538 -0
- package/3p/pi-plannotator/packages/server/agent-review-message.test.ts +135 -0
- package/3p/pi-plannotator/packages/server/agent-review-message.ts +243 -0
- package/3p/pi-plannotator/packages/server/ai-runtime.ts +108 -0
- package/3p/pi-plannotator/packages/server/annotate.ts +401 -0
- package/3p/pi-plannotator/packages/server/browser.test.ts +87 -0
- package/3p/pi-plannotator/packages/server/browser.ts +245 -0
- package/3p/pi-plannotator/packages/server/claude-review.ts +352 -0
- package/3p/pi-plannotator/packages/server/code-nav.ts +73 -0
- package/3p/pi-plannotator/packages/server/codex-review-schema.json +41 -0
- package/3p/pi-plannotator/packages/server/codex-review.ts +330 -0
- package/3p/pi-plannotator/packages/server/config.ts +8 -0
- package/3p/pi-plannotator/packages/server/draft.ts +1 -0
- package/3p/pi-plannotator/packages/server/editor-annotations.ts +76 -0
- package/3p/pi-plannotator/packages/server/external-annotations.test.ts +18 -0
- package/3p/pi-plannotator/packages/server/external-annotations.ts +207 -0
- package/3p/pi-plannotator/packages/server/git.ts +141 -0
- package/3p/pi-plannotator/packages/server/goal-setup.test.ts +55 -0
- package/3p/pi-plannotator/packages/server/goal-setup.ts +248 -0
- package/3p/pi-plannotator/packages/server/ide.ts +43 -0
- package/3p/pi-plannotator/packages/server/image.test.ts +63 -0
- package/3p/pi-plannotator/packages/server/image.ts +66 -0
- package/3p/pi-plannotator/packages/server/index.ts +648 -0
- package/3p/pi-plannotator/packages/server/integrations.test.ts +172 -0
- package/3p/pi-plannotator/packages/server/integrations.ts +214 -0
- package/3p/pi-plannotator/packages/server/jj.test.ts +69 -0
- package/3p/pi-plannotator/packages/server/jj.ts +86 -0
- package/3p/pi-plannotator/packages/server/p4.ts +417 -0
- package/3p/pi-plannotator/packages/server/package.json +40 -0
- package/3p/pi-plannotator/packages/server/path-utils.ts +18 -0
- package/3p/pi-plannotator/packages/server/pr.ts +146 -0
- package/3p/pi-plannotator/packages/server/project.test.ts +115 -0
- package/3p/pi-plannotator/packages/server/project.ts +45 -0
- package/3p/pi-plannotator/packages/server/reference-handlers.ts +404 -0
- package/3p/pi-plannotator/packages/server/remote.test.ts +150 -0
- package/3p/pi-plannotator/packages/server/remote.ts +74 -0
- package/3p/pi-plannotator/packages/server/repo.ts +80 -0
- package/3p/pi-plannotator/packages/server/resolve-file.test.ts +310 -0
- package/3p/pi-plannotator/packages/server/review-workspace.test.ts +1032 -0
- package/3p/pi-plannotator/packages/server/review-workspace.ts +48 -0
- package/3p/pi-plannotator/packages/server/review.ts +1318 -0
- package/3p/pi-plannotator/packages/server/sessions.ts +111 -0
- package/3p/pi-plannotator/packages/server/share-url.ts +53 -0
- package/3p/pi-plannotator/packages/server/shared-handlers.test.ts +43 -0
- package/3p/pi-plannotator/packages/server/shared-handlers.ts +177 -0
- package/3p/pi-plannotator/packages/server/storage.test.ts +176 -0
- package/3p/pi-plannotator/packages/server/storage.ts +17 -0
- package/3p/pi-plannotator/packages/server/tour/tour-review.test.ts +146 -0
- package/3p/pi-plannotator/packages/server/tour/tour-review.ts +604 -0
- package/3p/pi-plannotator/packages/server/tsconfig.json +15 -0
- package/3p/pi-plannotator/packages/server/vcs.test.ts +48 -0
- package/3p/pi-plannotator/packages/server/vcs.ts +80 -0
- package/3p/pi-plannotator/packages/shared/agent-jobs.ts +132 -0
- package/3p/pi-plannotator/packages/shared/agents.ts +53 -0
- package/3p/pi-plannotator/packages/shared/annotate-args.test.ts +386 -0
- package/3p/pi-plannotator/packages/shared/annotate-args.ts +107 -0
- package/3p/pi-plannotator/packages/shared/at-reference.test.ts +99 -0
- package/3p/pi-plannotator/packages/shared/at-reference.ts +52 -0
- package/3p/pi-plannotator/packages/shared/checklist.ts +52 -0
- package/3p/pi-plannotator/packages/shared/code-file.test.ts +112 -0
- package/3p/pi-plannotator/packages/shared/code-file.ts +41 -0
- package/3p/pi-plannotator/packages/shared/code-nav.test.ts +515 -0
- package/3p/pi-plannotator/packages/shared/code-nav.ts +436 -0
- package/3p/pi-plannotator/packages/shared/compress.ts +51 -0
- package/3p/pi-plannotator/packages/shared/config.ts +262 -0
- package/3p/pi-plannotator/packages/shared/crypto.test.ts +172 -0
- package/3p/pi-plannotator/packages/shared/crypto.ts +97 -0
- package/3p/pi-plannotator/packages/shared/data-dir.ts +42 -0
- package/3p/pi-plannotator/packages/shared/diff-paths.test.ts +30 -0
- package/3p/pi-plannotator/packages/shared/diff-paths.ts +137 -0
- package/3p/pi-plannotator/packages/shared/draft.ts +64 -0
- package/3p/pi-plannotator/packages/shared/external-annotation.ts +397 -0
- package/3p/pi-plannotator/packages/shared/extract-code-paths.test.ts +59 -0
- package/3p/pi-plannotator/packages/shared/extract-code-paths.ts +66 -0
- package/3p/pi-plannotator/packages/shared/favicon.ts +5 -0
- package/3p/pi-plannotator/packages/shared/feedback-templates.test.ts +65 -0
- package/3p/pi-plannotator/packages/shared/feedback-templates.ts +29 -0
- package/3p/pi-plannotator/packages/shared/goal-setup.test.ts +231 -0
- package/3p/pi-plannotator/packages/shared/goal-setup.ts +336 -0
- package/3p/pi-plannotator/packages/shared/html-to-markdown.test.ts +62 -0
- package/3p/pi-plannotator/packages/shared/html-to-markdown.ts +32 -0
- package/3p/pi-plannotator/packages/shared/improvement-hooks.test.ts +135 -0
- package/3p/pi-plannotator/packages/shared/improvement-hooks.ts +115 -0
- package/3p/pi-plannotator/packages/shared/integrations-common.ts +243 -0
- package/3p/pi-plannotator/packages/shared/jj-core.test.ts +236 -0
- package/3p/pi-plannotator/packages/shared/jj-core.ts +433 -0
- package/3p/pi-plannotator/packages/shared/package.json +55 -0
- package/3p/pi-plannotator/packages/shared/pfm-reminder.test.ts +88 -0
- package/3p/pi-plannotator/packages/shared/pfm-reminder.ts +80 -0
- package/3p/pi-plannotator/packages/shared/pr-github.ts +661 -0
- package/3p/pi-plannotator/packages/shared/pr-gitlab.test.ts +202 -0
- package/3p/pi-plannotator/packages/shared/pr-gitlab.ts +620 -0
- package/3p/pi-plannotator/packages/shared/pr-provider.test.ts +266 -0
- package/3p/pi-plannotator/packages/shared/pr-provider.ts +123 -0
- package/3p/pi-plannotator/packages/shared/pr-stack.test.ts +104 -0
- package/3p/pi-plannotator/packages/shared/pr-stack.ts +194 -0
- package/3p/pi-plannotator/packages/shared/pr-types.ts +326 -0
- package/3p/pi-plannotator/packages/shared/project.ts +71 -0
- package/3p/pi-plannotator/packages/shared/prompts-integration.test.ts +421 -0
- package/3p/pi-plannotator/packages/shared/prompts.test.ts +504 -0
- package/3p/pi-plannotator/packages/shared/prompts.ts +247 -0
- package/3p/pi-plannotator/packages/shared/reference-common.ts +87 -0
- package/3p/pi-plannotator/packages/shared/repo.ts +71 -0
- package/3p/pi-plannotator/packages/shared/resolve-file.test.ts +113 -0
- package/3p/pi-plannotator/packages/shared/resolve-file.ts +509 -0
- package/3p/pi-plannotator/packages/shared/review-args.test.ts +64 -0
- package/3p/pi-plannotator/packages/shared/review-args.ts +85 -0
- package/3p/pi-plannotator/packages/shared/review-core.test.ts +286 -0
- package/3p/pi-plannotator/packages/shared/review-core.ts +895 -0
- package/3p/pi-plannotator/packages/shared/review-workspace-node.ts +230 -0
- package/3p/pi-plannotator/packages/shared/review-workspace.ts +436 -0
- package/3p/pi-plannotator/packages/shared/semantic-diff-types.ts +76 -0
- package/3p/pi-plannotator/packages/shared/semantic-diff.test.ts +322 -0
- package/3p/pi-plannotator/packages/shared/semantic-diff.ts +520 -0
- package/3p/pi-plannotator/packages/shared/storage.ts +378 -0
- package/3p/pi-plannotator/packages/shared/tour.ts +61 -0
- package/3p/pi-plannotator/packages/shared/tsconfig.json +15 -0
- package/3p/pi-plannotator/packages/shared/types.ts +29 -0
- package/3p/pi-plannotator/packages/shared/url-to-markdown.test.ts +177 -0
- package/3p/pi-plannotator/packages/shared/url-to-markdown.ts +351 -0
- package/3p/pi-plannotator/packages/shared/vcs-core.test.ts +332 -0
- package/3p/pi-plannotator/packages/shared/vcs-core.ts +482 -0
- package/3p/pi-plannotator/packages/shared/worktree-pool.test.ts +162 -0
- package/3p/pi-plannotator/packages/shared/worktree-pool.ts +103 -0
- package/3p/pi-plannotator/packages/shared/worktree.ts +119 -0
- package/3p/pi-subagents/package.json +50 -0
- package/3p/pi-subagents/src/agent-manager.ts +413 -0
- package/3p/pi-subagents/src/agent-runner.ts +502 -0
- package/3p/pi-subagents/src/agent-types.ts +248 -0
- package/3p/pi-subagents/src/context.ts +58 -0
- package/3p/pi-subagents/src/cross-extension-rpc.ts +135 -0
- package/3p/pi-subagents/src/custom-agents.ts +137 -0
- package/3p/pi-subagents/src/default-agents.ts +144 -0
- package/3p/pi-subagents/src/env.ts +33 -0
- package/3p/pi-subagents/src/group-join.ts +141 -0
- package/3p/pi-subagents/src/index.ts +1811 -0
- package/3p/pi-subagents/src/invocation-config.ts +40 -0
- package/3p/pi-subagents/src/memory.ts +165 -0
- package/3p/pi-subagents/src/model-resolver.ts +81 -0
- package/3p/pi-subagents/src/output-file.ts +77 -0
- package/3p/pi-subagents/src/prompts.ts +85 -0
- package/3p/pi-subagents/src/skill-loader.ts +79 -0
- package/3p/pi-subagents/src/types.ts +111 -0
- package/3p/pi-subagents/src/ui/agent-widget.ts +496 -0
- package/3p/pi-subagents/src/ui/conversation-viewer.ts +250 -0
- package/3p/pi-subagents/src/worktree.ts +162 -0
- package/3p/pi-tasks/package.json +52 -0
- package/3p/pi-tasks/src/auto-clear.ts +91 -0
- package/3p/pi-tasks/src/index.ts +1150 -0
- package/3p/pi-tasks/src/process-tracker.ts +140 -0
- package/3p/pi-tasks/src/reminder-cadence.ts +90 -0
- package/3p/pi-tasks/src/task-store.ts +324 -0
- package/3p/pi-tasks/src/tasks-config.ts +27 -0
- package/3p/pi-tasks/src/types.ts +40 -0
- package/3p/pi-tasks/src/ui/settings-menu.ts +152 -0
- package/3p/pi-tasks/src/ui/task-widget.ts +296 -0
- package/AGENTS.md +28 -0
- package/LICENSE +201 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +77 -0
- package/extensions/orchestrator/agents/code-reviewer.ts +89 -0
- package/extensions/orchestrator/agents/explore.ts +33 -0
- package/extensions/orchestrator/agents/librarian.ts +43 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +75 -0
- package/extensions/orchestrator/agents/planner.ts +61 -0
- package/extensions/orchestrator/agents/registry.ts +156 -0
- package/extensions/orchestrator/agents/task.ts +50 -0
- package/extensions/orchestrator/agents/tool-routing.ts +84 -0
- package/extensions/orchestrator/ast-search.ts +85 -0
- package/extensions/orchestrator/cbm.ts +330 -0
- package/extensions/orchestrator/command-handlers.test.ts +163 -0
- package/extensions/orchestrator/command-handlers.ts +116 -0
- package/extensions/orchestrator/commands.test.ts +81 -0
- package/extensions/orchestrator/commands.ts +75 -0
- package/extensions/orchestrator/config.test.ts +221 -0
- package/extensions/orchestrator/config.ts +230 -0
- package/extensions/orchestrator/context.test.ts +293 -0
- package/extensions/orchestrator/context.ts +206 -0
- package/extensions/orchestrator/event-handlers.test.ts +190 -0
- package/extensions/orchestrator/event-handlers.ts +1416 -0
- package/extensions/orchestrator/exa.ts +104 -0
- package/extensions/orchestrator/flant-infra.ts +486 -0
- package/extensions/orchestrator/index.ts +80 -0
- package/extensions/orchestrator/integration.test.ts +1214 -0
- package/extensions/orchestrator/orchestrator.test.ts +252 -0
- package/extensions/orchestrator/orchestrator.ts +538 -0
- package/extensions/orchestrator/phases/brainstorm.test.ts +15 -0
- package/extensions/orchestrator/phases/brainstorm.ts +273 -0
- package/extensions/orchestrator/phases/implementation.ts +40 -0
- package/extensions/orchestrator/phases/machine.test.ts +293 -0
- package/extensions/orchestrator/phases/machine.ts +209 -0
- package/extensions/orchestrator/phases/planning.ts +255 -0
- package/extensions/orchestrator/phases/review.ts +193 -0
- package/extensions/orchestrator/plannotator.ts +56 -0
- package/extensions/orchestrator/pp-menu.ts +866 -0
- package/extensions/orchestrator/state.test.ts +343 -0
- package/extensions/orchestrator/state.ts +237 -0
- package/extensions/orchestrator/validate-artifacts.test.ts +88 -0
- package/extensions/orchestrator/validate-artifacts.ts +272 -0
- package/extensions/orchestrator/vendor.d.ts +26 -0
- package/package.json +73 -0
- package/scripts/postinstall.sh +18 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
export type GoalSetupStage = "interview" | "facts";
|
|
2
|
+
|
|
3
|
+
export type GoalSetupAnswerMode =
|
|
4
|
+
| "text"
|
|
5
|
+
| "single"
|
|
6
|
+
| "multi"
|
|
7
|
+
| "single-custom"
|
|
8
|
+
| "multi-custom"
|
|
9
|
+
| "custom";
|
|
10
|
+
|
|
11
|
+
export interface GoalSetupQuestionOption {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GoalSetupQuestion {
|
|
18
|
+
id: string;
|
|
19
|
+
prompt: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
answerMode?: GoalSetupAnswerMode;
|
|
22
|
+
recommendedAnswer?: string;
|
|
23
|
+
recommendedOptionIds?: string[];
|
|
24
|
+
options?: GoalSetupQuestionOption[];
|
|
25
|
+
required?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GoalSetupQuestionAnswer {
|
|
29
|
+
questionId: string;
|
|
30
|
+
selectedOptionIds: string[];
|
|
31
|
+
customAnswer: string;
|
|
32
|
+
note?: string;
|
|
33
|
+
answer: string;
|
|
34
|
+
completed: boolean;
|
|
35
|
+
skipped?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GoalSetupInterviewBundle {
|
|
39
|
+
stage: "interview";
|
|
40
|
+
title?: string;
|
|
41
|
+
goalSlug?: string;
|
|
42
|
+
questions: GoalSetupQuestion[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GoalSetupFact {
|
|
46
|
+
id: string;
|
|
47
|
+
text: string;
|
|
48
|
+
accepted: boolean;
|
|
49
|
+
removed: boolean;
|
|
50
|
+
comment?: string;
|
|
51
|
+
recommendedAutomatedVerification?: boolean;
|
|
52
|
+
automatedVerification: boolean;
|
|
53
|
+
previousText?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface GoalSetupFactsBundle {
|
|
57
|
+
stage: "facts";
|
|
58
|
+
title?: string;
|
|
59
|
+
goalSlug?: string;
|
|
60
|
+
facts: GoalSetupFact[];
|
|
61
|
+
showAccepted?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type GoalSetupBundle = GoalSetupInterviewBundle | GoalSetupFactsBundle;
|
|
65
|
+
|
|
66
|
+
export interface GoalSetupInterviewResult {
|
|
67
|
+
stage: "interview";
|
|
68
|
+
title?: string;
|
|
69
|
+
goalSlug?: string;
|
|
70
|
+
answers: GoalSetupQuestionAnswer[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface GoalSetupFactResult {
|
|
74
|
+
id: string;
|
|
75
|
+
text: string;
|
|
76
|
+
accepted: boolean;
|
|
77
|
+
removed: boolean;
|
|
78
|
+
comment?: string;
|
|
79
|
+
automatedVerification: boolean;
|
|
80
|
+
recommendedAutomatedVerification?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface GoalSetupFactsResult {
|
|
84
|
+
stage: "facts";
|
|
85
|
+
title?: string;
|
|
86
|
+
goalSlug?: string;
|
|
87
|
+
facts: GoalSetupFactResult[];
|
|
88
|
+
factsMarkdown: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type GoalSetupResult = GoalSetupInterviewResult | GoalSetupFactsResult;
|
|
92
|
+
|
|
93
|
+
function asRecord(value: unknown, context: string): Record<string, unknown> {
|
|
94
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
95
|
+
throw new Error(`${context} must be an object`);
|
|
96
|
+
}
|
|
97
|
+
return value as Record<string, unknown>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function asString(value: unknown, fallback = ""): string {
|
|
101
|
+
return typeof value === "string" ? value : fallback;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function asBoolean(value: unknown, fallback: boolean): boolean {
|
|
105
|
+
return typeof value === "boolean" ? value : fallback;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeId(value: unknown, fallback: string): string {
|
|
109
|
+
const raw = asString(value, fallback).trim();
|
|
110
|
+
return raw || fallback;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeAnswerMode(value: unknown): GoalSetupAnswerMode {
|
|
114
|
+
switch (value) {
|
|
115
|
+
case "single":
|
|
116
|
+
case "multi":
|
|
117
|
+
case "single-custom":
|
|
118
|
+
case "multi-custom":
|
|
119
|
+
case "custom":
|
|
120
|
+
case "text":
|
|
121
|
+
return value;
|
|
122
|
+
default:
|
|
123
|
+
return "text";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeOption(value: unknown, index: number): GoalSetupQuestionOption {
|
|
128
|
+
const item = asRecord(value, `questions[].options[${index}]`);
|
|
129
|
+
const label = asString(item.label).trim();
|
|
130
|
+
if (!label) {
|
|
131
|
+
throw new Error(`questions[].options[${index}].label is required`);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
id: normalizeId(item.id, `option-${index + 1}`),
|
|
135
|
+
label,
|
|
136
|
+
...(asString(item.description).trim()
|
|
137
|
+
? { description: asString(item.description).trim() }
|
|
138
|
+
: {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeQuestion(value: unknown, index: number): GoalSetupQuestion {
|
|
143
|
+
const item = asRecord(value, `questions[${index}]`);
|
|
144
|
+
const prompt = asString(item.prompt).trim();
|
|
145
|
+
if (!prompt) {
|
|
146
|
+
throw new Error(`questions[${index}].prompt is required`);
|
|
147
|
+
}
|
|
148
|
+
const options = Array.isArray(item.options)
|
|
149
|
+
? item.options.map(normalizeOption)
|
|
150
|
+
: undefined;
|
|
151
|
+
|
|
152
|
+
const recommendedOptionIds = Array.isArray(item.recommendedOptionIds)
|
|
153
|
+
? (item.recommendedOptionIds as unknown[]).filter((id): id is string => typeof id === 'string')
|
|
154
|
+
: undefined;
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
id: normalizeId(item.id, `question-${index + 1}`),
|
|
158
|
+
prompt,
|
|
159
|
+
...(asString(item.description).trim()
|
|
160
|
+
? { description: asString(item.description).trim() }
|
|
161
|
+
: {}),
|
|
162
|
+
answerMode: normalizeAnswerMode(item.answerMode),
|
|
163
|
+
...(asString(item.recommendedAnswer).trim()
|
|
164
|
+
? { recommendedAnswer: asString(item.recommendedAnswer).trim() }
|
|
165
|
+
: {}),
|
|
166
|
+
...(recommendedOptionIds && recommendedOptionIds.length > 0
|
|
167
|
+
? { recommendedOptionIds }
|
|
168
|
+
: {}),
|
|
169
|
+
...(options && options.length > 0 ? { options } : {}),
|
|
170
|
+
required: asBoolean(item.required, true),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeFact(value: unknown, index: number): GoalSetupFact {
|
|
175
|
+
const item = asRecord(value, `facts[${index}]`);
|
|
176
|
+
const text = asString(item.text).trim();
|
|
177
|
+
if (!text) {
|
|
178
|
+
throw new Error(`facts[${index}].text is required`);
|
|
179
|
+
}
|
|
180
|
+
const recommended = asBoolean(item.recommendedAutomatedVerification, false);
|
|
181
|
+
return {
|
|
182
|
+
id: normalizeId(item.id, `fact-${index + 1}`),
|
|
183
|
+
text,
|
|
184
|
+
accepted: asBoolean(item.accepted, false),
|
|
185
|
+
removed: asBoolean(item.removed, false),
|
|
186
|
+
...(asString(item.comment).trim()
|
|
187
|
+
? { comment: asString(item.comment).trim() }
|
|
188
|
+
: {}),
|
|
189
|
+
recommendedAutomatedVerification: recommended,
|
|
190
|
+
automatedVerification: asBoolean(item.automatedVerification, recommended),
|
|
191
|
+
...(asString(item.previousText).trim()
|
|
192
|
+
? { previousText: asString(item.previousText).trim() }
|
|
193
|
+
: {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function normalizeInterviewBundle(value: unknown): GoalSetupInterviewBundle {
|
|
198
|
+
const raw = asRecord(value, "goal setup interview bundle");
|
|
199
|
+
if (!Array.isArray(raw.questions) || raw.questions.length === 0) {
|
|
200
|
+
throw new Error("interview bundle requires at least one question");
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
stage: "interview",
|
|
204
|
+
...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}),
|
|
205
|
+
...(asString(raw.goalSlug).trim()
|
|
206
|
+
? { goalSlug: asString(raw.goalSlug).trim() }
|
|
207
|
+
: {}),
|
|
208
|
+
questions: raw.questions.map(normalizeQuestion),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function normalizeFactsBundle(value: unknown): GoalSetupFactsBundle {
|
|
213
|
+
const raw = asRecord(value, "goal setup facts bundle");
|
|
214
|
+
if (!Array.isArray(raw.facts)) {
|
|
215
|
+
throw new Error("facts bundle requires a facts array");
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
stage: "facts",
|
|
219
|
+
...(asString(raw.title).trim() ? { title: asString(raw.title).trim() } : {}),
|
|
220
|
+
...(asString(raw.goalSlug).trim()
|
|
221
|
+
? { goalSlug: asString(raw.goalSlug).trim() }
|
|
222
|
+
: {}),
|
|
223
|
+
facts: raw.facts.map(normalizeFact),
|
|
224
|
+
showAccepted: asBoolean(raw.showAccepted, false),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function normalizeGoalSetupBundle(
|
|
229
|
+
value: unknown,
|
|
230
|
+
expectedStage?: GoalSetupStage
|
|
231
|
+
): GoalSetupBundle {
|
|
232
|
+
const raw = asRecord(value, "goal setup bundle");
|
|
233
|
+
const stage = expectedStage ?? raw.stage;
|
|
234
|
+
if (stage === "interview") return normalizeInterviewBundle(raw);
|
|
235
|
+
if (stage === "facts") return normalizeFactsBundle(raw);
|
|
236
|
+
throw new Error("goal setup bundle stage must be interview or facts");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function hasQuestionAnswer(answer: GoalSetupQuestionAnswer): boolean {
|
|
240
|
+
return (
|
|
241
|
+
answer.selectedOptionIds.length > 0 ||
|
|
242
|
+
answer.customAnswer.trim().length > 0 ||
|
|
243
|
+
answer.answer.trim().length > 0
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function createInterviewResult(
|
|
248
|
+
bundle: GoalSetupInterviewBundle,
|
|
249
|
+
answers: GoalSetupQuestionAnswer[]
|
|
250
|
+
): GoalSetupInterviewResult {
|
|
251
|
+
const byId = new Map(answers.map((answer) => [answer.questionId, answer]));
|
|
252
|
+
return {
|
|
253
|
+
stage: "interview",
|
|
254
|
+
title: bundle.title,
|
|
255
|
+
goalSlug: bundle.goalSlug,
|
|
256
|
+
answers: bundle.questions.map((question) => {
|
|
257
|
+
const answer = byId.get(question.id);
|
|
258
|
+
const normalized: GoalSetupQuestionAnswer = {
|
|
259
|
+
questionId: question.id,
|
|
260
|
+
selectedOptionIds: Array.isArray(answer?.selectedOptionIds)
|
|
261
|
+
? answer!.selectedOptionIds
|
|
262
|
+
: [],
|
|
263
|
+
customAnswer: asString(answer?.customAnswer),
|
|
264
|
+
...(asString(answer?.note).trim()
|
|
265
|
+
? { note: asString(answer?.note).trim() }
|
|
266
|
+
: {}),
|
|
267
|
+
answer: asString(answer?.answer),
|
|
268
|
+
completed: asBoolean(answer?.completed, false),
|
|
269
|
+
};
|
|
270
|
+
const completed = normalized.completed || hasQuestionAnswer(normalized);
|
|
271
|
+
const skipped = asBoolean(answer?.skipped, false) && !completed;
|
|
272
|
+
return {
|
|
273
|
+
...normalized,
|
|
274
|
+
completed,
|
|
275
|
+
...(skipped ? { skipped: true } : {}),
|
|
276
|
+
};
|
|
277
|
+
}),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function filterReviewableFacts(bundle: GoalSetupFactsBundle): GoalSetupFact[] {
|
|
282
|
+
if (bundle.showAccepted) return bundle.facts;
|
|
283
|
+
return bundle.facts.filter((fact) => !fact.accepted);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function createFactsResult(
|
|
287
|
+
bundle: GoalSetupFactsBundle,
|
|
288
|
+
facts: GoalSetupFactResult[]
|
|
289
|
+
): GoalSetupFactsResult {
|
|
290
|
+
const byId = new Map(facts.map((fact) => [fact.id, fact]));
|
|
291
|
+
const merged = bundle.facts.map((fact) => {
|
|
292
|
+
const next = byId.get(fact.id);
|
|
293
|
+
const removed = asBoolean(next?.removed, fact.removed);
|
|
294
|
+
const text = asString(next?.text, fact.text).trim();
|
|
295
|
+
if (!removed && !text) {
|
|
296
|
+
throw new Error(`Fact "${fact.id}" text cannot be empty; edit it or remove the fact.`);
|
|
297
|
+
}
|
|
298
|
+
const comment = (next && Object.prototype.hasOwnProperty.call(next, "comment")
|
|
299
|
+
? asString(next.comment)
|
|
300
|
+
: asString(fact.comment)
|
|
301
|
+
).trim();
|
|
302
|
+
return {
|
|
303
|
+
id: fact.id,
|
|
304
|
+
text: text || fact.text,
|
|
305
|
+
accepted: asBoolean(next?.accepted, fact.accepted),
|
|
306
|
+
removed,
|
|
307
|
+
...(comment ? { comment } : {}),
|
|
308
|
+
automatedVerification: asBoolean(
|
|
309
|
+
next?.automatedVerification,
|
|
310
|
+
fact.automatedVerification
|
|
311
|
+
),
|
|
312
|
+
recommendedAutomatedVerification:
|
|
313
|
+
next?.recommendedAutomatedVerification ??
|
|
314
|
+
fact.recommendedAutomatedVerification,
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
stage: "facts",
|
|
320
|
+
title: bundle.title,
|
|
321
|
+
goalSlug: bundle.goalSlug,
|
|
322
|
+
facts: merged,
|
|
323
|
+
factsMarkdown: factsResultToMarkdown(merged),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function factsResultToMarkdown(facts: GoalSetupFactResult[]): string {
|
|
328
|
+
const accepted = facts.filter((fact) => fact.accepted && !fact.removed);
|
|
329
|
+
if (accepted.length === 0) return "# Facts\n\nNo accepted facts.";
|
|
330
|
+
|
|
331
|
+
const lines = ["# Facts", ""];
|
|
332
|
+
for (const fact of accepted) {
|
|
333
|
+
lines.push(`- ${fact.text}`);
|
|
334
|
+
}
|
|
335
|
+
return lines.join("\n");
|
|
336
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { htmlToMarkdown } from "./html-to-markdown";
|
|
3
|
+
|
|
4
|
+
describe("htmlToMarkdown", () => {
|
|
5
|
+
test("converts basic HTML to markdown", () => {
|
|
6
|
+
const md = htmlToMarkdown("<h1>Hello</h1><p>World</p>");
|
|
7
|
+
expect(md).toContain("# Hello");
|
|
8
|
+
expect(md).toContain("World");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("converts tables with explicit thead", () => {
|
|
12
|
+
const html = "<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>";
|
|
13
|
+
const md = htmlToMarkdown(html);
|
|
14
|
+
expect(md).toContain("| A");
|
|
15
|
+
expect(md).toContain("| ---");
|
|
16
|
+
expect(md).toContain("| 1");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("converts tables WITHOUT thead (common HTML pattern)", () => {
|
|
20
|
+
const html = "<table><tr><th>Name</th><th>Value</th></tr><tr><td>foo</td><td>bar</td></tr></table>";
|
|
21
|
+
const md = htmlToMarkdown(html);
|
|
22
|
+
expect(md).toContain("| Name");
|
|
23
|
+
expect(md).toContain("| ---");
|
|
24
|
+
expect(md).toContain("| foo");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("strips script, style, and noscript tags", () => {
|
|
28
|
+
const html = '<p>Visible</p><script>alert("xss")</script><style>.x{}</style><noscript>Hidden</noscript>';
|
|
29
|
+
const md = htmlToMarkdown(html);
|
|
30
|
+
expect(md).toContain("Visible");
|
|
31
|
+
expect(md).not.toContain("alert");
|
|
32
|
+
expect(md).not.toContain(".x{}");
|
|
33
|
+
expect(md).not.toContain("Hidden");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("converts strikethrough (GFM)", () => {
|
|
37
|
+
const md = htmlToMarkdown("<p>This is <del>deleted</del> text</p>");
|
|
38
|
+
expect(md).toContain("~deleted~");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("preserves code blocks as fenced markdown", () => {
|
|
42
|
+
const md = htmlToMarkdown('<pre><code>const x = 1;</code></pre>');
|
|
43
|
+
expect(md).toContain("```");
|
|
44
|
+
expect(md).toContain("const x = 1;");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("converts links", () => {
|
|
48
|
+
const md = htmlToMarkdown('<a href="https://example.com">Click</a>');
|
|
49
|
+
expect(md).toContain("[Click](https://example.com)");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("preserves dangerous link hrefs (sanitization is in the renderer, not here)", () => {
|
|
53
|
+
const md = htmlToMarkdown('<a href="javascript:alert(1)">XSS</a>');
|
|
54
|
+
// Turndown preserves the link — the UI renderer blocks it
|
|
55
|
+
expect(md).toContain("javascript:");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("handles empty HTML gracefully", () => {
|
|
59
|
+
expect(htmlToMarkdown("")).toBe("");
|
|
60
|
+
expect(htmlToMarkdown("<html><body></body></html>")).toBe("");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML-to-Markdown conversion via Turndown.
|
|
3
|
+
*
|
|
4
|
+
* Shared between the CLI (single HTML file / URL) and the server
|
|
5
|
+
* (on-demand conversion for HTML files in folder mode).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import TurndownService from "turndown";
|
|
9
|
+
// @ts-expect-error — @joplin/turndown-plugin-gfm ships JS only, no .d.ts (see declarations.d.ts for local types)
|
|
10
|
+
import { gfm } from "@joplin/turndown-plugin-gfm";
|
|
11
|
+
|
|
12
|
+
const td = new TurndownService({
|
|
13
|
+
headingStyle: "atx",
|
|
14
|
+
codeBlockStyle: "fenced",
|
|
15
|
+
bulletListMarker: "-",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
td.use(gfm);
|
|
19
|
+
|
|
20
|
+
// Strip <style> and <script> tags entirely (Turndown keeps unrecognised
|
|
21
|
+
// tags as blank by default, but their text content can leak through).
|
|
22
|
+
td.remove(["style", "script", "noscript"]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Convert an HTML string to Markdown.
|
|
26
|
+
*
|
|
27
|
+
* Uses a module-level TurndownService singleton (stateless, safe to reuse).
|
|
28
|
+
* GFM tables, strikethrough, and task lists are supported via turndown-plugin-gfm.
|
|
29
|
+
*/
|
|
30
|
+
export function htmlToMarkdown(html: string): string {
|
|
31
|
+
return td.turndown(html);
|
|
32
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for improvement hook reader.
|
|
3
|
+
*
|
|
4
|
+
* Run: bun test packages/shared/improvement-hooks.test.ts
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
8
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
|
|
12
|
+
// We need to override the base dirs used by readImprovementHook.
|
|
13
|
+
// Since the module uses homedir() at import time, we mock it via
|
|
14
|
+
// a test harness that sets HOME to a temp directory.
|
|
15
|
+
|
|
16
|
+
const TEST_HOME = join(tmpdir(), `improvement-hooks-test-${Date.now()}`);
|
|
17
|
+
const NEW_BASE = join(TEST_HOME, ".plannotator", "hooks");
|
|
18
|
+
const LEGACY_BASE = join(TEST_HOME, ".plannotator");
|
|
19
|
+
const HOOK_RELATIVE = "compound/enterplanmode-improve-hook.txt";
|
|
20
|
+
|
|
21
|
+
function setupTestHome() {
|
|
22
|
+
mkdirSync(join(NEW_BASE, "compound"), { recursive: true });
|
|
23
|
+
mkdirSync(join(LEGACY_BASE, "compound"), { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cleanTestHome() {
|
|
27
|
+
if (existsSync(TEST_HOME)) {
|
|
28
|
+
rmSync(TEST_HOME, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Since the module reads homedir() at import time, we need to
|
|
33
|
+
// re-import with HOME overridden. Use a helper that spawns a
|
|
34
|
+
// small inline script to test each scenario in isolation.
|
|
35
|
+
async function runScenario(setup: {
|
|
36
|
+
newPathContent?: string | null;
|
|
37
|
+
legacyPathContent?: string | null;
|
|
38
|
+
}): Promise<{ content: string; filePath: string } | null> {
|
|
39
|
+
setupTestHome();
|
|
40
|
+
|
|
41
|
+
const newPath = join(NEW_BASE, HOOK_RELATIVE);
|
|
42
|
+
const legacyPath = join(LEGACY_BASE, HOOK_RELATIVE);
|
|
43
|
+
|
|
44
|
+
if (setup.newPathContent !== undefined && setup.newPathContent !== null) {
|
|
45
|
+
writeFileSync(newPath, setup.newPathContent);
|
|
46
|
+
}
|
|
47
|
+
if (setup.legacyPathContent !== undefined && setup.legacyPathContent !== null) {
|
|
48
|
+
writeFileSync(legacyPath, setup.legacyPathContent);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Run in a subprocess with HOME overridden so homedir() returns TEST_HOME
|
|
52
|
+
const proc = Bun.spawn(
|
|
53
|
+
[
|
|
54
|
+
"bun",
|
|
55
|
+
"-e",
|
|
56
|
+
`
|
|
57
|
+
import { readImprovementHook } from "./packages/shared/improvement-hooks";
|
|
58
|
+
const result = readImprovementHook("enterplanmode-improve");
|
|
59
|
+
console.log(JSON.stringify(result));
|
|
60
|
+
`,
|
|
61
|
+
],
|
|
62
|
+
{
|
|
63
|
+
env: { ...process.env, HOME: TEST_HOME },
|
|
64
|
+
cwd: join(import.meta.dir, "../.."),
|
|
65
|
+
stdout: "pipe",
|
|
66
|
+
stderr: "pipe",
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const stdout = await new Response(proc.stdout).text();
|
|
71
|
+
const exitCode = await proc.exited;
|
|
72
|
+
|
|
73
|
+
if (exitCode !== 0) {
|
|
74
|
+
const stderr = await new Response(proc.stderr).text();
|
|
75
|
+
throw new Error(`Subprocess failed (exit ${exitCode}): ${stderr}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const parsed = JSON.parse(stdout.trim());
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("readImprovementHook", () => {
|
|
83
|
+
beforeEach(setupTestHome);
|
|
84
|
+
afterEach(cleanTestHome);
|
|
85
|
+
|
|
86
|
+
test("returns content from new path when file exists", async () => {
|
|
87
|
+
const result = await runScenario({
|
|
88
|
+
newPathContent: "Focus on error handling",
|
|
89
|
+
});
|
|
90
|
+
expect(result).not.toBeNull();
|
|
91
|
+
expect(result!.content).toBe("Focus on error handling");
|
|
92
|
+
expect(result!.filePath).toContain(".plannotator/hooks/compound/");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("new path wins over legacy path", async () => {
|
|
96
|
+
const result = await runScenario({
|
|
97
|
+
newPathContent: "New instructions",
|
|
98
|
+
legacyPathContent: "Old instructions",
|
|
99
|
+
});
|
|
100
|
+
expect(result).not.toBeNull();
|
|
101
|
+
expect(result!.content).toBe("New instructions");
|
|
102
|
+
expect(result!.filePath).toContain(".plannotator/hooks/compound/");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("falls back to legacy path when new path is absent", async () => {
|
|
106
|
+
const result = await runScenario({
|
|
107
|
+
legacyPathContent: "Legacy instructions",
|
|
108
|
+
});
|
|
109
|
+
expect(result).not.toBeNull();
|
|
110
|
+
expect(result!.content).toBe("Legacy instructions");
|
|
111
|
+
expect(result!.filePath).toContain(".plannotator/compound/");
|
|
112
|
+
expect(result!.filePath).not.toContain(".plannotator/hooks/");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("returns null when new path exists but is empty (no legacy fallback)", async () => {
|
|
116
|
+
const result = await runScenario({
|
|
117
|
+
newPathContent: "",
|
|
118
|
+
legacyPathContent: "Legacy instructions",
|
|
119
|
+
});
|
|
120
|
+
expect(result).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("returns null when no files exist", async () => {
|
|
124
|
+
const result = await runScenario({});
|
|
125
|
+
expect(result).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("returns null when new path is whitespace-only (no legacy fallback)", async () => {
|
|
129
|
+
const result = await runScenario({
|
|
130
|
+
newPathContent: " \n \n ",
|
|
131
|
+
legacyPathContent: "Legacy instructions",
|
|
132
|
+
});
|
|
133
|
+
expect(result).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Improvement Hook Reader
|
|
3
|
+
*
|
|
4
|
+
* Reads improvement hook files from ~/.plannotator/hooks/.
|
|
5
|
+
* Falls back to the legacy path (~/.plannotator/) when the new-path
|
|
6
|
+
* file is absent, for compatibility with files written before the
|
|
7
|
+
* path migration. If the new-path file exists but is invalid (empty,
|
|
8
|
+
* oversized, not a regular file), the legacy path is NOT consulted —
|
|
9
|
+
* this prevents resurrecting stale instructions.
|
|
10
|
+
*
|
|
11
|
+
* Runtime-agnostic: uses only node:fs, node:path, node:os.
|
|
12
|
+
*
|
|
13
|
+
* Security model:
|
|
14
|
+
* - Hardcoded base paths (no user input determines file path)
|
|
15
|
+
* - KNOWN_HOOKS allowlist (only pre-registered relative paths)
|
|
16
|
+
* - Size cap to prevent runaway context injection
|
|
17
|
+
* - Same trust model as ~/.plannotator/config.json
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { join } from "path";
|
|
21
|
+
import { readFileSync, statSync } from "fs";
|
|
22
|
+
import { getPlannotatorDataDir } from "./data-dir";
|
|
23
|
+
|
|
24
|
+
const DATA_DIR = getPlannotatorDataDir();
|
|
25
|
+
|
|
26
|
+
/** Hooks subdirectory (preferred location) */
|
|
27
|
+
const HOOKS_BASE_DIR = join(DATA_DIR, "hooks");
|
|
28
|
+
|
|
29
|
+
/** Fallback: hooks placed directly in the data dir (pre-hooks-subdir layout) */
|
|
30
|
+
const LEGACY_BASE_DIR = DATA_DIR;
|
|
31
|
+
|
|
32
|
+
/** Maximum file size to read (50 KB) */
|
|
33
|
+
const MAX_FILE_SIZE = 50 * 1024;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Known improvement hook file paths, keyed by hook name.
|
|
37
|
+
* `path` is relative to HOOKS_BASE_DIR (~/.plannotator/hooks/).
|
|
38
|
+
* `legacyPath` is relative to LEGACY_BASE_DIR (~/.plannotator/).
|
|
39
|
+
*/
|
|
40
|
+
const KNOWN_HOOKS = {
|
|
41
|
+
"enterplanmode-improve": {
|
|
42
|
+
path: "compound/enterplanmode-improve-hook.txt",
|
|
43
|
+
legacyPath: "compound/enterplanmode-improve-hook.txt",
|
|
44
|
+
},
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
export type ImprovementHookName = keyof typeof KNOWN_HOOKS;
|
|
48
|
+
|
|
49
|
+
export function getImprovementHookExpectedPath(
|
|
50
|
+
hookName: ImprovementHookName,
|
|
51
|
+
): string | null {
|
|
52
|
+
const entry = KNOWN_HOOKS[hookName];
|
|
53
|
+
if (!entry) return null;
|
|
54
|
+
return join(HOOKS_BASE_DIR, entry.path);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ImprovementHookResult {
|
|
58
|
+
content: string;
|
|
59
|
+
hookName: ImprovementHookName;
|
|
60
|
+
filePath: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Check whether a path exists on disk (any file type). */
|
|
64
|
+
function fileExists(path: string): boolean {
|
|
65
|
+
try {
|
|
66
|
+
statSync(path);
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Validate and read a hook file. Returns the result, or null if invalid. */
|
|
74
|
+
function tryReadHookFile(
|
|
75
|
+
filePath: string,
|
|
76
|
+
hookName: ImprovementHookName,
|
|
77
|
+
): ImprovementHookResult | null {
|
|
78
|
+
try {
|
|
79
|
+
const stat = statSync(filePath);
|
|
80
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_FILE_SIZE) return null;
|
|
81
|
+
|
|
82
|
+
const content = readFileSync(filePath, "utf-8").trim();
|
|
83
|
+
if (!content) return null;
|
|
84
|
+
|
|
85
|
+
return { content, hookName, filePath };
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read an improvement hook file by name.
|
|
93
|
+
*
|
|
94
|
+
* Lookup order:
|
|
95
|
+
* 1. New path (HOOKS_BASE_DIR + path). If it exists and validates, return it.
|
|
96
|
+
* 2. If the new path exists but is invalid (empty, oversized, etc.), return null.
|
|
97
|
+
* 3. Only if the new path does not exist, try the legacy path (LEGACY_BASE_DIR + legacyPath).
|
|
98
|
+
*/
|
|
99
|
+
export function readImprovementHook(
|
|
100
|
+
hookName: ImprovementHookName,
|
|
101
|
+
): ImprovementHookResult | null {
|
|
102
|
+
const entry = KNOWN_HOOKS[hookName];
|
|
103
|
+
if (!entry) return null;
|
|
104
|
+
|
|
105
|
+
const newPath = join(HOOKS_BASE_DIR, entry.path);
|
|
106
|
+
|
|
107
|
+
// New path exists — use it exclusively (even if invalid)
|
|
108
|
+
if (fileExists(newPath)) {
|
|
109
|
+
return tryReadHookFile(newPath, hookName);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// New path absent — fall back to legacy path
|
|
113
|
+
const legacyFilePath = join(LEGACY_BASE_DIR, entry.legacyPath);
|
|
114
|
+
return tryReadHookFile(legacyFilePath, hookName);
|
|
115
|
+
}
|