@lh8ppl/claude-memory-kit 0.1.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/bin/cmk-compress-lazy.mjs +59 -0
- package/bin/cmk-daily-distill.mjs +67 -0
- package/bin/cmk-weekly-curate.mjs +56 -0
- package/bin/cmk.mjs +12 -0
- package/package.json +50 -0
- package/src/audit-log.mjs +103 -0
- package/src/auto-extract.mjs +742 -0
- package/src/capture-prompt.mjs +61 -0
- package/src/capture-turn.mjs +273 -0
- package/src/claude-md.mjs +212 -0
- package/src/compress-session.mjs +349 -0
- package/src/compressor.mjs +376 -0
- package/src/conflict-queue.mjs +796 -0
- package/src/cooldown.mjs +61 -0
- package/src/daily-distill.mjs +252 -0
- package/src/doctor.mjs +528 -0
- package/src/forget.mjs +335 -0
- package/src/frontmatter.mjs +73 -0
- package/src/import-anthropic-memory.mjs +266 -0
- package/src/index-db.mjs +154 -0
- package/src/index-rebuild.mjs +597 -0
- package/src/index.mjs +90 -0
- package/src/inject-context.mjs +484 -0
- package/src/install.mjs +327 -0
- package/src/lazy-compress.mjs +326 -0
- package/src/lock-discipline.mjs +166 -0
- package/src/mcp-server.mjs +498 -0
- package/src/memory-write.mjs +565 -0
- package/src/merge-facts.mjs +213 -0
- package/src/observe-edit.mjs +87 -0
- package/src/platform-commands.mjs +138 -0
- package/src/poison-guard.mjs +245 -0
- package/src/privacy.mjs +21 -0
- package/src/provenance.mjs +217 -0
- package/src/register-crons.mjs +354 -0
- package/src/reindex.mjs +134 -0
- package/src/repair.mjs +316 -0
- package/src/result-shapes.mjs +155 -0
- package/src/review-queue.mjs +345 -0
- package/src/roll.mjs +115 -0
- package/src/scratchpad.mjs +335 -0
- package/src/search.mjs +311 -0
- package/src/subcommands.mjs +1252 -0
- package/src/tier-paths.mjs +74 -0
- package/src/transcripts.mjs +234 -0
- package/src/trust.mjs +226 -0
- package/src/weekly-curate.mjs +454 -0
- package/src/write-fact.mjs +205 -0
- package/template/.claude/hooks/pre-tool-memory.js +78 -0
- package/template/.claude/hooks/transcript-capture.js +69 -0
- package/template/.claude/settings.json +27 -0
- package/template/.claude/skills/memory-write/SKILL.md +117 -0
- package/template/.gitignore.fragment +12 -0
- package/template/CLAUDE.md.template +49 -0
- package/template/docs/journey/journey-log.md.template +292 -0
- package/template/local/machine-paths.md.template +37 -0
- package/template/local/overrides.md.template +36 -0
- package/template/project/.index/.gitkeep +0 -0
- package/template/project/MEMORY.md.template +47 -0
- package/template/project/SOUL.md.template +35 -0
- package/template/project/memory/INDEX.md.template +47 -0
- package/template/project/memory/archive/superseded/.gitkeep +0 -0
- package/template/project/memory/archive/tombstones/.gitkeep +0 -0
- package/template/project/queues/.gitkeep +0 -0
- package/template/project/sessions/.gitkeep +0 -0
- package/template/project/transcripts/.gitkeep +0 -0
- package/template/support/cron-jobs/daily-memory-distill.md +15 -0
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
- package/template/support/milvus-deploy/README.md +57 -0
- package/template/support/milvus-deploy/docker-compose.yml +66 -0
- package/template/support/scripts/auto-extract-memory.sh +102 -0
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
- package/template/support/scripts/refresh-distill-timestamp.py +35 -0
- package/template/support/scripts/register-crons.py +242 -0
- package/template/support/scripts/run-daily-distill.sh +67 -0
- package/template/support/scripts/run-weekly-curate.sh +58 -0
- package/template/user/HABITS.md.template +18 -0
- package/template/user/LESSONS.md.template +18 -0
- package/template/user/USER.md.template +18 -0
- package/template/user/fragments/INDEX.md.template +23 -0
package/src/reindex.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Granular-archive pointer-index writer (Task 8, refactored in
|
|
2
|
+
// cleanup-layer-2-cross-module-drift). Single public boundary:
|
|
3
|
+
// reindex(opts) → result. See design §2.3.
|
|
4
|
+
//
|
|
5
|
+
// Uses shared modules: tier-paths (path resolution), frontmatter (js-yaml
|
|
6
|
+
// parse). See CLAUDE.md "Shared modules" rule.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { VALID_TIERS, resolveTierRoot, resolveFactDir } from './tier-paths.mjs';
|
|
17
|
+
import { parse } from './frontmatter.mjs';
|
|
18
|
+
|
|
19
|
+
const INDEX_SIZE_WARN_BYTES = 25 * 1024;
|
|
20
|
+
const HOOK_MAX_LEN = 80;
|
|
21
|
+
|
|
22
|
+
const TIER_LABEL = {
|
|
23
|
+
P: 'project tier',
|
|
24
|
+
L: 'local tier',
|
|
25
|
+
U: 'user tier',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function extractHook(body) {
|
|
29
|
+
for (const raw of body.split('\n')) {
|
|
30
|
+
const line = raw.trim();
|
|
31
|
+
if (!line) continue;
|
|
32
|
+
if (line.startsWith('#')) continue;
|
|
33
|
+
if (line.length > HOOK_MAX_LEN) {
|
|
34
|
+
return line.slice(0, HOOK_MAX_LEN).trimEnd() + '...';
|
|
35
|
+
}
|
|
36
|
+
return line;
|
|
37
|
+
}
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatIndexLine({ id, type, title, filename, hook }) {
|
|
42
|
+
const head = `- (${id}) [${type}] [${title}](${filename})`;
|
|
43
|
+
return hook ? `${head} — ${hook}` : head;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function listFactFiles(factDir) {
|
|
47
|
+
if (!existsSync(factDir)) return [];
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const entry of readdirSync(factDir, { withFileTypes: true })) {
|
|
50
|
+
if (!entry.isFile()) continue;
|
|
51
|
+
if (!entry.name.endsWith('.md')) continue;
|
|
52
|
+
if (entry.name === 'INDEX.md') continue;
|
|
53
|
+
out.push(entry.name);
|
|
54
|
+
}
|
|
55
|
+
return out.sort();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function reindex(opts = {}) {
|
|
59
|
+
const { tier, projectRoot, userDir, warn } = opts;
|
|
60
|
+
if (!tier || !VALID_TIERS.has(tier)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`reindex: invalid tier ${JSON.stringify(tier)}. Must be 'U', 'P', or 'L'.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const emit = warn ?? ((msg) => process.stderr.write(msg + '\n'));
|
|
66
|
+
const warnings = [];
|
|
67
|
+
function pushWarning(msg) {
|
|
68
|
+
warnings.push(msg);
|
|
69
|
+
emit(msg);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
|
|
73
|
+
const factDir = resolveFactDir(tier, tierRoot);
|
|
74
|
+
mkdirSync(factDir, { recursive: true });
|
|
75
|
+
|
|
76
|
+
const entries = [];
|
|
77
|
+
for (const filename of listFactFiles(factDir)) {
|
|
78
|
+
const path = join(factDir, filename);
|
|
79
|
+
let text;
|
|
80
|
+
try {
|
|
81
|
+
text = readFileSync(path, 'utf8');
|
|
82
|
+
} catch (e) {
|
|
83
|
+
pushWarning(`reindex: failed to read ${filename}: ${e.message}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const { frontmatter, body, parseError } = parse(text);
|
|
87
|
+
if (!frontmatter) {
|
|
88
|
+
pushWarning(
|
|
89
|
+
`reindex: ${filename} skipped — ${parseError ?? 'no YAML frontmatter'}`,
|
|
90
|
+
);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!frontmatter.id || !frontmatter.type || !frontmatter.title) {
|
|
94
|
+
pushWarning(
|
|
95
|
+
`reindex: ${filename} skipped — missing required frontmatter field(s) (id/type/title)`,
|
|
96
|
+
);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (frontmatter.deleted_at) continue;
|
|
100
|
+
entries.push({
|
|
101
|
+
id: frontmatter.id,
|
|
102
|
+
type: frontmatter.type,
|
|
103
|
+
title: frontmatter.title,
|
|
104
|
+
filename,
|
|
105
|
+
hook: extractHook(body),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
110
|
+
|
|
111
|
+
const header = `# Granular memory index — ${TIER_LABEL[tier]}\n\n## Files\n`;
|
|
112
|
+
const bodyLines = entries.map(formatIndexLine).join('\n');
|
|
113
|
+
const content = entries.length
|
|
114
|
+
? `${header}\n${bodyLines}\n`
|
|
115
|
+
: `${header}\n`;
|
|
116
|
+
|
|
117
|
+
const indexPath = join(factDir, 'INDEX.md');
|
|
118
|
+
writeFileSync(indexPath, content, 'utf8');
|
|
119
|
+
|
|
120
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
121
|
+
if (bytes > INDEX_SIZE_WARN_BYTES) {
|
|
122
|
+
pushWarning(
|
|
123
|
+
`reindex: ${indexPath} is ${(bytes / 1024).toFixed(1)} KB (>25 KB); consider consolidation`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
tier,
|
|
129
|
+
indexPath,
|
|
130
|
+
factCount: entries.length,
|
|
131
|
+
bytes,
|
|
132
|
+
warnings,
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/repair.mjs
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// `cmk repair` (Task 39, T-033, parts 39.1–39.3).
|
|
2
|
+
//
|
|
3
|
+
// Public boundary:
|
|
4
|
+
// async runRepair({projectRoot, scope: 'hooks'|'locks'|'index'|'all', staleLockMs?, reindexer?})
|
|
5
|
+
// → {action, scope, repairs: [...], errors, duration_ms}
|
|
6
|
+
//
|
|
7
|
+
// Three repair scopes:
|
|
8
|
+
// - 'hooks' : deep-merge the kit's canonical hooks block from plugin/hooks/hooks.json
|
|
9
|
+
// into <projectRoot>/.claude/settings.json. Idempotent. Closes HC-2 failures.
|
|
10
|
+
// - 'locks' : remove stale lock files (default >1h old; configurable via staleLockMs).
|
|
11
|
+
// Live locks (holderAlive: true) are preserved. Closes HC-9 failures.
|
|
12
|
+
// - 'index' : invoke cmk reindex --full (Task 29's reindexFull boundary). Closes HC-5 failures.
|
|
13
|
+
// - 'all' : run all three in order. Default scope when --all flag is set OR no scope provided
|
|
14
|
+
// (v0.1.0 defaults to NO-OP if no scope flag — user must opt in to repairs).
|
|
15
|
+
//
|
|
16
|
+
// Per design §14 + tasks.md 39 (39.1–39.3).
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
statSync,
|
|
23
|
+
unlinkSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from 'node:fs';
|
|
26
|
+
import { dirname, join } from 'node:path';
|
|
27
|
+
import {
|
|
28
|
+
appendAuditEntry,
|
|
29
|
+
nowIso,
|
|
30
|
+
REASON_CODES,
|
|
31
|
+
} from './audit-log.mjs';
|
|
32
|
+
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
33
|
+
import { detectStaleLocks } from './lock-discipline.mjs';
|
|
34
|
+
|
|
35
|
+
const DEFAULT_STALE_LOCK_MS = 60 * 60 * 1000; // 1 hour
|
|
36
|
+
const SETTINGS_REL = ['.claude', 'settings.json'];
|
|
37
|
+
|
|
38
|
+
// I1 fix (Task 39 skill-review 2026-05-28): the canonical hooks block
|
|
39
|
+
// is embedded INLINE as a JS constant instead of being read from
|
|
40
|
+
// plugin/hooks/hooks.json. Rationale: packages/cli/package.json `files`
|
|
41
|
+
// lists ['bin/', 'src/', 'README.md'] — the plugin/ tree is OUTSIDE the
|
|
42
|
+
// published @lh8ppl/claude-memory-kit tarball. Reading from plugin/ works
|
|
43
|
+
// in-repo (where __dirname resolves up to the repo root) but breaks
|
|
44
|
+
// post-`npm install -g` where plugin/ doesn't exist.
|
|
45
|
+
//
|
|
46
|
+
// Same Task-33-B1 class of bug: cron-emission paths were also broken
|
|
47
|
+
// post-npm-install-g until they embedded absolute paths at registration
|
|
48
|
+
// time. The embed-the-canonical-constant pattern is the durable fix.
|
|
49
|
+
//
|
|
50
|
+
// Source of truth: keep this in sync with plugin/hooks/hooks.json. A
|
|
51
|
+
// future validator (scripts/validate-hooks-block-sync.mjs) would catch
|
|
52
|
+
// drift automatically; v0.1.x candidate.
|
|
53
|
+
const KIT_HOOKS_BLOCK = Object.freeze({
|
|
54
|
+
Setup: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-version-check"', timeout: 30 }] }],
|
|
55
|
+
SessionStart: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-inject-context"', timeout: 30 }] }],
|
|
56
|
+
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-capture-prompt"', timeout: 10 }] }],
|
|
57
|
+
PostToolUse: [{ matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-observe-edit"', async: true, timeout: 120 }] }],
|
|
58
|
+
Stop: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-capture-turn"', timeout: 30 }] }],
|
|
59
|
+
SessionEnd: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-compress-session"', timeout: 60 }] }],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Repair `<projectRoot>/.claude/settings.json` by merging in the kit's
|
|
64
|
+
* canonical hooks block. Preserves any other top-level keys + non-kit
|
|
65
|
+
* hook entries (e.g., the user's own PreToolUse hooks under different
|
|
66
|
+
* matchers).
|
|
67
|
+
*/
|
|
68
|
+
function repairHooks({ projectRoot, ts }) {
|
|
69
|
+
// I1 fix: use the inlined KIT_HOOKS_BLOCK constant; no file read,
|
|
70
|
+
// no npm-install-g brittleness.
|
|
71
|
+
const kitHooks = KIT_HOOKS_BLOCK;
|
|
72
|
+
|
|
73
|
+
const settingsPath = join(projectRoot, ...SETTINGS_REL);
|
|
74
|
+
let settings = {};
|
|
75
|
+
if (existsSync(settingsPath)) {
|
|
76
|
+
try {
|
|
77
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return {
|
|
80
|
+
kind: 'hooks',
|
|
81
|
+
changed: false,
|
|
82
|
+
error: `${settingsPath} parse error: ${err?.message ?? err}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const before = JSON.stringify(settings);
|
|
88
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
|
89
|
+
settings.hooks = {};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Merge each event array: replace the kit's hook entries (matched by
|
|
93
|
+
// command substring) with the canonical version; keep any user-added
|
|
94
|
+
// entries that don't reference the kit.
|
|
95
|
+
const KIT_COMMAND_TOKENS = [
|
|
96
|
+
'cmk-version-check',
|
|
97
|
+
'cmk-inject-context',
|
|
98
|
+
'cmk-capture-prompt',
|
|
99
|
+
'cmk-observe-edit',
|
|
100
|
+
'cmk-capture-turn',
|
|
101
|
+
'cmk-compress-session',
|
|
102
|
+
];
|
|
103
|
+
const isKitEntry = (entry) => {
|
|
104
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
105
|
+
// Entry shape varies: {command} or {hooks: [{command}]}.
|
|
106
|
+
const collectCommands = (e) => {
|
|
107
|
+
const cmds = [];
|
|
108
|
+
if (typeof e.command === 'string') cmds.push(e.command);
|
|
109
|
+
if (Array.isArray(e.hooks)) {
|
|
110
|
+
for (const h of e.hooks) if (typeof h.command === 'string') cmds.push(h.command);
|
|
111
|
+
}
|
|
112
|
+
return cmds;
|
|
113
|
+
};
|
|
114
|
+
const cmds = collectCommands(entry);
|
|
115
|
+
return cmds.some((c) => KIT_COMMAND_TOKENS.some((t) => c.includes(t)));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
for (const [eventName, kitEntries] of Object.entries(kitHooks)) {
|
|
119
|
+
const existing = Array.isArray(settings.hooks[eventName])
|
|
120
|
+
? settings.hooks[eventName]
|
|
121
|
+
: [];
|
|
122
|
+
const userEntries = existing.filter((e) => !isKitEntry(e));
|
|
123
|
+
settings.hooks[eventName] = [...userEntries, ...kitEntries];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const after = JSON.stringify(settings);
|
|
127
|
+
const changed = before !== after;
|
|
128
|
+
|
|
129
|
+
if (changed) {
|
|
130
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
131
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// I3 fix (Task 39 skill-review 2026-05-28): emit a Door-4 audit
|
|
135
|
+
// entry per outcome. cmk repair mutates user-visible state; without
|
|
136
|
+
// this, a "cmk repair broke my settings.json" report two weeks from
|
|
137
|
+
// now has no audit trail.
|
|
138
|
+
try {
|
|
139
|
+
appendAuditEntry(join(projectRoot, 'context'), {
|
|
140
|
+
ts,
|
|
141
|
+
action: 'repair',
|
|
142
|
+
tier: 'P',
|
|
143
|
+
id: 'P-RPHKAPLD', // synthetic stable id for hooks-repair events (base32 alphabet)
|
|
144
|
+
reasonCode: changed ? REASON_CODES.REPAIR_HOOKS_APPLIED : REASON_CODES.REPAIR_HOOKS_NOOP,
|
|
145
|
+
extra: { settingsPath, events: Object.keys(kitHooks) },
|
|
146
|
+
});
|
|
147
|
+
} catch {
|
|
148
|
+
// best-effort — never block repair on audit-log failure
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
kind: 'hooks',
|
|
153
|
+
changed,
|
|
154
|
+
settingsPath,
|
|
155
|
+
events: Object.keys(kitHooks),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Repair stale locks under <projectRoot>/context/.locks/ +
|
|
161
|
+
* <userDir>/.locks/. Removes locks older than staleLockMs whose holder
|
|
162
|
+
* process is no longer alive. Live locks are preserved.
|
|
163
|
+
*/
|
|
164
|
+
function repairLocks({ projectRoot, userDir, staleLockMs, now, ts }) {
|
|
165
|
+
const stale = detectStaleLocks(projectRoot, { userDir }).filter((r) => r.stale);
|
|
166
|
+
const removed = [];
|
|
167
|
+
const preserved = [];
|
|
168
|
+
// M1 fix (skill-review 2026-05-28): nowMs is anchored once and used
|
|
169
|
+
// for BOTH cutoff comparison AND preserved-entry ageMs reporting. Old
|
|
170
|
+
// code used injected `now` for cutoff but Date.now() for ageMs, which
|
|
171
|
+
// produced confusing test fixtures if anyone asserted on age values.
|
|
172
|
+
const nowMs = now ? new Date(now).getTime() : Date.now();
|
|
173
|
+
const cutoffMs = nowMs - staleLockMs;
|
|
174
|
+
for (const lock of stale) {
|
|
175
|
+
let mtimeMs;
|
|
176
|
+
try {
|
|
177
|
+
mtimeMs = statSync(lock.path).mtimeMs;
|
|
178
|
+
} catch {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (mtimeMs > cutoffMs) {
|
|
182
|
+
// Stale but recent (within the staleLockMs window) — keep for now
|
|
183
|
+
preserved.push({ path: lock.path, ageMs: nowMs - mtimeMs, reason: 'within-cutoff' });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
unlinkSync(lock.path);
|
|
188
|
+
removed.push({ path: lock.path, reason: lock.reason });
|
|
189
|
+
} catch (err) {
|
|
190
|
+
preserved.push({
|
|
191
|
+
path: lock.path,
|
|
192
|
+
ageMs: nowMs - mtimeMs,
|
|
193
|
+
reason: `unlink-failed: ${err?.message ?? err}`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// I3 fix (skill-review 2026-05-28): emit a Door-4 audit entry per
|
|
198
|
+
// removed lock so the audit trail records every file deletion the
|
|
199
|
+
// repair pipeline performed. Without this, a "cmk repair deleted my
|
|
200
|
+
// lock" post-mortem has nothing to read.
|
|
201
|
+
for (const r of removed) {
|
|
202
|
+
try {
|
|
203
|
+
appendAuditEntry(join(projectRoot, 'context'), {
|
|
204
|
+
ts,
|
|
205
|
+
action: 'repair',
|
|
206
|
+
tier: 'P',
|
|
207
|
+
id: 'P-RPLKRMVD', // synthetic stable id for repair-lock-removed events (base32 alphabet)
|
|
208
|
+
reasonCode: REASON_CODES.REPAIR_LOCK_REMOVED,
|
|
209
|
+
extra: { path: r.path, reason: r.reason },
|
|
210
|
+
});
|
|
211
|
+
} catch {
|
|
212
|
+
// best-effort
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
kind: 'locks',
|
|
217
|
+
changed: removed.length > 0,
|
|
218
|
+
removed,
|
|
219
|
+
preserved,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Repair the SQLite + FTS5 index by invoking the reindex full pipeline
|
|
225
|
+
* (Task 29's reindexFull). Lazy-loaded so test imports don't pull in
|
|
226
|
+
* the better-sqlite3 binary unnecessarily.
|
|
227
|
+
*
|
|
228
|
+
* @param {object} opts
|
|
229
|
+
* @param {Function} [opts.reindexer] test-injected reindex function; defaults to import('./index-rebuild.mjs').reindexFull
|
|
230
|
+
*/
|
|
231
|
+
async function repairIndex({ projectRoot, userDir, reindexer }) {
|
|
232
|
+
let reindexFn = reindexer;
|
|
233
|
+
if (!reindexFn) {
|
|
234
|
+
const mod = await import('./index-rebuild.mjs');
|
|
235
|
+
reindexFn = mod.reindexFull;
|
|
236
|
+
}
|
|
237
|
+
if (typeof reindexFn !== 'function') {
|
|
238
|
+
return {
|
|
239
|
+
kind: 'index',
|
|
240
|
+
changed: false,
|
|
241
|
+
error: 'reindexFull is not a function',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const r = await reindexFn({ projectRoot, userDir });
|
|
246
|
+
return {
|
|
247
|
+
kind: 'index',
|
|
248
|
+
changed: true,
|
|
249
|
+
result: r,
|
|
250
|
+
};
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return {
|
|
253
|
+
kind: 'index',
|
|
254
|
+
changed: false,
|
|
255
|
+
error: `reindex failed: ${err?.message ?? err}`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Public boundary: run the repair pipeline.
|
|
262
|
+
*
|
|
263
|
+
* @returns {Promise<object>}
|
|
264
|
+
*/
|
|
265
|
+
export async function runRepair({
|
|
266
|
+
projectRoot,
|
|
267
|
+
userDir,
|
|
268
|
+
scope = 'all',
|
|
269
|
+
staleLockMs = DEFAULT_STALE_LOCK_MS,
|
|
270
|
+
reindexer,
|
|
271
|
+
now,
|
|
272
|
+
} = {}) {
|
|
273
|
+
const ts = now ?? nowIso();
|
|
274
|
+
const t0 = Date.now();
|
|
275
|
+
if (!projectRoot) {
|
|
276
|
+
return errorResult({
|
|
277
|
+
category: ERROR_CATEGORIES.MISSING_PROJECT_ROOT,
|
|
278
|
+
errors: ['projectRoot is required'],
|
|
279
|
+
duration_ms: Date.now() - t0,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (!['hooks', 'locks', 'index', 'all'].includes(scope)) {
|
|
283
|
+
return errorResult({
|
|
284
|
+
category: ERROR_CATEGORIES.SCHEMA,
|
|
285
|
+
errors: [`invalid scope: ${scope}; expected 'hooks' | 'locks' | 'index' | 'all'`],
|
|
286
|
+
duration_ms: Date.now() - t0,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const scopes = scope === 'all' ? ['hooks', 'locks', 'index'] : [scope];
|
|
291
|
+
const repairs = [];
|
|
292
|
+
let errors = 0;
|
|
293
|
+
for (const s of scopes) {
|
|
294
|
+
if (s === 'hooks') {
|
|
295
|
+
const r = repairHooks({ projectRoot, ts });
|
|
296
|
+
if (r.error) errors += 1;
|
|
297
|
+
repairs.push(r);
|
|
298
|
+
} else if (s === 'locks') {
|
|
299
|
+
const r = repairLocks({ projectRoot, userDir, staleLockMs, now: ts, ts });
|
|
300
|
+
if (r.error) errors += 1;
|
|
301
|
+
repairs.push(r);
|
|
302
|
+
} else if (s === 'index') {
|
|
303
|
+
// eslint-disable-next-line no-await-in-loop
|
|
304
|
+
const r = await repairIndex({ projectRoot, userDir, reindexer });
|
|
305
|
+
if (r.error) errors += 1;
|
|
306
|
+
repairs.push(r);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
action: 'completed',
|
|
311
|
+
scope,
|
|
312
|
+
repairs,
|
|
313
|
+
errors,
|
|
314
|
+
duration_ms: Date.now() - t0,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Canonical result-shape conventions for cmk public boundaries.
|
|
2
|
+
//
|
|
3
|
+
// Per the Layer-2 review's I3 finding, `errorCategory` was tagged inconsistently
|
|
4
|
+
// across the four modules: writeFact set it; forget never did; mergeFacts set it
|
|
5
|
+
// only for input validation. A consumer doing `if (r.errorCategory === 'schema')`
|
|
6
|
+
// branched correctly for some failures and missed others. This module pins down
|
|
7
|
+
// the enum + provides helpers so error returns are uniform.
|
|
8
|
+
//
|
|
9
|
+
// Public surface:
|
|
10
|
+
// ERROR_CATEGORIES — frozen enum of errorCategory values
|
|
11
|
+
// ACTION_TYPES — frozen enum of action values
|
|
12
|
+
// errorResult({category, errors, ...rest}) → canonical error result object
|
|
13
|
+
// notFoundResult({errors, ...rest}) → canonical not-found result object
|
|
14
|
+
//
|
|
15
|
+
// The canonical result shape for write-side boundaries is:
|
|
16
|
+
// { action: enum, id?, path?, errorCategory?, errors?, ...extras }
|
|
17
|
+
// Where action is the discriminator. errorCategory + errors only appear when
|
|
18
|
+
// action === 'error'. See CLAUDE.md "Shared modules" + design §1.3.
|
|
19
|
+
|
|
20
|
+
export const ERROR_CATEGORIES = Object.freeze({
|
|
21
|
+
// Input shape wrong — a validation rule was violated. Caller passed bad
|
|
22
|
+
// arguments. Most validateOptions failures use this.
|
|
23
|
+
SCHEMA: 'schema',
|
|
24
|
+
|
|
25
|
+
// Runtime constraint violated. The arguments looked valid but the operation
|
|
26
|
+
// can't proceed without corrupting existing state. Examples:
|
|
27
|
+
// - mergeFacts: merged body would dedup against an unrelated existing fact
|
|
28
|
+
// - writeFact: same path exists with a different id (would overwrite)
|
|
29
|
+
COLLISION: 'collision',
|
|
30
|
+
|
|
31
|
+
// Lookup failed at runtime. Used by callers that distinguish "I couldn't
|
|
32
|
+
// find what you asked for" from "your input was wrong" — typically pairs
|
|
33
|
+
// with action: 'not-found' for the cleanest UX, but errorCategory: 'not-found'
|
|
34
|
+
// is available for write-side boundaries that need to report missing
|
|
35
|
+
// referenced ids without a discriminator change.
|
|
36
|
+
NOT_FOUND: 'not-found',
|
|
37
|
+
|
|
38
|
+
// Another writer holds a lock; retry later. Used by the auto-extract
|
|
39
|
+
// subagent (Task 23) when a prior invocation still holds the
|
|
40
|
+
// context/.locks/auto-extract.lock file.
|
|
41
|
+
CONCURRENT_RUN: 'concurrent_run',
|
|
42
|
+
|
|
43
|
+
// A scratchpad write would push the file past its configured cap even
|
|
44
|
+
// after consolidation (Task 12, design §2.1). Caller chose not to
|
|
45
|
+
// forcibly truncate; the write is rejected so no silent data loss.
|
|
46
|
+
CAP_EXCEEDED: 'cap_exceeded',
|
|
47
|
+
|
|
48
|
+
// --- Auto-extract / hook entrypoint validation (Task 23) -----------
|
|
49
|
+
// These pair with handlers that ALWAYS exit 0 (a crashed hook is
|
|
50
|
+
// worse than a missing capture). The category surfaces in
|
|
51
|
+
// sessions/{date}.extract.log so analytics can track failure modes.
|
|
52
|
+
|
|
53
|
+
// The caller did not pass `projectRoot`. Programmer error in the
|
|
54
|
+
// bin wrapper; ships as a guard against misuse.
|
|
55
|
+
MISSING_PROJECT_ROOT: 'missing_project_root',
|
|
56
|
+
|
|
57
|
+
// No CompressorBackend implementation was passed. Same shape as
|
|
58
|
+
// above — guards a programmer error.
|
|
59
|
+
MISSING_BACKEND: 'missing_backend',
|
|
60
|
+
|
|
61
|
+
// The expected turn buffer file (Task 21 wrote it under
|
|
62
|
+
// transcripts/.extract-*.tmp) doesn't exist by the time auto-extract
|
|
63
|
+
// gets scheduled. Could be a race with Task 21's writeFileSync, or
|
|
64
|
+
// a manual cleanup of stale temp files.
|
|
65
|
+
MISSING_TURN: 'missing_turn',
|
|
66
|
+
|
|
67
|
+
// The CompressorBackend's compress() rejected. For
|
|
68
|
+
// HaikuViaAnthropicApi this means the `claude --print` subprocess
|
|
69
|
+
// exited non-zero or the spawn itself failed. HAIKU_TIMEOUT
|
|
70
|
+
// (below) is the disambiguated case for "took too long" vs.
|
|
71
|
+
// "exited unhealthy"; analytics treat them differently.
|
|
72
|
+
HAIKU_FAILED: 'haiku_failed',
|
|
73
|
+
|
|
74
|
+
// CompressorBackend.compress() exceeded the caller-supplied
|
|
75
|
+
// timeoutMs (design §8.5). The subprocess was killed by the
|
|
76
|
+
// SIGTERM → grace → SIGKILL escalation in terminateSubprocess.
|
|
77
|
+
// Auto-extract passes timeoutMs=25_000 (under 30s Stop hook
|
|
78
|
+
// ceiling); compress-session passes 50_000 (under 60s SessionEnd
|
|
79
|
+
// ceiling). The inner timeout exists so the catch + finally +
|
|
80
|
+
// log-write all run BEFORE the outer hook ceiling kills the
|
|
81
|
+
// parent — without it, a hung Haiku call would leak the
|
|
82
|
+
// auto-extract.lock file and skip the NDJSON log entry.
|
|
83
|
+
// Distinct from HAIKU_FAILED so analytics can separate "the API
|
|
84
|
+
// is slow today" from "the API rejected our call".
|
|
85
|
+
HAIKU_TIMEOUT: 'haiku_timeout',
|
|
86
|
+
|
|
87
|
+
// SessionEnd compression (Task 22) — the CompressorBackend's
|
|
88
|
+
// compress() rejected when called with the §8.4 compression
|
|
89
|
+
// prompt against sessions/now.md. Disambiguates from
|
|
90
|
+
// HAIKU_FAILED in extract.log so analytics can separate
|
|
91
|
+
// extraction failures from compression failures (same root cause
|
|
92
|
+
// — the `claude` subprocess — but the call sites have different
|
|
93
|
+
// recovery semantics: extract is best-effort, compression
|
|
94
|
+
// leaves now.md intact for the next attempt).
|
|
95
|
+
COMPRESS_FAILED: 'compress_failed',
|
|
96
|
+
|
|
97
|
+
// Poison_Guard rejection (Task 24, design §6.7) — the pre-write
|
|
98
|
+
// regex filter matched a secret/injection pattern. memoryWrite()
|
|
99
|
+
// returns this category and the matched pattern_id surfaces in
|
|
100
|
+
// .locks/poison-guard.log (NDJSON, redacted) so audits can track
|
|
101
|
+
// frequency without exposing the cleartext that triggered the
|
|
102
|
+
// rejection. Pairs with POISON_GUARD_CATEGORIES from
|
|
103
|
+
// poison-guard.mjs for routing analytics.
|
|
104
|
+
POISON_GUARD: 'poison_guard',
|
|
105
|
+
|
|
106
|
+
// `cmk search` requested --mode=semantic or --mode=hybrid but the
|
|
107
|
+
// Layer 5b memsearch+Milvus install isn't present (Task 30, design
|
|
108
|
+
// §9.3). Pairs with `process.exitCode = 2` in subcommands.mjs per
|
|
109
|
+
// tasks.md 30.2's explicit "exit 2 when not installed" contract.
|
|
110
|
+
// NO silent fallback to keyword — the user asked for semantic,
|
|
111
|
+
// and the surface should fail-loud so they know what's missing.
|
|
112
|
+
SEMANTIC_UNAVAILABLE: 'semantic_unavailable',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export const ACTION_TYPES = Object.freeze({
|
|
116
|
+
CREATED: 'created',
|
|
117
|
+
SKIPPED: 'skipped',
|
|
118
|
+
TOMBSTONED: 'tombstoned',
|
|
119
|
+
MERGED: 'merged',
|
|
120
|
+
ERROR: 'error',
|
|
121
|
+
CANCELLED: 'cancelled',
|
|
122
|
+
NOT_FOUND: 'not-found',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const VALID_CATEGORIES = new Set(Object.values(ERROR_CATEGORIES));
|
|
126
|
+
|
|
127
|
+
export function errorResult({ category, errors, ...rest }) {
|
|
128
|
+
if (!VALID_CATEGORIES.has(category)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`errorResult: invalid category ${JSON.stringify(category)}. Must be one of: ${[
|
|
131
|
+
...VALID_CATEGORIES,
|
|
132
|
+
].join(', ')}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (!Array.isArray(errors) || errors.length === 0) {
|
|
136
|
+
throw new Error('errorResult: errors must be a non-empty array');
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
action: 'error',
|
|
140
|
+
errorCategory: category,
|
|
141
|
+
errors,
|
|
142
|
+
...rest,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function notFoundResult({ errors, ...rest }) {
|
|
147
|
+
if (!Array.isArray(errors) || errors.length === 0) {
|
|
148
|
+
throw new Error('notFoundResult: errors must be a non-empty array');
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
action: 'not-found',
|
|
152
|
+
errors,
|
|
153
|
+
...rest,
|
|
154
|
+
};
|
|
155
|
+
}
|