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.
- package/bin/checksums.json +12 -0
- package/bin/harbor.mjs +188 -0
- package/package.json +14 -30
- package/dist/api.d.ts +0 -109
- package/dist/api.js +0 -409
- package/dist/auth/login.d.ts +0 -14
- package/dist/auth/login.js +0 -158
- package/dist/auth/loopback.d.ts +0 -11
- package/dist/auth/loopback.js +0 -87
- package/dist/auth/pkce.d.ts +0 -7
- package/dist/auth/pkce.js +0 -14
- package/dist/auth/store.d.ts +0 -26
- package/dist/auth/store.js +0 -107
- package/dist/cli-release.d.ts +0 -31
- package/dist/cli-release.js +0 -102
- package/dist/commands/bind.d.ts +0 -23
- package/dist/commands/bind.js +0 -86
- package/dist/commands/context.d.ts +0 -12
- package/dist/commands/context.js +0 -80
- package/dist/commands/daemon.d.ts +0 -39
- package/dist/commands/daemon.js +0 -140
- package/dist/commands/doctor.d.ts +0 -16
- package/dist/commands/doctor.js +0 -937
- package/dist/commands/init.d.ts +0 -36
- package/dist/commands/init.js +0 -192
- package/dist/commands/status.d.ts +0 -5
- package/dist/commands/status.js +0 -164
- package/dist/commands/sync.d.ts +0 -40
- package/dist/commands/sync.js +0 -242
- package/dist/commands/toggle.d.ts +0 -16
- package/dist/commands/toggle.js +0 -69
- package/dist/commands/unbind.d.ts +0 -14
- package/dist/commands/unbind.js +0 -60
- package/dist/commands/workspaces.d.ts +0 -18
- package/dist/commands/workspaces.js +0 -41
- package/dist/context-budget.d.ts +0 -40
- package/dist/context-budget.js +0 -259
- package/dist/daemon/install.d.ts +0 -50
- package/dist/daemon/install.js +0 -192
- package/dist/daemon/loop.d.ts +0 -15
- package/dist/daemon/loop.js +0 -84
- package/dist/daemon/state.d.ts +0 -79
- package/dist/daemon/state.js +0 -213
- package/dist/daemon/tick.d.ts +0 -37
- package/dist/daemon/tick.js +0 -231
- package/dist/documents/apply.d.ts +0 -21
- package/dist/documents/apply.js +0 -58
- package/dist/documents/bundle.d.ts +0 -41
- package/dist/documents/bundle.js +0 -43
- package/dist/documents/inspect.d.ts +0 -12
- package/dist/documents/inspect.js +0 -33
- package/dist/documents/manifest.d.ts +0 -14
- package/dist/documents/manifest.js +0 -52
- package/dist/documents/pointer.d.ts +0 -32
- package/dist/documents/pointer.js +0 -135
- package/dist/documents/write.d.ts +0 -57
- package/dist/documents/write.js +0 -211
- package/dist/endpoints.d.ts +0 -7
- package/dist/endpoints.js +0 -15
- package/dist/env-bundle.d.ts +0 -25
- package/dist/env-bundle.js +0 -33
- package/dist/env-scan.d.ts +0 -27
- package/dist/env-scan.js +0 -41
- package/dist/env-sync.d.ts +0 -31
- package/dist/env-sync.js +0 -37
- package/dist/index.d.ts +0 -11
- package/dist/index.js +0 -282
- package/dist/install-hooks.d.ts +0 -11
- package/dist/install-hooks.js +0 -64
- package/dist/lock.d.ts +0 -31
- package/dist/lock.js +0 -192
- package/dist/machine.d.ts +0 -13
- package/dist/machine.js +0 -53
- package/dist/memories.d.ts +0 -31
- package/dist/memories.js +0 -74
- package/dist/origin.d.ts +0 -4
- package/dist/origin.js +0 -59
- package/dist/paths.d.ts +0 -12
- package/dist/paths.js +0 -45
- package/dist/providers/claude.d.ts +0 -65
- package/dist/providers/claude.js +0 -412
- package/dist/providers/codex-trust.d.ts +0 -36
- package/dist/providers/codex-trust.js +0 -141
- package/dist/providers/codex.d.ts +0 -46
- package/dist/providers/codex.js +0 -520
- package/dist/providers/cursor.d.ts +0 -45
- package/dist/providers/cursor.js +0 -344
- package/dist/providers/detect.d.ts +0 -16
- package/dist/providers/detect.js +0 -70
- package/dist/providers/hook-coverage.d.ts +0 -5
- package/dist/providers/hook-coverage.js +0 -47
- package/dist/providers/hook-entry.d.ts +0 -1
- package/dist/providers/hook-entry.js +0 -8
- package/dist/providers/surfaces.d.ts +0 -19
- package/dist/providers/surfaces.js +0 -91
- package/dist/reclaim.d.ts +0 -17
- package/dist/reclaim.js +0 -33
- package/dist/render.d.ts +0 -17
- package/dist/render.js +0 -61
- package/dist/repo-sync.d.ts +0 -27
- package/dist/repo-sync.js +0 -34
- package/dist/repos.d.ts +0 -11
- package/dist/repos.js +0 -82
- package/dist/skills.d.ts +0 -84
- package/dist/skills.js +0 -383
- package/dist/slugify.d.ts +0 -1
- package/dist/slugify.js +0 -8
- package/dist/state.d.ts +0 -23
- package/dist/state.js +0 -161
- package/dist/sync-report.d.ts +0 -59
- package/dist/sync-report.js +0 -87
- package/hooks/_recall.mjs +0 -288
- package/hooks/_receipt.mjs +0 -47
- package/hooks/_shared.mjs +0 -1610
- package/hooks/_upgrade.mjs +0 -51
- package/hooks/session-capture.mjs +0 -60
- package/hooks/session-compact.mjs +0 -19
- package/hooks/session-context.mjs +0 -258
- package/hooks/session-report.mjs +0 -42
- package/hooks/session-toolcontext.mjs +0 -183
package/dist/commands/doctor.js
DELETED
|
@@ -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
|
-
}
|