@oortmem/claude-code 0.0.0-stage → 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.
@@ -0,0 +1,71 @@
1
+ import { type ObserveResult } from '@oortmem/core';
2
+ import type { HookDeps } from './deps.js';
3
+ export interface ImportOptions {
4
+ /** Claude Code's projects directory, usually `~/.claude/projects`. */
5
+ projectsDir: string;
6
+ /** Only transcripts modified at or after this time. */
7
+ since?: Date;
8
+ /** Count and estimate only; write nothing. */
9
+ estimateOnly: boolean;
10
+ /** Share of observations expected to reach the writer (after the gate). Default 0.25. */
11
+ writerFraction?: number;
12
+ /**
13
+ * Import sessions whose project cannot be resolved into global scope.
14
+ * Default false: they are skipped, since a deleted private project's
15
+ * memories would otherwise surface in every project.
16
+ */
17
+ includeUnresolved?: boolean;
18
+ }
19
+ export interface ImportResult {
20
+ /** Sessions with at least one observation to import. */
21
+ sessions: number;
22
+ observations: number;
23
+ /** Writer tokens: Σ(chars / 4 + 400) × writerFraction. No price table in Phase 1. */
24
+ estimatedTokens: number;
25
+ /**
26
+ * Sessions with observations that were left out because their project could
27
+ * not be resolved (recorded `cwd` gone, or not in a git repository).
28
+ */
29
+ skippedUnresolved: number;
30
+ /** Transcripts that vanished or could not be read during the walk. */
31
+ skippedUnreadable: number;
32
+ results?: ObserveResult[];
33
+ /** Per-outcome counts, plus `failed` for observations whose observe threw. */
34
+ outcomes?: Record<string, number>;
35
+ /**
36
+ * Sessions that stopped at a failed observation: its cursor stays before it,
37
+ * and the session's later observations were not attempted (the Stop hook or
38
+ * a later import picks them up).
39
+ */
40
+ failedSessions?: Record<string, {
41
+ failed: number;
42
+ notAttempted: number;
43
+ reason: string;
44
+ }>;
45
+ /**
46
+ * Observations skipped because the failure is permanent for that input (the
47
+ * writer's output never fits the schema, or the model refused or truncated),
48
+ * keyed by `failureReason`. Their sessions continue and the cursor passes them.
49
+ */
50
+ unwritable?: Record<string, number>;
51
+ }
52
+ /**
53
+ * Why an observation failed, from the error's class and the typed fields our
54
+ * adapters set (`kind`, `status`, `fields`). Never the message: a provider's
55
+ * message may echo the observation it was given.
56
+ */
57
+ export declare function failureReason(e: unknown): string;
58
+ /**
59
+ * Imports existing Claude Code transcripts. Every transcript is parsed,
60
+ * paired and scrubbed; turns already behind a session's cursor (observed by
61
+ * the Stop hook or an earlier import) are skipped. With `estimateOnly` it
62
+ * only reports the estimate. Otherwise it observes oldest first across all
63
+ * sessions, advances each session's cursor so the Stop hook never re-observes
64
+ * imported turns, and appends one eval row per added memory to
65
+ * `$OORT_HOME/eval/import-<YYYY-MM-DD>.jsonl`.
66
+ *
67
+ * The scope of each transcript is derived from the `cwd` its rows recorded.
68
+ * A session whose project cannot be resolved is skipped (and counted in
69
+ * `skippedUnresolved`) unless `includeUnresolved` imports it as global.
70
+ */
71
+ export declare function importTranscripts(opts: ImportOptions, deps: HookDeps): Promise<ImportResult>;
package/dist/import.js ADDED
@@ -0,0 +1,244 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { appendPrivateFile, localScope, observe, resolveProjectId, scrubObservation, } from '@oortmem/core';
4
+ import { cursorKey, grantFor } from './hooks.js';
5
+ import { parseTranscriptDetailed, selectAfterCursor } from './transcript.js';
6
+ /** A failure a re-run would repeat for the same observation: skip it rather than stop the session. */
7
+ function isPermanent(e) {
8
+ if (!(e instanceof Error))
9
+ return false;
10
+ if (e.name === 'WriterOutputError')
11
+ return true;
12
+ return e.name === 'WriterError' && e.kind === 'bad-response';
13
+ }
14
+ /**
15
+ * Why an observation failed, from the error's class and the typed fields our
16
+ * adapters set (`kind`, `status`, `fields`). Never the message: a provider's
17
+ * message may echo the observation it was given.
18
+ */
19
+ export function failureReason(e) {
20
+ if (!(e instanceof Error))
21
+ return 'non-error thrown';
22
+ const { kind, status, fields } = e;
23
+ const parts = [];
24
+ if (typeof kind === 'string')
25
+ parts.push(kind);
26
+ if (typeof status === 'number')
27
+ parts.push(`HTTP ${status}`);
28
+ if (Array.isArray(fields) && fields.every((f) => typeof f === 'string'))
29
+ parts.push(`fields: ${fields.join(', ')}`);
30
+ return parts.length === 0 ? e.name : `${e.name} (${parts.join(', ')})`;
31
+ }
32
+ /** Fixed per-call writer overhead (system prompt, schema), in tokens. */
33
+ const WRITER_OVERHEAD_TOKENS = 400;
34
+ const DEFAULT_WRITER_FRACTION = 0.25;
35
+ /**
36
+ * Session transcripts: `<projectsDir>/<project>/<session>.jsonl`. Subagent
37
+ * transcripts (one level deeper) and orphaned copies Claude Code set aside
38
+ * (`<session>.orphaned-…jsonl`) are not sessions of their own.
39
+ */
40
+ function transcripts(projectsDir, since, skip) {
41
+ const out = [];
42
+ let projects;
43
+ try {
44
+ projects = fs.readdirSync(projectsDir, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return out;
48
+ }
49
+ for (const p of projects) {
50
+ if (!p.isDirectory())
51
+ continue;
52
+ const dir = path.join(projectsDir, p.name);
53
+ let files;
54
+ try {
55
+ files = fs.readdirSync(dir, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ for (const f of files) {
61
+ if (!f.isFile() || !f.name.endsWith('.jsonl') || f.name.includes('.orphaned-'))
62
+ continue;
63
+ const file = path.join(dir, f.name);
64
+ if (since !== undefined) {
65
+ let mtime;
66
+ try {
67
+ mtime = fs.statSync(file).mtimeMs;
68
+ }
69
+ catch {
70
+ skip(); // Vanished mid-walk.
71
+ continue;
72
+ }
73
+ if (mtime < since.getTime())
74
+ continue;
75
+ }
76
+ out.push(file);
77
+ }
78
+ }
79
+ return out.sort();
80
+ }
81
+ /**
82
+ * The session's project id, or undefined when it cannot be resolved. A missing
83
+ * directory is unresolved even inside a git repo: walking up from it would
84
+ * attribute the session to the enclosing repository instead.
85
+ */
86
+ function resolveProject(cwd, home) {
87
+ if (cwd === undefined || !fs.existsSync(cwd))
88
+ return undefined;
89
+ return resolveProjectId(cwd, home);
90
+ }
91
+ function chars(o) {
92
+ return (o.userText.length + o.assistantText.length + o.toolSummaries.reduce((n, s) => n + s.length, 0));
93
+ }
94
+ /**
95
+ * Imports existing Claude Code transcripts. Every transcript is parsed,
96
+ * paired and scrubbed; turns already behind a session's cursor (observed by
97
+ * the Stop hook or an earlier import) are skipped. With `estimateOnly` it
98
+ * only reports the estimate. Otherwise it observes oldest first across all
99
+ * sessions, advances each session's cursor so the Stop hook never re-observes
100
+ * imported turns, and appends one eval row per added memory to
101
+ * `$OORT_HOME/eval/import-<YYYY-MM-DD>.jsonl`.
102
+ *
103
+ * The scope of each transcript is derived from the `cwd` its rows recorded.
104
+ * A session whose project cannot be resolved is skipped (and counted in
105
+ * `skippedUnresolved`) unless `includeUnresolved` imports it as global.
106
+ */
107
+ export async function importTranscripts(opts, deps) {
108
+ const items = [];
109
+ let weighted = 0;
110
+ const sessions = new Set();
111
+ let skippedUnresolved = 0;
112
+ let skippedUnreadable = 0;
113
+ const skip = () => {
114
+ skippedUnreadable++;
115
+ };
116
+ for (const file of transcripts(opts.projectsDir, opts.since, skip)) {
117
+ const session = path.basename(file, '.jsonl');
118
+ let text;
119
+ try {
120
+ text = fs.readFileSync(file, 'utf8');
121
+ }
122
+ catch {
123
+ skip();
124
+ continue;
125
+ }
126
+ const detailed = parseTranscriptDetailed(text);
127
+ const cwd = detailed.find((d) => d.turn.cwd.length > 0)?.turn.cwd;
128
+ const projectId = resolveProject(cwd, deps.config.home);
129
+ const scope = localScope(projectId);
130
+ const { fresh } = selectAfterCursor(detailed, await deps.store.getCursor(grantFor(scope), cursorKey(session)));
131
+ const mine = [];
132
+ for (const raw of fresh) {
133
+ const s = scrubObservation(raw);
134
+ if (s.dropped)
135
+ continue;
136
+ mine.push({ session, pos: mine.length, obs: s.obs, scope });
137
+ }
138
+ if (mine.length === 0)
139
+ continue;
140
+ if (projectId === undefined && opts.includeUnresolved !== true) {
141
+ skippedUnresolved++;
142
+ continue;
143
+ }
144
+ for (const item of mine)
145
+ weighted += chars(item.obs) / 4 + WRITER_OVERHEAD_TOKENS;
146
+ items.push(...mine);
147
+ sessions.add(session);
148
+ }
149
+ const fraction = opts.writerFraction ?? DEFAULT_WRITER_FRACTION;
150
+ const summary = {
151
+ sessions: sessions.size,
152
+ observations: items.length,
153
+ estimatedTokens: Math.ceil(weighted * fraction),
154
+ skippedUnresolved,
155
+ skippedUnreadable,
156
+ };
157
+ if (opts.estimateOnly)
158
+ return summary;
159
+ const { embed, decide, write } = deps;
160
+ if (embed === undefined || decide === undefined || write === undefined) {
161
+ throw new Error('import needs the embed, decide and write adapters');
162
+ }
163
+ // Oldest first across sessions, but never out of order within one: each item
164
+ // sorts at its session's running maximum timestamp (the sort is stable).
165
+ const runningMax = new Map();
166
+ const key = new Map();
167
+ for (const item of items) {
168
+ const t = Date.parse(item.obs.at);
169
+ const k = Math.max(runningMax.get(item.session) ?? 0, Number.isNaN(t) ? 0 : t);
170
+ runningMax.set(item.session, k);
171
+ key.set(item, k);
172
+ }
173
+ items.sort((a, b) => (key.get(a) ?? 0) - (key.get(b) ?? 0));
174
+ const day = (deps.now?.() ?? new Date()).toISOString().slice(0, 10);
175
+ const evalFile = path.join(deps.config.home, 'eval', `import-${day}.jsonl`);
176
+ const results = [];
177
+ const outcomes = {};
178
+ const failedSessions = {};
179
+ const unwritable = {};
180
+ const done = new Map();
181
+ // Only forward within a session, and only past observations that are finished with.
182
+ const advance = async (item) => {
183
+ const prev = done.get(item.session) ?? -1;
184
+ if (item.pos > prev && item.obs.turnRef !== undefined) {
185
+ await deps.store.setCursor(grantFor(item.scope), cursorKey(item.session), item.obs.turnRef);
186
+ done.set(item.session, item.pos);
187
+ }
188
+ };
189
+ for (const item of items) {
190
+ const stopped = failedSessions[item.session];
191
+ if (stopped !== undefined) {
192
+ // The session stopped at an earlier failure; its cursor must not pass it.
193
+ stopped.notAttempted++;
194
+ continue;
195
+ }
196
+ let r;
197
+ try {
198
+ r = await observe(item.obs, item.scope, {
199
+ store: deps.store,
200
+ embed,
201
+ decide,
202
+ write,
203
+ config: deps.config,
204
+ now: deps.now?.() ?? new Date(),
205
+ harness: 'claude-code',
206
+ });
207
+ }
208
+ catch (e) {
209
+ if (isPermanent(e)) {
210
+ outcomes['unwritable'] = (outcomes['unwritable'] ?? 0) + 1;
211
+ const why = failureReason(e);
212
+ unwritable[why] = (unwritable[why] ?? 0) + 1;
213
+ await advance(item);
214
+ continue;
215
+ }
216
+ outcomes['failed'] = (outcomes['failed'] ?? 0) + 1;
217
+ failedSessions[item.session] = { failed: 1, notAttempted: 0, reason: failureReason(e) };
218
+ continue;
219
+ }
220
+ results.push(r);
221
+ // A turn can become several memories (CON-56): count and log each one.
222
+ for (const m of r.memories) {
223
+ outcomes[m.outcome] = (outcomes[m.outcome] ?? 0) + 1;
224
+ if (m.outcome !== 'added' || m.memoryId === undefined)
225
+ continue;
226
+ const row = {
227
+ memoryId: m.memoryId,
228
+ question: '',
229
+ expectedMemoryIds: [m.memoryId],
230
+ asOf: item.obs.at,
231
+ };
232
+ appendPrivateFile(evalFile, `${JSON.stringify(row)}\n`);
233
+ }
234
+ await advance(item);
235
+ }
236
+ return {
237
+ ...summary,
238
+ results,
239
+ outcomes,
240
+ ...(Object.keys(failedSessions).length > 0 && { failedSessions }),
241
+ ...(Object.keys(unwritable).length > 0 && { unwritable }),
242
+ };
243
+ }
244
+ //# sourceMappingURL=import.js.map
@@ -0,0 +1,7 @@
1
+ export type { HookDeps } from './deps.js';
2
+ export { createHookLogger, HOOK_LOG_MAX_BYTES, type HookLogEntry, type HookLogger } from './log.js';
3
+ export { HARNESS_TAGS, OORT_MEMORY_TAG, parseTranscript, parseTranscriptDetailed, selectAfterCursor, turnsToObservations, type DetailedTurn, type Turn, } from './transcript.js';
4
+ export { cursorKey, filterFor, grantFor, handleSessionStart, handleStop, handleUserPromptSubmit, RECALL_TIMEOUT_MS, runHook, STOP_MAX_OBSERVATIONS, type HookEvent, type HookOutput, } from './hooks.js';
5
+ export { drainQueue, enqueueObservations, queueDir, type DrainResult, type QueueLine, } from './queue.js';
6
+ export { HOOK_EVENTS, HOOK_TIMEOUTS, hookStatus, installHooks, uninstallHooks, type HookEventStatus, } from './install.js';
7
+ export { importTranscripts, type ImportOptions, type ImportResult } from './import.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { createHookLogger, HOOK_LOG_MAX_BYTES } from './log.js';
2
+ export { HARNESS_TAGS, OORT_MEMORY_TAG, parseTranscript, parseTranscriptDetailed, selectAfterCursor, turnsToObservations, } from './transcript.js';
3
+ export { cursorKey, filterFor, grantFor, handleSessionStart, handleStop, handleUserPromptSubmit, RECALL_TIMEOUT_MS, runHook, STOP_MAX_OBSERVATIONS, } from './hooks.js';
4
+ export { drainQueue, enqueueObservations, queueDir, } from './queue.js';
5
+ export { HOOK_EVENTS, HOOK_TIMEOUTS, hookStatus, installHooks, uninstallHooks, } from './install.js';
6
+ export { importTranscripts } from './import.js';
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Installs Oort's hooks into a Claude Code settings file (usually
3
+ * `~/.claude/settings.json`). Schema, per https://code.claude.com/docs/en/hooks:
4
+ *
5
+ * { "hooks": { "<Event>": [ { "matcher"?: string, "hooks": [ { "type": "command", "command": string, "timeout"?: seconds } ] } ] } }
6
+ *
7
+ * UserPromptSubmit and Stop take no matcher (one would be ignored); SessionStart's
8
+ * matcher filters the session source, and is omitted so it fires on every source.
9
+ */
10
+ export declare const HOOK_EVENTS: readonly ['UserPromptSubmit', 'Stop', 'SessionStart'];
11
+ export type InstalledEvent = (typeof HOOK_EVENTS)[number];
12
+ /** Seconds. Claude Code's defaults are 30 (UserPromptSubmit) and 600 (others). */
13
+ export declare const HOOK_TIMEOUTS: Record<InstalledEvent, number>;
14
+ /** `cmd` as one shell word: as is when it is plainly safe, otherwise single-quoted. */
15
+ export declare function shellQuote(cmd: string): string;
16
+ /**
17
+ * Merges Oort's three hook entries (`<cmd> hook <Event>`) into the settings
18
+ * file, creating it if missing. Idempotent; replaces a stale Oort entry in
19
+ * place; never removes or reorders anyone else's hooks. Writes atomically and
20
+ * keeps a one-time backup of the original at `<settingsPath>.oort-backup`.
21
+ * Refuses (file untouched) settings that are not JSON, or whose `hooks` or
22
+ * hook event values have the wrong shape.
23
+ */
24
+ export declare function installHooks(settingsPath: string, cmd: string): {
25
+ changed: boolean;
26
+ };
27
+ /**
28
+ * Removes only Oort's entries. A group left empty by the removal goes too; an
29
+ * event array or `hooks` object left empty goes only if Oort created it (it is
30
+ * absent from the pre-install backup). A missing file is left missing.
31
+ */
32
+ export declare function uninstallHooks(settingsPath: string, cmd: string): {
33
+ changed: boolean;
34
+ };
35
+ export interface HookEventStatus {
36
+ event: InstalledEvent;
37
+ /**
38
+ * `current`: `<cmd> hook <event>` for this bin. `stale`: Oort's entry from
39
+ * another install path. `found`: Oort's entry, with no bin to compare
40
+ * against. `missing`: no Oort entry (a wrapper such as `cd x && oort hook
41
+ * Stop` is the user's own and does not count).
42
+ */
43
+ state: 'current' | 'stale' | 'found' | 'missing';
44
+ /** The bin the entry runs, unquoted, for `stale` and `found`. */
45
+ command?: string;
46
+ }
47
+ /**
48
+ * Read-only: which of Oort's hooks the settings file holds, for `oort
49
+ * doctor`. Throws, like `installHooks`, on settings that are not JSON or have
50
+ * the wrong shape.
51
+ */
52
+ export declare function hookStatus(settingsPath: string, cmd?: string): {
53
+ exists: boolean;
54
+ events: HookEventStatus[];
55
+ };
@@ -0,0 +1,257 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { writeFileAtomic } from './fsutil.js';
4
+ /**
5
+ * Installs Oort's hooks into a Claude Code settings file (usually
6
+ * `~/.claude/settings.json`). Schema, per https://code.claude.com/docs/en/hooks:
7
+ *
8
+ * { "hooks": { "<Event>": [ { "matcher"?: string, "hooks": [ { "type": "command", "command": string, "timeout"?: seconds } ] } ] } }
9
+ *
10
+ * UserPromptSubmit and Stop take no matcher (one would be ignored); SessionStart's
11
+ * matcher filters the session source, and is omitted so it fires on every source.
12
+ */
13
+ export const HOOK_EVENTS = ['UserPromptSubmit', 'Stop', 'SessionStart'];
14
+ /** Seconds. Claude Code's defaults are 30 (UserPromptSubmit) and 600 (others). */
15
+ export const HOOK_TIMEOUTS = {
16
+ UserPromptSubmit: 10,
17
+ Stop: 5,
18
+ SessionStart: 10,
19
+ };
20
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
21
+ /** `cmd` as one shell word: as is when it is plainly safe, otherwise single-quoted. */
22
+ export function shellQuote(cmd) {
23
+ return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(cmd) ? cmd : `'${cmd.replaceAll("'", `'\\''`)}'`;
24
+ }
25
+ const commandFor = (cmd, event) => `${shellQuote(cmd)} hook ${event}`;
26
+ /**
27
+ * Ours, under `event`: exactly `<cmd> hook <event>`, or exactly `oort hook
28
+ * <event>` from any install path (a stale entry from a moved binary). Anything
29
+ * with more around it (`cd x && oort hook Stop`, `oort hook Stop --flag`) is
30
+ * the user's own wrapper and is never touched.
31
+ */
32
+ function isOurs(h, cmd, event) {
33
+ if (!isObject(h) || h['type'] !== 'command' || typeof h['command'] !== 'string')
34
+ return false;
35
+ const c = h['command'];
36
+ return (c === commandFor(cmd, event) ||
37
+ new RegExp(`^(?:\\S*/)?oort hook ${event}$`).test(c) ||
38
+ // A package's bin, as `oort install` writes it: `<prefix>/oort(mem)/dist/main.js`.
39
+ new RegExp(`^(?:'[^']*/oort(?:mem)?/dist/main\\.js'|[^'\\s]*/oort(?:mem)?/dist/main\\.js) hook ${event}$`).test(c));
40
+ }
41
+ function desired(cmd, event) {
42
+ return { type: 'command', command: commandFor(cmd, event), timeout: HOOK_TIMEOUTS[event] };
43
+ }
44
+ function parseSettings(text, file) {
45
+ let parsed;
46
+ try {
47
+ parsed = text.trim().length === 0 ? {} : JSON.parse(text);
48
+ }
49
+ catch (e) {
50
+ throw new Error(`Claude Code settings ${file} is not valid JSON: ${e.message}`);
51
+ }
52
+ if (!isObject(parsed))
53
+ throw new Error(`Claude Code settings ${file} is not a JSON object`);
54
+ const hooks = parsed['hooks'];
55
+ if (hooks === undefined)
56
+ return parsed;
57
+ if (!isObject(hooks))
58
+ throw new Error(`Claude Code settings ${file}: "hooks" is not an object`);
59
+ for (const event of HOOK_EVENTS) {
60
+ if (hooks[event] !== undefined && !Array.isArray(hooks[event])) {
61
+ throw new Error(`Claude Code settings ${file}: "hooks.${event}" is not an array`);
62
+ }
63
+ }
64
+ return parsed;
65
+ }
66
+ function readSettings(file) {
67
+ let text;
68
+ try {
69
+ text = fs.readFileSync(file, 'utf8');
70
+ }
71
+ catch (e) {
72
+ if (e.code === 'ENOENT') {
73
+ return { settings: {}, existed: false, mode: 0o600 };
74
+ }
75
+ throw e;
76
+ }
77
+ return {
78
+ settings: parseSettings(text, file),
79
+ existed: true,
80
+ mode: fs.statSync(file).mode & 0o777,
81
+ };
82
+ }
83
+ const backupPath = (file) => `${file}.oort-backup`;
84
+ function writeSettings(file, settings, existed, mode) {
85
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
86
+ const backup = backupPath(file);
87
+ if (existed && !fs.existsSync(backup)) {
88
+ fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
89
+ if (process.platform !== 'win32')
90
+ fs.chmodSync(backup, 0o600);
91
+ }
92
+ // A settings file symlinked from a dotfiles repo stays a symlink: write its target.
93
+ const target = existed ? fs.realpathSync(file) : file;
94
+ writeFileAtomic(target, `${JSON.stringify(settings, null, 2)}\n`, mode);
95
+ }
96
+ /**
97
+ * Containers (`hooks`, `hooks.<Event>`) that existed before Oort first
98
+ * installed, read from the one-time backup. No backup means the file did not
99
+ * exist then, so every container is ours. An unreadable backup keeps them all.
100
+ */
101
+ function preexisting(file) {
102
+ let text;
103
+ try {
104
+ text = fs.readFileSync(backupPath(file), 'utf8');
105
+ }
106
+ catch {
107
+ return { hooks: false, events: new Set() };
108
+ }
109
+ try {
110
+ const before = parseSettings(text, backupPath(file));
111
+ const hooks = before['hooks'];
112
+ return {
113
+ hooks: isObject(hooks),
114
+ events: new Set(isObject(hooks) ? Object.keys(hooks) : []),
115
+ };
116
+ }
117
+ catch {
118
+ return { hooks: true, events: new Set(HOOK_EVENTS) };
119
+ }
120
+ }
121
+ /**
122
+ * Merges Oort's three hook entries (`<cmd> hook <Event>`) into the settings
123
+ * file, creating it if missing. Idempotent; replaces a stale Oort entry in
124
+ * place; never removes or reorders anyone else's hooks. Writes atomically and
125
+ * keeps a one-time backup of the original at `<settingsPath>.oort-backup`.
126
+ * Refuses (file untouched) settings that are not JSON, or whose `hooks` or
127
+ * hook event values have the wrong shape.
128
+ */
129
+ export function installHooks(settingsPath, cmd) {
130
+ const { settings, existed, mode } = readSettings(settingsPath);
131
+ const before = JSON.stringify(settings);
132
+ const hooks = isObject(settings['hooks']) ? { ...settings['hooks'] } : {};
133
+ for (const event of HOOK_EVENTS) {
134
+ const want = desired(cmd, event);
135
+ const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
136
+ // Replace the first of ours in place, drop any other copies.
137
+ let placed = false;
138
+ const next = [];
139
+ for (const g of groups) {
140
+ if (!isObject(g) || !Array.isArray(g['hooks'])) {
141
+ next.push(g);
142
+ continue;
143
+ }
144
+ const hs = [];
145
+ let touched = false;
146
+ for (const h of g['hooks']) {
147
+ if (!isOurs(h, cmd, event)) {
148
+ hs.push(h);
149
+ }
150
+ else if (!placed) {
151
+ hs.push(want);
152
+ placed = true;
153
+ touched = true;
154
+ }
155
+ else {
156
+ touched = true;
157
+ }
158
+ }
159
+ if (!touched)
160
+ next.push(g);
161
+ else if (hs.length > 0)
162
+ next.push({ ...g, hooks: hs });
163
+ }
164
+ if (!placed)
165
+ next.push({ hooks: [want] });
166
+ hooks[event] = next;
167
+ }
168
+ const updated = { ...settings, hooks };
169
+ if (JSON.stringify(updated) === before)
170
+ return { changed: false };
171
+ writeSettings(settingsPath, updated, existed, mode);
172
+ return { changed: true };
173
+ }
174
+ /**
175
+ * Removes only Oort's entries. A group left empty by the removal goes too; an
176
+ * event array or `hooks` object left empty goes only if Oort created it (it is
177
+ * absent from the pre-install backup). A missing file is left missing.
178
+ */
179
+ export function uninstallHooks(settingsPath, cmd) {
180
+ if (!fs.existsSync(settingsPath))
181
+ return { changed: false };
182
+ const { settings, existed, mode } = readSettings(settingsPath);
183
+ if (!isObject(settings['hooks']))
184
+ return { changed: false };
185
+ const hooks = { ...settings['hooks'] };
186
+ const kept = preexisting(settingsPath);
187
+ let removed = false;
188
+ for (const event of HOOK_EVENTS) {
189
+ const groups = hooks[event];
190
+ if (!Array.isArray(groups))
191
+ continue;
192
+ const next = [];
193
+ for (const g of groups) {
194
+ if (!isObject(g) || !Array.isArray(g['hooks'])) {
195
+ next.push(g);
196
+ continue;
197
+ }
198
+ const hs = g['hooks'];
199
+ const left = hs.filter((h) => !isOurs(h, cmd, event));
200
+ if (left.length === hs.length)
201
+ next.push(g);
202
+ else if (left.length > 0)
203
+ next.push({ ...g, hooks: left });
204
+ }
205
+ if (next.length === groups.length && next.every((g, i) => g === groups[i]))
206
+ continue;
207
+ removed = true;
208
+ if (next.length === 0 && !kept.events.has(event))
209
+ delete hooks[event];
210
+ else
211
+ hooks[event] = next;
212
+ }
213
+ if (!removed)
214
+ return { changed: false };
215
+ const updated = { ...settings, hooks };
216
+ if (Object.keys(hooks).length === 0 && !kept.hooks)
217
+ delete updated['hooks'];
218
+ writeSettings(settingsPath, updated, existed, mode);
219
+ return { changed: true };
220
+ }
221
+ /** The bin part of `<bin> hook <event>`, with shellQuote's quoting undone. */
222
+ function binOf(command, event) {
223
+ const bin = command.slice(0, -` hook ${event}`.length);
224
+ return bin.startsWith("'") && bin.endsWith("'") ? bin.slice(1, -1).replaceAll(`'\\''`, "'") : bin;
225
+ }
226
+ /**
227
+ * Read-only: which of Oort's hooks the settings file holds, for `oort
228
+ * doctor`. Throws, like `installHooks`, on settings that are not JSON or have
229
+ * the wrong shape.
230
+ */
231
+ export function hookStatus(settingsPath, cmd) {
232
+ const { settings, existed } = readSettings(settingsPath);
233
+ const hooks = isObject(settings['hooks']) ? settings['hooks'] : {};
234
+ const events = HOOK_EVENTS.map((event) => {
235
+ const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
236
+ for (const g of groups) {
237
+ if (!isObject(g) || !Array.isArray(g['hooks']))
238
+ continue;
239
+ for (const h of g['hooks']) {
240
+ if (!isOurs(h, cmd ?? 'oort', event))
241
+ continue;
242
+ const command = h.command;
243
+ if (cmd !== undefined && command === commandFor(cmd, event)) {
244
+ return { event, state: 'current' };
245
+ }
246
+ return {
247
+ event,
248
+ state: cmd === undefined ? 'found' : 'stale',
249
+ command: binOf(command, event),
250
+ };
251
+ }
252
+ }
253
+ return { event, state: 'missing' };
254
+ });
255
+ return { exists: existed, events };
256
+ }
257
+ //# sourceMappingURL=install.js.map
package/dist/log.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * One JSON line per hook run in `$OORT_HOME/logs/hooks.log`. Entries hold ids,
3
+ * outcomes, counts and timings only, never prompt, memory or transcript text:
4
+ * the logger takes no free-form string beyond the fields below, and callers
5
+ * pass outcome names they choose, never input.
6
+ */
7
+ export interface HookLogEntry {
8
+ ts: string;
9
+ event: string;
10
+ /** Claude Code's session id (sanitised). */
11
+ session: string | null;
12
+ project: string | null;
13
+ outcome: string;
14
+ counts: Record<string, number>;
15
+ timings: Record<string, number>;
16
+ degraded?: string[];
17
+ /** Queue line ids (random uuids), e.g. for dead-lettered lines. */
18
+ ids?: string[];
19
+ }
20
+ export interface HookLogger {
21
+ /** Appends one line. Never throws: a broken log must not break a session. */
22
+ write(entry: Omit<HookLogEntry, 'ts'> & {
23
+ ts?: string;
24
+ }): void;
25
+ }
26
+ /** The log rotates to `hooks.log.1` (one rotation kept) once it exceeds this. */
27
+ export declare const HOOK_LOG_MAX_BYTES: number;
28
+ export declare function createHookLogger(home: string, opts?: {
29
+ maxBytes?: number;
30
+ }): HookLogger;