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
@@ -1,937 +0,0 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { ApiError, fetchLoopMetrics } from '../api.js';
5
- import { decodeJwtClaims, isPendingWorkspace, jwtExpiry, readAuth, } from '../auth/store.js';
6
- import { daemonVerdict, readDaemonHeartbeat } from '../daemon/state.js';
7
- import { inspectRepoDocuments, inState } from '../documents/inspect.js';
8
- import { DOCS_DIR } from '../documents/manifest.js';
9
- import { inspectEnvSkillRoots, inspectRepoSkillRoots, skillsInState, } from '../skills.js';
10
- import { isLocalEndpoint } from '../endpoints.js';
11
- import { refreshCliRelease } from '../cli-release.js';
12
- import { hookScriptsAreCurrent } from '../install-hooks.js';
13
- import { gitRoot, repoOrigin } from '../origin.js';
14
- import { hooksBinDir, legacyDirs, recallOutcomesFile } from '../paths.js';
15
- import * as claude from '../providers/claude.js';
16
- import * as codex from '../providers/codex.js';
17
- import * as cursor from '../providers/cursor.js';
18
- import { detectAgents } from '../providers/detect.js';
19
- import { AGENT_LABEL, REPO_SKILL_DIR } from '../providers/surfaces.js';
20
- import { CODEX_CAPABILITY_BY_EVENT, CURSOR_CAPABILITY_BY_EVENT, describePartialHooks, } from '../providers/hook-coverage.js';
21
- import { ratio, relativeTime } from '../render.js';
22
- import { measureReportBudget } from '../sync-report.js';
23
- import { readSyncedConfig, resolveArms } from '../state.js';
24
- export async function runDoctor(opts) {
25
- const checks = [];
26
- checks.push({
27
- label: 'Endpoint',
28
- state: 'ok',
29
- detail: isLocalEndpoint(opts.endpoints)
30
- ? `${opts.endpoints.apiUrl} (local)`
31
- : opts.endpoints.apiUrl,
32
- });
33
- await appendMcpTargetCheck(checks, opts.cwd, opts.home ?? homedir(), opts.endpoints);
34
- const auth = await readAuth();
35
- if (!auth) {
36
- checks.push({
37
- label: 'Signed in',
38
- state: 'fail',
39
- detail: 'no credential on this machine',
40
- fix: 'harbor init',
41
- });
42
- }
43
- else if (auth.apiUrl !== opts.endpoints.apiUrl) {
44
- checks.push({
45
- label: 'Signed in',
46
- state: 'fail',
47
- detail: `credential is for ${auth.apiUrl}, not ${opts.endpoints.apiUrl}`,
48
- fix: 'harbor init',
49
- });
50
- }
51
- else if (isPendingWorkspace(auth)) {
52
- checks.push({
53
- label: 'Signed in',
54
- state: 'fail',
55
- detail: 'signed in, but no workspace chosen yet',
56
- fix: 'harbor login',
57
- });
58
- }
59
- else {
60
- const expiry = jwtExpiry(auth.runnerToken);
61
- const expired = expiry !== null && expiry.getTime() <= Date.now();
62
- checks.push({
63
- label: 'Signed in',
64
- state: expired ? 'fail' : 'ok',
65
- detail: expired
66
- ? `workspace "${auth.workspaceName}" — browser sign-in expired ${relativeTime(expiry)}`
67
- : `workspace "${auth.workspaceName}"`,
68
- ...(expired ? { fix: 'harbor login' } : {}),
69
- });
70
- }
71
- const [settings, claudeMcpConfig, present, codexConfig, codexHooksJson, cursorMcpJson, cursorHooksJson] = await Promise.all([
72
- claude.readClaudeSettings(),
73
- claude.readClaudeMcpConfig(),
74
- detectAgents(),
75
- codex.readCodexConfig(),
76
- codex.readCodexHooksJson(),
77
- cursor.readCursorMcpJson(),
78
- cursor.readCursorHooksJson(),
79
- ]);
80
- const installedToken = claude.readInstalledToken(claudeMcpConfig) ??
81
- codex.readInstalledToken(codexConfig) ??
82
- cursor.readInstalledToken(cursorMcpJson);
83
- if (!installedToken) {
84
- const searched = [
85
- '~/.claude.json',
86
- ...(present.codex ? ['~/.codex/config.toml'] : []),
87
- ...(present.cursor ? ['~/.cursor/mcp.json'] : []),
88
- ];
89
- checks.push({
90
- label: 'Token',
91
- state: 'fail',
92
- detail: `no MCP bearer in ${searched.join(' or ')}`,
93
- fix: 'harbor init',
94
- });
95
- }
96
- else {
97
- const expiry = jwtExpiry(installedToken);
98
- const claims = decodeJwtClaims(installedToken);
99
- const wrongWorkspace = auth !== null &&
100
- typeof claims?.workspaceId === 'string' &&
101
- claims.workspaceId !== auth.workspaceId;
102
- if (expiry !== null && expiry.getTime() <= Date.now()) {
103
- checks.push({
104
- label: 'Token',
105
- state: 'fail',
106
- detail: `expired ${relativeTime(expiry)}`,
107
- fix: 'the next prompt re-mints it; run `harbor init` if this persists',
108
- });
109
- }
110
- else if (wrongWorkspace) {
111
- checks.push({
112
- label: 'Token',
113
- state: 'fail',
114
- detail: 'installed bearer is scoped to a different workspace',
115
- fix: 'harbor init',
116
- });
117
- }
118
- else {
119
- checks.push({
120
- label: 'Token',
121
- state: 'ok',
122
- detail: expiry ? `valid, expires ${relativeTime(expiry)}` : 'valid',
123
- });
124
- }
125
- }
126
- const agentChecks = [];
127
- const hooks = claude.readInstalledHooks(settings);
128
- const missingClaudeEvents = claude.MANAGED_HOOK_EVENTS.filter((event) => !hooks.events.includes(event));
129
- const claudeWired = claude.readInstalledToken(claudeMcpConfig) !== null || hooks.installed;
130
- if (!present.claude && !claudeWired) {
131
- }
132
- else if (!hooks.installed) {
133
- agentChecks.push(claudeHooksMissing());
134
- }
135
- else {
136
- const stale = hooks.commands.filter((c) => !c.startsWith(hooksBinDir()));
137
- const current = await hookScriptsAreCurrent();
138
- if (stale.length > 0) {
139
- const dir = stale[0].replace(/\/[^/]+$/, '');
140
- agentChecks.push(staleHooksCheck(dir));
141
- }
142
- else if (!current) {
143
- agentChecks.push({
144
- label: 'Hooks',
145
- state: 'warn',
146
- detail: 'installed scripts are older than this CLI',
147
- fix: 'harbor init',
148
- });
149
- }
150
- else if (missingClaudeEvents.length > 0) {
151
- agentChecks.push({
152
- label: 'Hooks',
153
- state: 'warn',
154
- detail: describePartialHooks(hooks.events.length, claude.MANAGED_HOOK_EVENTS.length, missingClaudeEvents),
155
- fix: 'harbor init (registers the events that are missing)',
156
- });
157
- }
158
- else {
159
- agentChecks.push({
160
- label: 'Hooks',
161
- state: 'ok',
162
- detail: `${hooks.commands.length} installed from ${hooksBinDir()}`,
163
- });
164
- }
165
- }
166
- appendCodexChecks(agentChecks, present, codexConfig, codexHooksJson);
167
- appendCursorChecks(agentChecks, present, cursorMcpJson, cursorHooksJson);
168
- if (agentChecks.length === 0)
169
- agentChecks.push(claudeHooksMissing());
170
- checks.push(...agentChecks);
171
- const config = await readSyncedConfig();
172
- const origin = repoOrigin(opts.cwd);
173
- const bound = origin !== '' && Boolean(config?.origins.includes(origin));
174
- if (origin === '') {
175
- checks.push({
176
- label: 'This repo',
177
- state: 'warn',
178
- detail: 'not a git repo with an origin — sessions here get no scope',
179
- });
180
- }
181
- else if (!bound) {
182
- checks.push({
183
- label: 'This repo',
184
- state: 'fail',
185
- detail: `${origin} is not in the local allowlist`,
186
- fix: 'harbor bind',
187
- });
188
- }
189
- else {
190
- checks.push({ label: 'This repo', state: 'ok', detail: origin });
191
- }
192
- if (bound)
193
- await appendDocumentChecks(checks, opts.cwd);
194
- await appendSkillChecks(checks, opts.cwd, present, opts.home);
195
- await appendDaemonCheck(checks);
196
- await appendPayloadCheck(checks, opts.home);
197
- const arms = config ? resolveArms(config.pause, origin) : null;
198
- if (arms && !arms.recall) {
199
- checks.push({
200
- label: 'Recording',
201
- state: 'paused',
202
- detail: 'paused, on purpose — no context in, nothing recorded',
203
- fix: 'harbor on',
204
- });
205
- }
206
- else if (arms && !arms.capture) {
207
- checks.push({
208
- label: 'Recording',
209
- state: 'paused',
210
- detail: 'recall on, recording paused on purpose',
211
- fix: 'harbor on',
212
- });
213
- }
214
- else {
215
- checks.push({ label: 'Recording', state: 'ok', detail: 'on' });
216
- }
217
- appendReceiptCheck(checks, config, present);
218
- await appendVersionCheck(checks, opts);
219
- const metrics = await appendLoopChecks(checks, opts, {
220
- installedToken,
221
- origin,
222
- paused: arms !== null && !arms.recall,
223
- });
224
- await appendRecallDeliveryCheck(checks);
225
- return { checks, metrics };
226
- }
227
- const RECALL_HEALTHY = new Set([
228
- 'delivered',
229
- 'delivered-delta',
230
- 'redelivered-after-compact',
231
- 'no-shift',
232
- 'no-query',
233
- 'desktop-chat',
234
- 'recall-off',
235
- 'empty-bundle',
236
- ]);
237
- function shortRepoName(origin) {
238
- const match = /([^/:]+\/[^/]+?)(?:\.git)?\/?$/.exec(origin);
239
- return match?.[1] ?? origin;
240
- }
241
- const RECALL_FIXES = {
242
- 'no-auth': 'harbor init',
243
- 'no-config': 'harbor sync',
244
- 'not-allowlisted': 'harbor bind',
245
- };
246
- async function appendPayloadCheck(checks, home) {
247
- const budget = await measureReportBudget(home === undefined ? {} : { home });
248
- if (budget === null) {
249
- checks.push({
250
- label: 'Sent to harbor',
251
- state: 'warn',
252
- detail: 'machine facts and file digests — the skill inventory could not be measured here',
253
- fix: 'harbor context',
254
- });
255
- return;
256
- }
257
- const owned = { harbor: 0, 'other-tool': 0, nobody: 0 };
258
- for (const skill of budget.skills)
259
- owned[skill.owner] += 1;
260
- checks.push({
261
- label: 'Sent to harbor',
262
- state: 'ok',
263
- detail: `file digests · ~${budget.tokens.toLocaleString('en-US')} tok floor · ` +
264
- `${budget.skills.length} skill name${budget.skills.length === 1 ? '' : 's'} ` +
265
- `(${owned.harbor} ours · ${owned['other-tool']} other tools · ${owned.nobody} unowned), ` +
266
- 'home only. Never file contents.',
267
- });
268
- }
269
- async function appendMcpTargetCheck(checks, cwd, home, endpoints) {
270
- const readEntry = async (path) => {
271
- try {
272
- const root = JSON.parse(await readFile(path, 'utf8'));
273
- const servers = root?.mcpServers;
274
- if (!servers || typeof servers !== 'object')
275
- return null;
276
- const entry = servers[claude.MCP_KEY];
277
- return entry && typeof entry === 'object'
278
- ? entry
279
- : null;
280
- }
281
- catch {
282
- return null;
283
- }
284
- };
285
- const projectRoot = gitRoot(cwd) ?? cwd;
286
- const project = await readEntry(join(projectRoot, '.mcp.json'));
287
- const user = await readEntry(claude.resolveClaudePaths(home).mcpConfigPath);
288
- const winner = project ?? user;
289
- if (!winner) {
290
- checks.push({
291
- label: 'MCP target',
292
- state: 'warn',
293
- detail: 'no harbor MCP server registered — tool calls reach no graph',
294
- fix: 'harbor init',
295
- });
296
- return;
297
- }
298
- const url = typeof winner.url === 'string' ? winner.url : null;
299
- const isStdio = typeof winner.command === 'string';
300
- const source = project ? '.mcp.json (project)' : '~/.claude.json (user)';
301
- if (project && isStdio && !isLocalEndpoint(endpoints)) {
302
- checks.push({
303
- label: 'MCP target',
304
- state: 'fail',
305
- detail: `SPLIT BRAIN — ${source} runs harbor as a local stdio server, but ` +
306
- `recall reads ${endpoints.apiUrl}. Learnings you record go to the ` +
307
- 'local graph; injections come from the remote one.',
308
- fix: 'rename the .mcp.json entry (e.g. "harbor-dev") so the tool name says which graph',
309
- });
310
- return;
311
- }
312
- if (url && url !== endpoints.mcpUrl) {
313
- checks.push({
314
- label: 'MCP target',
315
- state: 'warn',
316
- detail: `${url} from ${source}, but recall reads ${endpoints.apiUrl}`,
317
- fix: 'harbor init',
318
- });
319
- return;
320
- }
321
- checks.push({
322
- label: 'MCP target',
323
- state: 'ok',
324
- detail: isStdio
325
- ? `local stdio server from ${source} (recall is local too)`
326
- : `${url ?? endpoints.mcpUrl} — same graph recall reads`,
327
- });
328
- }
329
- async function appendDaemonCheck(checks) {
330
- const verdict = daemonVerdict(await readDaemonHeartbeat());
331
- const state = verdict.kind === 'ok'
332
- ? 'ok'
333
- : verdict.kind === 'never_run' ||
334
- verdict.kind === 'degraded' ||
335
- verdict.kind === 'idle'
336
- ? 'warn'
337
- : 'fail';
338
- checks.push({
339
- label: 'Background sync',
340
- state,
341
- detail: verdict.detail,
342
- ...('fix' in verdict ? { fix: verdict.fix } : {}),
343
- });
344
- }
345
- async function appendRecallDeliveryCheck(checks) {
346
- let text;
347
- try {
348
- text = await readFile(recallOutcomesFile(), 'utf8');
349
- }
350
- catch {
351
- checks.push({
352
- label: 'Recall delivery',
353
- state: 'ok',
354
- detail: 'no prompts recorded yet',
355
- });
356
- return;
357
- }
358
- const counts = new Map();
359
- const emptyByRepo = new Map();
360
- let total = 0;
361
- for (const line of text.split('\n')) {
362
- if (line.trim() === '')
363
- continue;
364
- try {
365
- const row = JSON.parse(line);
366
- const outcome = typeof row.outcome === 'string'
367
- ? row.outcome
368
- : 'unknown';
369
- counts.set(outcome, (counts.get(outcome) ?? 0) + 1);
370
- const origin = row.origin;
371
- if (outcome === 'empty-bundle' && typeof origin === 'string' && origin !== '') {
372
- emptyByRepo.set(origin, (emptyByRepo.get(origin) ?? 0) + 1);
373
- }
374
- total += 1;
375
- }
376
- catch {
377
- }
378
- }
379
- if (total === 0) {
380
- checks.push({
381
- label: 'Recall delivery',
382
- state: 'ok',
383
- detail: 'no prompts recorded yet',
384
- });
385
- return;
386
- }
387
- const unhealthy = [...counts.entries()]
388
- .filter(([outcome]) => !RECALL_HEALTHY.has(outcome))
389
- .sort((a, b) => b[1] - a[1]);
390
- const healthy = total - unhealthy.reduce((sum, [, n]) => sum + n, 0);
391
- const restored = counts.get('redelivered-after-compact') ?? 0;
392
- const lost = counts.get('context-lost') ?? 0;
393
- const served = (counts.get('delivered') ?? 0) +
394
- (counts.get('delivered-delta') ?? 0) +
395
- restored;
396
- const truncations = restored + lost;
397
- const possession = truncations === 0
398
- ? ''
399
- : ` · ${restored} of ${truncations} restored after a compaction`;
400
- const topEmpty = [...emptyByRepo.entries()].sort((a, b) => b[1] - a[1])[0];
401
- const attribution = topEmpty
402
- ? ` · ${counts.get('empty-bundle') ?? 0} empty bundles, most in ${shortRepoName(topEmpty[0])}×${topEmpty[1]}`
403
- : '';
404
- const summary = `${ratio(healthy, total)} healthy over ${total} prompts — ${served} served context${possession}${attribution}`;
405
- if (unhealthy.length === 0) {
406
- checks.push({ label: 'Recall delivery', state: 'ok', detail: summary });
407
- return;
408
- }
409
- const worst = unhealthy[0];
410
- const breakdown = unhealthy.map(([o, n]) => `${o}×${n}`).join(', ');
411
- checks.push({
412
- label: 'Recall delivery',
413
- state: 'warn',
414
- detail: `${summary} · ${breakdown}`,
415
- ...(worst && RECALL_FIXES[worst[0]] ? { fix: RECALL_FIXES[worst[0]] } : {}),
416
- });
417
- }
418
- async function appendDocumentChecks(checks, cwd) {
419
- const root = gitRoot(cwd);
420
- if (root === null)
421
- return;
422
- const docs = await inspectRepoDocuments(root);
423
- if (!docs.managed || docs.documents.length === 0) {
424
- checks.push({
425
- label: 'Documents',
426
- state: 'unknown',
427
- detail: 'none in .claude/docs/ — no document is connected to this repo',
428
- });
429
- return;
430
- }
431
- const forked = inState(docs, 'forked');
432
- const missing = inState(docs, 'missing');
433
- const unverifiable = inState(docs, 'unverifiable');
434
- const total = docs.documents.length;
435
- if (forked.length > 0) {
436
- const [first, ...rest] = forked;
437
- const others = rest.length > 0 ? ` (and ${rest.length} more)` : '';
438
- checks.push({
439
- label: 'Documents',
440
- state: 'warn',
441
- detail: `${first}${others} has local edits — harbor will not overwrite it, ` +
442
- 'and it stops updating in this repo',
443
- fix: `discard yours: rm ${DOCS_DIR}/${first} (the next sync restores it)`,
444
- });
445
- return;
446
- }
447
- if (unverifiable.length > 0) {
448
- checks.push({
449
- label: 'Documents',
450
- state: 'unknown',
451
- detail: `${unverifiable.length} of ${total} synced before this CLI could check for edits — the next sync stamps them`,
452
- });
453
- return;
454
- }
455
- if (missing.length > 0) {
456
- checks.push({
457
- label: 'Documents',
458
- state: 'warn',
459
- detail: `${missing.length} of ${total} are listed but not on disk — ${missing[0]} is gone`,
460
- fix: 'the next sync restores them; the desktop app must be running',
461
- });
462
- return;
463
- }
464
- checks.push({
465
- label: 'Documents',
466
- state: 'ok',
467
- detail: `${total} synced from harbor, unedited`,
468
- });
469
- }
470
- async function appendSkillChecks(checks, cwd, present, home) {
471
- const root = gitRoot(cwd);
472
- const report = (label, managed, emptyDetail, fixHint) => {
473
- if (!managed.managed || managed.skills.length === 0) {
474
- checks.push({ label, state: 'unknown', detail: emptyDetail });
475
- return;
476
- }
477
- const forked = skillsInState(managed, 'forked');
478
- const missing = skillsInState(managed, 'missing');
479
- const unverifiable = skillsInState(managed, 'unverifiable');
480
- const total = managed.skills.length;
481
- if (forked.length > 0) {
482
- const [first, ...rest] = forked;
483
- const others = rest.length > 0 ? ` (and ${rest.length} more)` : '';
484
- checks.push({
485
- label,
486
- state: 'warn',
487
- detail: `${first}${others} has local edits — harbor will not overwrite it, ` +
488
- 'and it stops updating here',
489
- fix: `discard yours: rm -r ${fixHint}/${first} (the next sync restores it)`,
490
- });
491
- return;
492
- }
493
- if (unverifiable.length > 0) {
494
- checks.push({
495
- label,
496
- state: 'unknown',
497
- detail: `${unverifiable.length} of ${total} synced before this CLI could check for edits — the next sync stamps them`,
498
- });
499
- return;
500
- }
501
- if (missing.length > 0) {
502
- checks.push({
503
- label,
504
- state: 'warn',
505
- detail: `${missing.length} of ${total} are listed but not on disk — ${missing[0]} is gone`,
506
- fix: 'harbor sync',
507
- });
508
- return;
509
- }
510
- checks.push({ label, state: 'ok', detail: `${total} synced from harbor, unedited` });
511
- };
512
- if (root !== null) {
513
- const repoRoots = await inspectRepoSkillRoots(root, {
514
- agents: present,
515
- ...(home === undefined ? {} : { home }),
516
- });
517
- for (const repoRoot of repoRoots) {
518
- const dir = REPO_SKILL_DIR[repoRoot.agent];
519
- report(repoRoot.agent === 'claude'
520
- ? 'Skills'
521
- : `Skills (${AGENT_LABEL[repoRoot.agent]})`, repoRoot, `none in ${dir}/ — no skill is connected to this repo`, dir);
522
- }
523
- }
524
- const envRoots = await inspectEnvSkillRoots({
525
- agents: present,
526
- ...(home === undefined ? {} : { home }),
527
- });
528
- for (const root of envRoots) {
529
- const dir = home === undefined ? root.dir.replace(homedir(), '~') : root.dir;
530
- report(root.agent === 'claude'
531
- ? 'Your skills'
532
- : `Your skills (${AGENT_LABEL[root.agent]})`, root, `none in ${dir}/ — no skill is connected to you`, dir);
533
- }
534
- }
535
- function appendReceiptCheck(checks, config, present) {
536
- if (config === null)
537
- return;
538
- if (!config.receipt) {
539
- checks.push({
540
- label: 'Receipt',
541
- state: 'paused',
542
- detail: 'off, on purpose - rules still reach the agent, silently',
543
- fix: 'harbor on --receipt',
544
- });
545
- return;
546
- }
547
- const cursorOnly = present.cursor && !present.claude && !present.codex;
548
- if (cursorOnly) {
549
- checks.push({
550
- label: 'Receipt',
551
- state: 'paused',
552
- detail: 'Cursor cannot show it - its session-start hook discards the message',
553
- });
554
- return;
555
- }
556
- checks.push({
557
- label: 'Receipt',
558
- state: 'ok',
559
- detail: present.cursor
560
- ? 'on (not in Cursor - that host discards it)'
561
- : 'on',
562
- });
563
- }
564
- async function appendVersionCheck(checks, opts) {
565
- if (opts.offline) {
566
- checks.push({ label: 'Version', state: 'unknown', detail: 'not checked (--offline)' });
567
- return;
568
- }
569
- const check = await refreshCliRelease({
570
- force: true,
571
- ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
572
- });
573
- if (check.kind === 'unreachable') {
574
- checks.push({
575
- label: 'Version',
576
- state: 'unknown',
577
- detail: 'could not reach the npm registry',
578
- });
579
- return;
580
- }
581
- if (check.kind === 'unknown-version') {
582
- checks.push({
583
- label: 'Version',
584
- state: 'unknown',
585
- detail: 'this build cannot read its own version',
586
- });
587
- return;
588
- }
589
- const { latest, current, behind, command } = check.release;
590
- checks.push(behind
591
- ? { label: 'Version', state: 'warn', detail: `${current} - ${latest} is available`, fix: command }
592
- : { label: 'Version', state: 'ok', detail: current });
593
- }
594
- function claudeHooksMissing() {
595
- return {
596
- label: 'Hooks',
597
- state: 'fail',
598
- detail: 'not installed — nothing runs on a prompt',
599
- fix: 'harbor init',
600
- };
601
- }
602
- function appendCodexChecks(checks, present, config, hooksJson) {
603
- const hasMcp = codex.hasMcpServer(config);
604
- const hooks = codex.readInstalledHooks(hooksJson);
605
- if (!present.codex && !hasMcp && !hooks.installed)
606
- return;
607
- if (!hasMcp && !hooks.installed) {
608
- checks.push({
609
- label: 'Codex',
610
- state: 'warn',
611
- detail: 'installed on this machine, but not configured — its sessions get nothing',
612
- fix: 'harbor init',
613
- });
614
- return;
615
- }
616
- if (!hooks.installed) {
617
- checks.push({
618
- label: 'Codex',
619
- state: 'fail',
620
- detail: 'MCP tools are configured, but no hooks — nothing runs on a prompt',
621
- fix: 'harbor init',
622
- });
623
- return;
624
- }
625
- if (!hasMcp) {
626
- checks.push({
627
- label: 'Codex',
628
- state: 'fail',
629
- detail: 'hooks are registered, but there is no MCP bearer for them to use',
630
- fix: 'harbor init',
631
- });
632
- return;
633
- }
634
- const paths = codex.resolveCodexPaths();
635
- const stamps = codex.countHookTrustStamps(config, paths.hooksJsonPath);
636
- if (stamps === 0) {
637
- checks.push({
638
- label: 'Codex',
639
- state: 'fail',
640
- detail: `${hooks.commands.length} hooks registered and NONE trusted — Codex will not run them`,
641
- fix: 'harbor init (stamps the trust hashes `codex` reports)',
642
- });
643
- return;
644
- }
645
- const stale = hooks.commands.filter((c) => !c.startsWith(hooksBinDir()));
646
- if (stale.length > 0) {
647
- const dir = stale[0].replace(/\/[^/]+$/, '');
648
- checks.push(staleHooksCheck(dir, 'Codex'));
649
- return;
650
- }
651
- const missing = codex.MANAGED_HOOK_EVENTS.filter((event) => !hooks.events.includes(event));
652
- if (missing.length > 0) {
653
- checks.push({
654
- label: 'Codex',
655
- state: 'warn',
656
- detail: describePartialHooks(hooks.events.length, codex.MANAGED_HOOK_EVENTS.length, missing, CODEX_CAPABILITY_BY_EVENT),
657
- fix: 'harbor init (registers the events that are missing)',
658
- });
659
- return;
660
- }
661
- checks.push({
662
- label: 'Codex',
663
- state: 'ok',
664
- detail: `${hooks.commands.length} hooks installed, ${stamps} trusted`,
665
- });
666
- }
667
- function appendCursorChecks(checks, present, mcpJson, hooksJson) {
668
- const hasMcp = cursor.hasMcpServer(mcpJson);
669
- const hooks = cursor.readInstalledHooks(hooksJson);
670
- if (!present.cursor && !hasMcp && !hooks.installed)
671
- return;
672
- if (!hasMcp && !hooks.installed) {
673
- checks.push({
674
- label: 'Cursor',
675
- state: 'warn',
676
- detail: 'installed on this machine, but not configured — its sessions get nothing',
677
- fix: 'harbor init',
678
- });
679
- return;
680
- }
681
- if (!hooks.installed) {
682
- checks.push({
683
- label: 'Cursor',
684
- state: 'fail',
685
- detail: 'MCP tools are configured, but no hooks — nothing runs on a session',
686
- fix: 'harbor init',
687
- });
688
- return;
689
- }
690
- if (!hasMcp) {
691
- checks.push({
692
- label: 'Cursor',
693
- state: 'fail',
694
- detail: 'hooks are registered, but there is no MCP bearer for them to use',
695
- fix: 'harbor init',
696
- });
697
- return;
698
- }
699
- const commented = [
700
- ...(cursor.stripJsonComments(mcpJson).hadComments ? ['~/.cursor/mcp.json'] : []),
701
- ...(cursor.stripJsonComments(hooksJson).hadComments ? ['~/.cursor/hooks.json'] : []),
702
- ];
703
- if (commented.length > 0) {
704
- checks.push({
705
- label: 'Cursor',
706
- state: 'warn',
707
- detail: `${commented.join(' and ')} contains comments — Harbor will not rewrite it`,
708
- fix: 'remove the comments so `harbor init` can update this file',
709
- });
710
- return;
711
- }
712
- const stale = hooks.commands.filter((c) => !c.startsWith(hooksBinDir()));
713
- if (stale.length > 0) {
714
- const dir = stale[0].replace(/\/[^/]+$/, '');
715
- checks.push(staleHooksCheck(dir, 'Cursor'));
716
- return;
717
- }
718
- const missing = cursor.MANAGED_HOOK_EVENTS.filter((event) => !hooks.events.includes(event));
719
- if (missing.length > 0) {
720
- checks.push({
721
- label: 'Cursor',
722
- state: 'warn',
723
- detail: describePartialHooks(hooks.events.length, cursor.MANAGED_HOOK_EVENTS.length, missing, CURSOR_CAPABILITY_BY_EVENT),
724
- fix: 'harbor init (registers the events that are missing)',
725
- });
726
- return;
727
- }
728
- checks.push({
729
- label: 'Cursor',
730
- state: 'ok',
731
- detail: `${hooks.commands.length} hooks installed — recall lands at session start, restored after a compaction (Cursor has no per-turn injection)`,
732
- });
733
- }
734
- function staleHooksCheck(dir, label = 'Hooks') {
735
- const legacy = legacyDirs().some((home) => dir === home || dir.startsWith(`${home}/`));
736
- if (legacy) {
737
- return {
738
- label,
739
- state: 'fail',
740
- detail: `installed from ${dir} — a PREVIOUS release's home; those hooks scrape for an older sentinel, so citations and capture from this machine are lost`,
741
- fix: 'harbor init (re-points them at ~/.harbor/bin)',
742
- };
743
- }
744
- return {
745
- label,
746
- state: 'warn',
747
- detail: `installed from ${dir} — ${isAppBundlePath(dir)
748
- ? 'a path that dies with the app'
749
- : 'not where this CLI installs them'}`,
750
- fix: 'harbor init (re-points them at ~/.harbor/bin)',
751
- };
752
- }
753
- function isAppBundlePath(dir) {
754
- return /\.app\//i.test(dir) || /resources\/hooks/i.test(dir);
755
- }
756
- function markTokenRejected(checks) {
757
- const index = checks.findIndex((check) => check.label === 'Token');
758
- if (index === -1)
759
- return;
760
- checks[index] = {
761
- label: 'Token',
762
- state: 'fail',
763
- detail: 'the backend rejected it — well-formed, but not accepted',
764
- fix: 'the next prompt re-mints it; run `harbor init` if this persists',
765
- };
766
- }
767
- async function appendLoopChecks(checks, opts, ctx) {
768
- const labels = ['Recall', 'Fit', 'Attribution', 'Contributed'];
769
- if (opts.offline) {
770
- for (const label of labels) {
771
- checks.push({ label, state: 'unknown', detail: 'not checked (--offline)' });
772
- }
773
- return null;
774
- }
775
- if (!ctx.installedToken) {
776
- for (const label of labels) {
777
- checks.push({
778
- label,
779
- state: 'unknown',
780
- detail: 'no bearer to ask with',
781
- });
782
- }
783
- return null;
784
- }
785
- let metrics;
786
- try {
787
- metrics = await fetchLoopMetrics(opts.endpoints.apiUrl, ctx.installedToken, { originUrl: ctx.origin || undefined, windowDays: opts.windowDays }, opts.fetchImpl);
788
- }
789
- catch (err) {
790
- const skewed = err instanceof ApiError && err.kind === 'shape';
791
- const unpaid = err instanceof ApiError && err.kind === 'payment_required';
792
- const reason = skewed
793
- ? 'the backend answered, but not in a shape this CLI can read'
794
- : unpaid
795
- ? 'this workspace is past due, so recall is paused'
796
- : err instanceof ApiError && err.kind === 'network'
797
- ? 'the backend is unreachable'
798
- : err instanceof ApiError
799
- ? `the backend said ${err.status ?? err.kind}`
800
- : 'the measurement call failed';
801
- if (err instanceof ApiError && (err.kind === 'unauthorized' || err.kind === 'forbidden')) {
802
- markTokenRejected(checks);
803
- }
804
- checks.push({
805
- label: 'Recall',
806
- state: 'unknown',
807
- detail: reason,
808
- });
809
- checks.push(skewed
810
- ? {
811
- label: 'Attribution',
812
- state: 'fail',
813
- detail: `the loop numbers are unreadable — this CLI and the backend disagree about their shape (${err.message})`,
814
- fix: 'upgrade this CLI: npx harborloop@latest doctor',
815
- }
816
- : unpaid
817
- ? {
818
- label: 'Attribution',
819
- state: 'fail',
820
- detail: err.message,
821
- fix: 'update billing in the harbor app to restore recall',
822
- }
823
- : {
824
- label: 'Attribution',
825
- state: 'fail',
826
- detail: "this repo's sessions are not being counted",
827
- fix: 'harbor doctor --offline shows what is reachable',
828
- });
829
- for (const label of ['Fit', 'Contributed', 'Corpus']) {
830
- checks.push({ label, state: 'unknown', detail: reason });
831
- }
832
- return null;
833
- }
834
- const scope = metrics.projectId ? 'this project' : 'workspace-wide';
835
- const window = `last ${metrics.windowDays} days, ${scope}`;
836
- const { recall, fit, attribution, contributed } = metrics;
837
- checks.push({
838
- label: 'Recall',
839
- state: recall.sessions === 0 ? 'unknown' : recall.served === 0 ? 'warn' : 'ok',
840
- detail: recall.sessions === 0
841
- ? `${window} — no sessions yet`
842
- : `${window} ${recall.sessions} sessions · ${ratio(recall.served, recall.sessions)} received context`,
843
- ...(recall.sessions > 0 && recall.served === 0
844
- ? { fix: 'nothing was served — check that this repo is bound to a project' }
845
- : {}),
846
- });
847
- const uncited = fit.servedAtoms - fit.citedAtoms;
848
- checks.push({
849
- label: 'Fit',
850
- state: fit.servedAtoms === 0
851
- ? 'unknown'
852
- : fit.citedAtoms / fit.servedAtoms < 0.5
853
- ? 'warn'
854
- : 'ok',
855
- detail: fit.servedAtoms === 0
856
- ? 'nothing served yet'
857
- : `avg bundle ${fit.avgBundle?.toFixed(1) ?? '—'} ${ratio(uncited, fit.servedAtoms)} served atoms went uncited`,
858
- });
859
- checks.push({
860
- label: 'Attribution',
861
- state: ctx.paused
862
- ? 'paused'
863
- : attribution.recalls === 0
864
- ? 'unknown'
865
- : attribution.keyed === attribution.recalls
866
- ? 'ok'
867
- : 'fail',
868
- detail: ctx.paused
869
- ? 'paused, on purpose — nothing to count'
870
- : attribution.recalls === 0
871
- ? 'no recalls to count yet'
872
- : attribution.keyed === attribution.recalls
873
- ? `${attribution.keyed}/${attribution.recalls} recalls counted`
874
- : `this repo's sessions are not all being counted — ${ratio(attribution.keyed, attribution.recalls)}`,
875
- ...(attribution.recalls > 0 && attribution.keyed < attribution.recalls
876
- ? { fix: 'an unkeyed recall is invisible to the loop — re-run `harbor init`' }
877
- : {}),
878
- });
879
- const byTransport = metrics.byTransport;
880
- checks.push({
881
- label: 'Surfaces',
882
- state: !byTransport || byTransport.length === 0 ? 'unknown' : 'ok',
883
- detail: !byTransport
884
- ? 'this backend does not group recalls by surface yet'
885
- : byTransport.length === 0
886
- ? `${window} — no recalls to place`
887
- : byTransport
888
- .map((row) => `${row.label} ${ratio(row.keyed, row.recalls)}` +
889
- (row.clients.length > 1 ? ` [${row.clients.join(' + ')}]` : ''))
890
- .join(' '),
891
- });
892
- checks.push({
893
- label: 'Contributed',
894
- state: contributed.total === 0 ? 'unknown' : 'ok',
895
- detail: contributed.total === 0
896
- ? `${window} — nothing recorded from this repo`
897
- : `${contributed.total} learnings ${contributed.approved} approved · ${contributed.pending} pending review`,
898
- });
899
- const corpus = metrics.corpus;
900
- const topCause = corpus?.retiredByCause?.[0];
901
- checks.push({
902
- label: 'Corpus',
903
- state: !corpus || corpus.ledgerEmpty ? 'unknown' : 'ok',
904
- detail: !corpus
905
- ? 'this backend does not keep a corpus ledger yet'
906
- : corpus.ledgerEmpty
907
- ? `${corpus.liveAtoms} live atoms — nothing added or retired has been recorded yet`
908
- : `last ${metrics.windowDays} days, workspace-wide ` +
909
- `+${corpus.admitted} / −${corpus.retired} ` +
910
- `${corpus.liveAtoms} live` +
911
- (topCause
912
- ? ` (most retirements: ${topCause.cause} ${topCause.count})`
913
- : ''),
914
- });
915
- const spend = metrics.spend;
916
- checks.push({
917
- label: 'Cost',
918
- state: !spend
919
- ? 'unknown'
920
- : spend.costPerCitedAtomUsd === null
921
- ? 'unknown'
922
- : 'ok',
923
- detail: !spend
924
- ? 'this backend does not price the corpus yet'
925
- : spend.costPerCitedAtomUsd === null
926
- ? spend.spendRows === 0
927
- ? `last ${metrics.windowDays} days, workspace-wide — no write-path calls`
928
- : `${spend.spendRows} write-path calls bought nothing anyone cited — no denominator`
929
- : `last ${metrics.windowDays} days, workspace-wide ` +
930
- `$${spend.costPerCitedAtomUsd.toFixed(4)} per cited atom ` +
931
- `($${spend.writeSpendUsd.toFixed(4)} over ${spend.citedAtoms} atoms` +
932
- (spend.unpricedRows > 0
933
- ? `, ${spend.unpricedRows}/${spend.spendRows} calls unpriced — a floor)`
934
- : ')'),
935
- });
936
- return metrics;
937
- }