harborloop 0.3.0 → 0.4.1

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 (120) hide show
  1. package/bin/checksums.json +12 -0
  2. package/bin/harbor.mjs +188 -0
  3. package/package.json +14 -30
  4. package/dist/api.d.ts +0 -109
  5. package/dist/api.js +0 -409
  6. package/dist/auth/login.d.ts +0 -14
  7. package/dist/auth/login.js +0 -158
  8. package/dist/auth/loopback.d.ts +0 -11
  9. package/dist/auth/loopback.js +0 -87
  10. package/dist/auth/pkce.d.ts +0 -7
  11. package/dist/auth/pkce.js +0 -14
  12. package/dist/auth/store.d.ts +0 -26
  13. package/dist/auth/store.js +0 -107
  14. package/dist/cli-release.d.ts +0 -31
  15. package/dist/cli-release.js +0 -102
  16. package/dist/commands/bind.d.ts +0 -23
  17. package/dist/commands/bind.js +0 -86
  18. package/dist/commands/context.d.ts +0 -12
  19. package/dist/commands/context.js +0 -80
  20. package/dist/commands/daemon.d.ts +0 -39
  21. package/dist/commands/daemon.js +0 -140
  22. package/dist/commands/doctor.d.ts +0 -16
  23. package/dist/commands/doctor.js +0 -937
  24. package/dist/commands/init.d.ts +0 -36
  25. package/dist/commands/init.js +0 -192
  26. package/dist/commands/status.d.ts +0 -5
  27. package/dist/commands/status.js +0 -164
  28. package/dist/commands/sync.d.ts +0 -40
  29. package/dist/commands/sync.js +0 -242
  30. package/dist/commands/toggle.d.ts +0 -16
  31. package/dist/commands/toggle.js +0 -69
  32. package/dist/commands/unbind.d.ts +0 -14
  33. package/dist/commands/unbind.js +0 -60
  34. package/dist/commands/workspaces.d.ts +0 -18
  35. package/dist/commands/workspaces.js +0 -41
  36. package/dist/context-budget.d.ts +0 -40
  37. package/dist/context-budget.js +0 -259
  38. package/dist/daemon/install.d.ts +0 -50
  39. package/dist/daemon/install.js +0 -192
  40. package/dist/daemon/loop.d.ts +0 -15
  41. package/dist/daemon/loop.js +0 -84
  42. package/dist/daemon/state.d.ts +0 -79
  43. package/dist/daemon/state.js +0 -213
  44. package/dist/daemon/tick.d.ts +0 -37
  45. package/dist/daemon/tick.js +0 -231
  46. package/dist/documents/apply.d.ts +0 -21
  47. package/dist/documents/apply.js +0 -58
  48. package/dist/documents/bundle.d.ts +0 -41
  49. package/dist/documents/bundle.js +0 -43
  50. package/dist/documents/inspect.d.ts +0 -12
  51. package/dist/documents/inspect.js +0 -33
  52. package/dist/documents/manifest.d.ts +0 -14
  53. package/dist/documents/manifest.js +0 -52
  54. package/dist/documents/pointer.d.ts +0 -32
  55. package/dist/documents/pointer.js +0 -135
  56. package/dist/documents/write.d.ts +0 -57
  57. package/dist/documents/write.js +0 -211
  58. package/dist/endpoints.d.ts +0 -7
  59. package/dist/endpoints.js +0 -15
  60. package/dist/env-bundle.d.ts +0 -25
  61. package/dist/env-bundle.js +0 -33
  62. package/dist/env-scan.d.ts +0 -27
  63. package/dist/env-scan.js +0 -41
  64. package/dist/env-sync.d.ts +0 -31
  65. package/dist/env-sync.js +0 -37
  66. package/dist/index.d.ts +0 -11
  67. package/dist/index.js +0 -282
  68. package/dist/install-hooks.d.ts +0 -11
  69. package/dist/install-hooks.js +0 -64
  70. package/dist/lock.d.ts +0 -31
  71. package/dist/lock.js +0 -192
  72. package/dist/machine.d.ts +0 -13
  73. package/dist/machine.js +0 -53
  74. package/dist/memories.d.ts +0 -31
  75. package/dist/memories.js +0 -74
  76. package/dist/origin.d.ts +0 -4
  77. package/dist/origin.js +0 -59
  78. package/dist/paths.d.ts +0 -12
  79. package/dist/paths.js +0 -45
  80. package/dist/providers/claude.d.ts +0 -65
  81. package/dist/providers/claude.js +0 -412
  82. package/dist/providers/codex-trust.d.ts +0 -36
  83. package/dist/providers/codex-trust.js +0 -141
  84. package/dist/providers/codex.d.ts +0 -46
  85. package/dist/providers/codex.js +0 -520
  86. package/dist/providers/cursor.d.ts +0 -45
  87. package/dist/providers/cursor.js +0 -344
  88. package/dist/providers/detect.d.ts +0 -16
  89. package/dist/providers/detect.js +0 -70
  90. package/dist/providers/hook-coverage.d.ts +0 -5
  91. package/dist/providers/hook-coverage.js +0 -47
  92. package/dist/providers/hook-entry.d.ts +0 -1
  93. package/dist/providers/hook-entry.js +0 -8
  94. package/dist/providers/surfaces.d.ts +0 -19
  95. package/dist/providers/surfaces.js +0 -91
  96. package/dist/reclaim.d.ts +0 -17
  97. package/dist/reclaim.js +0 -33
  98. package/dist/render.d.ts +0 -17
  99. package/dist/render.js +0 -61
  100. package/dist/repo-sync.d.ts +0 -27
  101. package/dist/repo-sync.js +0 -34
  102. package/dist/repos.d.ts +0 -11
  103. package/dist/repos.js +0 -82
  104. package/dist/skills.d.ts +0 -84
  105. package/dist/skills.js +0 -383
  106. package/dist/slugify.d.ts +0 -1
  107. package/dist/slugify.js +0 -8
  108. package/dist/state.d.ts +0 -23
  109. package/dist/state.js +0 -161
  110. package/dist/sync-report.d.ts +0 -59
  111. package/dist/sync-report.js +0 -87
  112. package/hooks/_recall.mjs +0 -288
  113. package/hooks/_receipt.mjs +0 -47
  114. package/hooks/_shared.mjs +0 -1610
  115. package/hooks/_upgrade.mjs +0 -51
  116. package/hooks/session-capture.mjs +0 -60
  117. package/hooks/session-compact.mjs +0 -19
  118. package/hooks/session-context.mjs +0 -258
  119. package/hooks/session-report.mjs +0 -42
  120. package/hooks/session-toolcontext.mjs +0 -183
package/hooks/_shared.mjs DELETED
@@ -1,1610 +0,0 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { createHash, randomUUID } from 'node:crypto';
3
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync, } from 'node:fs';
4
- import { homedir } from 'node:os';
5
- import { basename, dirname, join, resolve, sep } from 'node:path';
6
- import { StringDecoder } from 'node:string_decoder';
7
- export const HOME = homedir();
8
- const HARBOR_HOME_ENV = process.env.HARBOR_HOME && process.env.HARBOR_HOME.trim() !== ''
9
- ? process.env.HARBOR_HOME
10
- : process.env.ALEF0_HOME && process.env.ALEF0_HOME.trim() !== ''
11
- ? process.env.ALEF0_HOME
12
- : '';
13
- export const HARBOR_DIR = HARBOR_HOME_ENV !== '' ? HARBOR_HOME_ENV : join(HOME, '.harbor');
14
- export const LEGACY_DIRS = [join(HOME, '.alef0'), join(HOME, '.milestone')];
15
- export const CLI_SESSIONS_DIR = join(HARBOR_DIR, 'cli-sessions');
16
- export const LEGACY_CLI_SESSIONS_DIRS = LEGACY_DIRS.map((d) => join(d, 'cli-sessions'));
17
- export const CAPTURE_DIR = join(HARBOR_DIR, 'capture');
18
- export const SYNCED_ORIGINS_PATH = join(HARBOR_DIR, 'synced-origins.json');
19
- export const LEGACY_SYNCED_ORIGINS_PATHS = LEGACY_DIRS.map((d) => join(d, 'synced-origins.json'));
20
- export const ORIGINS_REFRESH_STAMP_PATH = join(HARBOR_DIR, 'origins-refreshed-at');
21
- export const CLAUDE_SETTINGS_PATH = join(HOME, '.claude', 'settings.json');
22
- export const CLAUDE_MCP_CONFIG_PATH = join(HOME, '.claude.json');
23
- export const CODEX_CONFIG_PATH = join(process.env.CODEX_HOME && process.env.CODEX_HOME.trim() !== ''
24
- ? process.env.CODEX_HOME
25
- : join(HOME, '.codex'), 'config.toml');
26
- export const CURSOR_MCP_PATH = join(HOME, '.cursor', 'mcp.json');
27
- const HARBOR_MCP_KEY = 'harbor';
28
- function isOurCodexHeadersTable(trimmed) {
29
- return (trimmed === `[mcp_servers.${HARBOR_MCP_KEY}.http_headers]` ||
30
- trimmed === `[mcp_servers."${HARBOR_MCP_KEY}".http_headers]`);
31
- }
32
- export const REQUEST_TIMEOUT_MS = 4000;
33
- export const ORIGINS_REFRESH_INTERVAL_MS = 10 * 60 * 1000;
34
- export const MAX_DRAIN = 20;
35
- export const MARKER_TTL_MS = 3 * 24 * 60 * 60 * 1000;
36
- export const DRAIN_IDLE_MS = 30 * 60 * 1000;
37
- export const MAX_SCRAPE_PER_DRAIN = 5;
38
- const LINE_READ_CHUNK_BYTES = 1024 * 1024;
39
- function forEachLineSync(path, onLine) {
40
- let fd;
41
- try {
42
- fd = openSync(path, 'r');
43
- }
44
- catch {
45
- return false;
46
- }
47
- try {
48
- const decoder = new StringDecoder('utf8');
49
- const buf = Buffer.allocUnsafe(LINE_READ_CHUNK_BYTES);
50
- let carry = '';
51
- for (;;) {
52
- const read = readSync(fd, buf, 0, LINE_READ_CHUNK_BYTES, null);
53
- if (read <= 0)
54
- break;
55
- carry += decoder.write(buf.subarray(0, read));
56
- let start = 0;
57
- let nl = carry.indexOf('\n', start);
58
- while (nl !== -1) {
59
- onLine(carry.slice(start, nl));
60
- start = nl + 1;
61
- nl = carry.indexOf('\n', start);
62
- }
63
- if (start > 0)
64
- carry = carry.slice(start);
65
- }
66
- carry += decoder.end();
67
- if (carry !== '')
68
- onLine(carry);
69
- return true;
70
- }
71
- catch {
72
- return false;
73
- }
74
- finally {
75
- try {
76
- closeSync(fd);
77
- }
78
- catch {
79
- }
80
- }
81
- }
82
- export async function readStdin() {
83
- if (process.stdin.isTTY)
84
- return '';
85
- const chunks = [];
86
- try {
87
- for await (const chunk of process.stdin)
88
- chunks.push(chunk);
89
- }
90
- catch {
91
- return '';
92
- }
93
- return Buffer.concat(chunks).toString('utf8');
94
- }
95
- export function readJsonFile(path) {
96
- try {
97
- if (!existsSync(path))
98
- return null;
99
- return JSON.parse(readFileSync(path, 'utf8'));
100
- }
101
- catch {
102
- return null;
103
- }
104
- }
105
- let agentHint = null;
106
- export function hookAgent() {
107
- return agentHint;
108
- }
109
- export function adoptHookInput(input) {
110
- agentHint =
111
- typeof input?.cursor_version === 'string' && input.cursor_version !== ''
112
- ? 'cursor'
113
- : null;
114
- return {
115
- agent: agentHint,
116
- sessionId: typeof input?.session_id === 'string' ? input.session_id : '',
117
- cwd: hookCwd(input),
118
- event: canonicalEvent(input?.hook_event_name),
119
- toolName: canonicalTool(input?.tool_name),
120
- transcriptPath: typeof input?.transcript_path === 'string' ? input.transcript_path : '',
121
- };
122
- }
123
- function hookCwd(input) {
124
- if (typeof input?.cwd === 'string' && input.cwd.trim() !== '')
125
- return fromUriPath(input.cwd);
126
- const roots = input?.workspace_roots;
127
- if (Array.isArray(roots) && typeof roots[0] === 'string' && roots[0].trim() !== '') {
128
- return fromUriPath(roots[0]);
129
- }
130
- return '';
131
- }
132
- function fromUriPath(path) {
133
- if (!/^\/[A-Za-z]:\//.test(path))
134
- return path;
135
- return path.slice(1).replace(/\//g, '\\');
136
- }
137
- const EVENT_ALIASES = {
138
- sessionStart: 'SessionStart',
139
- sessionEnd: 'SessionEnd',
140
- stop: 'Stop',
141
- beforeSubmitPrompt: 'UserPromptSubmit',
142
- preToolUse: 'PreToolUse',
143
- postToolUse: 'PostToolUse',
144
- };
145
- export function canonicalEvent(raw) {
146
- if (typeof raw !== 'string' || raw === '')
147
- return '';
148
- return EVENT_ALIASES[raw] ?? raw;
149
- }
150
- const TOOL_ALIASES = { Shell: 'Bash' };
151
- export function canonicalTool(raw) {
152
- if (typeof raw !== 'string' || raw === '')
153
- return '';
154
- return TOOL_ALIASES[raw] ?? raw;
155
- }
156
- export function readAuthHeader() {
157
- return readActiveCredential().header;
158
- }
159
- function readActiveCredential() {
160
- if (agentHint === 'cursor') {
161
- const hinted = asBearer(readCursorAuthHeader());
162
- if (hinted !== null)
163
- return { agent: 'cursor', header: hinted };
164
- }
165
- const claude = asBearer(readClaudeAuthHeader());
166
- if (claude !== null)
167
- return { agent: 'claude', header: claude };
168
- const codex = asBearer(readCodexAuthHeader());
169
- if (codex !== null)
170
- return { agent: 'codex', header: codex };
171
- const cursor = asBearer(readCursorAuthHeader());
172
- if (cursor !== null)
173
- return { agent: 'cursor', header: cursor };
174
- return { agent: null, header: null };
175
- }
176
- function readUsableAuth() {
177
- const auth = readJsonFile(join(HARBOR_DIR, 'auth.json'));
178
- if (!auth ||
179
- typeof auth.runnerToken !== 'string' ||
180
- typeof auth.workspaceId !== 'string' ||
181
- auth.workspaceId === '' ||
182
- typeof auth.apiUrl !== 'string') {
183
- return null;
184
- }
185
- return auth;
186
- }
187
- function asBearer(raw) {
188
- if (typeof raw !== 'string' || raw.trim() === '')
189
- return null;
190
- return raw.startsWith('Bearer ') ? raw : `Bearer ${raw}`;
191
- }
192
- function readClaudeAuthHeader() {
193
- for (const path of [CLAUDE_MCP_CONFIG_PATH, CLAUDE_SETTINGS_PATH]) {
194
- const root = readJsonFile(path);
195
- const header = root?.mcpServers?.[HARBOR_MCP_KEY]?.headers?.Authorization;
196
- if (typeof header === 'string' && header.trim() !== '')
197
- return header;
198
- }
199
- return null;
200
- }
201
- function readCursorAuthHeader() {
202
- let raw;
203
- try {
204
- if (!existsSync(CURSOR_MCP_PATH))
205
- return null;
206
- raw = readFileSync(CURSOR_MCP_PATH, 'utf8');
207
- }
208
- catch {
209
- return null;
210
- }
211
- let root;
212
- try {
213
- root = JSON.parse(stripJsonComments(raw));
214
- }
215
- catch {
216
- return null;
217
- }
218
- const header = root?.mcpServers?.[HARBOR_MCP_KEY]?.headers?.Authorization;
219
- return typeof header === 'string' && header.trim() !== '' ? header : null;
220
- }
221
- export function stripJsonComments(raw) {
222
- let out = '';
223
- let i = 0;
224
- while (i < raw.length) {
225
- const ch = raw[i];
226
- if (ch === '"') {
227
- out += ch;
228
- i += 1;
229
- while (i < raw.length) {
230
- const c = raw[i];
231
- out += c;
232
- i += 1;
233
- if (c === '\\') {
234
- if (i < raw.length) {
235
- out += raw[i];
236
- i += 1;
237
- }
238
- continue;
239
- }
240
- if (c === '"')
241
- break;
242
- }
243
- continue;
244
- }
245
- if (ch === '/' && raw[i + 1] === '/') {
246
- while (i < raw.length && raw[i] !== '\n')
247
- i += 1;
248
- continue;
249
- }
250
- if (ch === '/' && raw[i + 1] === '*') {
251
- i += 2;
252
- while (i < raw.length && !(raw[i] === '*' && raw[i + 1] === '/'))
253
- i += 1;
254
- i += 2;
255
- continue;
256
- }
257
- out += ch;
258
- i += 1;
259
- }
260
- return out;
261
- }
262
- function scanTomlMultilineState(line, state) {
263
- const BASIC = '"""';
264
- const LITERAL = "'''";
265
- let open = state;
266
- let i = 0;
267
- while (i < line.length) {
268
- if (open !== null) {
269
- if (line.startsWith(open, i)) {
270
- open = null;
271
- i += 3;
272
- continue;
273
- }
274
- i += 1;
275
- continue;
276
- }
277
- if (line.startsWith(BASIC, i)) {
278
- open = BASIC;
279
- i += 3;
280
- continue;
281
- }
282
- if (line.startsWith(LITERAL, i)) {
283
- open = LITERAL;
284
- i += 3;
285
- continue;
286
- }
287
- const ch = line[i];
288
- if (ch === '#')
289
- break;
290
- if (ch === '"' || ch === "'") {
291
- const quote = ch;
292
- i += 1;
293
- while (i < line.length) {
294
- if (quote === '"' && line[i] === '\\') {
295
- i += 2;
296
- continue;
297
- }
298
- if (line[i] === quote) {
299
- i += 1;
300
- break;
301
- }
302
- i += 1;
303
- }
304
- continue;
305
- }
306
- i += 1;
307
- }
308
- return open;
309
- }
310
- function readCodexAuthHeader() {
311
- let raw;
312
- try {
313
- if (!existsSync(CODEX_CONFIG_PATH))
314
- return null;
315
- raw = readFileSync(CODEX_CONFIG_PATH, 'utf8');
316
- }
317
- catch {
318
- return null;
319
- }
320
- const lines = raw.split('\n');
321
- let inTable = false;
322
- let multiline = null;
323
- for (const line of lines) {
324
- const startedInsideString = multiline !== null;
325
- multiline = scanTomlMultilineState(line, multiline);
326
- if (startedInsideString)
327
- continue;
328
- const trimmed = line.trim();
329
- if (trimmed.startsWith('[')) {
330
- inTable = isOurCodexHeadersTable(trimmed);
331
- continue;
332
- }
333
- if (!inTable || trimmed === '' || trimmed.startsWith('#'))
334
- continue;
335
- const match = /^Authorization\s*=\s*"((?:[^"\\]|\\.)*)"/.exec(trimmed);
336
- if (match)
337
- return match[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
338
- }
339
- return null;
340
- }
341
- function tomlQuote(value) {
342
- return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
343
- }
344
- export function setCodexAuthorization(content, headerValue) {
345
- const lines = content.split('\n');
346
- let inTable = false;
347
- let multiline = null;
348
- for (let i = 0; i < lines.length; i += 1) {
349
- const startedInsideString = multiline !== null;
350
- multiline = scanTomlMultilineState(lines[i] ?? '', multiline);
351
- if (startedInsideString)
352
- continue;
353
- const trimmed = (lines[i] ?? '').trim();
354
- if (trimmed.startsWith('[')) {
355
- inTable = isOurCodexHeadersTable(trimmed);
356
- continue;
357
- }
358
- if (!inTable || trimmed === '' || trimmed.startsWith('#'))
359
- continue;
360
- if (!/^Authorization\s*=/.test(trimmed))
361
- continue;
362
- lines[i] = `Authorization = ${tomlQuote(headerValue)}`;
363
- return lines.join('\n');
364
- }
365
- return null;
366
- }
367
- export function resolveMintProvider(hasClaude = readClaudeAuthHeader() !== null, hasCodex = readCodexAuthHeader() !== null, hasCursor = readCursorAuthHeader() !== null) {
368
- if (hasClaude)
369
- return 'claude_code';
370
- if (hasCodex)
371
- return 'codex';
372
- if (hasCursor)
373
- return 'cursor';
374
- return 'claude_code';
375
- }
376
- export function readSyncedConfig() {
377
- let cfg = readJsonFile(SYNCED_ORIGINS_PATH);
378
- for (const legacy of LEGACY_SYNCED_ORIGINS_PATHS) {
379
- if (cfg)
380
- break;
381
- cfg = readJsonFile(legacy);
382
- }
383
- if (!cfg || typeof cfg.apiBase !== 'string' || !Array.isArray(cfg.origins)) {
384
- return null;
385
- }
386
- const apiBase = cfg.apiBase.replace(/\/+$/, '');
387
- const origins = new Set(cfg.origins.map((o) => normalizeOriginUrl(String(o))).filter(Boolean));
388
- return {
389
- apiBase,
390
- origins,
391
- pause: readPause(cfg),
392
- toolGuard: cfg.toolGuard === true,
393
- receipt: cfg.receipt !== false,
394
- };
395
- }
396
- export async function refreshSyncedOrigins() {
397
- try {
398
- const age = Date.now() - statSync(ORIGINS_REFRESH_STAMP_PATH).mtimeMs;
399
- if (age < ORIGINS_REFRESH_INTERVAL_MS)
400
- return null;
401
- }
402
- catch {
403
- }
404
- const auth = readUsableAuth();
405
- if (!auth)
406
- return null;
407
- try {
408
- mkdirSync(HARBOR_DIR, { recursive: true, mode: 0o700 });
409
- writeFileSync(ORIGINS_REFRESH_STAMP_PATH, '', { encoding: 'utf8', mode: 0o600 });
410
- }
411
- catch {
412
- }
413
- const apiUrl = auth.apiUrl.replace(/\/+$/, '');
414
- const fetched = await getJsonRaw(`${apiUrl}/workspaces/${encodeURIComponent(auth.workspaceId)}/context/repo-bindings`, `Bearer ${auth.runnerToken}`);
415
- if (fetched?.status !== 200)
416
- return null;
417
- const rows = Array.isArray(fetched.body?.data)
418
- ? fetched.body.data
419
- : Array.isArray(fetched.body)
420
- ? fetched.body
421
- : null;
422
- if (!rows)
423
- return null;
424
- const origins = [
425
- ...new Set(rows
426
- .map((row) => normalizeOriginUrl(String(row?.originUrl ?? '')))
427
- .filter(Boolean)),
428
- ];
429
- if (origins.length === 0)
430
- return new Set();
431
- const release = tryLock(SYNCED_ORIGINS_PATH);
432
- if (!release)
433
- return new Set(origins);
434
- try {
435
- const current = readJsonFile(SYNCED_ORIGINS_PATH) ?? {};
436
- const body = { ...current, apiBase: current.apiBase ?? apiUrl, origins };
437
- writeAtomicSync(SYNCED_ORIGINS_PATH, `${JSON.stringify(body, null, 2)}\n`);
438
- }
439
- catch {
440
- }
441
- finally {
442
- release();
443
- }
444
- return new Set(origins);
445
- }
446
- function readPause(cfg) {
447
- const out = { global: null, repos: {} };
448
- if (cfg.capture === false)
449
- out.global = 'capture';
450
- const raw = cfg.pause;
451
- if (!raw || typeof raw !== 'object' || Array.isArray(raw))
452
- return out;
453
- if (isPauseMode(raw.global))
454
- out.global = raw.global;
455
- if (raw.repos && typeof raw.repos === 'object' && !Array.isArray(raw.repos)) {
456
- for (const [key, value] of Object.entries(raw.repos)) {
457
- if (isPauseMode(value))
458
- out.repos[normalizeOriginUrl(String(key))] = value;
459
- }
460
- }
461
- return out;
462
- }
463
- const isPauseMode = (value) => value === 'off' || value === 'capture' || value === 'on';
464
- export function repoArms(config, originUrl) {
465
- const mode = config?.pause?.repos?.[originUrl] ?? config?.pause?.global ?? null;
466
- if (mode === 'off')
467
- return { recall: false, capture: false };
468
- if (mode === 'capture')
469
- return { recall: true, capture: false };
470
- return { recall: true, capture: true };
471
- }
472
- export function gitOrigin(cwd) {
473
- try {
474
- const out = execFileSync('git', ['-C', cwd, 'remote', 'get-url', 'origin'], {
475
- encoding: 'utf8',
476
- stdio: ['ignore', 'pipe', 'ignore'],
477
- timeout: 2000,
478
- });
479
- const trimmed = out.trim();
480
- return trimmed.length > 0 ? trimmed : null;
481
- }
482
- catch {
483
- return null;
484
- }
485
- }
486
- export function gitRoot(cwd) {
487
- try {
488
- const out = execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
489
- encoding: 'utf8',
490
- stdio: ['ignore', 'pipe', 'ignore'],
491
- timeout: 2000,
492
- });
493
- const trimmed = out.trim();
494
- return trimmed.length > 0 ? trimmed : null;
495
- }
496
- catch {
497
- return null;
498
- }
499
- }
500
- function realpathBestEffort(path) {
501
- const resolved = resolve(path);
502
- let current = resolved;
503
- const peeled = [];
504
- for (let depth = 0; depth < 64; depth += 1) {
505
- try {
506
- const real = realpathSync(current);
507
- return peeled.length === 0 ? real : join(real, ...peeled.reverse());
508
- }
509
- catch {
510
- const parent = dirname(current);
511
- if (parent === current)
512
- break;
513
- peeled.push(basename(current));
514
- current = parent;
515
- }
516
- }
517
- return resolved;
518
- }
519
- export function isPathInside(candidate, root, cwd) {
520
- if (typeof candidate !== 'string' || candidate.trim() === '')
521
- return false;
522
- if (typeof root !== 'string' || root.trim() === '')
523
- return false;
524
- const base = realpathBestEffort(resolve(root));
525
- const abs = realpathBestEffort(resolve(cwd ?? base, candidate));
526
- return abs === base || abs.startsWith(base.endsWith(sep) ? base : base + sep);
527
- }
528
- export function normalizeOriginUrl(raw) {
529
- let url = (raw ?? '').trim();
530
- if (url === '')
531
- return '';
532
- const scpMatch = url.match(/^[^@/]+@([^:/]+):(.+)$/);
533
- if (scpMatch) {
534
- url = `https://${scpMatch[1]}/${scpMatch[2]}`;
535
- }
536
- else if (/^ssh:\/\//i.test(url)) {
537
- url = url.replace(/^ssh:\/\//i, 'https://');
538
- }
539
- else if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
540
- url = `https://${url}`;
541
- }
542
- url = url.replace(/\.git$/i, '');
543
- try {
544
- const parsed = new URL(url);
545
- parsed.username = '';
546
- parsed.password = '';
547
- parsed.hostname = parsed.hostname.toLowerCase().replace(/^www\./, '');
548
- parsed.pathname = parsed.pathname.toLowerCase();
549
- return parsed.toString().replace(/\/$/, '');
550
- }
551
- catch {
552
- return url.toLowerCase().replace(/\/+$/, '');
553
- }
554
- }
555
- let healAttempted = false;
556
- export function lockPathFor(targetPath) {
557
- const digest = createHash('sha256').update(targetPath).digest('hex').slice(0, 16);
558
- return join(HARBOR_DIR, 'locks', `${digest}.lock`);
559
- }
560
- export function tryLock(targetPath) {
561
- const path = lockPathFor(targetPath);
562
- try {
563
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
564
- }
565
- catch {
566
- return null;
567
- }
568
- for (let attempt = 0; attempt < 3; attempt += 1) {
569
- try {
570
- const token = randomUUID();
571
- const fd = openSync(path, 'wx', 0o600);
572
- writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token }));
573
- closeSync(fd);
574
- return () => {
575
- try {
576
- const owner = JSON.parse(readFileSync(path, 'utf8'));
577
- if (owner.token !== token)
578
- return;
579
- rmSync(path, { force: true });
580
- }
581
- catch {
582
- }
583
- };
584
- }
585
- catch {
586
- try {
587
- const age = Date.now() - statSync(path).mtimeMs;
588
- if (age > 10_000)
589
- rmSync(path, { force: true });
590
- else
591
- return null;
592
- }
593
- catch {
594
- }
595
- }
596
- }
597
- return null;
598
- }
599
- function writeAtomicSync(path, contents) {
600
- const target = realpathBestEffort(path);
601
- const tmp = `${target}.${process.pid}.tmp`;
602
- writeFileSync(tmp, contents, { encoding: 'utf8', mode: 0o600 });
603
- renameSync(tmp, target);
604
- }
605
- async function healAuthHeader() {
606
- if (healAttempted)
607
- return null;
608
- healAttempted = true;
609
- const auth = readUsableAuth();
610
- if (!auth)
611
- return null;
612
- const apiUrl = auth.apiUrl.replace(/\/+$/, '');
613
- let runnerToken = auth.runnerToken;
614
- const rotated = await postJsonRaw(`${apiUrl}/auth/runner-token/refresh`, `Bearer ${runnerToken}`, {});
615
- if (rotated?.status === 200 && typeof rotated.body?.token === 'string') {
616
- runnerToken = rotated.body.token;
617
- try {
618
- const release = tryLock(join(HARBOR_DIR, 'auth.json'));
619
- if (release) {
620
- try {
621
- writeAtomicSync(join(HARBOR_DIR, 'auth.json'), `${JSON.stringify({ ...auth, runnerToken }, null, 2)}\n`);
622
- }
623
- finally {
624
- release();
625
- }
626
- }
627
- }
628
- catch {
629
- }
630
- }
631
- else if (rotated?.status === 401) {
632
- return null;
633
- }
634
- const active = readActiveCredential();
635
- const minted = await postJsonRaw(`${apiUrl}/workspaces/${encodeURIComponent(auth.workspaceId)}/context/setup/token`, `Bearer ${runnerToken}`, {
636
- provider: resolveMintProvider(active.agent === 'claude', active.agent === 'codex', active.agent === 'cursor'),
637
- });
638
- const token = minted?.body?.data?.token;
639
- if (minted?.status !== 201 && minted?.status !== 200)
640
- return null;
641
- if (typeof token !== 'string' || token === '')
642
- return null;
643
- persistHealedBearer(`Bearer ${token}`, active.agent);
644
- return `Bearer ${token}`;
645
- }
646
- function persistHealedBearer(headerValue, agent) {
647
- if (agent === 'claude')
648
- persistClaudeBearer(headerValue);
649
- else if (agent === 'codex')
650
- persistCodexBearer(headerValue);
651
- else if (agent === 'cursor')
652
- persistCursorBearer(headerValue);
653
- }
654
- function persistClaudeBearer(headerValue) {
655
- persistClaudeBearerIn(CLAUDE_MCP_CONFIG_PATH, headerValue);
656
- persistClaudeBearerIn(CLAUDE_SETTINGS_PATH, headerValue);
657
- }
658
- function persistClaudeBearerIn(path, headerValue) {
659
- const release = tryLock(path);
660
- if (!release)
661
- return;
662
- try {
663
- let root = {};
664
- try {
665
- const raw = readFileSync(path, 'utf8');
666
- if (raw.trim() !== '')
667
- root = JSON.parse(raw);
668
- }
669
- catch {
670
- root = {};
671
- }
672
- if (root === null || typeof root !== 'object' || Array.isArray(root))
673
- return;
674
- const servers = root.mcpServers && typeof root.mcpServers === 'object' && !Array.isArray(root.mcpServers)
675
- ? root.mcpServers
676
- : {};
677
- const previous = servers[HARBOR_MCP_KEY];
678
- if (!previous || typeof previous !== 'object' || typeof previous.url !== 'string') {
679
- return;
680
- }
681
- servers[HARBOR_MCP_KEY] = {
682
- ...previous,
683
- headers: { ...(previous.headers ?? {}), Authorization: headerValue },
684
- };
685
- root.mcpServers = servers;
686
- writeAtomicSync(path, `${JSON.stringify(root, null, 2)}\n`);
687
- }
688
- catch {
689
- }
690
- finally {
691
- release();
692
- }
693
- }
694
- function persistCodexBearer(headerValue) {
695
- const release = tryLock(CODEX_CONFIG_PATH);
696
- if (!release)
697
- return;
698
- try {
699
- const raw = readFileSync(CODEX_CONFIG_PATH, 'utf8');
700
- const next = setCodexAuthorization(raw, headerValue);
701
- if (next === null || next === raw)
702
- return;
703
- writeAtomicSync(CODEX_CONFIG_PATH, next);
704
- }
705
- catch {
706
- }
707
- finally {
708
- release();
709
- }
710
- }
711
- function persistCursorBearer(headerValue) {
712
- const release = tryLock(CURSOR_MCP_PATH);
713
- if (!release)
714
- return;
715
- try {
716
- const raw = readFileSync(CURSOR_MCP_PATH, 'utf8');
717
- const stripped = stripJsonComments(raw);
718
- if (stripped !== raw)
719
- return;
720
- const root = JSON.parse(stripped);
721
- if (root === null || typeof root !== 'object' || Array.isArray(root))
722
- return;
723
- const servers = root.mcpServers;
724
- if (!servers || typeof servers !== 'object' || Array.isArray(servers))
725
- return;
726
- const previous = servers[HARBOR_MCP_KEY];
727
- if (!previous || typeof previous !== 'object' || typeof previous.url !== 'string') {
728
- return;
729
- }
730
- servers[HARBOR_MCP_KEY] = {
731
- ...previous,
732
- headers: { ...(previous.headers ?? {}), Authorization: headerValue },
733
- };
734
- writeAtomicSync(CURSOR_MCP_PATH, `${JSON.stringify(root, null, 2)}\n`);
735
- }
736
- catch {
737
- }
738
- finally {
739
- release();
740
- }
741
- }
742
- async function getJsonRaw(url, authHeader) {
743
- const controller = new AbortController();
744
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
745
- try {
746
- const res = await fetch(url, {
747
- method: 'GET',
748
- headers: { authorization: authHeader },
749
- signal: controller.signal,
750
- });
751
- let parsed = null;
752
- try {
753
- parsed = await res.json();
754
- }
755
- catch {
756
- parsed = null;
757
- }
758
- return { status: res.status, body: parsed };
759
- }
760
- catch {
761
- return null;
762
- }
763
- finally {
764
- clearTimeout(timer);
765
- }
766
- }
767
- async function postJsonRaw(url, authHeader, body) {
768
- const controller = new AbortController();
769
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
770
- try {
771
- const res = await fetch(url, {
772
- method: 'POST',
773
- headers: { 'content-type': 'application/json', authorization: authHeader },
774
- body: JSON.stringify(body),
775
- signal: controller.signal,
776
- });
777
- let parsed = null;
778
- try {
779
- parsed = await res.json();
780
- }
781
- catch {
782
- parsed = null;
783
- }
784
- return { status: res.status, body: parsed };
785
- }
786
- catch {
787
- return null;
788
- }
789
- finally {
790
- clearTimeout(timer);
791
- }
792
- }
793
- export async function postJson(url, authHeader, body, timeoutMs) {
794
- const first = await postJsonOnce(url, authHeader, body, timeoutMs);
795
- if (first?.status !== 401)
796
- return first;
797
- const healed = await healAuthHeader();
798
- if (!healed)
799
- return first;
800
- return postJsonOnce(url, healed, body, timeoutMs);
801
- }
802
- let lastRequestError = null;
803
- export function lastRequestFailureReason() {
804
- return lastRequestError ?? 'network';
805
- }
806
- function describeFetchError(err) {
807
- if (err?.name === 'AbortError' || err?.name === 'TimeoutError')
808
- return 'timeout';
809
- const code = err?.cause?.code ?? err?.code;
810
- if (typeof code === 'string' && code !== '')
811
- return code;
812
- return typeof err?.name === 'string' && err.name !== '' ? err.name : 'network';
813
- }
814
- async function postJsonOnce(url, authHeader, body, timeoutMs) {
815
- const controller = new AbortController();
816
- const timer = setTimeout(() => controller.abort(), timeoutMs ?? REQUEST_TIMEOUT_MS);
817
- lastRequestError = null;
818
- try {
819
- const res = await fetch(url, {
820
- method: 'POST',
821
- headers: {
822
- 'content-type': 'application/json',
823
- authorization: authHeader,
824
- },
825
- body: JSON.stringify(body),
826
- signal: controller.signal,
827
- });
828
- let parsed = null;
829
- if (res.status === 200) {
830
- try {
831
- parsed = await res.json();
832
- }
833
- catch {
834
- parsed = null;
835
- }
836
- }
837
- return { status: res.status, body: parsed };
838
- }
839
- catch (err) {
840
- lastRequestError = describeFetchError(err);
841
- return null;
842
- }
843
- finally {
844
- clearTimeout(timer);
845
- }
846
- }
847
- function markerFileName(sessionId) {
848
- const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
849
- return `${safe}.json`;
850
- }
851
- export function markerPathFor(sessionId) {
852
- return join(CLI_SESSIONS_DIR, markerFileName(sessionId));
853
- }
854
- export function readMarker(path) {
855
- return readJsonFile(path);
856
- }
857
- export function writeMarker(sessionId, reported, signature, transcriptPath) {
858
- try {
859
- const existing = readMarker(markerPathFor(sessionId));
860
- const marker = {
861
- sessionId,
862
- createdAt: existing?.createdAt ?? new Date().toISOString(),
863
- lastSeenAt: new Date().toISOString(),
864
- reported: Boolean(reported),
865
- };
866
- if (Array.isArray(signature)) {
867
- marker.signature = signature;
868
- }
869
- else if (Array.isArray(existing?.signature)) {
870
- marker.signature = existing.signature;
871
- }
872
- const path = typeof transcriptPath === 'string' && transcriptPath !== ''
873
- ? transcriptPath
874
- : existing?.transcriptPath;
875
- if (typeof path === 'string' && path !== '')
876
- marker.transcriptPath = path;
877
- if (typeof existing?.deliveredAt === 'string') {
878
- marker.deliveredAt = existing.deliveredAt;
879
- }
880
- if (typeof existing?.scanOffset === 'number') {
881
- marker.scanOffset = existing.scanOffset;
882
- }
883
- if (existing?.contextReset === true)
884
- marker.contextReset = true;
885
- if (Array.isArray(existing?.emptyAsk))
886
- marker.emptyAsk = existing.emptyAsk;
887
- mkdirSync(CLI_SESSIONS_DIR, { recursive: true, mode: 0o700 });
888
- writeFileSync(markerPathFor(sessionId), JSON.stringify(marker), {
889
- encoding: 'utf8',
890
- mode: 0o600,
891
- });
892
- }
893
- catch {
894
- }
895
- }
896
- export function updateMarkerSignature(sessionId, signature) {
897
- const path = markerPathFor(sessionId);
898
- const marker = readMarker(path);
899
- if (!marker)
900
- return;
901
- try {
902
- writeFileSync(path, JSON.stringify({
903
- ...marker,
904
- lastSeenAt: new Date().toISOString(),
905
- signature: Array.isArray(signature) ? signature : [],
906
- }), { encoding: 'utf8', mode: 0o600 });
907
- }
908
- catch {
909
- }
910
- }
911
- export function updateMarkerEmptyAsk(sessionId, emptyAsk) {
912
- const path = markerPathFor(sessionId);
913
- const marker = readMarker(path);
914
- if (!marker)
915
- return;
916
- try {
917
- writeFileSync(path, JSON.stringify({
918
- ...marker,
919
- lastSeenAt: new Date().toISOString(),
920
- emptyAsk: Array.isArray(emptyAsk) ? emptyAsk : [],
921
- }), { encoding: 'utf8', mode: 0o600 });
922
- }
923
- catch {
924
- }
925
- }
926
- export function updateMarkerRecall(sessionId, patch) {
927
- const path = markerPathFor(sessionId);
928
- const marker = readMarker(path);
929
- if (!marker)
930
- return;
931
- const next = { ...marker, lastSeenAt: new Date().toISOString() };
932
- if (typeof patch?.scanOffset === 'number' && Number.isFinite(patch.scanOffset)) {
933
- next.scanOffset = patch.scanOffset;
934
- }
935
- if (typeof patch?.deliveredAt === 'string')
936
- next.deliveredAt = patch.deliveredAt;
937
- if (typeof patch?.contextReset === 'boolean') {
938
- if (patch.contextReset)
939
- next.contextReset = true;
940
- else
941
- delete next.contextReset;
942
- }
943
- try {
944
- writeFileSync(path, JSON.stringify(next), { encoding: 'utf8', mode: 0o600 });
945
- }
946
- catch {
947
- }
948
- }
949
- export function touchMarker(sessionId) {
950
- const path = markerPathFor(sessionId);
951
- const marker = readMarker(path);
952
- if (!marker)
953
- return;
954
- try {
955
- writeFileSync(path, JSON.stringify({ ...marker, lastSeenAt: new Date().toISOString() }), { encoding: 'utf8', mode: 0o600 });
956
- }
957
- catch {
958
- }
959
- }
960
- export function markReported(sessionId, explicitPath) {
961
- const path = explicitPath || markerPathFor(sessionId);
962
- const marker = readMarker(path);
963
- if (!marker)
964
- return;
965
- try {
966
- writeFileSync(path, JSON.stringify({ ...marker, reported: true }), { encoding: 'utf8', mode: 0o600 });
967
- }
968
- catch {
969
- }
970
- }
971
- export function listMarkers() {
972
- const out = [];
973
- const entries = [];
974
- for (const dir of [CLI_SESSIONS_DIR, ...LEGACY_CLI_SESSIONS_DIRS]) {
975
- try {
976
- for (const name of readdirSync(dir))
977
- entries.push([dir, name]);
978
- }
979
- catch {
980
- }
981
- }
982
- if (entries.length === 0)
983
- return out;
984
- const now = Date.now();
985
- for (const [dir, name] of entries) {
986
- if (!name.endsWith('.json'))
987
- continue;
988
- const path = join(dir, name);
989
- const marker = readMarker(path);
990
- if (!marker || typeof marker.sessionId !== 'string')
991
- continue;
992
- const created = Date.parse(marker.createdAt ?? '');
993
- if (Number.isFinite(created) && now - created > MARKER_TTL_MS) {
994
- try {
995
- rmSync(path, { force: true });
996
- }
997
- catch {
998
- }
999
- continue;
1000
- }
1001
- out.push({
1002
- sessionId: marker.sessionId,
1003
- path,
1004
- reported: Boolean(marker.reported),
1005
- createdAt: created,
1006
- lastSeenAt: Number.isFinite(Date.parse(marker.lastSeenAt ?? ''))
1007
- ? Date.parse(marker.lastSeenAt)
1008
- : created,
1009
- transcriptPath: typeof marker.transcriptPath === 'string' ? marker.transcriptPath : '',
1010
- });
1011
- }
1012
- return out;
1013
- }
1014
- function captureMarkerFileName(sessionId) {
1015
- const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
1016
- return `${safe}.json`;
1017
- }
1018
- export function captureMarkerPathFor(sessionId) {
1019
- return join(CAPTURE_DIR, captureMarkerFileName(sessionId));
1020
- }
1021
- export function hasCaptureMarker(sessionId) {
1022
- return existsSync(captureMarkerPathFor(sessionId));
1023
- }
1024
- export const CAPTURE_MAX_RETRIES = 1;
1025
- export function readCaptureMarker(sessionId) {
1026
- return readJsonFile(captureMarkerPathFor(sessionId));
1027
- }
1028
- export function writeCaptureMarker(sessionId, retries = 0) {
1029
- try {
1030
- mkdirSync(CAPTURE_DIR, { recursive: true, mode: 0o700 });
1031
- writeFileSync(captureMarkerPathFor(sessionId), JSON.stringify({ sessionId, createdAt: new Date().toISOString(), retries }), { encoding: 'utf8', mode: 0o600 });
1032
- }
1033
- catch {
1034
- }
1035
- }
1036
- export function settleCaptureMarker(sessionId) {
1037
- const marker = readCaptureMarker(sessionId) ?? { sessionId };
1038
- try {
1039
- mkdirSync(CAPTURE_DIR, { recursive: true, mode: 0o700 });
1040
- writeFileSync(captureMarkerPathFor(sessionId), JSON.stringify({ ...marker, settled: true }), { encoding: 'utf8', mode: 0o600 });
1041
- }
1042
- catch {
1043
- }
1044
- }
1045
- const CAPTURE_TRIGGER_SENTINEL = 'Capture pass: call harbor_record_learning';
1046
- const TOOL_REJECTED_RE = /doesn't want to proceed with this tool use|tool use was rejected|Request interrupted/i;
1047
- function carriesCaptureTrigger(entry) {
1048
- const content = entry?.message?.content;
1049
- if (typeof content === 'string' && content.includes(CAPTURE_TRIGGER_SENTINEL))
1050
- return true;
1051
- const blocking = entry?.attachment?.blockingError?.blockingError;
1052
- return typeof blocking === 'string' && blocking.includes(CAPTURE_TRIGGER_SENTINEL);
1053
- }
1054
- export function capturePassOutcome(transcriptPath) {
1055
- let verdict = 'unknown';
1056
- const pending = new Set();
1057
- for (const entry of transcriptEntries(transcriptPath)) {
1058
- if (carriesCaptureTrigger(entry)) {
1059
- verdict = 'none';
1060
- pending.clear();
1061
- continue;
1062
- }
1063
- if (verdict === 'unknown')
1064
- continue;
1065
- const content = entry?.message?.content;
1066
- if (!Array.isArray(content))
1067
- continue;
1068
- for (const block of content) {
1069
- if (block?.type === 'tool_use' && (block.name ?? '').endsWith(RECORD_TOOL_SUFFIX)) {
1070
- pending.add(block.id);
1071
- continue;
1072
- }
1073
- if (block?.type !== 'tool_result' || !pending.has(block.tool_use_id))
1074
- continue;
1075
- pending.delete(block.tool_use_id);
1076
- if (verdict === 'landed')
1077
- continue;
1078
- verdict = TOOL_REJECTED_RE.test(toolResultText(block.content)) ? 'rejected' : 'landed';
1079
- }
1080
- }
1081
- if (verdict !== 'landed' && pending.size > 0)
1082
- return 'rejected';
1083
- return verdict;
1084
- }
1085
- export function pruneCaptureMarkers() {
1086
- let names = [];
1087
- try {
1088
- names = readdirSync(CAPTURE_DIR);
1089
- }
1090
- catch {
1091
- return;
1092
- }
1093
- const now = Date.now();
1094
- for (const name of names) {
1095
- if (!name.endsWith('.json'))
1096
- continue;
1097
- const marker = readJsonFile(join(CAPTURE_DIR, name));
1098
- const created = Date.parse(marker?.createdAt ?? '');
1099
- if (Number.isFinite(created) && now - created > MARKER_TTL_MS) {
1100
- try {
1101
- rmSync(join(CAPTURE_DIR, name), { force: true });
1102
- }
1103
- catch {
1104
- }
1105
- }
1106
- }
1107
- }
1108
- const EDIT_TOOLS = new Set([
1109
- 'Edit',
1110
- 'Write',
1111
- 'MultiEdit',
1112
- 'NotebookEdit',
1113
- 'StrReplace',
1114
- 'ApplyPatch',
1115
- ]);
1116
- const SHELL_TOOLS = new Set(['Bash', 'Shell']);
1117
- const APPLY_PATCH_TARGET = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm;
1118
- const CODEX_EXEC_CMD = /["']?cmd["']?\s*:\s*(["'])((?:\\.|(?!\1)[^\\])*)\1/g;
1119
- const IGNORED_PATH_SEGMENTS = ['/node_modules/', '/.git/'];
1120
- const GENERATED_PATH_SEGMENTS = [
1121
- '/node_modules/',
1122
- '/.git/',
1123
- '/dist/',
1124
- '/build/',
1125
- '/coverage/',
1126
- '/.next/',
1127
- '/.turbo/',
1128
- '/.vite/',
1129
- ];
1130
- const BASH_WRITE = (() => {
1131
- const AT_COMMAND_START = String.raw `(?:^|[\n;&|(]|\bthen\b|\bdo\b|\belse\b)\s*(?:sudo\s+)?`;
1132
- const VERBS = 'sed\\s+-[a-zA-Z]*i|perl\\s+-[a-zA-Z]*i|tee|cp|mv|rm|mkdir|touch|ln|chmod|chown|rsync|patch|truncate|dd';
1133
- return new RegExp([
1134
- String.raw `>>?\s*(?!&|/dev/(null|stdout|stderr)\b)\S`,
1135
- String.raw `<<-?\s*'?[A-Za-z_]`,
1136
- `${AT_COMMAND_START}(?:${VERBS})\\b`,
1137
- String.raw `\b(npm|pnpm|yarn|bun)\s+(i|install|ci|add|remove|uninstall|link)\b`,
1138
- String.raw `\bgit\s+(apply|am|commit|checkout|switch|restore|revert|merge|rebase|mv|rm|stash|clean|reset|push)\b`,
1139
- ].join('|'));
1140
- })();
1141
- export const CAPTURE_BAR = '<capture_bar>\n' +
1142
- 'When you are asked to run the capture pass, this is the bar. Record only what ' +
1143
- 'a teammate would need MONTHS from now and could not get by reading the code, ' +
1144
- 'the docs, or the same source you just read. An item qualifies only if ALL ' +
1145
- 'THREE hold: (1) DURABLE — a mechanism or a decision, not a current value, a ' +
1146
- 'status, or anything phrased "currently broken / not yet"; (2) OURS — about ' +
1147
- 'this repo, this product, or a decision we actually made, not about your ' +
1148
- 'tooling, your environment, or a summary of external material; (3) COSTLY — ' +
1149
- 'without it a teammate would take the wrong path or reintroduce a bug. Do NOT ' +
1150
- 'record: how you got a tool or a throwaway script working, anything about ' +
1151
- 'temp/scratch files, or an idea that was only proposed rather than decided. For ' +
1152
- 'each item that clears all three, call harbor_record_learning — do not ask ' +
1153
- 'permission. Most sessions yield ZERO or ONE; recording nothing is the normal, ' +
1154
- 'correct outcome. Until you are asked, this is a definition and not a task: do ' +
1155
- 'not act on it and do not mention it.\n' +
1156
- '</capture_bar>';
1157
- export const CAPTURE_TRIGGER = 'Capture pass: call harbor_record_learning for anything DURABLE + OURS + ' +
1158
- 'COSTLY from this session (see <capture_bar>) — zero is the normal outcome; ' +
1159
- 'if nothing qualifies, just finish.';
1160
- function editedPaths(block) {
1161
- if (!block || typeof block !== 'object')
1162
- return [];
1163
- if (block.type !== 'tool_use' || !EDIT_TOOLS.has(block.name))
1164
- return [];
1165
- const input = block.input;
1166
- if (typeof input === 'string') {
1167
- return [...input.matchAll(APPLY_PATCH_TARGET)]
1168
- .map((match) => match[1].trim())
1169
- .filter((path) => path !== '');
1170
- }
1171
- if (!input || typeof input !== 'object')
1172
- return [];
1173
- const path = input.file_path ?? input.notebook_path ?? input.path;
1174
- return typeof path === 'string' && path !== '' ? [path] : [];
1175
- }
1176
- function codexPatchedPaths(entry) {
1177
- const payload = entry?.payload ?? entry;
1178
- if (payload?.type !== 'patch_apply_end' || payload.success !== true)
1179
- return [];
1180
- const changes = payload.changes;
1181
- return changes && typeof changes === 'object' ? Object.keys(changes) : [];
1182
- }
1183
- function entryEditedPaths(entry) {
1184
- const content = entry?.message?.content;
1185
- const blocks = Array.isArray(content) ? content.flatMap(editedPaths) : [];
1186
- return blocks.concat(codexPatchedPaths(entry));
1187
- }
1188
- function shellCommands(entry) {
1189
- const commands = [];
1190
- const content = entry?.message?.content;
1191
- if (Array.isArray(content)) {
1192
- for (const block of content) {
1193
- if (!block || block.type !== 'tool_use' || !SHELL_TOOLS.has(block.name))
1194
- continue;
1195
- const command = block.input?.command;
1196
- if (typeof command === 'string')
1197
- commands.push(command);
1198
- }
1199
- }
1200
- const payload = entry?.payload ?? entry;
1201
- if (payload?.type === 'custom_tool_call' && typeof payload.input === 'string') {
1202
- for (const match of payload.input.matchAll(CODEX_EXEC_CMD))
1203
- commands.push(match[2]);
1204
- }
1205
- return commands;
1206
- }
1207
- export function sessionEditedRepoFiles(transcriptPath, root, cwd) {
1208
- for (const entry of transcriptEntries(transcriptPath)) {
1209
- for (const path of entryEditedPaths(entry)) {
1210
- if (IGNORED_PATH_SEGMENTS.some((seg) => path.includes(seg)))
1211
- continue;
1212
- if (isPathInside(path, root, cwd))
1213
- return true;
1214
- }
1215
- }
1216
- return false;
1217
- }
1218
- export function sessionRanWritingCommand(transcriptPath) {
1219
- for (const entry of transcriptEntries(transcriptPath)) {
1220
- if (shellCommands(entry).some((command) => BASH_WRITE.test(command)))
1221
- return true;
1222
- }
1223
- return false;
1224
- }
1225
- function* transcriptEntries(transcriptPath) {
1226
- if (typeof transcriptPath !== 'string' || transcriptPath === '')
1227
- return;
1228
- let raw;
1229
- try {
1230
- raw = readFileSync(transcriptPath, 'utf8');
1231
- }
1232
- catch {
1233
- return;
1234
- }
1235
- for (const line of raw.split('\n')) {
1236
- if (line.trim() === '')
1237
- continue;
1238
- try {
1239
- yield JSON.parse(line);
1240
- }
1241
- catch {
1242
- continue;
1243
- }
1244
- }
1245
- }
1246
- export function repoFileChangedSince(root, sinceMs) {
1247
- if (typeof root !== 'string' || root === '')
1248
- return false;
1249
- if (typeof sinceMs !== 'number' || !Number.isFinite(sinceMs))
1250
- return false;
1251
- let out;
1252
- try {
1253
- out = execFileSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=all'], {
1254
- encoding: 'utf8',
1255
- stdio: ['ignore', 'pipe', 'ignore'],
1256
- timeout: 3000,
1257
- maxBuffer: 8 * 1024 * 1024,
1258
- });
1259
- }
1260
- catch {
1261
- return false;
1262
- }
1263
- for (const line of out.split('\n')) {
1264
- if (line.length < 4)
1265
- continue;
1266
- let rel = line.slice(3);
1267
- const arrow = rel.indexOf(' -> ');
1268
- if (arrow !== -1)
1269
- rel = rel.slice(arrow + 4);
1270
- if (rel.startsWith('"') && rel.endsWith('"'))
1271
- rel = rel.slice(1, -1);
1272
- if (rel === '')
1273
- continue;
1274
- const full = join(root, rel);
1275
- if (GENERATED_PATH_SEGMENTS.some((seg) => `/${rel}`.includes(seg)))
1276
- continue;
1277
- try {
1278
- if (statSync(full).mtimeMs >= sinceMs)
1279
- return true;
1280
- }
1281
- catch {
1282
- continue;
1283
- }
1284
- }
1285
- return false;
1286
- }
1287
- export function sessionDidRepoWork(transcriptPath, root, cwd, sinceMs) {
1288
- if (sessionEditedRepoFiles(transcriptPath, root, cwd))
1289
- return true;
1290
- if (!sessionRanWritingCommand(transcriptPath))
1291
- return false;
1292
- const since = typeof sinceMs === 'number' ? sinceMs : transcriptStartedAt(transcriptPath);
1293
- if (since === null)
1294
- return false;
1295
- return repoFileChangedSince(root, since);
1296
- }
1297
- function transcriptStartedAt(transcriptPath) {
1298
- if (typeof transcriptPath !== 'string' || transcriptPath === '')
1299
- return null;
1300
- try {
1301
- const s = statSync(transcriptPath);
1302
- return s.birthtimeMs > 0 ? s.birthtimeMs : s.mtimeMs;
1303
- }
1304
- catch {
1305
- return null;
1306
- }
1307
- }
1308
- export const SENTINEL_NAMES = ['harbor', 'milestone', 'alef0'];
1309
- const SENTINEL_PREFIX = `(?:${SENTINEL_NAMES.join('|')})-applied:`;
1310
- const APPLIED_LINE = new RegExp(`${SENTINEL_PREFIX}\\s*([#\\d\\s,]+)`, 'gi');
1311
- const MAX_APPLIED_ORDINALS = 100;
1312
- const MARKER_MENTION = new RegExp(SENTINEL_PREFIX, 'i');
1313
- const CODE_SPAN = /```[\s\S]*?```|``[\s\S]*?``|`[^`\n]*`/g;
1314
- function stripQuotedMarkers(text) {
1315
- if (!text.includes('`'))
1316
- return text;
1317
- return text.replace(CODE_SPAN, (span) => MARKER_MENTION.test(span) ? ' ' : span);
1318
- }
1319
- const LEARNING_REF = /\b(lrn_[A-Za-z0-9]{1,32})\b/g;
1320
- const RECORD_TOOL_SUFFIX = 'record_learning';
1321
- const MAX_LEARNING_REFS = 100;
1322
- function contentBlocks(content) {
1323
- if (Array.isArray(content))
1324
- return content;
1325
- if (typeof content === 'string')
1326
- return [{ type: 'text', text: content }];
1327
- return [];
1328
- }
1329
- function toolResultText(body) {
1330
- if (typeof body === 'string')
1331
- return body;
1332
- if (!Array.isArray(body))
1333
- return '';
1334
- return body.map((b) => (typeof b?.text === 'string' ? b.text : '')).join('\n');
1335
- }
1336
- const TEXT_BLOCK_TYPES = new Set(['text', 'output_text']);
1337
- function normalizeEntry(entry) {
1338
- if (!entry || typeof entry !== 'object')
1339
- return null;
1340
- const payload = entry.payload;
1341
- if (payload && typeof payload === 'object') {
1342
- if (payload.type === 'message') {
1343
- return { role: payload.role, blocks: contentBlocks(payload.content) };
1344
- }
1345
- if (payload.type === 'agent_message' && typeof payload.message === 'string') {
1346
- return { role: 'assistant', blocks: [{ type: 'text', text: payload.message }] };
1347
- }
1348
- return null;
1349
- }
1350
- const message = entry.message;
1351
- if (!message || typeof message !== 'object')
1352
- return null;
1353
- const role = typeof message.role === 'string' ? message.role : entry.role;
1354
- return { role, blocks: contentBlocks(message.content) };
1355
- }
1356
- function codexMcpRefText(entry) {
1357
- const payload = entry?.payload;
1358
- if (!payload || payload.type !== 'mcp_tool_call_end')
1359
- return null;
1360
- const tool = payload.invocation?.tool;
1361
- if (typeof tool !== 'string' || !tool.endsWith(RECORD_TOOL_SUFFIX))
1362
- return '';
1363
- const ok = payload.result?.Ok;
1364
- return ok ? toolResultText(ok.content) : '';
1365
- }
1366
- const TRUNCATION_PROBE = /"isCompactSummary"\s*:\s*true|"compactMetadata"\s*:/;
1367
- const TRUNCATION_BACKFILL_BYTES = 4 * 1024 * 1024;
1368
- export function scanForContextTruncation(transcriptPath, scanOffset) {
1369
- const known = typeof scanOffset === 'number' && Number.isFinite(scanOffset) && scanOffset >= 0
1370
- ? scanOffset
1371
- : null;
1372
- const unchanged = { truncated: false, offset: known ?? 0 };
1373
- if (typeof transcriptPath !== 'string' || transcriptPath === '')
1374
- return unchanged;
1375
- let size;
1376
- try {
1377
- size = statSync(transcriptPath).size;
1378
- }
1379
- catch {
1380
- return unchanged;
1381
- }
1382
- const resumable = known !== null && known <= size;
1383
- const floor = Math.max(0, size - TRUNCATION_BACKFILL_BYTES);
1384
- const start = resumable ? Math.max(known, floor) : floor;
1385
- const length = size - start;
1386
- if (length <= 0)
1387
- return { truncated: false, offset: size };
1388
- let fd;
1389
- try {
1390
- fd = openSync(transcriptPath, 'r');
1391
- const buf = Buffer.allocUnsafe(length);
1392
- const read = readSync(fd, buf, 0, length, start);
1393
- return {
1394
- truncated: TRUNCATION_PROBE.test(buf.toString('utf8', 0, read)),
1395
- offset: size,
1396
- };
1397
- }
1398
- catch {
1399
- return unchanged;
1400
- }
1401
- finally {
1402
- if (fd !== undefined) {
1403
- try {
1404
- closeSync(fd);
1405
- }
1406
- catch {
1407
- }
1408
- }
1409
- }
1410
- }
1411
- export function scrapeSessionTranscript(transcriptPath) {
1412
- if (typeof transcriptPath !== 'string' || transcriptPath === '')
1413
- return null;
1414
- const ordinals = new Set();
1415
- const recordToolUseIds = new Set();
1416
- const refsByToolUse = new Map();
1417
- const learningRefs = new Set();
1418
- let understood = 0;
1419
- const addRefs = (text) => {
1420
- for (const match of text.matchAll(LEARNING_REF))
1421
- learningRefs.add(match[1]);
1422
- };
1423
- const readable = forEachLineSync(transcriptPath, (line) => {
1424
- if (line.trim() === '')
1425
- return;
1426
- let entry;
1427
- try {
1428
- entry = JSON.parse(line);
1429
- }
1430
- catch {
1431
- return;
1432
- }
1433
- const codexRefs = codexMcpRefText(entry);
1434
- if (codexRefs !== null) {
1435
- understood++;
1436
- if (codexRefs !== '')
1437
- addRefs(codexRefs);
1438
- }
1439
- const normalized = normalizeEntry(entry);
1440
- if (!normalized)
1441
- return;
1442
- understood++;
1443
- for (const block of normalized.blocks) {
1444
- if (TEXT_BLOCK_TYPES.has(block?.type) && typeof block.text === 'string') {
1445
- if (normalized.role !== 'assistant')
1446
- continue;
1447
- for (const match of stripQuotedMarkers(block.text).matchAll(APPLIED_LINE)) {
1448
- for (const token of match[1].split(/[\s,#]+/)) {
1449
- const n = Number.parseInt(token, 10);
1450
- if (Number.isInteger(n) && n > 0)
1451
- ordinals.add(n);
1452
- }
1453
- }
1454
- continue;
1455
- }
1456
- if (block?.type === 'tool_use' &&
1457
- typeof block.name === 'string' &&
1458
- typeof block.id === 'string' &&
1459
- block.name.endsWith(RECORD_TOOL_SUFFIX)) {
1460
- recordToolUseIds.add(block.id);
1461
- continue;
1462
- }
1463
- if (block?.type === 'tool_result' && typeof block.tool_use_id === 'string') {
1464
- const text = toolResultText(block.content);
1465
- if (text === '' || !text.includes('lrn_'))
1466
- continue;
1467
- const refs = refsByToolUse.get(block.tool_use_id) ?? new Set();
1468
- for (const match of text.matchAll(LEARNING_REF))
1469
- refs.add(match[1]);
1470
- refsByToolUse.set(block.tool_use_id, refs);
1471
- }
1472
- }
1473
- });
1474
- if (!readable)
1475
- return null;
1476
- if (understood === 0)
1477
- return null;
1478
- for (const [toolUseId, refs] of refsByToolUse) {
1479
- if (!recordToolUseIds.has(toolUseId))
1480
- continue;
1481
- for (const ref of refs)
1482
- learningRefs.add(ref);
1483
- }
1484
- return {
1485
- ordinals: [...ordinals].slice(0, MAX_APPLIED_ORDINALS),
1486
- learningRefs: [...learningRefs].slice(0, MAX_LEARNING_REFS),
1487
- };
1488
- }
1489
- export const REDELIVERY_LEAD = 'Your context was compacted and the team context from earlier in this ' +
1490
- 'session was dropped with it. Re-sent in full below — treat this as what ' +
1491
- 'you know, not as a repeat:';
1492
- export function wrapRedelivery(block) {
1493
- return `<prior_knowledge>\n${REDELIVERY_LEAD}\n${block}\n</prior_knowledge>`;
1494
- }
1495
- export const RECALL_OUTCOMES_PATH = join(HARBOR_DIR, 'recall-outcomes.jsonl');
1496
- const RECALL_OUTCOMES_MAX_BYTES = 512 * 1024;
1497
- export const RECALL_OUTCOMES = Object.freeze({
1498
- DELIVERED: 'delivered',
1499
- DELIVERED_DELTA: 'delivered-delta',
1500
- REDELIVERED_AFTER_COMPACT: 'redelivered-after-compact',
1501
- CONTEXT_LOST: 'context-lost',
1502
- NO_CONFIG: 'no-config',
1503
- NO_ORIGIN: 'no-origin',
1504
- NOT_ALLOWLISTED: 'not-allowlisted',
1505
- RECALL_OFF: 'recall-off',
1506
- NO_AUTH: 'no-auth',
1507
- DESKTOP_CHAT: 'desktop-chat',
1508
- NO_SHIFT: 'no-shift',
1509
- NO_QUERY: 'no-query',
1510
- EMPTY_BUNDLE: 'empty-bundle',
1511
- REQUEST_FAILED: 'request-failed',
1512
- PAYMENT_REQUIRED: 'payment-required',
1513
- });
1514
- let recallOrigin = null;
1515
- export function setRecallOrigin(origin) {
1516
- recallOrigin = typeof origin === 'string' && origin !== '' ? origin : null;
1517
- }
1518
- export function recordRecallOutcome(sessionId, outcome, detail) {
1519
- try {
1520
- mkdirSync(HARBOR_DIR, { recursive: true, mode: 0o700 });
1521
- try {
1522
- if (statSync(RECALL_OUTCOMES_PATH).size > RECALL_OUTCOMES_MAX_BYTES) {
1523
- rmSync(RECALL_OUTCOMES_PATH, { force: true });
1524
- }
1525
- }
1526
- catch {
1527
- }
1528
- const row = {
1529
- at: new Date().toISOString(),
1530
- sessionId: typeof sessionId === 'string' ? sessionId : '',
1531
- outcome,
1532
- };
1533
- if (detail !== undefined && detail !== null)
1534
- row.detail = String(detail);
1535
- if (recallOrigin !== null)
1536
- row.origin = recallOrigin;
1537
- const agent = hookAgent();
1538
- if (agent !== null)
1539
- row.agent = agent;
1540
- appendFileSync(RECALL_OUTCOMES_PATH, `${JSON.stringify(row)}\n`, {
1541
- encoding: 'utf8',
1542
- mode: 0o600,
1543
- });
1544
- }
1545
- catch {
1546
- }
1547
- }
1548
- export function summarizeRecallOutcomes(path = RECALL_OUTCOMES_PATH) {
1549
- const counts = {};
1550
- let total = 0;
1551
- let malformed = 0;
1552
- try {
1553
- const text = readFileSync(path, 'utf8');
1554
- for (const line of text.split('\n')) {
1555
- if (line.trim() === '')
1556
- continue;
1557
- try {
1558
- const row = JSON.parse(line);
1559
- const key = typeof row?.outcome === 'string' ? row.outcome : 'unknown';
1560
- counts[key] = (counts[key] ?? 0) + 1;
1561
- total += 1;
1562
- }
1563
- catch {
1564
- malformed += 1;
1565
- }
1566
- }
1567
- }
1568
- catch {
1569
- return { counts: {}, total: 0, malformed: 0 };
1570
- }
1571
- return { counts, total, malformed };
1572
- }
1573
- export function firedKeysPathFor(sessionId) {
1574
- const safe = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_');
1575
- return join(HARBOR_DIR, 'toolcontext', `${safe}.json`);
1576
- }
1577
- export function claimFiredKey(sessionId, key) {
1578
- const path = firedKeysPathFor(sessionId);
1579
- const existing = readJsonFile(path);
1580
- const keys = Array.isArray(existing?.keys) ? existing.keys : [];
1581
- if (keys.includes(key))
1582
- return false;
1583
- try {
1584
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1585
- const next = [...keys, key].slice(-200);
1586
- writeFileSync(path, JSON.stringify({ ...(existing ?? {}), keys: next }), { encoding: 'utf8', mode: 0o600 });
1587
- }
1588
- catch {
1589
- }
1590
- return true;
1591
- }
1592
- export const TOOL_MISS_BACKOFF = 3;
1593
- export function noteToolLookup(sessionId, served) {
1594
- const path = firedKeysPathFor(sessionId);
1595
- const existing = readJsonFile(path) ?? {};
1596
- const misses = served ? 0 : (Number(existing.misses) || 0) + 1;
1597
- try {
1598
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1599
- writeFileSync(path, JSON.stringify({ ...existing, misses }), {
1600
- encoding: 'utf8',
1601
- mode: 0o600,
1602
- });
1603
- }
1604
- catch {
1605
- }
1606
- }
1607
- export function toolLookupsSuspended(sessionId) {
1608
- const existing = readJsonFile(firedKeysPathFor(sessionId));
1609
- return (Number(existing?.misses) || 0) >= TOOL_MISS_BACKOFF;
1610
- }