one-code-extension 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.
Files changed (153) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +202 -0
  3. package/agents/explore.md +24 -0
  4. package/agents/general-purpose.md +21 -0
  5. package/agents/plan.md +22 -0
  6. package/docs/decisions/auto-mode.md +526 -0
  7. package/docs/decisions/branding.md +127 -0
  8. package/docs/decisions/code-review-remediation.md +106 -0
  9. package/docs/decisions/compaction.md +140 -0
  10. package/docs/decisions/distribution.md +133 -0
  11. package/docs/decisions/hooks.md +64 -0
  12. package/docs/decisions/lsp.md +58 -0
  13. package/docs/decisions/mcp.md +104 -0
  14. package/docs/decisions/memory-state.md +170 -0
  15. package/docs/decisions/model-policy.md +126 -0
  16. package/docs/decisions/modes.md +178 -0
  17. package/docs/decisions/skills-plugins.md +56 -0
  18. package/docs/decisions/subagents-workflows.md +225 -0
  19. package/docs/decisions/system-prompt.md +98 -0
  20. package/docs/decisions/tools.md +407 -0
  21. package/docs/decisions/tui.md +202 -0
  22. package/docs/decisions.md +148 -0
  23. package/extensions/ask-user/index.ts +124 -0
  24. package/extensions/ask-user/questions.ts +60 -0
  25. package/extensions/auto-mode/actions.ts +78 -0
  26. package/extensions/auto-mode/classifier.ts +388 -0
  27. package/extensions/auto-mode/config.ts +237 -0
  28. package/extensions/auto-mode/decision-log.ts +60 -0
  29. package/extensions/auto-mode/defaults.ts +65 -0
  30. package/extensions/auto-mode/instructions.ts +74 -0
  31. package/extensions/auto-mode/model-picker.ts +225 -0
  32. package/extensions/auto-mode/model-select.ts +178 -0
  33. package/extensions/auto-mode/paths.ts +109 -0
  34. package/extensions/auto-mode/pause.ts +96 -0
  35. package/extensions/auto-mode/prompt.ts +307 -0
  36. package/extensions/auto-mode/rules.ts +96 -0
  37. package/extensions/auto-mode/safety-floor.ts +100 -0
  38. package/extensions/auto-mode/sensitive.ts +137 -0
  39. package/extensions/auto-mode/shell-analysis.ts +702 -0
  40. package/extensions/background/index.ts +405 -0
  41. package/extensions/background/registry.ts +81 -0
  42. package/extensions/background/wakeup.ts +41 -0
  43. package/extensions/bash/background.ts +136 -0
  44. package/extensions/bash/index.ts +149 -0
  45. package/extensions/branding/assistant-marker.ts +53 -0
  46. package/extensions/branding/index.ts +366 -0
  47. package/extensions/branding/prompt-editor.ts +42 -0
  48. package/extensions/branding/prompt-marker.ts +29 -0
  49. package/extensions/branding/startup.ts +152 -0
  50. package/extensions/claude-compat/index.ts +50 -0
  51. package/extensions/claude-context/index.ts +79 -0
  52. package/extensions/compaction/index.ts +231 -0
  53. package/extensions/compaction/prompt.ts +191 -0
  54. package/extensions/context-management/index.ts +147 -0
  55. package/extensions/effort/index.ts +216 -0
  56. package/extensions/effort/slider.ts +253 -0
  57. package/extensions/exit/index.ts +28 -0
  58. package/extensions/file-tracker/index.ts +103 -0
  59. package/extensions/file-tracker/tracker.ts +145 -0
  60. package/extensions/hooks/debug.ts +53 -0
  61. package/extensions/hooks/executor.ts +132 -0
  62. package/extensions/hooks/index.ts +310 -0
  63. package/extensions/hooks/matcher.ts +72 -0
  64. package/extensions/hooks/plugin-hooks.ts +79 -0
  65. package/extensions/hooks/protocol.ts +151 -0
  66. package/extensions/hooks/settings.ts +163 -0
  67. package/extensions/hooks/trust.ts +151 -0
  68. package/extensions/lib/branch-restore.ts +35 -0
  69. package/extensions/lib/claude-context.ts +148 -0
  70. package/extensions/lib/deferred.ts +119 -0
  71. package/extensions/lib/git.ts +14 -0
  72. package/extensions/lib/memory.ts +219 -0
  73. package/extensions/lib/model-policy.ts +436 -0
  74. package/extensions/lib/model-tier.ts +99 -0
  75. package/extensions/lib/notifications.ts +18 -0
  76. package/extensions/lib/paths.ts +26 -0
  77. package/extensions/lib/per-cwd.ts +21 -0
  78. package/extensions/lib/persisted-output.ts +70 -0
  79. package/extensions/lib/pi-version.ts +54 -0
  80. package/extensions/lib/plugins.ts +227 -0
  81. package/extensions/lib/reminders.ts +212 -0
  82. package/extensions/lib/scratchpad.ts +68 -0
  83. package/extensions/lib/tui-render.ts +522 -0
  84. package/extensions/lsp/client.ts +278 -0
  85. package/extensions/lsp/format.ts +46 -0
  86. package/extensions/lsp/index.ts +193 -0
  87. package/extensions/lsp/protocol.ts +64 -0
  88. package/extensions/lsp/servers.ts +79 -0
  89. package/extensions/mcp/client.ts +205 -0
  90. package/extensions/mcp/config.ts +199 -0
  91. package/extensions/mcp/index.ts +373 -0
  92. package/extensions/mcp/schema.ts +161 -0
  93. package/extensions/memory/index.ts +90 -0
  94. package/extensions/notebook/index.ts +77 -0
  95. package/extensions/notebook/notebook.ts +108 -0
  96. package/extensions/permissions/index.ts +876 -0
  97. package/extensions/permissions/matcher.ts +455 -0
  98. package/extensions/permissions/modes.ts +107 -0
  99. package/extensions/permissions/protected-paths.ts +125 -0
  100. package/extensions/permissions/settings.ts +98 -0
  101. package/extensions/plan-mode/index.ts +209 -0
  102. package/extensions/plan-mode/reminder.ts +33 -0
  103. package/extensions/plan-mode/slug.ts +28 -0
  104. package/extensions/plan-mode/viewer.ts +124 -0
  105. package/extensions/plugins/index.ts +115 -0
  106. package/extensions/plugins/template.ts +42 -0
  107. package/extensions/search-tools/index.ts +0 -0
  108. package/extensions/search-tools/tools.ts +24 -0
  109. package/extensions/skill/index.ts +170 -0
  110. package/extensions/subagents/agents.ts +126 -0
  111. package/extensions/subagents/child.ts +414 -0
  112. package/extensions/subagents/default-model.ts +122 -0
  113. package/extensions/subagents/index.ts +1119 -0
  114. package/extensions/subagents/model-select.ts +346 -0
  115. package/extensions/subagents/rpc-turns.ts +123 -0
  116. package/extensions/subagents/runs.ts +93 -0
  117. package/extensions/subagents/usage.ts +53 -0
  118. package/extensions/subagents/worktree.ts +69 -0
  119. package/extensions/system-prompt/environment.ts +35 -0
  120. package/extensions/system-prompt/index.ts +54 -0
  121. package/extensions/system-prompt/template.ts +103 -0
  122. package/extensions/system-prompt/tiers/common.ts +63 -0
  123. package/extensions/system-prompt/tiers/frontier.ts +24 -0
  124. package/extensions/system-prompt/tiers/low.ts +71 -0
  125. package/extensions/system-prompt/tiers/mid.ts +56 -0
  126. package/extensions/system-reminder/index.ts +37 -0
  127. package/extensions/tasks/index.ts +140 -0
  128. package/extensions/tasks/store.ts +174 -0
  129. package/extensions/todo/index.ts +133 -0
  130. package/extensions/tool-search/index.ts +168 -0
  131. package/extensions/tool-style/index.ts +84 -0
  132. package/extensions/web/index.ts +43 -0
  133. package/extensions/web-fetch/extract.ts +139 -0
  134. package/extensions/web-fetch/index.ts +291 -0
  135. package/extensions/web-fetch/summarize.ts +78 -0
  136. package/extensions/workflow/agent-session.ts +368 -0
  137. package/extensions/workflow/globals.ts +251 -0
  138. package/extensions/workflow/index.ts +364 -0
  139. package/extensions/workflow/journal.ts +75 -0
  140. package/extensions/workflow/permission-gate.ts +71 -0
  141. package/extensions/workflow/run-manager.ts +306 -0
  142. package/extensions/workflow/saved-workflows.ts +45 -0
  143. package/extensions/workflow/script-source.ts +201 -0
  144. package/extensions/workflow/types.ts +99 -0
  145. package/extensions/workflow/vm-runtime.ts +31 -0
  146. package/extensions/workflow/widget.ts +56 -0
  147. package/extensions/worktree/index.ts +266 -0
  148. package/extensions/worktree/rewrite.ts +59 -0
  149. package/package.json +101 -0
  150. package/themes/one-code-light.json +76 -0
  151. package/themes/one-code.json +76 -0
  152. package/types/app.d.ts +5 -0
  153. package/types/community-packages.d.ts +12 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Claude Code hook protocol (pure): the stdin payload shape, the stdout JSON
3
+ * envelope, and the mapping from a finished hook run to what One Code should do
4
+ * about it. Envelope semantics follow Claude Code's hooks reference; the
5
+ * fail-closed choices are One Code's own (see docs/decisions.md).
6
+ */
7
+
8
+ export type CcHookEvent =
9
+ | "PreToolUse"
10
+ | "PostToolUse"
11
+ | "UserPromptSubmit"
12
+ | "SessionStart"
13
+ | "Stop"
14
+ | "SessionEnd"
15
+ | "PreCompact"
16
+ | "PostCompact";
17
+
18
+ export const CC_HOOK_EVENTS: readonly CcHookEvent[] = [
19
+ "PreToolUse",
20
+ "PostToolUse",
21
+ "UserPromptSubmit",
22
+ "SessionStart",
23
+ "Stop",
24
+ "SessionEnd",
25
+ "PreCompact",
26
+ "PostCompact",
27
+ ];
28
+
29
+ /** What a hook command reads from stdin — Claude Code's field names exactly. */
30
+ export interface HookStdinPayload {
31
+ session_id: string;
32
+ /** Empty string when the session has no file yet (e.g. --no-session). */
33
+ transcript_path: string;
34
+ cwd: string;
35
+ hook_event_name: CcHookEvent;
36
+ tool_name?: string;
37
+ tool_input?: Record<string, unknown>;
38
+ tool_response?: { content: unknown; is_error: boolean };
39
+ prompt?: string;
40
+ stop_hook_active?: boolean;
41
+ trigger?: "manual" | "auto";
42
+ source?: string;
43
+ }
44
+
45
+ export interface HookEnvelope {
46
+ continue?: boolean;
47
+ stopReason?: string;
48
+ suppressOutput?: boolean;
49
+ systemMessage?: string;
50
+ /** Legacy top-level fields, still emitted by many hook scripts. */
51
+ decision?: string;
52
+ reason?: string;
53
+ hookSpecificOutput?: {
54
+ hookEventName?: string;
55
+ permissionDecision?: string;
56
+ permissionDecisionReason?: string;
57
+ additionalContext?: string;
58
+ updatedInput?: Record<string, unknown>;
59
+ updatedToolResult?: unknown;
60
+ };
61
+ }
62
+
63
+ /** Parse a hook's stdout as the JSON envelope; non-JSON stdout is not an error. */
64
+ export function parseEnvelope(stdout: string): HookEnvelope | undefined {
65
+ const trimmed = stdout.trim();
66
+ if (!trimmed.startsWith("{")) return undefined;
67
+ try {
68
+ const parsed: unknown = JSON.parse(trimmed);
69
+ return typeof parsed === "object" && parsed !== null ? (parsed as HookEnvelope) : undefined;
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ /** What one finished hook run asks One Code to do. */
76
+ export interface HookOutcome {
77
+ block?: { reason: string };
78
+ updatedInput?: Record<string, unknown>;
79
+ updatedToolResult?: unknown;
80
+ additionalContext?: string;
81
+ systemMessage?: string;
82
+ }
83
+
84
+ export interface FinishedRun {
85
+ exitCode: number | null;
86
+ timedOut: boolean;
87
+ spawnError?: string;
88
+ stdout: string;
89
+ stderr: string;
90
+ }
91
+
92
+ /** Events where a hung or killed hook must fail closed rather than open. */
93
+ const TIMEOUT_BLOCKS: ReadonlySet<CcHookEvent> = new Set(["PreToolUse", "UserPromptSubmit"]);
94
+ /**
95
+ * Events where CC's plain (non-JSON) stdout becomes model-visible context
96
+ * rather than being discarded.
97
+ */
98
+ const STDOUT_IS_CONTEXT: ReadonlySet<CcHookEvent> = new Set(["UserPromptSubmit", "SessionStart"]);
99
+
100
+ export function interpretHookResult(event: CcHookEvent, run: FinishedRun): HookOutcome {
101
+ // Killed (timeout or otherwise): a null exit code must never read as a
102
+ // clean allow. Closed for the two events that gate something; open for the
103
+ // rest — the side effect already happened, or blocking would wedge exit.
104
+ if (run.timedOut || (run.exitCode === null && !run.spawnError)) {
105
+ return TIMEOUT_BLOCKS.has(event) ? { block: { reason: "Hook timed out" } } : {};
106
+ }
107
+ // A hook that couldn't spawn at all is an environment problem, not a
108
+ // verdict — fail open everywhere (CC treats non-2 errors as non-blocking).
109
+ if (run.spawnError) return {};
110
+
111
+ if (run.exitCode === 2) {
112
+ return { block: { reason: run.stderr.trim() || "Hook blocked the action (exit 2)" } };
113
+ }
114
+ if (run.exitCode !== 0) return {};
115
+
116
+ const envelope = parseEnvelope(run.stdout);
117
+ if (!envelope) {
118
+ const text = run.stdout.trim();
119
+ return text && STDOUT_IS_CONTEXT.has(event) ? { additionalContext: text } : {};
120
+ }
121
+
122
+ const outcome: HookOutcome = {};
123
+ const specific = envelope.hookSpecificOutput;
124
+
125
+ if (envelope.continue === false) {
126
+ outcome.block = { reason: envelope.stopReason?.trim() || "Hook stopped continuation" };
127
+ }
128
+ const decision = specific?.permissionDecision;
129
+ if (decision === "deny" || decision === "ask") {
130
+ // pi's tool_call is allow-or-block; treating "ask" as a silent allow
131
+ // would be the unsafe reading, so it blocks with the hook's reason.
132
+ outcome.block ??= {
133
+ reason:
134
+ specific?.permissionDecisionReason?.trim() ||
135
+ (decision === "ask" ? "Hook requested user confirmation" : "Hook denied the action"),
136
+ };
137
+ }
138
+ if (envelope.decision === "block") {
139
+ outcome.block ??= { reason: envelope.reason?.trim() || "Hook blocked the action" };
140
+ }
141
+ // "allow" is deliberately not handled: a hook cannot pre-approve anything —
142
+ // One Code's permission gate, safety floor, and classifier still run.
143
+
144
+ if (specific?.updatedInput && typeof specific.updatedInput === "object") {
145
+ outcome.updatedInput = specific.updatedInput;
146
+ }
147
+ if (specific && "updatedToolResult" in specific) outcome.updatedToolResult = specific.updatedToolResult;
148
+ if (specific?.additionalContext?.trim()) outcome.additionalContext = specific.additionalContext.trim();
149
+ if (envelope.systemMessage?.trim()) outcome.systemMessage = envelope.systemMessage.trim();
150
+ return outcome;
151
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Hook config discovery and merge (pure fs reads, no pi imports).
3
+ *
4
+ * Sources, lowest authority first: user ~/.claude/settings.json, managed
5
+ * settings (Claude Code's platform paths), project .claude/settings.json,
6
+ * project .claude/settings.local.json. Project and local sources are
7
+ * *returned flagged, not filtered* — whether they run is a trust decision
8
+ * (hooks are arbitrary code execution) that index.ts applies via trust.ts;
9
+ * this module stays pure.
10
+ *
11
+ * Files are re-read only when their mtime changes: hook dispatch happens on
12
+ * every tool call, so the cache keeps that to a stat() per source, while
13
+ * config edits still land mid-session (unlike a session_start-only reload).
14
+ */
15
+
16
+ import { readFileSync, statSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { CC_HOOK_EVENTS, type CcHookEvent } from "./protocol.ts";
19
+
20
+ export interface HookCommand {
21
+ type: "command";
22
+ command: string;
23
+ /** Seconds, CC convention. */
24
+ timeout?: number;
25
+ }
26
+
27
+ export interface HookMatcherEntry {
28
+ matcher?: string;
29
+ hooks: HookCommand[];
30
+ }
31
+
32
+ export type HooksFileConfig = Partial<Record<CcHookEvent, HookMatcherEntry[]>>;
33
+
34
+ export type HookScope = "user" | "managed" | "project" | "local" | "plugin";
35
+
36
+ export interface HooksSource {
37
+ scope: HookScope;
38
+ path: string;
39
+ pluginName?: string;
40
+ config: HooksFileConfig;
41
+ }
42
+
43
+ export interface LoadedHooks {
44
+ sources: HooksSource[];
45
+ /** Malformed entries are skipped and reported, never fatal. */
46
+ diagnostics: string[];
47
+ }
48
+
49
+ /** Managed-settings locations — Claude Code's exact per-platform paths. */
50
+ export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
51
+ if (platform === "darwin") return "/Library/Application Support/ClaudeCode/managed-settings.json";
52
+ if (platform === "win32") return "C:\\ProgramData\\ClaudeCode\\managed-settings.json";
53
+ return "/etc/claude-code/managed-settings.json";
54
+ }
55
+
56
+ export function hookSettingsPaths(claudeDir: string, cwd: string): Array<{ scope: HookScope; path: string }> {
57
+ return [
58
+ { scope: "user", path: join(claudeDir, "settings.json") },
59
+ { scope: "managed", path: managedSettingsPath() },
60
+ { scope: "project", path: join(cwd, ".claude", "settings.json") },
61
+ { scope: "local", path: join(cwd, ".claude", "settings.local.json") },
62
+ ];
63
+ }
64
+
65
+ /**
66
+ * Validate one file's `hooks` block into HooksFileConfig. Unknown events,
67
+ * non-command hook types, and shape errors become diagnostics, not throws —
68
+ * a typo must not disable the rest of the user's hooks.
69
+ */
70
+ export function parseHooksBlock(raw: unknown, origin: string, diagnostics: string[]): HooksFileConfig {
71
+ const config: HooksFileConfig = {};
72
+ if (raw === undefined || raw === null) return config;
73
+ if (typeof raw !== "object") {
74
+ diagnostics.push(`${origin}: "hooks" is not an object`);
75
+ return config;
76
+ }
77
+ for (const [event, entries] of Object.entries(raw as Record<string, unknown>)) {
78
+ if (!(CC_HOOK_EVENTS as readonly string[]).includes(event)) {
79
+ diagnostics.push(`${origin}: unsupported hook event "${event}" skipped`);
80
+ continue;
81
+ }
82
+ if (!Array.isArray(entries)) {
83
+ diagnostics.push(`${origin}: ${event} is not an array`);
84
+ continue;
85
+ }
86
+ const parsed: HookMatcherEntry[] = [];
87
+ for (const entry of entries) {
88
+ if (typeof entry !== "object" || entry === null || !Array.isArray((entry as { hooks?: unknown }).hooks)) {
89
+ diagnostics.push(`${origin}: ${event} entry without a hooks array skipped`);
90
+ continue;
91
+ }
92
+ const { matcher } = entry as { matcher?: unknown };
93
+ const hooks: HookCommand[] = [];
94
+ for (const hook of (entry as { hooks: unknown[] }).hooks) {
95
+ const candidate = hook as { type?: unknown; command?: unknown; timeout?: unknown };
96
+ if (candidate?.type !== "command" || typeof candidate.command !== "string" || !candidate.command.trim()) {
97
+ diagnostics.push(`${origin}: ${event} hook of type "${String(candidate?.type)}" skipped (only "command" is supported)`);
98
+ continue;
99
+ }
100
+ hooks.push({
101
+ type: "command",
102
+ command: candidate.command,
103
+ timeout: typeof candidate.timeout === "number" && candidate.timeout > 0 ? candidate.timeout : undefined,
104
+ });
105
+ }
106
+ if (hooks.length > 0) {
107
+ parsed.push({ matcher: typeof matcher === "string" ? matcher : undefined, hooks });
108
+ }
109
+ }
110
+ if (parsed.length > 0) config[event as CcHookEvent] = parsed;
111
+ }
112
+ return config;
113
+ }
114
+
115
+ interface CacheEntry {
116
+ mtimeMs: number;
117
+ config: HooksFileConfig;
118
+ diagnostics: string[];
119
+ }
120
+
121
+ const cache = new Map<string, CacheEntry>();
122
+
123
+ /** Read one settings file's hooks block, via the mtime cache. */
124
+ function readHooksFile(path: string, diagnostics: string[]): HooksFileConfig | undefined {
125
+ let mtimeMs: number;
126
+ try {
127
+ mtimeMs = statSync(path).mtimeMs;
128
+ } catch {
129
+ cache.delete(path);
130
+ return undefined;
131
+ }
132
+ const cached = cache.get(path);
133
+ if (cached && cached.mtimeMs === mtimeMs) {
134
+ diagnostics.push(...cached.diagnostics);
135
+ return cached.config;
136
+ }
137
+ const fileDiagnostics: string[] = [];
138
+ let config: HooksFileConfig = {};
139
+ try {
140
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as { hooks?: unknown };
141
+ config = parseHooksBlock(parsed.hooks, path, fileDiagnostics);
142
+ } catch (error) {
143
+ fileDiagnostics.push(`${path}: unreadable settings file skipped (${error instanceof Error ? error.message : error})`);
144
+ }
145
+ cache.set(path, { mtimeMs, config, diagnostics: fileDiagnostics });
146
+ diagnostics.push(...fileDiagnostics);
147
+ return config;
148
+ }
149
+
150
+ export function loadHookSettings(claudeDir: string, cwd: string): LoadedHooks {
151
+ const diagnostics: string[] = [];
152
+ const sources: HooksSource[] = [];
153
+ for (const { scope, path } of hookSettingsPaths(claudeDir, cwd)) {
154
+ const config = readHooksFile(path, diagnostics);
155
+ if (config && Object.keys(config).length > 0) sources.push({ scope, path, config });
156
+ }
157
+ return { sources, diagnostics };
158
+ }
159
+
160
+ /** Test seam: drop the mtime cache. */
161
+ export function resetHookSettingsCache(): void {
162
+ cache.clear();
163
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Project-hook consent. Hooks in a repo's `.claude/settings.json` are
3
+ * arbitrary code execution the moment the repo is opened, so they run only
4
+ * after the user approves them — once per configuration: approval is keyed to
5
+ * a hash of the project+local hook config and persisted under `~/.one-code`, so
6
+ * it survives restarts but any change to the hooks re-prompts (Claude Code's
7
+ * own hooks-review behaviour). User, managed, and plugin hooks never prompt
8
+ * (installing a plugin was the consent).
9
+ *
10
+ * pi's own project-trust store is deliberately not reused: it only triggers
11
+ * on `.pi/*` resources, so a repo with only `.claude/settings.json` would
12
+ * never be asked — silently never approved.
13
+ *
14
+ * A declined prompt sticks for the process (module state), not on disk:
15
+ * "no" means "not this session", not "never ask again".
16
+ */
17
+
18
+ import { createHash } from "node:crypto";
19
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { oneCodeStateDir } from "../lib/paths.ts";
22
+ import type { HooksSource } from "./settings.ts";
23
+
24
+ interface ApprovalStore {
25
+ version: 1;
26
+ approvals: Record<string, { configHash: string; approvedAt: string }>;
27
+ }
28
+
29
+ export function approvalStorePath(): string {
30
+ return join(oneCodeStateDir(), "hooks", "project-approvals.json");
31
+ }
32
+
33
+ /** Key order must not change the hash — two spellings of one config are one consent. */
34
+ function canonicalize(value: unknown): unknown {
35
+ if (Array.isArray(value)) return value.map(canonicalize);
36
+ if (value && typeof value === "object") {
37
+ return Object.fromEntries(
38
+ Object.entries(value as Record<string, unknown>)
39
+ .sort(([a], [b]) => a.localeCompare(b))
40
+ .map(([key, inner]) => [key, canonicalize(inner)]),
41
+ );
42
+ }
43
+ return value;
44
+ }
45
+
46
+ export function hashProjectHooks(projectSources: HooksSource[]): string {
47
+ const canonical = JSON.stringify(projectSources.map((source) => [source.scope, canonicalize(source.config)]));
48
+ return createHash("sha256").update(canonical).digest("hex");
49
+ }
50
+
51
+ export function readStoredApproval(projectRoot: string, storePath = approvalStorePath()): string | undefined {
52
+ try {
53
+ const store = JSON.parse(readFileSync(storePath, "utf-8")) as ApprovalStore;
54
+ return store.approvals?.[projectRoot]?.configHash;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ export function persistApproval(projectRoot: string, configHash: string, storePath = approvalStorePath()): void {
61
+ let store: ApprovalStore = { version: 1, approvals: {} };
62
+ try {
63
+ const existing = JSON.parse(readFileSync(storePath, "utf-8")) as ApprovalStore;
64
+ if (existing && typeof existing === "object" && existing.approvals) store = existing;
65
+ } catch {
66
+ // Fresh store.
67
+ }
68
+ store.approvals[projectRoot] = { configHash, approvedAt: new Date().toISOString() };
69
+ try {
70
+ mkdirSync(dirname(storePath), { recursive: true });
71
+ writeFileSync(storePath, `${JSON.stringify(store, null, "\t")}\n`);
72
+ } catch {
73
+ // Approval still holds for this session; it will just re-prompt next run.
74
+ }
75
+ }
76
+
77
+ /** A short human summary of what the project wants to run, for the consent dialog. */
78
+ export function describeProjectHooks(projectSources: HooksSource[]): string {
79
+ const commands: string[] = [];
80
+ for (const source of projectSources) {
81
+ for (const entries of Object.values(source.config)) {
82
+ for (const entry of entries) {
83
+ for (const hook of entry.hooks) commands.push(hook.command);
84
+ }
85
+ }
86
+ }
87
+ const shown = commands.slice(0, 5).map((cmd) => (cmd.length > 80 ? `${cmd.slice(0, 80)}…` : cmd));
88
+ const more = commands.length > shown.length ? `\n… and ${commands.length - shown.length} more` : "";
89
+ return `${commands.length} command hook(s):\n${shown.join("\n")}${more}`;
90
+ }
91
+
92
+ export interface TrustDecisionDeps {
93
+ hasUI: boolean;
94
+ confirm: (title: string, message: string) => Promise<boolean | undefined>;
95
+ notify: (message: string) => void;
96
+ storePath?: string;
97
+ }
98
+
99
+ /** Process-lifetime memory of declined/approved hashes per project, so a dispatch storm asks once. */
100
+ const sessionDecisions = new Map<string, boolean>();
101
+ const pendingPrompts = new Map<string, Promise<boolean>>();
102
+
103
+ /**
104
+ * Whether the project/local hook sources may run. Resolves without prompting
105
+ * when the stored approval matches; otherwise prompts once (concurrent
106
+ * dispatches share the in-flight prompt) and persists a yes.
107
+ */
108
+ export async function projectHooksApproved(
109
+ projectRoot: string,
110
+ projectSources: HooksSource[],
111
+ deps: TrustDecisionDeps,
112
+ ): Promise<boolean> {
113
+ if (projectSources.length === 0) return true;
114
+ const configHash = hashProjectHooks(projectSources);
115
+ const key = `${projectRoot}:${configHash}`;
116
+
117
+ const remembered = sessionDecisions.get(key);
118
+ if (remembered !== undefined) return remembered;
119
+ if (readStoredApproval(projectRoot, deps.storePath) === configHash) {
120
+ sessionDecisions.set(key, true);
121
+ return true;
122
+ }
123
+ if (!deps.hasUI) {
124
+ sessionDecisions.set(key, false);
125
+ deps.notify("Project hooks skipped: not yet approved and no UI to ask (user/managed hooks still run).");
126
+ return false;
127
+ }
128
+
129
+ let pending = pendingPrompts.get(key);
130
+ if (!pending) {
131
+ pending = (async () => {
132
+ const approved =
133
+ (await deps.confirm(
134
+ "Run this project's hooks?",
135
+ `This project's .claude settings define ${describeProjectHooks(projectSources)}\n\nThey run as shell commands on your machine. Approval is remembered until the hook config changes.`,
136
+ )) === true;
137
+ sessionDecisions.set(key, approved);
138
+ if (approved) persistApproval(projectRoot, configHash, deps.storePath);
139
+ else deps.notify("Project hooks disabled for this session (user/managed hooks still run).");
140
+ return approved;
141
+ })().finally(() => pendingPrompts.delete(key));
142
+ pendingPrompts.set(key, pending);
143
+ }
144
+ return pending;
145
+ }
146
+
147
+ /** Test seam. */
148
+ export function resetTrustSessionState(): void {
149
+ sessionDecisions.clear();
150
+ pendingPrompts.clear();
151
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared "restore latest tool-result details from branch history" scan.
3
+ *
4
+ * Several extensions (todo, tasks, worktree) keep their live state in
5
+ * tool-result `details`, so branching or resuming a session can rebuild it by
6
+ * replaying `ctx.sessionManager.getBranch()`. Each one only wants the LAST
7
+ * matching result that counts as a real state update — a failed or no-op call
8
+ * (e.g. a refused exit_worktree) still produces a toolResult with the right
9
+ * toolName, but its details must not clobber an earlier valid restore point.
10
+ * `isValid` is exactly that per-site validity check.
11
+ *
12
+ * A forward loop that only overwrites its running value when `isValid` holds
13
+ * is equivalent to "the last entry for which `isValid` holds wins" — entries
14
+ * that fail the check are no-ops for the running value. So scanning backward
15
+ * and returning at the first entry that passes `isValid` reproduces the same
16
+ * result without walking the rest of the branch.
17
+ */
18
+
19
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
20
+
21
+ export function restoreLatestDetails<T>(
22
+ branch: SessionEntry[],
23
+ toolNames: ReadonlySet<string>,
24
+ isValid: (details: T | undefined) => boolean = (details) => details !== undefined,
25
+ ): T | undefined {
26
+ for (let i = branch.length - 1; i >= 0; i--) {
27
+ const entry = branch[i];
28
+ if (entry.type !== "message") continue;
29
+ const msg = entry.message;
30
+ if (msg.role !== "toolResult" || !toolNames.has(msg.toolName)) continue;
31
+ const details = msg.details as T | undefined;
32
+ if (isValid(details)) return details;
33
+ }
34
+ return undefined;
35
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Claude Code's `# claudeMd` context block — the single `<system-reminder>` that
3
+ * carries the CLAUDE.md files, the MEMORY.md index, the user's email, and the
4
+ * date, prepended to the first user message (see extensions/claude-context for
5
+ * the wiring, and lib/reminders.ts for placement).
6
+ *
7
+ * This module is pure (no pi imports) and byte-exact against real Claude Code
8
+ * captures (opus-4-8.json / latest-haiku.json). The block is identical across
9
+ * model tiers — only the `# Memory` *spec* in the system prompt varies by tier
10
+ * (handled in lib/memory.ts), never this block.
11
+ *
12
+ * Discovery mirrors Claude Code, not pi's own loader: global `~/.claude/CLAUDE.md`
13
+ * first, then project `CLAUDE.md` / `CLAUDE.local.md` from the farthest ancestor
14
+ * down to the cwd. (pi's resource-loader prefers AGENTS.md over CLAUDE.md per
15
+ * dir and omits CLAUDE.local.md / MEMORY.md, so it can't produce these bytes.)
16
+ *
17
+ * What is NOT yet replicated: enterprise-policy files and nested subtree
18
+ * CLAUDE.md loaded on-demand when a file under them is read (a follow-up needing
19
+ * file-tracker integration; absent from every turn-1 capture we have).
20
+ */
21
+
22
+ import { existsSync, statSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
+ import { tryReadFile } from "./plugins.ts";
25
+
26
+ /** One CLAUDE.md-family file as it appears in the block. `content` is raw (untrimmed). */
27
+ export interface ContextFile {
28
+ path: string;
29
+ content: string;
30
+ descriptor: string;
31
+ }
32
+
33
+ export const GLOBAL_DESCRIPTOR = "user's private global instructions for all projects";
34
+ export const PROJECT_DESCRIPTOR = "project instructions, checked into the codebase";
35
+ export const LOCAL_DESCRIPTOR = "user's private project instructions, not checked in";
36
+ export const MEMORY_DESCRIPTOR = "user's auto-memory, persists across conversations";
37
+
38
+ const PREAMBLE =
39
+ "As you answer the user's questions, you can use the following context:\n" +
40
+ "# claudeMd\n" +
41
+ "Codebase and user instructions are shown below. Be sure to adhere to these instructions. " +
42
+ "IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.";
43
+
44
+ const TRAILER =
45
+ " IMPORTANT: this context may or may not be relevant to your tasks. " +
46
+ "You should not respond to this context unless it is highly relevant to your task.";
47
+
48
+ function readFileIfPresent(path: string): string | null {
49
+ try {
50
+ if (!existsSync(path) || !statSync(path).isFile()) return null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ const content = tryReadFile(path);
55
+ return content === undefined ? null : content;
56
+ }
57
+
58
+ /**
59
+ * Claude Code's CLAUDE.md discovery, ordered as it appears in the block: global
60
+ * first, then project files from the farthest ancestor down to the cwd. Within a
61
+ * directory, `CLAUDE.md` then `CLAUDE.local.md`. `homeClaudeDir` is `~/.claude`.
62
+ */
63
+ export function discoverContextFiles(opts: {
64
+ cwd: string;
65
+ homeClaudeDir: string;
66
+ }): ContextFile[] {
67
+ const files: ContextFile[] = [];
68
+ const seen = new Set<string>();
69
+
70
+ const globalPath = join(opts.homeClaudeDir, "CLAUDE.md");
71
+ const globalContent = readFileIfPresent(globalPath);
72
+ if (globalContent !== null) {
73
+ files.push({ path: globalPath, content: globalContent, descriptor: GLOBAL_DESCRIPTOR });
74
+ seen.add(globalPath);
75
+ }
76
+
77
+ // Walk cwd → root collecting directories, then emit farthest-ancestor first.
78
+ const dirs: string[] = [];
79
+ let dir = opts.cwd;
80
+ while (true) {
81
+ dirs.unshift(dir);
82
+ const parent = dirname(dir);
83
+ if (parent === dir) break;
84
+ dir = parent;
85
+ }
86
+
87
+ for (const d of dirs) {
88
+ for (const [name, descriptor] of [
89
+ ["CLAUDE.md", PROJECT_DESCRIPTOR],
90
+ ["CLAUDE.local.md", LOCAL_DESCRIPTOR],
91
+ ] as const) {
92
+ const p = join(d, name);
93
+ if (seen.has(p)) continue;
94
+ const content = readFileIfPresent(p);
95
+ if (content !== null) {
96
+ files.push({ path: p, content, descriptor });
97
+ seen.add(p);
98
+ }
99
+ }
100
+ }
101
+
102
+ return files;
103
+ }
104
+
105
+ /**
106
+ * Assemble the block's inner text (the `<system-reminder>` wrapper is added by
107
+ * lib/reminders.ts). Byte-exact rule reverse-engineered from opus-4-8.json:
108
+ *
109
+ * {PREAMBLE}\n\n
110
+ * {section}\n{section}\n… ← sections joined by a single "\n"
111
+ * # userEmail\nThe user's email address is {email}.\n
112
+ * # currentDate\nToday's date is {date}.\n
113
+ * \n{TRAILER}
114
+ *
115
+ * where each section is `Contents of {path} ({descriptor}):\n\n{content}` and
116
+ * `content` keeps its own trailing newline (files are read raw, never trimmed —
117
+ * a file ending in "\n" plus the join "\n" is the "\n\n" seen between sections;
118
+ * the last section's own trailing "\n" is the single "\n" before `# userEmail`).
119
+ *
120
+ * `memoryIndex`, when present, is appended as a final context section with the
121
+ * memory descriptor. Returns null when there is nothing at all to inject.
122
+ */
123
+ export function buildClaudeMdBlock(opts: {
124
+ contextFiles: ContextFile[];
125
+ memoryIndex?: { path: string; content: string } | null;
126
+ email?: string | null;
127
+ date: string;
128
+ }): string | null {
129
+ const sections = [...opts.contextFiles];
130
+ if (opts.memoryIndex && opts.memoryIndex.content.trim()) {
131
+ sections.push({
132
+ path: opts.memoryIndex.path,
133
+ content: opts.memoryIndex.content,
134
+ descriptor: MEMORY_DESCRIPTOR,
135
+ });
136
+ }
137
+
138
+ if (sections.length === 0 && !opts.email) return null;
139
+
140
+ let inner = `${PREAMBLE}\n\n`;
141
+ inner += sections.map((s) => `Contents of ${s.path} (${s.descriptor}):\n\n${s.content}`).join("\n");
142
+ if (opts.email) {
143
+ inner += `# userEmail\nThe user's email address is ${opts.email}.\n`;
144
+ }
145
+ inner += `# currentDate\nToday's date is ${opts.date}.\n`;
146
+ inner += `\n${TRAILER}`;
147
+ return inner;
148
+ }