hypomnema 1.0.1 → 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 +12 -5
- package/README.md +12 -5
- package/commands/audit.md +46 -0
- 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 +83 -9
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hooks.json +39 -1
- package/hooks/hypo-auto-commit.mjs +23 -4
- package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
- package/hooks/hypo-auto-stage.mjs +9 -5
- 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 +31 -8
- 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 +60 -0
- package/hooks/hypo-session-start.mjs +312 -44
- package/hooks/hypo-shared.mjs +880 -28
- 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 +739 -46
- 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 +442 -114
- 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 +277 -0
- package/scripts/smoke-pack.mjs +224 -0
- package/scripts/stats.mjs +24 -10
- package/scripts/uninstall.mjs +369 -48
- package/scripts/upgrade.mjs +766 -195
- package/scripts/verify.mjs +24 -14
- package/scripts/weekly-report.mjs +211 -0
- package/skills/crystallize/SKILL.md +24 -7
- package/skills/graph/SKILL.md +4 -0
- package/skills/ingest/SKILL.md +29 -5
- package/skills/lint/SKILL.md +4 -0
- package/skills/query/SKILL.md +4 -0
- package/skills/verify/SKILL.md +4 -0
- package/templates/.hypoignore +19 -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 +63 -1
- 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,19 +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, '..');
|
|
39
|
+
|
|
40
|
+
// Shown after every fatal package-integrity error. These conditions mean the
|
|
41
|
+
// shipped hooks/hooks.json is missing or malformed — never a user mistake —
|
|
42
|
+
// so the only useful next step is a re-install of the package.
|
|
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).';
|
|
27
45
|
|
|
28
46
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
29
47
|
|
|
30
48
|
function parseArgs(argv) {
|
|
31
|
-
const args = { hypoDir: null, json: false };
|
|
49
|
+
const args = { hypoDir: null, json: false, codex: false, claudeHome: null, projectId: null };
|
|
32
50
|
for (const arg of argv.slice(2)) {
|
|
33
51
|
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
34
|
-
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);
|
|
35
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.
|
|
36
61
|
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
37
62
|
return args;
|
|
38
63
|
}
|
|
@@ -41,9 +66,15 @@ function parseArgs(argv) {
|
|
|
41
66
|
|
|
42
67
|
const checks = [];
|
|
43
68
|
|
|
44
|
-
function pass(label, detail = '') {
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
}
|
|
47
78
|
|
|
48
79
|
// ── hook map (loaded from hooks/hooks.json — single source of truth) ─────────
|
|
49
80
|
|
|
@@ -52,14 +83,21 @@ try {
|
|
|
52
83
|
_hookConfig = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
|
|
53
84
|
} catch {
|
|
54
85
|
console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
|
|
86
|
+
console.error(PKG_INTEGRITY_HINT);
|
|
55
87
|
process.exit(1);
|
|
56
88
|
}
|
|
57
89
|
if (!_hookConfig || typeof _hookConfig !== 'object' || Array.isArray(_hookConfig)) {
|
|
58
90
|
console.error('Error: hooks/hooks.json must be a JSON object');
|
|
91
|
+
console.error(PKG_INTEGRITY_HINT);
|
|
59
92
|
process.exit(1);
|
|
60
93
|
}
|
|
61
|
-
if (
|
|
94
|
+
if (
|
|
95
|
+
!_hookConfig.hooks ||
|
|
96
|
+
typeof _hookConfig.hooks !== 'object' ||
|
|
97
|
+
Array.isArray(_hookConfig.hooks)
|
|
98
|
+
) {
|
|
62
99
|
console.error('Error: hooks/hooks.json must contain a "hooks" object');
|
|
100
|
+
console.error(PKG_INTEGRITY_HINT);
|
|
63
101
|
process.exit(1);
|
|
64
102
|
}
|
|
65
103
|
function _extractCommandFileName(command) {
|
|
@@ -75,48 +113,93 @@ function _isHookFileName(file) {
|
|
|
75
113
|
}
|
|
76
114
|
|
|
77
115
|
function _isHookGroup(group) {
|
|
78
|
-
return
|
|
116
|
+
return (
|
|
117
|
+
group &&
|
|
79
118
|
typeof group === 'object' &&
|
|
80
119
|
!Array.isArray(group) &&
|
|
81
120
|
Array.isArray(group.hooks) &&
|
|
82
121
|
group.hooks.length > 0 &&
|
|
83
|
-
group.hooks.every(
|
|
84
|
-
hook
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
+
);
|
|
90
131
|
}
|
|
91
132
|
|
|
92
133
|
// Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
|
|
93
134
|
function _extractFileNames(groups) {
|
|
94
|
-
return groups.flatMap(group => {
|
|
135
|
+
return groups.flatMap((group) => {
|
|
95
136
|
if (typeof group === 'string') return [group.trim()];
|
|
96
|
-
return group.hooks.map(hook => _extractCommandFileName(hook.command));
|
|
137
|
+
return group.hooks.map((hook) => _extractCommandFileName(hook.command));
|
|
97
138
|
});
|
|
98
139
|
}
|
|
99
140
|
|
|
100
141
|
for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
|
|
101
|
-
const valid =
|
|
142
|
+
const valid =
|
|
143
|
+
Array.isArray(groups) &&
|
|
102
144
|
groups.length > 0 &&
|
|
103
|
-
groups.every(group => _isHookFileName(group) || _isHookGroup(group)) &&
|
|
145
|
+
groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
|
|
104
146
|
_extractFileNames(groups).length > 0;
|
|
105
147
|
if (!valid) {
|
|
106
|
-
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
|
+
);
|
|
151
|
+
console.error(PKG_INTEGRITY_HINT);
|
|
107
152
|
process.exit(1);
|
|
108
153
|
}
|
|
109
154
|
}
|
|
110
|
-
if (
|
|
155
|
+
if (
|
|
156
|
+
_hookConfig.shared !== undefined &&
|
|
157
|
+
(!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every((f) => _isHookFileName(f)))
|
|
158
|
+
) {
|
|
111
159
|
console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
|
|
160
|
+
console.error(PKG_INTEGRITY_HINT);
|
|
112
161
|
process.exit(1);
|
|
113
162
|
}
|
|
114
163
|
|
|
115
|
-
const HOOK_MAP
|
|
164
|
+
const HOOK_MAP = Object.fromEntries(
|
|
165
|
+
Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]),
|
|
166
|
+
);
|
|
116
167
|
const SHARED_FILES = _hookConfig.shared ?? [];
|
|
117
168
|
|
|
118
169
|
// ── checks ───────────────────────────────────────────────────────────────────
|
|
119
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
|
+
|
|
120
203
|
function checkHypoRoot(hypoDir) {
|
|
121
204
|
if (!existsSync(hypoDir)) {
|
|
122
205
|
fail('Wiki root exists', hypoDir);
|
|
@@ -133,7 +216,17 @@ function checkHypoRoot(hypoDir) {
|
|
|
133
216
|
}
|
|
134
217
|
|
|
135
218
|
function checkDirectories(hypoDir) {
|
|
136
|
-
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
|
+
];
|
|
137
230
|
for (const d of required) {
|
|
138
231
|
if (existsSync(join(hypoDir, d))) {
|
|
139
232
|
pass(`Directory: ${d}/`);
|
|
@@ -196,8 +289,8 @@ function checkSettingsJson() {
|
|
|
196
289
|
total++;
|
|
197
290
|
const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
|
|
198
291
|
const found = (Array.isArray(settings.hooks?.[event]) ? settings.hooks[event] : [])
|
|
199
|
-
.flatMap(g => g.hooks || [])
|
|
200
|
-
.some(h => h.command === cmd);
|
|
292
|
+
.flatMap((g) => g.hooks || [])
|
|
293
|
+
.some((h) => h.command === cmd);
|
|
201
294
|
if (found) registered++;
|
|
202
295
|
}
|
|
203
296
|
}
|
|
@@ -205,25 +298,111 @@ function checkSettingsJson() {
|
|
|
205
298
|
if (registered === total) {
|
|
206
299
|
pass('settings.json hook registrations', `${registered}/${total} registered`);
|
|
207
300
|
} else if (registered > 0) {
|
|
208
|
-
warn(
|
|
301
|
+
warn(
|
|
302
|
+
'settings.json hook registrations',
|
|
303
|
+
`${registered}/${total} registered — run /hypo:init to merge missing entries`,
|
|
304
|
+
);
|
|
209
305
|
} else {
|
|
210
306
|
fail('settings.json hook registrations', `0/${total} registered — run /hypo:init`);
|
|
211
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
|
+
}
|
|
212
368
|
}
|
|
213
369
|
|
|
214
370
|
function checkGit(hypoDir) {
|
|
215
371
|
if (!existsSync(join(hypoDir, '.git'))) {
|
|
216
|
-
warn(
|
|
372
|
+
warn(
|
|
373
|
+
'Git repository',
|
|
374
|
+
'Not a git repo — run /hypo:init with git-remote option for sync/backup',
|
|
375
|
+
);
|
|
217
376
|
return;
|
|
218
377
|
}
|
|
219
378
|
pass('Git repository');
|
|
220
379
|
|
|
221
|
-
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
|
+
});
|
|
222
383
|
if (remote.status === 0 && remote.stdout.trim()) {
|
|
223
384
|
pass('Git remote origin', remote.stdout.trim());
|
|
224
385
|
} else {
|
|
225
386
|
warn('Git remote origin', 'No remote configured — wiki will not sync/backup automatically');
|
|
226
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
|
+
}
|
|
227
406
|
}
|
|
228
407
|
|
|
229
408
|
function checkBrokenLinks(hypoDir, ignorePatterns = []) {
|
|
@@ -234,7 +413,9 @@ function checkBrokenLinks(hypoDir, ignorePatterns = []) {
|
|
|
234
413
|
for (const file of mdFiles) {
|
|
235
414
|
const raw = readFileSync(file, 'utf-8');
|
|
236
415
|
const content = raw.replace(/<!--[\s\S]*?-->/g, '').replace(/`[^`\n]+`/g, '');
|
|
237
|
-
const links = [...content.matchAll(/\[\[([^\]|#\n]+?)(?:[|#][^\]]*?)?\]\]/g)].map(m =>
|
|
416
|
+
const links = [...content.matchAll(/\[\[([^\]|#\n]+?)(?:[|#][^\]]*?)?\]\]/g)].map((m) =>
|
|
417
|
+
m[1].trim(),
|
|
418
|
+
);
|
|
238
419
|
for (const link of links) {
|
|
239
420
|
// skip object-path references (e.g. [[hooks.SessionStart]])
|
|
240
421
|
if (link.includes('.') && !link.endsWith('.md')) continue;
|
|
@@ -250,8 +431,11 @@ function checkBrokenLinks(hypoDir, ignorePatterns = []) {
|
|
|
250
431
|
if (broken.length === 0) {
|
|
251
432
|
pass('Broken wiki links', `Scanned ${mdFiles.length} files`);
|
|
252
433
|
} else {
|
|
253
|
-
const sample = broken
|
|
254
|
-
|
|
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)` : '';
|
|
255
439
|
warn('Broken wiki links', `${broken.length} broken: ${sample}${extra}`);
|
|
256
440
|
}
|
|
257
441
|
}
|
|
@@ -262,7 +446,7 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
|
|
|
262
446
|
if (entry.startsWith('.')) continue;
|
|
263
447
|
const full = join(dir, entry);
|
|
264
448
|
if (hypoDir && isIgnored(full, hypoDir, ignorePatterns)) continue;
|
|
265
|
-
const st
|
|
449
|
+
const st = statSync(full);
|
|
266
450
|
if (st.isDirectory()) collectMdFiles(full, acc, hypoDir, ignorePatterns);
|
|
267
451
|
else if (extname(entry) === '.md') acc.push(full);
|
|
268
452
|
}
|
|
@@ -285,14 +469,14 @@ function buildSlugSet(files, hypoDir) {
|
|
|
285
469
|
}
|
|
286
470
|
|
|
287
471
|
function checkVerifyBy(hypoDir, ignorePatterns = []) {
|
|
288
|
-
const today
|
|
289
|
-
const mdFiles
|
|
290
|
-
const overdue
|
|
291
|
-
const missing
|
|
472
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
473
|
+
const mdFiles = collectMdFiles(hypoDir, [], hypoDir, ignorePatterns);
|
|
474
|
+
const overdue = [];
|
|
475
|
+
const missing = [];
|
|
292
476
|
|
|
293
477
|
for (const file of mdFiles) {
|
|
294
478
|
const content = readFileSync(file, 'utf-8');
|
|
295
|
-
const fm
|
|
479
|
+
const fm = parseFrontmatter(content);
|
|
296
480
|
if (!fm) continue;
|
|
297
481
|
|
|
298
482
|
const type = fm.type || '';
|
|
@@ -302,30 +486,533 @@ function checkVerifyBy(hypoDir, ignorePatterns = []) {
|
|
|
302
486
|
if (!fm.verify_by) {
|
|
303
487
|
missing.push(relative(hypoDir, file));
|
|
304
488
|
}
|
|
305
|
-
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
|
+
) {
|
|
306
494
|
overdue.push({ file: relative(hypoDir, file), due: fm.verify_by_date });
|
|
307
495
|
}
|
|
308
496
|
}
|
|
309
497
|
|
|
310
498
|
if (overdue.length > 0) {
|
|
311
|
-
const sample = overdue
|
|
312
|
-
|
|
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)` : '';
|
|
313
504
|
warn('verify_by_date overdue', `${overdue.length} overdue: ${sample}${extra}`);
|
|
314
505
|
} else {
|
|
315
506
|
pass('verify_by_date overdue', 'No overdue pages');
|
|
316
507
|
}
|
|
317
508
|
|
|
318
509
|
if (missing.length > 0) {
|
|
319
|
-
warn(
|
|
510
|
+
warn(
|
|
511
|
+
'verify_by coverage',
|
|
512
|
+
`${missing.length} pages (adr/page/learning) missing verify_by question`,
|
|
513
|
+
);
|
|
320
514
|
} else {
|
|
321
515
|
pass('verify_by coverage', 'All tracked pages have verify_by question');
|
|
322
516
|
}
|
|
323
517
|
}
|
|
324
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
|
+
|
|
325
1011
|
// ── main ─────────────────────────────────────────────────────────────────────
|
|
326
1012
|
|
|
327
1013
|
const args = parseArgs(process.argv);
|
|
328
1014
|
|
|
1015
|
+
checkExternalDeps();
|
|
329
1016
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
330
1017
|
const rootOk = checkHypoRoot(args.hypoDir);
|
|
331
1018
|
if (rootOk) {
|
|
@@ -336,6 +1023,12 @@ if (rootOk) {
|
|
|
336
1023
|
}
|
|
337
1024
|
checkHooks();
|
|
338
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);
|
|
339
1032
|
checkGit(args.hypoDir);
|
|
340
1033
|
|
|
341
1034
|
// ── report ───────────────────────────────────────────────────────────────────
|
|
@@ -349,13 +1042,13 @@ if (args.json) {
|
|
|
349
1042
|
console.log(`${icons[c.status]} ${c.label}${detail}`);
|
|
350
1043
|
}
|
|
351
1044
|
|
|
352
|
-
const fails = checks.filter(c => c.status === 'fail').length;
|
|
353
|
-
const warns = checks.filter(c => c.status === 'warn').length;
|
|
354
|
-
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;
|
|
355
1048
|
|
|
356
1049
|
console.log('');
|
|
357
1050
|
console.log(`Result: ${passes} passed, ${warns} warnings, ${fails} failed`);
|
|
358
1051
|
if (fails > 0) console.log('Run /hypo:init to repair installation issues.');
|
|
359
1052
|
}
|
|
360
1053
|
|
|
361
|
-
if (checks.some(c => c.status === 'fail')) process.exit(1);
|
|
1054
|
+
if (checks.some((c) => c.status === 'fail')) process.exit(1);
|