hypomnema 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +4 -2
- package/README.md +4 -2
- package/commands/audit.md +2 -2
- package/commands/crystallize.md +113 -23
- package/commands/feedback.md +40 -26
- package/commands/ingest.md +31 -9
- package/commands/upgrade.md +2 -2
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONTRIBUTING.md +1 -1
- package/hooks/hooks.json +30 -1
- package/hooks/hypo-auto-commit.mjs +10 -4
- package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
- package/hooks/hypo-auto-stage.mjs +4 -3
- package/hooks/hypo-compact-guard.mjs +33 -24
- package/hooks/hypo-cwd-change.mjs +107 -24
- package/hooks/hypo-file-watch.mjs +23 -10
- package/hooks/hypo-first-prompt.mjs +37 -23
- package/hooks/hypo-hot-rebuild.mjs +22 -10
- package/hooks/hypo-lookup.mjs +171 -65
- package/hooks/hypo-personal-check.mjs +207 -112
- package/hooks/hypo-pre-commit.mjs +46 -0
- package/hooks/hypo-session-end.mjs +58 -0
- package/hooks/hypo-session-record.mjs +11 -5
- package/hooks/hypo-session-start.mjs +298 -52
- package/hooks/hypo-shared.mjs +793 -37
- package/hooks/hypo-web-fetch-ingest.mjs +121 -0
- package/hooks/version-check-fetch.mjs +74 -0
- package/hooks/version-check.mjs +184 -0
- package/package.json +17 -3
- package/scripts/crystallize.mjs +623 -18
- package/scripts/doctor.mjs +730 -47
- package/scripts/feedback-sync.mjs +974 -0
- package/scripts/feedback.mjs +253 -44
- package/scripts/graph.mjs +35 -22
- package/scripts/ingest.mjs +89 -16
- package/scripts/init.mjs +398 -113
- package/scripts/lib/design-history-stale.mjs +83 -0
- package/scripts/lib/extensions.mjs +749 -0
- package/scripts/lib/frontmatter.mjs +5 -1
- package/scripts/lib/hypo-ignore.mjs +12 -10
- package/scripts/lib/pkg-json.mjs +23 -5
- package/scripts/lib/project-create.mjs +225 -0
- package/scripts/lib/schema-vocab.mjs +96 -0
- package/scripts/lint.mjs +238 -31
- package/scripts/query.mjs +26 -10
- package/scripts/resume.mjs +11 -5
- package/scripts/session-audit.mjs +37 -27
- package/scripts/smoke-pack.mjs +224 -0
- package/scripts/stats.mjs +24 -10
- package/scripts/uninstall.mjs +363 -49
- package/scripts/upgrade.mjs +706 -202
- package/scripts/verify.mjs +24 -14
- package/scripts/weekly-report.mjs +59 -25
- package/skills/crystallize/SKILL.md +20 -7
- package/skills/ingest/SKILL.md +25 -5
- package/templates/.hypoignore +16 -2
- package/templates/Home.md +2 -0
- package/templates/SCHEMA.md +61 -6
- package/templates/extensions/agents/.gitkeep +0 -0
- package/templates/extensions/commands/.gitkeep +0 -0
- package/templates/extensions/hooks/.gitkeep +0 -0
- package/templates/extensions/skills/.gitkeep +0 -0
- package/templates/gitignore +5 -0
- package/templates/hot.md +2 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +42 -2
- package/templates/hypo-help.md +1 -1
- package/templates/pages/observability/_index.md +77 -0
- package/templates/projects/_template/index.md +2 -2
- package/templates/projects/_template/prd.md +1 -1
package/scripts/doctor.mjs
CHANGED
|
@@ -20,24 +20,44 @@ import { fileURLToPath } from 'url';
|
|
|
20
20
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
21
21
|
import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
|
|
22
22
|
import { parseFrontmatter } from './lib/frontmatter.mjs';
|
|
23
|
+
import { readSyncState, projectSuggestionsPath } from '../hooks/hypo-shared.mjs';
|
|
24
|
+
import {
|
|
25
|
+
discoverExtensions,
|
|
26
|
+
parseManifest,
|
|
27
|
+
buildExpectedSettingsEntries,
|
|
28
|
+
readExtensionPkgStateNoMutate,
|
|
29
|
+
collectOurOccurrences,
|
|
30
|
+
pickCanonicalOccurrence,
|
|
31
|
+
EXT_TYPES,
|
|
32
|
+
CODEX_TYPES,
|
|
33
|
+
} from './lib/extensions.mjs';
|
|
34
|
+
import { sha256, readFileIfRegular } from './lib/pkg-json.mjs';
|
|
23
35
|
|
|
24
|
-
const HOME
|
|
36
|
+
const HOME = homedir();
|
|
25
37
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
26
|
-
const PKG_ROOT
|
|
38
|
+
const PKG_ROOT = join(SCRIPT_DIR, '..');
|
|
27
39
|
|
|
28
40
|
// Shown after every fatal package-integrity error. These conditions mean the
|
|
29
41
|
// shipped hooks/hooks.json is missing or malformed — never a user mistake —
|
|
30
42
|
// so the only useful next step is a re-install of the package.
|
|
31
|
-
const PKG_INTEGRITY_HINT =
|
|
43
|
+
const PKG_INTEGRITY_HINT =
|
|
44
|
+
'→ This indicates a corrupt or incomplete install. Re-install with `npm install -g hypomnema` (or re-install the Claude Code plugin).';
|
|
32
45
|
|
|
33
46
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
34
47
|
|
|
35
48
|
function parseArgs(argv) {
|
|
36
|
-
const args = { hypoDir: null, json: false };
|
|
49
|
+
const args = { hypoDir: null, json: false, codex: false, claudeHome: null, projectId: null };
|
|
37
50
|
for (const arg of argv.slice(2)) {
|
|
38
51
|
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
39
|
-
else if (arg === '--json')
|
|
52
|
+
else if (arg === '--json') args.json = true;
|
|
53
|
+
else if (arg === '--codex') args.codex = true;
|
|
54
|
+
else if (arg.startsWith('--claude-home=')) args.claudeHome = expandHome(arg.slice(14));
|
|
55
|
+
else if (arg.startsWith('--project-id=')) args.projectId = arg.slice(13);
|
|
40
56
|
}
|
|
57
|
+
if (!args.claudeHome) args.claudeHome = join(HOME, '.claude');
|
|
58
|
+
// projectId intentionally left null when not user-supplied — let feedback-sync
|
|
59
|
+
// derive it and exercise its own "derived dir missing → skip MEMORY" path so
|
|
60
|
+
// doctor reports "unresolved/skipped" rather than a misleading stale warning.
|
|
41
61
|
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
42
62
|
return args;
|
|
43
63
|
}
|
|
@@ -46,9 +66,15 @@ function parseArgs(argv) {
|
|
|
46
66
|
|
|
47
67
|
const checks = [];
|
|
48
68
|
|
|
49
|
-
function pass(label, detail = '') {
|
|
50
|
-
|
|
51
|
-
|
|
69
|
+
function pass(label, detail = '') {
|
|
70
|
+
checks.push({ status: 'pass', label, detail });
|
|
71
|
+
}
|
|
72
|
+
function warn(label, detail = '') {
|
|
73
|
+
checks.push({ status: 'warn', label, detail });
|
|
74
|
+
}
|
|
75
|
+
function fail(label, detail = '') {
|
|
76
|
+
checks.push({ status: 'fail', label, detail });
|
|
77
|
+
}
|
|
52
78
|
|
|
53
79
|
// ── hook map (loaded from hooks/hooks.json — single source of truth) ─────────
|
|
54
80
|
|
|
@@ -65,7 +91,11 @@ if (!_hookConfig || typeof _hookConfig !== 'object' || Array.isArray(_hookConfig
|
|
|
65
91
|
console.error(PKG_INTEGRITY_HINT);
|
|
66
92
|
process.exit(1);
|
|
67
93
|
}
|
|
68
|
-
if (
|
|
94
|
+
if (
|
|
95
|
+
!_hookConfig.hooks ||
|
|
96
|
+
typeof _hookConfig.hooks !== 'object' ||
|
|
97
|
+
Array.isArray(_hookConfig.hooks)
|
|
98
|
+
) {
|
|
69
99
|
console.error('Error: hooks/hooks.json must contain a "hooks" object');
|
|
70
100
|
console.error(PKG_INTEGRITY_HINT);
|
|
71
101
|
process.exit(1);
|
|
@@ -83,50 +113,93 @@ function _isHookFileName(file) {
|
|
|
83
113
|
}
|
|
84
114
|
|
|
85
115
|
function _isHookGroup(group) {
|
|
86
|
-
return
|
|
116
|
+
return (
|
|
117
|
+
group &&
|
|
87
118
|
typeof group === 'object' &&
|
|
88
119
|
!Array.isArray(group) &&
|
|
89
120
|
Array.isArray(group.hooks) &&
|
|
90
121
|
group.hooks.length > 0 &&
|
|
91
|
-
group.hooks.every(
|
|
92
|
-
hook
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
122
|
+
group.hooks.every(
|
|
123
|
+
(hook) =>
|
|
124
|
+
hook &&
|
|
125
|
+
typeof hook === 'object' &&
|
|
126
|
+
!Array.isArray(hook) &&
|
|
127
|
+
hook.type === 'command' &&
|
|
128
|
+
_extractCommandFileName(hook.command),
|
|
129
|
+
)
|
|
130
|
+
);
|
|
98
131
|
}
|
|
99
132
|
|
|
100
133
|
// Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
|
|
101
134
|
function _extractFileNames(groups) {
|
|
102
|
-
return groups.flatMap(group => {
|
|
135
|
+
return groups.flatMap((group) => {
|
|
103
136
|
if (typeof group === 'string') return [group.trim()];
|
|
104
|
-
return group.hooks.map(hook => _extractCommandFileName(hook.command));
|
|
137
|
+
return group.hooks.map((hook) => _extractCommandFileName(hook.command));
|
|
105
138
|
});
|
|
106
139
|
}
|
|
107
140
|
|
|
108
141
|
for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
|
|
109
|
-
const valid =
|
|
142
|
+
const valid =
|
|
143
|
+
Array.isArray(groups) &&
|
|
110
144
|
groups.length > 0 &&
|
|
111
|
-
groups.every(group => _isHookFileName(group) || _isHookGroup(group)) &&
|
|
145
|
+
groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
|
|
112
146
|
_extractFileNames(groups).length > 0;
|
|
113
147
|
if (!valid) {
|
|
114
|
-
console.error(
|
|
148
|
+
console.error(
|
|
149
|
+
`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
|
|
150
|
+
);
|
|
115
151
|
console.error(PKG_INTEGRITY_HINT);
|
|
116
152
|
process.exit(1);
|
|
117
153
|
}
|
|
118
154
|
}
|
|
119
|
-
if (
|
|
155
|
+
if (
|
|
156
|
+
_hookConfig.shared !== undefined &&
|
|
157
|
+
(!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every((f) => _isHookFileName(f)))
|
|
158
|
+
) {
|
|
120
159
|
console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
|
|
121
160
|
console.error(PKG_INTEGRITY_HINT);
|
|
122
161
|
process.exit(1);
|
|
123
162
|
}
|
|
124
163
|
|
|
125
|
-
const HOOK_MAP
|
|
164
|
+
const HOOK_MAP = Object.fromEntries(
|
|
165
|
+
Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]),
|
|
166
|
+
);
|
|
126
167
|
const SHARED_FILES = _hookConfig.shared ?? [];
|
|
127
168
|
|
|
128
169
|
// ── checks ───────────────────────────────────────────────────────────────────
|
|
129
170
|
|
|
171
|
+
function checkExternalDeps() {
|
|
172
|
+
const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
|
|
173
|
+
if (nodeMajor >= 18) {
|
|
174
|
+
pass('Node.js ≥ 18', `v${process.versions.node}`);
|
|
175
|
+
} else {
|
|
176
|
+
fail('Node.js ≥ 18', `v${process.versions.node} — upgrade to Node.js 18+`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const npm = spawnSync('npm', ['--version'], { encoding: 'utf-8' });
|
|
180
|
+
if (npm.status === 0) {
|
|
181
|
+
pass('npm', `v${npm.stdout.trim()}`);
|
|
182
|
+
} else {
|
|
183
|
+
fail('npm', 'Not found — install npm');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const git = spawnSync('git', ['--version'], { encoding: 'utf-8' });
|
|
187
|
+
if (git.status === 0) {
|
|
188
|
+
pass('git', git.stdout.trim().replace('git version ', 'v'));
|
|
189
|
+
} else {
|
|
190
|
+
fail('git', 'Not found — install git');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const shell = process.env.SHELL || '';
|
|
194
|
+
if (shell.endsWith('zsh') || shell.endsWith('bash')) {
|
|
195
|
+
pass('Shell (zsh/bash)', shell);
|
|
196
|
+
} else if (!shell) {
|
|
197
|
+
warn('Shell (zsh/bash)', '$SHELL not set');
|
|
198
|
+
} else {
|
|
199
|
+
warn('Shell (zsh/bash)', `${shell} — zsh or bash recommended`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
130
203
|
function checkHypoRoot(hypoDir) {
|
|
131
204
|
if (!existsSync(hypoDir)) {
|
|
132
205
|
fail('Wiki root exists', hypoDir);
|
|
@@ -143,7 +216,17 @@ function checkHypoRoot(hypoDir) {
|
|
|
143
216
|
}
|
|
144
217
|
|
|
145
218
|
function checkDirectories(hypoDir) {
|
|
146
|
-
const required = [
|
|
219
|
+
const required = [
|
|
220
|
+
'pages',
|
|
221
|
+
'projects',
|
|
222
|
+
'sources',
|
|
223
|
+
// Extensions baseline (ADR 0024). Existence only — SHA / settings /
|
|
224
|
+
// manifest integrity is E5 (#33).
|
|
225
|
+
'extensions/hooks',
|
|
226
|
+
'extensions/commands',
|
|
227
|
+
'extensions/skills',
|
|
228
|
+
'extensions/agents',
|
|
229
|
+
];
|
|
147
230
|
for (const d of required) {
|
|
148
231
|
if (existsSync(join(hypoDir, d))) {
|
|
149
232
|
pass(`Directory: ${d}/`);
|
|
@@ -206,8 +289,8 @@ function checkSettingsJson() {
|
|
|
206
289
|
total++;
|
|
207
290
|
const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
|
|
208
291
|
const found = (Array.isArray(settings.hooks?.[event]) ? settings.hooks[event] : [])
|
|
209
|
-
.flatMap(g => g.hooks || [])
|
|
210
|
-
.some(h => h.command === cmd);
|
|
292
|
+
.flatMap((g) => g.hooks || [])
|
|
293
|
+
.some((h) => h.command === cmd);
|
|
211
294
|
if (found) registered++;
|
|
212
295
|
}
|
|
213
296
|
}
|
|
@@ -215,25 +298,111 @@ function checkSettingsJson() {
|
|
|
215
298
|
if (registered === total) {
|
|
216
299
|
pass('settings.json hook registrations', `${registered}/${total} registered`);
|
|
217
300
|
} else if (registered > 0) {
|
|
218
|
-
warn(
|
|
301
|
+
warn(
|
|
302
|
+
'settings.json hook registrations',
|
|
303
|
+
`${registered}/${total} registered — run /hypo:init to merge missing entries`,
|
|
304
|
+
);
|
|
219
305
|
} else {
|
|
220
306
|
fail('settings.json hook registrations', `0/${total} registered — run /hypo:init`);
|
|
221
307
|
}
|
|
308
|
+
|
|
309
|
+
// fix #7: stale hypo-* entries (uninstall remnants).
|
|
310
|
+
// hypo-ext-* commands are user-extension entries (ADR 0024) — not core hooks,
|
|
311
|
+
// so they are intentionally absent from HOOK_MAP. Excluded here; their
|
|
312
|
+
// integrity (SHA + manifest + entry match) is checked separately in E5 (#33).
|
|
313
|
+
const isExtCommand = (cmd) => /(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(cmd);
|
|
314
|
+
const expectedCmds = new Set(
|
|
315
|
+
Object.entries(HOOK_MAP).flatMap(([, files]) =>
|
|
316
|
+
files.map((f) => `node ${hooksDir.replace(HOME, '$HOME')}/${f}`),
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
const stale = [];
|
|
320
|
+
for (const [, groups] of Object.entries(settings.hooks || {})) {
|
|
321
|
+
if (!Array.isArray(groups)) continue;
|
|
322
|
+
for (const g of groups) {
|
|
323
|
+
if (!g || typeof g !== 'object') continue;
|
|
324
|
+
for (const h of g.hooks || []) {
|
|
325
|
+
if (
|
|
326
|
+
typeof h.command === 'string' &&
|
|
327
|
+
/hypo-[^/]+\.mjs/.test(h.command) &&
|
|
328
|
+
!isExtCommand(h.command) &&
|
|
329
|
+
!expectedCmds.has(h.command)
|
|
330
|
+
) {
|
|
331
|
+
stale.push(h.command);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (stale.length > 0) {
|
|
337
|
+
warn(
|
|
338
|
+
'settings.json stale hypo-* entries',
|
|
339
|
+
`${stale.length} unrecognised hypo-* command(s) — run /hypo:uninstall then /hypo:init: ${stale.slice(0, 3).join(', ')}`,
|
|
340
|
+
);
|
|
341
|
+
} else {
|
|
342
|
+
pass('settings.json stale hypo-* entries', 'None');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// fix #7: duplicate hypo-* entries per event
|
|
346
|
+
const dupes = [];
|
|
347
|
+
for (const [event, groups] of Object.entries(settings.hooks || {})) {
|
|
348
|
+
if (!Array.isArray(groups)) continue;
|
|
349
|
+
const seen = new Set();
|
|
350
|
+
for (const g of groups) {
|
|
351
|
+
if (!g || typeof g !== 'object') continue;
|
|
352
|
+
for (const h of g.hooks || []) {
|
|
353
|
+
if (typeof h.command !== 'string' || !/hypo-[^/]+\.mjs/.test(h.command)) continue;
|
|
354
|
+
if (isExtCommand(h.command)) continue; // ext duplicates are E5's concern (#33)
|
|
355
|
+
if (seen.has(h.command)) dupes.push(`${event}:${h.command}`);
|
|
356
|
+
else seen.add(h.command);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (dupes.length > 0) {
|
|
361
|
+
warn(
|
|
362
|
+
'settings.json duplicate hypo-* entries',
|
|
363
|
+
`${dupes.length} duplicate(s) — run /hypo:init to repair: ${dupes.slice(0, 2).join(', ')}`,
|
|
364
|
+
);
|
|
365
|
+
} else {
|
|
366
|
+
pass('settings.json duplicate hypo-* entries', 'None');
|
|
367
|
+
}
|
|
222
368
|
}
|
|
223
369
|
|
|
224
370
|
function checkGit(hypoDir) {
|
|
225
371
|
if (!existsSync(join(hypoDir, '.git'))) {
|
|
226
|
-
warn(
|
|
372
|
+
warn(
|
|
373
|
+
'Git repository',
|
|
374
|
+
'Not a git repo — run /hypo:init with git-remote option for sync/backup',
|
|
375
|
+
);
|
|
227
376
|
return;
|
|
228
377
|
}
|
|
229
378
|
pass('Git repository');
|
|
230
379
|
|
|
231
|
-
const remote = spawnSync('git', ['-C', hypoDir, 'remote', 'get-url', 'origin'], {
|
|
380
|
+
const remote = spawnSync('git', ['-C', hypoDir, 'remote', 'get-url', 'origin'], {
|
|
381
|
+
encoding: 'utf-8',
|
|
382
|
+
});
|
|
232
383
|
if (remote.status === 0 && remote.stdout.trim()) {
|
|
233
384
|
pass('Git remote origin', remote.stdout.trim());
|
|
234
385
|
} else {
|
|
235
386
|
warn('Git remote origin', 'No remote configured — wiki will not sync/backup automatically');
|
|
236
387
|
}
|
|
388
|
+
|
|
389
|
+
const preCommitPath = join(hypoDir, '.git', 'hooks', 'pre-commit');
|
|
390
|
+
if (existsSync(preCommitPath)) {
|
|
391
|
+
const content = readFileSync(preCommitPath, 'utf-8');
|
|
392
|
+
if (content.includes('# hypo-managed:pre-commit:start')) {
|
|
393
|
+
pass('.git/hooks/pre-commit', 'Hypomnema .hypoignore guard installed');
|
|
394
|
+
} else {
|
|
395
|
+
warn(
|
|
396
|
+
'.git/hooks/pre-commit',
|
|
397
|
+
'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore',
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
warn(
|
|
402
|
+
'.git/hooks/pre-commit',
|
|
403
|
+
'Not installed — run /hypo:init to install .hypoignore guard (fix #24)',
|
|
404
|
+
);
|
|
405
|
+
}
|
|
237
406
|
}
|
|
238
407
|
|
|
239
408
|
function checkBrokenLinks(hypoDir, ignorePatterns = []) {
|
|
@@ -244,7 +413,9 @@ function checkBrokenLinks(hypoDir, ignorePatterns = []) {
|
|
|
244
413
|
for (const file of mdFiles) {
|
|
245
414
|
const raw = readFileSync(file, 'utf-8');
|
|
246
415
|
const content = raw.replace(/<!--[\s\S]*?-->/g, '').replace(/`[^`\n]+`/g, '');
|
|
247
|
-
const links = [...content.matchAll(/\[\[([^\]|#\n]+?)(?:[|#][^\]]*?)?\]\]/g)].map(m =>
|
|
416
|
+
const links = [...content.matchAll(/\[\[([^\]|#\n]+?)(?:[|#][^\]]*?)?\]\]/g)].map((m) =>
|
|
417
|
+
m[1].trim(),
|
|
418
|
+
);
|
|
248
419
|
for (const link of links) {
|
|
249
420
|
// skip object-path references (e.g. [[hooks.SessionStart]])
|
|
250
421
|
if (link.includes('.') && !link.endsWith('.md')) continue;
|
|
@@ -260,8 +431,11 @@ function checkBrokenLinks(hypoDir, ignorePatterns = []) {
|
|
|
260
431
|
if (broken.length === 0) {
|
|
261
432
|
pass('Broken wiki links', `Scanned ${mdFiles.length} files`);
|
|
262
433
|
} else {
|
|
263
|
-
const sample = broken
|
|
264
|
-
|
|
434
|
+
const sample = broken
|
|
435
|
+
.slice(0, 5)
|
|
436
|
+
.map((b) => `${b.file} → [[${b.link}]]`)
|
|
437
|
+
.join(', ');
|
|
438
|
+
const extra = broken.length > 5 ? ` (+${broken.length - 5} more)` : '';
|
|
265
439
|
warn('Broken wiki links', `${broken.length} broken: ${sample}${extra}`);
|
|
266
440
|
}
|
|
267
441
|
}
|
|
@@ -272,7 +446,7 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
|
|
|
272
446
|
if (entry.startsWith('.')) continue;
|
|
273
447
|
const full = join(dir, entry);
|
|
274
448
|
if (hypoDir && isIgnored(full, hypoDir, ignorePatterns)) continue;
|
|
275
|
-
const st
|
|
449
|
+
const st = statSync(full);
|
|
276
450
|
if (st.isDirectory()) collectMdFiles(full, acc, hypoDir, ignorePatterns);
|
|
277
451
|
else if (extname(entry) === '.md') acc.push(full);
|
|
278
452
|
}
|
|
@@ -295,14 +469,14 @@ function buildSlugSet(files, hypoDir) {
|
|
|
295
469
|
}
|
|
296
470
|
|
|
297
471
|
function checkVerifyBy(hypoDir, ignorePatterns = []) {
|
|
298
|
-
const today
|
|
299
|
-
const mdFiles
|
|
300
|
-
const overdue
|
|
301
|
-
const missing
|
|
472
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
473
|
+
const mdFiles = collectMdFiles(hypoDir, [], hypoDir, ignorePatterns);
|
|
474
|
+
const overdue = [];
|
|
475
|
+
const missing = [];
|
|
302
476
|
|
|
303
477
|
for (const file of mdFiles) {
|
|
304
478
|
const content = readFileSync(file, 'utf-8');
|
|
305
|
-
const fm
|
|
479
|
+
const fm = parseFrontmatter(content);
|
|
306
480
|
if (!fm) continue;
|
|
307
481
|
|
|
308
482
|
const type = fm.type || '';
|
|
@@ -312,30 +486,533 @@ function checkVerifyBy(hypoDir, ignorePatterns = []) {
|
|
|
312
486
|
if (!fm.verify_by) {
|
|
313
487
|
missing.push(relative(hypoDir, file));
|
|
314
488
|
}
|
|
315
|
-
if (
|
|
489
|
+
if (
|
|
490
|
+
fm.verify_by_date &&
|
|
491
|
+
/^\d{4}-\d{2}-\d{2}$/.test(fm.verify_by_date) &&
|
|
492
|
+
fm.verify_by_date < today
|
|
493
|
+
) {
|
|
316
494
|
overdue.push({ file: relative(hypoDir, file), due: fm.verify_by_date });
|
|
317
495
|
}
|
|
318
496
|
}
|
|
319
497
|
|
|
320
498
|
if (overdue.length > 0) {
|
|
321
|
-
const sample = overdue
|
|
322
|
-
|
|
499
|
+
const sample = overdue
|
|
500
|
+
.slice(0, 3)
|
|
501
|
+
.map((o) => `${o.file} (due ${o.due})`)
|
|
502
|
+
.join(', ');
|
|
503
|
+
const extra = overdue.length > 3 ? ` (+${overdue.length - 3} more)` : '';
|
|
323
504
|
warn('verify_by_date overdue', `${overdue.length} overdue: ${sample}${extra}`);
|
|
324
505
|
} else {
|
|
325
506
|
pass('verify_by_date overdue', 'No overdue pages');
|
|
326
507
|
}
|
|
327
508
|
|
|
328
509
|
if (missing.length > 0) {
|
|
329
|
-
warn(
|
|
510
|
+
warn(
|
|
511
|
+
'verify_by coverage',
|
|
512
|
+
`${missing.length} pages (adr/page/learning) missing verify_by question`,
|
|
513
|
+
);
|
|
330
514
|
} else {
|
|
331
515
|
pass('verify_by coverage', 'All tracked pages have verify_by question');
|
|
332
516
|
}
|
|
333
517
|
}
|
|
334
518
|
|
|
519
|
+
function checkSyncState(hypoDir) {
|
|
520
|
+
// "open" = file exists with ≥1 entries; session-start clears once
|
|
521
|
+
// sync is healthy again. Schema + parsing live in hooks/hypo-shared.mjs.
|
|
522
|
+
const { entries, parseError } = readSyncState(hypoDir);
|
|
523
|
+
|
|
524
|
+
if (parseError) {
|
|
525
|
+
warn('Sync state', 'Cannot parse .cache/sync-state.json — inspect manually');
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (entries.length === 0) {
|
|
530
|
+
pass('Sync state', 'No unresolved sync failures');
|
|
531
|
+
} else {
|
|
532
|
+
const last = entries[entries.length - 1];
|
|
533
|
+
warn(
|
|
534
|
+
'Sync state',
|
|
535
|
+
`${entries.length} unresolved failure(s) — last: ${last.op || '?'} at ${last.timestamp || '?'}. Inspect .cache/sync-state.json or push/pull manually to clear.`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function checkProjectSuggestions(hypoDir) {
|
|
541
|
+
// fix #23 / ADR 0023: the auto-project skip-persistence store. Absent file is
|
|
542
|
+
// healthy (no offers declined yet). Validate the RAW JSON shape here rather
|
|
543
|
+
// than via readProjectSuggestions(): that helper deliberately normalizes a
|
|
544
|
+
// non-array `skips` to [] for fail-open hook reads, which would mask a
|
|
545
|
+
// malformed file and silently break permanent "N" suppression (codex review
|
|
546
|
+
// 2026-05-22). Doctor must catch the malformation the helper hides.
|
|
547
|
+
const path = projectSuggestionsPath(hypoDir);
|
|
548
|
+
if (!existsSync(path)) {
|
|
549
|
+
pass('Auto-project suggestions', 'No skip-persistence file (clean)');
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
let data;
|
|
553
|
+
try {
|
|
554
|
+
data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
555
|
+
} catch {
|
|
556
|
+
warn(
|
|
557
|
+
'Auto-project suggestions',
|
|
558
|
+
'Cannot parse .cache/project-suggestions.json — inspect manually',
|
|
559
|
+
);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
563
|
+
warn('Auto-project suggestions', 'project-suggestions.json must be a JSON object');
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (!Array.isArray(data.skips)) {
|
|
567
|
+
warn(
|
|
568
|
+
'Auto-project suggestions',
|
|
569
|
+
'`skips` must be an array — declined-cwd suppression will not work',
|
|
570
|
+
);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (
|
|
574
|
+
data.cooldowns !== undefined &&
|
|
575
|
+
(typeof data.cooldowns !== 'object' || data.cooldowns === null || Array.isArray(data.cooldowns))
|
|
576
|
+
) {
|
|
577
|
+
warn('Auto-project suggestions', '`cooldowns` must be a plain object');
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const bad = data.skips.filter((s) => !s || typeof s.cwd !== 'string' || !s.cwd);
|
|
581
|
+
if (bad.length > 0) {
|
|
582
|
+
warn(
|
|
583
|
+
'Auto-project suggestions',
|
|
584
|
+
`${bad.length} malformed skip entr(ies) missing a string \`cwd\` in .cache/project-suggestions.json`,
|
|
585
|
+
);
|
|
586
|
+
} else {
|
|
587
|
+
pass('Auto-project suggestions', `${data.skips.length} declined cwd(s) recorded`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function checkCodexPaths() {
|
|
592
|
+
const codexHooks = join(HOME, '.codex', 'hooks');
|
|
593
|
+
const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
|
|
594
|
+
|
|
595
|
+
let missing = 0;
|
|
596
|
+
for (const file of allFiles) {
|
|
597
|
+
if (!existsSync(join(codexHooks, file))) missing++;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (missing === 0) {
|
|
601
|
+
pass('Codex hook files installed', codexHooks);
|
|
602
|
+
} else if (missing < allFiles.length) {
|
|
603
|
+
warn(
|
|
604
|
+
'Codex hook files installed',
|
|
605
|
+
`${missing}/${allFiles.length} missing in ${codexHooks} — run /hypo:init --codex`,
|
|
606
|
+
);
|
|
607
|
+
} else {
|
|
608
|
+
fail(
|
|
609
|
+
'Codex hook files installed',
|
|
610
|
+
`No hook files found in ${codexHooks} — run /hypo:init --codex`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const settingsPath = join(HOME, '.codex', 'settings.json');
|
|
615
|
+
if (!existsSync(settingsPath)) {
|
|
616
|
+
warn(
|
|
617
|
+
'Codex settings.json hook registrations',
|
|
618
|
+
'settings.json not found — run /hypo:init --codex',
|
|
619
|
+
);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
let settings;
|
|
624
|
+
try {
|
|
625
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
626
|
+
} catch {
|
|
627
|
+
fail('Codex settings.json hook registrations', 'settings.json is not valid JSON');
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const hooksDir = codexHooks;
|
|
632
|
+
let registered = 0;
|
|
633
|
+
let total = 0;
|
|
634
|
+
|
|
635
|
+
for (const [event, files] of Object.entries(HOOK_MAP)) {
|
|
636
|
+
for (const file of files) {
|
|
637
|
+
total++;
|
|
638
|
+
const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
|
|
639
|
+
const found = (Array.isArray(settings.hooks?.[event]) ? settings.hooks[event] : [])
|
|
640
|
+
.flatMap((g) => g.hooks || [])
|
|
641
|
+
.some((h) => h.command === cmd);
|
|
642
|
+
if (found) registered++;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (registered === total) {
|
|
647
|
+
pass('Codex settings.json hook registrations', `${registered}/${total} registered`);
|
|
648
|
+
} else if (registered > 0) {
|
|
649
|
+
warn(
|
|
650
|
+
'Codex settings.json hook registrations',
|
|
651
|
+
`${registered}/${total} registered — run /hypo:init --codex`,
|
|
652
|
+
);
|
|
653
|
+
} else {
|
|
654
|
+
fail(
|
|
655
|
+
'Codex settings.json hook registrations',
|
|
656
|
+
`0/${total} registered — run /hypo:init --codex`,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ── extensions integrity (ADR 0024, E5) ───────────────────────────
|
|
662
|
+
|
|
663
|
+
// Detect drift between the user's `~/hypomnema/extensions/` source, the recorded
|
|
664
|
+
// per-target SHA map (`hypo-pkg.json`), and the installed copies + settings.json
|
|
665
|
+
// entries under `~/.claude` (or `~/.codex` with --codex). Reuses E2's read-only
|
|
666
|
+
// helpers (plan §5 D4) — never re-derives discovery/manifest/SHA logic.
|
|
667
|
+
//
|
|
668
|
+
// Severity taxonomy (plan §5 #4 pins manifest; the rest mirror the slash-command
|
|
669
|
+
// / settings-stale precedent that recoverable drift is a warn, not a ship blocker):
|
|
670
|
+
// manifest malformed (parse fail / unknown event) → FAIL (won't self-heal; §5 #4)
|
|
671
|
+
// manifest missing → warn (hook just won't register; §5 #4)
|
|
672
|
+
// installed copy SHA ≠ recorded (user-modified) → warn (--force-extensions recovers)
|
|
673
|
+
// recorded entry but copy absent / non-regular → warn (upgrade --apply recovers)
|
|
674
|
+
// expected settings entry missing → warn (upgrade --apply recovers)
|
|
675
|
+
// orphan settings entry (source removed) → warn (uninstall recovers; boost #2)
|
|
676
|
+
// A malformed manifest failing here is what makes the §5.1.3 `fails=0` ship gate
|
|
677
|
+
// actually cover §8.12-7(c) — asserted by the doctor-extensions-integrity test.
|
|
678
|
+
//
|
|
679
|
+
// E5 is doctor SURFACE for extensions integrity. The mixed-group surgical
|
|
680
|
+
// *write* (preserve sibling-plugin hooks, swap only ours) used to be deferred
|
|
681
|
+
// here; fix #47 (ADR 0024 amendment 2026-05-23) lifted that deferral —
|
|
682
|
+
// registerSettings (extensions.mjs:478 docstring) now does occurrence-first +
|
|
683
|
+
// 8-rank canonical write, and the (b) loop below mirrors that read-path via
|
|
684
|
+
// collectOurOccurrences so a valid mixed-group occurrence is no longer warned
|
|
685
|
+
// as `not registered`.
|
|
686
|
+
function checkExtensions(hypoDir, claudeHome, target = 'claude') {
|
|
687
|
+
const extDir = join(hypoDir, 'extensions');
|
|
688
|
+
// E1 baseline absent (e.g. --from-remote clone, plan §5 #8) → nothing to check.
|
|
689
|
+
if (!existsSync(extDir)) return;
|
|
690
|
+
|
|
691
|
+
const root = target === 'codex' ? join(HOME, '.codex') : claudeHome;
|
|
692
|
+
const label = target === 'codex' ? 'Codex extensions integrity' : 'Extensions integrity';
|
|
693
|
+
// The per-target SHA map lives in ~/.claude/hypo-pkg.json under
|
|
694
|
+
// `extensions.{claude,codex}` (sync writes both targets into the one file —
|
|
695
|
+
// upgrade.mjs:681), so the pkg path is always claude regardless of target.
|
|
696
|
+
const pkgPath = join(claudeHome, 'hypo-pkg.json');
|
|
697
|
+
const settingsPath = join(root, 'settings.json');
|
|
698
|
+
const hooksDir = join(root, 'hooks');
|
|
699
|
+
const types = target === 'codex' ? CODEX_TYPES : EXT_TYPES;
|
|
700
|
+
|
|
701
|
+
const patterns = loadHypoIgnore(hypoDir);
|
|
702
|
+
const discovered = discoverExtensions(extDir, patterns, hypoDir);
|
|
703
|
+
|
|
704
|
+
const problems = [];
|
|
705
|
+
|
|
706
|
+
// (c) manifest health — hooks only (plan §0 D3: non-hook manifests don't register).
|
|
707
|
+
for (const ext of discovered.hooks) {
|
|
708
|
+
if (!ext.manifestPath) {
|
|
709
|
+
problems.push({
|
|
710
|
+
severity: 'warn',
|
|
711
|
+
msg: `${ext.name}.manifest.json missing — hook will not auto-register`,
|
|
712
|
+
});
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
const parsed = parseManifest(ext.manifestPath);
|
|
716
|
+
if (!parsed.ok) {
|
|
717
|
+
problems.push({ severity: 'fail', msg: `${ext.manifestName} malformed (${parsed.error})` });
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// (a) hard-copy SHA: recorded SHA vs the installed copy on disk.
|
|
722
|
+
const recorded = readExtensionPkgStateNoMutate(pkgPath, target);
|
|
723
|
+
for (const [key, recSHA] of Object.entries(recorded)) {
|
|
724
|
+
// Skip keys outside this target's covered types (defensive: a Claude run records
|
|
725
|
+
// skills/agents that a codex target never installs — don't false-flag them).
|
|
726
|
+
if (!types.includes(key.split('/')[0])) continue;
|
|
727
|
+
const destPath = join(root, key);
|
|
728
|
+
if (!existsSync(destPath)) {
|
|
729
|
+
problems.push({
|
|
730
|
+
severity: 'warn',
|
|
731
|
+
msg: `${key} recorded but not installed — run upgrade --apply`,
|
|
732
|
+
});
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const onDisk = readFileIfRegular(destPath);
|
|
736
|
+
if (onDisk === null) {
|
|
737
|
+
problems.push({ severity: 'warn', msg: `${key} is not a regular file — left untouched` });
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (sha256(onDisk) !== recSHA) {
|
|
741
|
+
problems.push({
|
|
742
|
+
severity: 'warn',
|
|
743
|
+
msg: `${key} modified since install (drift) — use --force-extensions`,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// (b) settings.json entries. Distinguish three states:
|
|
749
|
+
// - malformed JSON → skip (checkSettingsJson / checkCodexPaths already FAILs it;
|
|
750
|
+
// piling on a misleading "not registered" warn helps no one)
|
|
751
|
+
// - missing file / no `hooks` object → treat as an EMPTY hooks map, so a synced
|
|
752
|
+
// extension whose registration is absent still surfaces as expected-but-missing
|
|
753
|
+
// (§8.12-7(b)). A missing file alone with no extensions yields no problem (gate-safe).
|
|
754
|
+
let settingsParseFailed = false;
|
|
755
|
+
let hooksObj = {};
|
|
756
|
+
if (existsSync(settingsPath)) {
|
|
757
|
+
try {
|
|
758
|
+
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
759
|
+
if (
|
|
760
|
+
parsed &&
|
|
761
|
+
parsed.hooks &&
|
|
762
|
+
typeof parsed.hooks === 'object' &&
|
|
763
|
+
!Array.isArray(parsed.hooks)
|
|
764
|
+
) {
|
|
765
|
+
hooksObj = parsed.hooks;
|
|
766
|
+
}
|
|
767
|
+
} catch {
|
|
768
|
+
settingsParseFailed = true;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (!settingsParseFailed) {
|
|
772
|
+
const expected = buildExpectedSettingsEntries(discovered.hooks, hooksDir);
|
|
773
|
+
|
|
774
|
+
// (b) for each registrable hook: locate every occurrence of our command
|
|
775
|
+
// (single-hook OR mixed group, fix #47) and pick the canonical via the
|
|
776
|
+
// SAME 8-rank logic registerSettings uses.
|
|
777
|
+
// Without this mirror, doctor picked the first traversal-order occurrence
|
|
778
|
+
// under the target event and warned "differs" even when a later
|
|
779
|
+
// occurrence was the rank-1 canonical that upgrade --apply silently
|
|
780
|
+
// accepts — a confusing gap between report and action.
|
|
781
|
+
//
|
|
782
|
+
// Outcomes:
|
|
783
|
+
// - no occurrence in any event → warn `not registered`
|
|
784
|
+
// - canonical on a non-target event → warn `not registered under <event>`
|
|
785
|
+
// - canonical rank 1/2 (exact) → no drift; pass through
|
|
786
|
+
// (ext-command duplicates are surfaced separately below — core
|
|
787
|
+
// duplicate-warn at line ~344 excludes hypo-ext-* on purpose)
|
|
788
|
+
// - canonical rank 3/4/5 on target → warn `settings entry differs`
|
|
789
|
+
//
|
|
790
|
+
// Mixed-group: a foreign sibling sharing our matcher group does NOT itself
|
|
791
|
+
// count as drift — only our own hook fields ({type, command, timeout?}) and
|
|
792
|
+
// the group's matcher are compared. Doctor never inspects foreign hook
|
|
793
|
+
// shape (no peering into `if`/`args`/`async`/`statusMessage` etc.).
|
|
794
|
+
for (const entry of expected) {
|
|
795
|
+
const desiredHook = { type: 'command', command: entry.command };
|
|
796
|
+
if (entry.timeout) desiredHook.timeout = entry.timeout;
|
|
797
|
+
|
|
798
|
+
const occurrences = collectOurOccurrences(hooksObj, entry.command);
|
|
799
|
+
const picked = pickCanonicalOccurrence(occurrences, entry, desiredHook);
|
|
800
|
+
if (!picked) {
|
|
801
|
+
problems.push({
|
|
802
|
+
severity: 'warn',
|
|
803
|
+
msg: `${entry.name} not registered under ${entry.event} — run upgrade --apply`,
|
|
804
|
+
});
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
if (picked.occ.event !== entry.event) {
|
|
808
|
+
problems.push({
|
|
809
|
+
severity: 'warn',
|
|
810
|
+
msg: `${entry.name} not registered under ${entry.event} — run upgrade --apply`,
|
|
811
|
+
});
|
|
812
|
+
} else if (picked.rank >= 3) {
|
|
813
|
+
// ranks 3/4/5 — on target event, but hook or matcher drifted.
|
|
814
|
+
// The manifest boundary normalize
|
|
815
|
+
// (extensions.mjs:178) collapses `matcher: ""` → absent only on the
|
|
816
|
+
// manifest path. A hand-edited settings.json with `matcher: ""` still
|
|
817
|
+
// mismatches an absent manifest matcher (rankOccurrence treats "" vs
|
|
818
|
+
// undefined as non-equal). Surface the empty-string-vs-absent
|
|
819
|
+
// equivalence ONLY when the hook itself is otherwise exact —
|
|
820
|
+
// otherwise the specific message would hide a co-occurring hook /
|
|
821
|
+
// timeout drift. The hookExact
|
|
822
|
+
// comparison mirrors rankOccurrence's own canonical check
|
|
823
|
+
// (extensions.mjs ~580) so doctor's report tracks --apply intent.
|
|
824
|
+
const hookExact = JSON.stringify(picked.occ.hook) === JSON.stringify(desiredHook);
|
|
825
|
+
if (picked.occ.group.matcher === '' && entry.matcher === undefined && hookExact) {
|
|
826
|
+
problems.push({
|
|
827
|
+
severity: 'warn',
|
|
828
|
+
msg: `${entry.name} settings has matcher: "" (equivalent to absent) — run upgrade --apply to normalize`,
|
|
829
|
+
});
|
|
830
|
+
} else {
|
|
831
|
+
problems.push({
|
|
832
|
+
severity: 'warn',
|
|
833
|
+
msg: `${entry.name} settings entry differs from manifest (matcher/hook/timeout) — run upgrade --apply`,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
// ext-aware duplicate surface: core duplicate-warn at checkSettingsJson
|
|
838
|
+
// intentionally skips hypo-ext-* (line ~353 isExtCommand guard). With
|
|
839
|
+
// the canonical-pick above, exact rank-1 duplicates would otherwise be
|
|
840
|
+
// invisible to doctor until upgrade --apply runs cleanup. Surface them
|
|
841
|
+
// here so the report still names the work that --apply will do.
|
|
842
|
+
if (occurrences.length > 1) {
|
|
843
|
+
problems.push({
|
|
844
|
+
severity: 'warn',
|
|
845
|
+
msg: `${entry.name} has ${occurrences.length} occurrences in settings — run upgrade --apply to clean up`,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// orphan (boost #2): a hypo-ext-* command in settings with no source extension.
|
|
851
|
+
// E4 excluded hypo-ext-* from the core stale checker (doctor.mjs:302), so this
|
|
852
|
+
// is the ONLY place orphaned extension entries are caught.
|
|
853
|
+
//
|
|
854
|
+
// Two distinct orphan classes:
|
|
855
|
+
// - source-removed: settings entry whose source file is gone → uninstall
|
|
856
|
+
// - unregistrable : source file present but manifest malformed/non-hook,
|
|
857
|
+
// so (b) above skipped it and (c) only FAIL/warned the
|
|
858
|
+
// manifest itself, never naming the stale settings entry.
|
|
859
|
+
// Surfaced separately so the user knows the lingering
|
|
860
|
+
// entry needs cleanup independent of the manifest fix.
|
|
861
|
+
const cmdFor = (ext) => `node ${hooksDir.replace(HOME, '$HOME')}/${ext.file}`;
|
|
862
|
+
const sourceCmds = new Set(discovered.hooks.map(cmdFor));
|
|
863
|
+
const unregistrableCmds = new Set();
|
|
864
|
+
for (const ext of discovered.hooks) {
|
|
865
|
+
if (!ext.manifestPath) continue; // (c-warn) already names this case
|
|
866
|
+
const parsed = parseManifest(ext.manifestPath);
|
|
867
|
+
if (!parsed.ok || !parsed.registrable) unregistrableCmds.add(cmdFor(ext));
|
|
868
|
+
}
|
|
869
|
+
// A single hypo-ext-* command can appear
|
|
870
|
+
// in multiple groups/events when settings.json was hand-edited or migrated
|
|
871
|
+
// across events. The registrable-entry duplicate surface above
|
|
872
|
+
// (`occurrences.length > 1`) only iterates `expected`, so orphan-class
|
|
873
|
+
// duplicates were silently de-duped to a single warn. Count occurrences per
|
|
874
|
+
// orphan command and append `(N occurrences)` when count > 1.
|
|
875
|
+
//
|
|
876
|
+
// Order: check `unregistrableCmds` BEFORE `sourceCmds` — a malformed or
|
|
877
|
+
// non-hook manifest still has the source file present, so the
|
|
878
|
+
// `sourceCmds.has` check would otherwise misclassify them as non-orphan.
|
|
879
|
+
const orphanInfo = new Map(); // command → { kind, count }
|
|
880
|
+
for (const groups of Object.values(hooksObj)) {
|
|
881
|
+
if (!Array.isArray(groups)) continue;
|
|
882
|
+
for (const g of groups) {
|
|
883
|
+
if (!g || typeof g !== 'object') continue;
|
|
884
|
+
if (!Array.isArray(g.hooks)) continue;
|
|
885
|
+
for (const h of g.hooks) {
|
|
886
|
+
if (typeof h.command !== 'string') continue;
|
|
887
|
+
if (!/(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(h.command)) continue;
|
|
888
|
+
let kind = null;
|
|
889
|
+
if (unregistrableCmds.has(h.command)) kind = 'unregistrable';
|
|
890
|
+
else if (!sourceCmds.has(h.command)) kind = 'source-removed';
|
|
891
|
+
else continue;
|
|
892
|
+
const info = orphanInfo.get(h.command);
|
|
893
|
+
if (info) info.count += 1;
|
|
894
|
+
else orphanInfo.set(h.command, { kind, count: 1 });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
for (const [cmd, { kind, count }] of orphanInfo) {
|
|
899
|
+
const suffix = count > 1 ? ` (${count} occurrences)` : '';
|
|
900
|
+
const msg =
|
|
901
|
+
kind === 'unregistrable'
|
|
902
|
+
? `orphan settings entry (${cmd}) — manifest unregistrable${suffix}; run uninstall`
|
|
903
|
+
: `orphan settings entry (${cmd}) — source extension removed${suffix}; run uninstall`;
|
|
904
|
+
problems.push({ severity: 'warn', msg });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (problems.length === 0) {
|
|
909
|
+
pass(label, 'All extensions consistent');
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const hasFail = problems.some((p) => p.severity === 'fail');
|
|
913
|
+
const sample = problems
|
|
914
|
+
.slice(0, 4)
|
|
915
|
+
.map((p) => p.msg)
|
|
916
|
+
.join('; ');
|
|
917
|
+
const extra = problems.length > 4 ? ` (+${problems.length - 4} more)` : '';
|
|
918
|
+
if (hasFail) fail(label, `${sample}${extra}`);
|
|
919
|
+
else warn(label, `${sample}${extra}`);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ── feedback projection (ADR 0031) ──────────────────────────────
|
|
923
|
+
|
|
924
|
+
// Spawn feedback-sync.mjs --check --json and map its drift report onto doctor's
|
|
925
|
+
// pass/warn/fail. Integrity violations (exit-3 class: conflict / unpaired marker
|
|
926
|
+
// / intruder line / block out of container) are FAIL. Plain drift, build errors,
|
|
927
|
+
// over-cap, and an unresolved project-id are WARN — a fresh system that has not
|
|
928
|
+
// run `feedback-sync --write` yet is normal and must not block doctor.
|
|
929
|
+
function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
|
|
930
|
+
const cliPath = join(PKG_ROOT, 'scripts', 'feedback-sync.mjs');
|
|
931
|
+
const cliArgs = [
|
|
932
|
+
cliPath,
|
|
933
|
+
'--check',
|
|
934
|
+
'--json',
|
|
935
|
+
'--no-input', // never let the child block on a TTY prompt under doctor
|
|
936
|
+
`--hypo-dir=${hypoDir}`,
|
|
937
|
+
`--claude-home=${claudeHome}`,
|
|
938
|
+
];
|
|
939
|
+
// forward --project-id ONLY when the user supplied one; otherwise let
|
|
940
|
+
// feedback-sync derive it and run its derived-missing → skip-MEMORY path
|
|
941
|
+
if (projectId) cliArgs.push(`--project-id=${projectId}`);
|
|
942
|
+
const r = spawnSync(process.execPath, cliArgs, { encoding: 'utf-8' });
|
|
943
|
+
|
|
944
|
+
// feedback-sync exits non-zero on drift/over-cap/conflict — that is expected
|
|
945
|
+
// and still prints a JSON report on stdout. Only treat a missing process or
|
|
946
|
+
// unparseable stdout as a doctor-level problem (warn, never crash).
|
|
947
|
+
if (r.error || r.status === null) {
|
|
948
|
+
warn(
|
|
949
|
+
'Feedback projection',
|
|
950
|
+
`feedback-sync could not run: ${r.error?.message || 'no exit code'}`,
|
|
951
|
+
);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
let report;
|
|
955
|
+
try {
|
|
956
|
+
report = JSON.parse(r.stdout);
|
|
957
|
+
} catch {
|
|
958
|
+
warn('Feedback projection', 'feedback-sync produced no JSON report — inspect manually');
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (report.error) {
|
|
962
|
+
warn('Feedback projection', report.error);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const targets = Object.entries(report.targets || {});
|
|
967
|
+
|
|
968
|
+
// 1) integrity violations → FAIL
|
|
969
|
+
const broken = targets.find(
|
|
970
|
+
([, t]) => (t.conflicts && t.conflicts.length) || t.unpaired || t.intruder || t.outOfContainer,
|
|
971
|
+
);
|
|
972
|
+
if (broken) {
|
|
973
|
+
const [name, t] = broken;
|
|
974
|
+
const reason =
|
|
975
|
+
t.conflicts && t.conflicts.length
|
|
976
|
+
? `conflict (${t.conflicts.join(', ')})`
|
|
977
|
+
: t.unpaired
|
|
978
|
+
? 'unpaired managed marker'
|
|
979
|
+
: t.intruder
|
|
980
|
+
? 'hand-edited line inside managed region'
|
|
981
|
+
: 'managed block outside its container';
|
|
982
|
+
fail(
|
|
983
|
+
'Feedback projection integrity',
|
|
984
|
+
`${name}: ${reason} — run \`hypomnema feedback-sync --import-target-change\` to reconcile`,
|
|
985
|
+
);
|
|
986
|
+
} else {
|
|
987
|
+
// 2) build error → WARN
|
|
988
|
+
const buildErr = targets.find(([, t]) => t.buildError);
|
|
989
|
+
if (buildErr) {
|
|
990
|
+
warn('Feedback projection', buildErr[1].buildError);
|
|
991
|
+
} else if (targets.find(([, t]) => t.overCap)) {
|
|
992
|
+
warn('Feedback projection', 'projection over cap — demote/archive feedback pages');
|
|
993
|
+
} else if (targets.find(([, t]) => t.dirty)) {
|
|
994
|
+
warn('Feedback projection', 'projections stale — run `hypomnema feedback-sync --write`');
|
|
995
|
+
} else {
|
|
996
|
+
const candidates = targets.reduce((n, [, t]) => n + (t.candidates || 0), 0);
|
|
997
|
+
if (candidates > 0) pass('Feedback projection', 'in sync');
|
|
998
|
+
else pass('Feedback projection', 'no projection candidates');
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// 3) unresolved project-id is a separate, non-fatal concern (MEMORY skipped)
|
|
1003
|
+
if (report.projectIdResolved === false) {
|
|
1004
|
+
warn(
|
|
1005
|
+
'Feedback projection',
|
|
1006
|
+
`project-id ${report.projectId} unresolved — MEMORY projection skipped; pass --project-id`,
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
335
1011
|
// ── main ─────────────────────────────────────────────────────────────────────
|
|
336
1012
|
|
|
337
1013
|
const args = parseArgs(process.argv);
|
|
338
1014
|
|
|
1015
|
+
checkExternalDeps();
|
|
339
1016
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
340
1017
|
const rootOk = checkHypoRoot(args.hypoDir);
|
|
341
1018
|
if (rootOk) {
|
|
@@ -346,6 +1023,12 @@ if (rootOk) {
|
|
|
346
1023
|
}
|
|
347
1024
|
checkHooks();
|
|
348
1025
|
checkSettingsJson();
|
|
1026
|
+
if (args.codex) checkCodexPaths();
|
|
1027
|
+
if (rootOk) checkExtensions(args.hypoDir, args.claudeHome, 'claude');
|
|
1028
|
+
if (rootOk && args.codex) checkExtensions(args.hypoDir, args.claudeHome, 'codex');
|
|
1029
|
+
if (rootOk) checkSyncState(args.hypoDir);
|
|
1030
|
+
if (rootOk) checkProjectSuggestions(args.hypoDir);
|
|
1031
|
+
if (rootOk) checkFeedbackProjection(args.hypoDir, args.claudeHome, args.projectId);
|
|
349
1032
|
checkGit(args.hypoDir);
|
|
350
1033
|
|
|
351
1034
|
// ── report ───────────────────────────────────────────────────────────────────
|
|
@@ -359,13 +1042,13 @@ if (args.json) {
|
|
|
359
1042
|
console.log(`${icons[c.status]} ${c.label}${detail}`);
|
|
360
1043
|
}
|
|
361
1044
|
|
|
362
|
-
const fails = checks.filter(c => c.status === 'fail').length;
|
|
363
|
-
const warns = checks.filter(c => c.status === 'warn').length;
|
|
364
|
-
const passes = checks.filter(c => c.status === 'pass').length;
|
|
1045
|
+
const fails = checks.filter((c) => c.status === 'fail').length;
|
|
1046
|
+
const warns = checks.filter((c) => c.status === 'warn').length;
|
|
1047
|
+
const passes = checks.filter((c) => c.status === 'pass').length;
|
|
365
1048
|
|
|
366
1049
|
console.log('');
|
|
367
1050
|
console.log(`Result: ${passes} passed, ${warns} warnings, ${fails} failed`);
|
|
368
1051
|
if (fails > 0) console.log('Run /hypo:init to repair installation issues.');
|
|
369
1052
|
}
|
|
370
1053
|
|
|
371
|
-
if (checks.some(c => c.status === 'fail')) process.exit(1);
|
|
1054
|
+
if (checks.some((c) => c.status === 'fail')) process.exit(1);
|