@lh8ppl/claude-memory-kit 0.2.1 → 0.2.3
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/README.md +7 -6
- package/bin/cmk-capture-prompt.mjs +17 -17
- package/bin/cmk-capture-turn.mjs +22 -21
- package/bin/cmk-compress-session.mjs +2 -2
- package/bin/cmk-inject-context.mjs +11 -11
- package/bin/cmk-observe-edit.mjs +17 -16
- package/package.json +1 -1
- package/src/audit-log.mjs +1 -0
- package/src/auto-extract.mjs +258 -6
- package/src/auto-persona.mjs +40 -8
- package/src/capture-turn.mjs +48 -1
- package/src/compress-session.mjs +89 -26
- package/src/compressor.mjs +1 -1
- package/src/conflict-queue.mjs +14 -0
- package/src/doctor.mjs +3 -3
- package/src/forget.mjs +29 -0
- package/src/graduation.mjs +1 -1
- package/src/index-rebuild.mjs +42 -0
- package/src/inject-context.mjs +5 -1
- package/src/install.mjs +29 -6
- package/src/lazy-compress.mjs +58 -9
- package/src/mcp-server.mjs +353 -124
- package/src/merge-facts.mjs +4 -0
- package/src/persona-portability.mjs +24 -1
- package/src/read-core.mjs +87 -0
- package/src/register-crons.mjs +64 -33
- package/src/remember-core.mjs +91 -0
- package/src/review-queue.mjs +13 -0
- package/src/rich-fact.mjs +46 -0
- package/src/settings-hooks.mjs +56 -2
- package/src/subcommands.mjs +419 -182
- package/src/weekly-curate.mjs +5 -0
- package/src/write-fact.mjs +25 -1
- package/template/.claude/skills/memory-write/SKILL.md +52 -35
- package/template/.gitignore.fragment +9 -3
- package/template/CLAUDE.md.template +2 -2
- package/template/docs/journey/journey-log.md.template +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared read cores (Task 108b / ADR-0014).
|
|
2
|
+
//
|
|
3
|
+
// The query logic behind the MCP read tools (mk_get / mk_timeline / mk_cite /
|
|
4
|
+
// mk_recent_activity), extracted so the CLI read verbs (cmk get / timeline /
|
|
5
|
+
// cite / recent-activity) call the SAME logic — identical results from both
|
|
6
|
+
// surfaces, one implementation. Pure (db + args in, plain data out); the MCP
|
|
7
|
+
// adapter wraps the result in a content envelope, the CLI adapter prints it.
|
|
8
|
+
|
|
9
|
+
import { ID_PATTERN } from './tier-paths.mjs';
|
|
10
|
+
|
|
11
|
+
const GET_COLUMNS =
|
|
12
|
+
'id, body, heading_path, source_file, source_line, tier, trust, ' +
|
|
13
|
+
'write_source, created_at, superseded_by, deleted_at';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetch full observation rows by id. An invalid-format or missing id becomes
|
|
17
|
+
* a `{ id, error }` entry (the array stays positionally aligned with `ids`).
|
|
18
|
+
*/
|
|
19
|
+
export function getObservations(db, ids) {
|
|
20
|
+
const stmt = db.prepare(`SELECT ${GET_COLUMNS} FROM observations WHERE id = ?`);
|
|
21
|
+
return ids.map((id) => {
|
|
22
|
+
if (!ID_PATTERN.test(id)) return { id, error: 'invalid id format' };
|
|
23
|
+
const row = stmt.get(id);
|
|
24
|
+
if (!row) return { id, error: 'not found' };
|
|
25
|
+
return row;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The canonical Markdown citation link for an id. Pure (no DB). */
|
|
30
|
+
export function citeLink(id) {
|
|
31
|
+
if (!ID_PATTERN.test(id)) return { ok: false, error: 'id must match ID_PATTERN' };
|
|
32
|
+
return { ok: true, link: `[#${id}](memkit://obs/${id})` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const TIMELINE_COLUMNS = 'id, body, source_file, source_line, tier, trust, created_at';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Sequential context: N observations before the anchor + the anchor + N after,
|
|
39
|
+
* by created_at (id as the tiebreaker so same-millisecond rows stay
|
|
40
|
+
* deterministic). Returns `{ ok:false, error }` for a bad / missing anchor.
|
|
41
|
+
*/
|
|
42
|
+
export function buildTimeline(db, { anchor, depthBefore = 5, depthAfter = 5 } = {}) {
|
|
43
|
+
if (!ID_PATTERN.test(anchor)) return { ok: false, error: 'anchor must be a valid kit ID' };
|
|
44
|
+
const anchorRow = db
|
|
45
|
+
.prepare('SELECT created_at, tier FROM observations WHERE id = ?')
|
|
46
|
+
.get(anchor);
|
|
47
|
+
if (!anchorRow) return { ok: false, error: 'anchor not found' };
|
|
48
|
+
const beforeRows = db
|
|
49
|
+
.prepare(`
|
|
50
|
+
SELECT ${TIMELINE_COLUMNS} FROM observations
|
|
51
|
+
WHERE created_at < ? AND deleted_at IS NULL
|
|
52
|
+
ORDER BY created_at DESC, id DESC LIMIT ?
|
|
53
|
+
`)
|
|
54
|
+
.all(anchorRow.created_at, depthBefore);
|
|
55
|
+
const anchorFull = db
|
|
56
|
+
.prepare(`SELECT ${TIMELINE_COLUMNS} FROM observations WHERE id = ?`)
|
|
57
|
+
.get(anchor);
|
|
58
|
+
const afterRows = db
|
|
59
|
+
.prepare(`
|
|
60
|
+
SELECT ${TIMELINE_COLUMNS} FROM observations
|
|
61
|
+
WHERE created_at > ? AND deleted_at IS NULL
|
|
62
|
+
ORDER BY created_at ASC, id ASC LIMIT ?
|
|
63
|
+
`)
|
|
64
|
+
.all(anchorRow.created_at, depthAfter);
|
|
65
|
+
return { ok: true, timeline: [...beforeRows.reverse(), anchorFull, ...afterRows] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const RECENT_WINDOWS = Object.freeze({
|
|
69
|
+
'1h': 60 * 60 * 1000,
|
|
70
|
+
'24h': 24 * 60 * 60 * 1000,
|
|
71
|
+
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** Observations changed within a time window, newest first. */
|
|
75
|
+
export function recentActivity(db, { window = '24h', limit = 20 } = {}) {
|
|
76
|
+
if (!RECENT_WINDOWS[window]) return { ok: false, error: 'window must be 1h|24h|7d' };
|
|
77
|
+
const cutoff = Date.now() - RECENT_WINDOWS[window];
|
|
78
|
+
const rows = db
|
|
79
|
+
.prepare(`
|
|
80
|
+
SELECT id, body, source_file, source_line, tier, trust, created_at
|
|
81
|
+
FROM observations
|
|
82
|
+
WHERE created_at >= ? AND deleted_at IS NULL
|
|
83
|
+
ORDER BY created_at DESC LIMIT ?
|
|
84
|
+
`)
|
|
85
|
+
.all(cutoff, limit);
|
|
86
|
+
return { ok: true, rows };
|
|
87
|
+
}
|
package/src/register-crons.mjs
CHANGED
|
@@ -74,7 +74,18 @@ function macOsPlistPath(entryName) {
|
|
|
74
74
|
function buildMacOsPlist({ command, entryName, hour, minute, dayOfWeek }) {
|
|
75
75
|
// Split command on whitespace for the ProgramArguments array.
|
|
76
76
|
// launchd doesn't honor shell quoting — each arg is its own element.
|
|
77
|
-
|
|
77
|
+
// Strip the surrounding double-quotes the caller wraps each path in (the
|
|
78
|
+
// command is `"<node>" "<script>" "<projectRoot>"`): launchd execs the arg
|
|
79
|
+
// LITERALLY, so a `<string>"/path/node"</string>` with quotes baked in is a
|
|
80
|
+
// path that starts with `"` → ENOENT (Task 109: the macOS sibling of the
|
|
81
|
+
// Windows D-83 bug). Each split token is one quoted path; drop the wrapping
|
|
82
|
+
// quotes to get the clean path. (A path that itself contains a space is the
|
|
83
|
+
// remaining edge — rare for node/project paths — and needs the argv-array
|
|
84
|
+
// refactor noted in the Task 109 follow-up.)
|
|
85
|
+
const args = command
|
|
86
|
+
.split(/\s+/)
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.map((a) => a.replace(/^"(.*)"$/, '$1'));
|
|
78
89
|
const argXml = args
|
|
79
90
|
.map((a) => ` <string>${escapeXml(a)}</string>`)
|
|
80
91
|
.join('\n');
|
|
@@ -116,34 +127,35 @@ function escapeXml(s) {
|
|
|
116
127
|
.replace(/'/g, ''');
|
|
117
128
|
}
|
|
118
129
|
|
|
119
|
-
function buildWindowsSchtasks({ command, entryName, hour, minute, dayOfWeek }) {
|
|
120
|
-
// schtasks
|
|
121
|
-
//
|
|
122
|
-
//
|
|
130
|
+
export function buildWindowsSchtasks({ command, entryName, hour, minute, dayOfWeek }) {
|
|
131
|
+
// Returns the schtasks.exe ARGV ARRAY (not a shell string). The /TR value — the
|
|
132
|
+
// command to run, `"<node>" "<script>" "<projectRoot>"` with its own quotes
|
|
133
|
+
// around each spaced path — is ONE array element, delivered to schtasks.exe
|
|
134
|
+
// verbatim via CreateProcess (Node's Windows arg-quoting), with NO cmd.exe
|
|
135
|
+
// re-parse at registration time.
|
|
136
|
+
//
|
|
137
|
+
// This is the D-83 fix. The old `/TR "${command}"` shell-string form double-
|
|
138
|
+
// wrapped the inner quotes (schtasks AND cmd.exe both tried to parse them) and
|
|
139
|
+
// the registerCron guard then rejected the inner `"` outright — so cron could
|
|
140
|
+
// NEVER register on Windows. Array-exec sidesteps the nesting entirely: Task
|
|
141
|
+
// Scheduler stores the /TR value and cmd.exe parses the quoted paths only when
|
|
142
|
+
// the task FIRES. (No `\"`-escaping needed → no CodeQL js/incomplete-
|
|
143
|
+
// sanitization, no path-corrupting backslash-doubling.)
|
|
144
|
+
//
|
|
145
|
+
// /ST is HH:mm; /F forces re-create for idempotency; /RL LIMITED (not HIGHEST)
|
|
146
|
+
// because distill needs no admin. Task 34: /SC WEEKLY /D <SUN|...> for weekly.
|
|
123
147
|
const time = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
|
124
|
-
|
|
125
|
-
// backslash as a PATH separator, not an escape character, so the only
|
|
126
|
-
// char that can break out of the quotes is a literal `"` — and that is
|
|
127
|
-
// rejected at the registerCron boundary (see the validation below), the
|
|
128
|
-
// same way an embedded `'` is rejected for the Linux cron line. So no
|
|
129
|
-
// escaping is applied here. (The earlier `command.replace(/"/g, '\\"')`
|
|
130
|
-
// was both unnecessary — controlled bin-name+path commands never contain
|
|
131
|
-
// `"` — and CodeQL-flagged js/incomplete-sanitization, since it didn't
|
|
132
|
-
// escape backslashes; but `\"`/`\\` is not how cmd.exe quotes anyway, and
|
|
133
|
-
// doubling backslashes would corrupt Windows paths. Reject-at-boundary is
|
|
134
|
-
// the correct, safe contract.)
|
|
135
|
-
// Task 34: /SC WEEKLY /D <SUN|MON|...> for weekly cadence; /SC DAILY otherwise.
|
|
136
|
-
let scheduleFlags;
|
|
148
|
+
let scheduleArgs;
|
|
137
149
|
if (dayOfWeek !== undefined && dayOfWeek !== null) {
|
|
138
150
|
const day = WIN_DAY_MAP[dayOfWeek];
|
|
139
151
|
if (!day) {
|
|
140
152
|
throw new Error(`buildWindowsSchtasks: invalid dayOfWeek ${dayOfWeek}`);
|
|
141
153
|
}
|
|
142
|
-
|
|
154
|
+
scheduleArgs = ['/SC', 'WEEKLY', '/D', day];
|
|
143
155
|
} else {
|
|
144
|
-
|
|
156
|
+
scheduleArgs = ['/SC', 'DAILY'];
|
|
145
157
|
}
|
|
146
|
-
return
|
|
158
|
+
return ['/Create', '/TN', entryName, ...scheduleArgs, '/ST', time, '/TR', command, '/RL', 'LIMITED', '/F'];
|
|
147
159
|
}
|
|
148
160
|
|
|
149
161
|
/**
|
|
@@ -169,12 +181,16 @@ export function registerCron(opts = {}) {
|
|
|
169
181
|
// cron command needs to either escape POSIX-style ('\'') or
|
|
170
182
|
// we extend this helper with a sanitizer (v0.1.x candidate).
|
|
171
183
|
errors.push("command: must not contain single quotes (Linux cron-line shell-quoting contract)");
|
|
172
|
-
} else if (opts.command.includes('"')) {
|
|
173
|
-
// Windows schtasks /TR wraps the command in double-quotes; an embedded
|
|
174
|
-
// `"` would break out of the quoting. Reject at the boundary (cmd.exe
|
|
175
|
-
// has no usable in-quote escape for `"`), mirroring the `'` contract.
|
|
176
|
-
errors.push('command: must not contain double quotes (Windows schtasks /TR quoting contract)');
|
|
177
184
|
}
|
|
185
|
+
// NOTE (Task 109 / D-83): there is deliberately NO double-quote rejection. The
|
|
186
|
+
// Windows command legitimately CONTAINS double-quotes — it's the quoted path
|
|
187
|
+
// triple `"<node>" "<script>" "<projectRoot>"` (Task 36 B1/B2). The earlier
|
|
188
|
+
// guard rejected `"` because the old `/TR "${command}"` SHELL form double-
|
|
189
|
+
// wrapped them; that made cron un-registerable on Windows (the whole D-83 bug).
|
|
190
|
+
// The win32 branch now execs schtasks with an ARGS ARRAY (no shell), so the
|
|
191
|
+
// /TR value is delivered verbatim and the inner quotes never need escaping.
|
|
192
|
+
// macOS XML-escapes them in the plist; Linux nests them inside its single-quote
|
|
193
|
+
// `echo '...'` — so `"` is safe on every platform.
|
|
178
194
|
const entryName = opts.entryName ?? CRON_ENTRY_NAME;
|
|
179
195
|
if (!entryName || typeof entryName !== 'string' || !/^[a-zA-Z0-9_.-]+$/.test(entryName)) {
|
|
180
196
|
errors.push("entryName: must match /^[a-zA-Z0-9_.-]+$/ (used in shell + plist + schtasks identifiers)");
|
|
@@ -199,7 +215,9 @@ export function registerCron(opts = {}) {
|
|
|
199
215
|
return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
|
|
200
216
|
}
|
|
201
217
|
|
|
202
|
-
|
|
218
|
+
// opts.platform is a test seam (detectPlatform() reads process.platform, which
|
|
219
|
+
// can't vary on a single CI host) — production never passes it.
|
|
220
|
+
const platform = opts.platform ?? detectPlatform();
|
|
203
221
|
const dryRun = opts.dryRun === true;
|
|
204
222
|
|
|
205
223
|
if (platform === 'linux') {
|
|
@@ -259,24 +277,37 @@ export function registerCron(opts = {}) {
|
|
|
259
277
|
}
|
|
260
278
|
|
|
261
279
|
if (platform === 'win32') {
|
|
262
|
-
const
|
|
280
|
+
const argv = buildWindowsSchtasks({ command: opts.command, entryName, hour, minute, dayOfWeek });
|
|
281
|
+
const displayCmd = `schtasks ${argv.join(' ')}`; // informational (dry-run + result.command)
|
|
263
282
|
if (dryRun) {
|
|
264
283
|
return {
|
|
265
284
|
action: 'dry-run',
|
|
266
285
|
platform,
|
|
267
286
|
executed: false,
|
|
268
|
-
command:
|
|
287
|
+
command: displayCmd,
|
|
269
288
|
output: '',
|
|
270
289
|
};
|
|
271
290
|
}
|
|
272
|
-
// schtasks
|
|
273
|
-
//
|
|
274
|
-
|
|
291
|
+
// Exec schtasks.exe with the ARGS ARRAY — NOT shell:true. This delivers the
|
|
292
|
+
// /TR value's inner quotes to schtasks verbatim (CreateProcess arg-quoting),
|
|
293
|
+
// never re-parsed by cmd.exe at registration time (the D-83 fix). Task
|
|
294
|
+
// Scheduler stores the command; cmd.exe parses the quoted paths at fire time.
|
|
295
|
+
// Resolve the ABSOLUTE System32 path rather than relying on PATH: schtasks
|
|
296
|
+
// creates a scheduled task, so a PATH-hijacked `schtasks.exe` in a writable
|
|
297
|
+
// dir would be a privilege-escalation vector (Sonar S4036). %SystemRoot% is
|
|
298
|
+
// a fixed, unwriteable system directory.
|
|
299
|
+
const schtasksExe = join(process.env.SystemRoot || process.env.windir || 'C:\\Windows', 'System32', 'schtasks.exe');
|
|
300
|
+
// opts.spawn is a test seam (defaults to spawnSync): the real schtasks exec
|
|
301
|
+
// can't run on a non-Windows CI host, so a fake lets the exec branch be
|
|
302
|
+
// covered in-process AND asserts WHAT gets spawned (Door 3: the absolute
|
|
303
|
+
// schtasks path + the verbatim argv). Production never passes it.
|
|
304
|
+
const spawn = opts.spawn ?? spawnSync;
|
|
305
|
+
const r = spawn(schtasksExe, argv, { encoding: 'utf8', windowsHide: true, timeout: 10_000 });
|
|
275
306
|
return {
|
|
276
307
|
action: r.status === 0 ? 'registered' : 'error',
|
|
277
308
|
platform,
|
|
278
309
|
executed: true,
|
|
279
|
-
command:
|
|
310
|
+
command: displayCmd,
|
|
280
311
|
output: (r.stdout || '') + (r.stderr || ''),
|
|
281
312
|
...(r.status === 0 ? {} : { error: `schtasks exit ${r.status}` }),
|
|
282
313
|
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Shared rich-capture core (Task 108b / ADR-0014).
|
|
2
|
+
//
|
|
3
|
+
// `rememberRich` writes a granular Why/How fact file via writeFact(). BOTH
|
|
4
|
+
// surfaces call it so they produce byte-identical fact files with one
|
|
5
|
+
// implementation of the contract:
|
|
6
|
+
// - CLI — subcommands.runRememberRich (re-exports this) ← `cmk remember --why/--how` + `--from-file`
|
|
7
|
+
// - MCP — mcp-server.makeMkRemember rich path ← `mk_remember` with why/how/title/type
|
|
8
|
+
//
|
|
9
|
+
// It lives in its own module (not subcommands.mjs) because subcommands.mjs
|
|
10
|
+
// imports runMcpServer from mcp-server.mjs — so mcp-server importing the core
|
|
11
|
+
// back from subcommands would be a circular dependency. The core depends only
|
|
12
|
+
// on the write/format primitives, never on either front-end.
|
|
13
|
+
//
|
|
14
|
+
// Logging is the CALLER's concern: this returns the writeFact result and the
|
|
15
|
+
// CLI/MCP adapter formats its own message (the CLI's "saved rich fact" line vs
|
|
16
|
+
// the MCP's JSON envelope) — so stdout-purity on the MCP path is the adapter's
|
|
17
|
+
// to keep (design §10.1), not the core's.
|
|
18
|
+
|
|
19
|
+
import { resolve as resolvePath } from 'node:path';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { writeFact as defaultWriteFact } from './write-fact.mjs';
|
|
22
|
+
import { buildRichFactBody, slugifyFact } from './rich-fact.mjs';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The note shown when a non-project tier (U/L) is requested on a capture. Both
|
|
26
|
+
* `cmk remember` and `mk_remember` write the PROJECT tier (P) regardless of the
|
|
27
|
+
* requested tier; a fact becomes cross-project via lessons-promote, not a direct
|
|
28
|
+
* tier write (direct U/L routing is the deferred feature in design §16.40). This is the ONE
|
|
29
|
+
* source of truth for that note across all three adapter paths (CLI terse, CLI
|
|
30
|
+
* rich, MCP) — Task 108 unified the write core but the tier message had drifted
|
|
31
|
+
* into three divergent, independently-stale copies (D-102). Centralizing it here
|
|
32
|
+
* means it can't drift again.
|
|
33
|
+
*/
|
|
34
|
+
export function nonProjectTierNote(tier) {
|
|
35
|
+
return (
|
|
36
|
+
`tier '${tier}' is not a direct write target — captured to the project tier (P). ` +
|
|
37
|
+
'To make it cross-project, promote it (`cmk lessons promote <id>` / the `mk_lessons_promote` tool).'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Write a rich Why/How fact file. Pure (no console logging) — returns the
|
|
43
|
+
* writeFact result so the caller can format its own message / envelope.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} text - the fact headline.
|
|
46
|
+
* @param {object} [options] - { why, how, type, title, links, trust }. (tier is
|
|
47
|
+
* not honored here — rich capture writes the project tier P; the CLI/MCP
|
|
48
|
+
* adapters surface the non-project-tier note before/around calling.)
|
|
49
|
+
* @param {object} [deps] - { projectRoot, writeFact } injection seams for tests.
|
|
50
|
+
* @returns the writeFact result: { action:'created'|'skipped'|'error', id?, path?, errorCategory?, errors?, skipReason? }
|
|
51
|
+
*/
|
|
52
|
+
export function rememberRich(text, options = {}, deps = {}) {
|
|
53
|
+
const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
|
|
54
|
+
const write = deps.writeFact ?? defaultWriteFact;
|
|
55
|
+
|
|
56
|
+
const headline = String(text).trim();
|
|
57
|
+
const title = (options.title && String(options.title).trim()) || headline.split('\n')[0].slice(0, 80);
|
|
58
|
+
const body = buildRichFactBody({ text: headline, why: options.why, how: options.how });
|
|
59
|
+
// `links` arrives as an ARRAY from the MCP tool (z.array) and as a
|
|
60
|
+
// comma-STRING from the CLI flag — accept both. The old `String(links)` path
|
|
61
|
+
// coerced an array via toString (works only until a link contains a comma);
|
|
62
|
+
// handle the array explicitly (D-102 / 121.6).
|
|
63
|
+
const related = Array.isArray(options.links)
|
|
64
|
+
? options.links.map((s) => String(s).trim()).filter(Boolean)
|
|
65
|
+
: options.links
|
|
66
|
+
? String(options.links).split(',').map((s) => s.trim()).filter(Boolean)
|
|
67
|
+
: undefined;
|
|
68
|
+
|
|
69
|
+
return write({
|
|
70
|
+
tier: 'P',
|
|
71
|
+
type: options.type ?? 'feedback',
|
|
72
|
+
slug: slugifyFact(title),
|
|
73
|
+
title,
|
|
74
|
+
body,
|
|
75
|
+
writeSource: 'user-explicit',
|
|
76
|
+
trust: options.trust ?? 'high',
|
|
77
|
+
sourceFile: 'user-explicit',
|
|
78
|
+
sourceLine: 1,
|
|
79
|
+
// Content fingerprint for provenance/dedup — NOT a security context. Matches
|
|
80
|
+
// the kit's sha1-of-content convention (memory-write.mjs, index-rebuild.mjs);
|
|
81
|
+
// writeFact dedups by content-addressed id, this is the source_sha1 field. // NOSONAR
|
|
82
|
+
sourceSha1: createHash('sha1').update(body).digest('hex'), // NOSONAR
|
|
83
|
+
related,
|
|
84
|
+
projectRoot,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The title rememberRich() will derive for `text`/`options` (for caller messages). */
|
|
89
|
+
export function richFactTitle(text, options = {}) {
|
|
90
|
+
return (options.title && String(options.title).trim()) || String(text).trim().split('\n')[0].slice(0, 80);
|
|
91
|
+
}
|
package/src/review-queue.mjs
CHANGED
|
@@ -209,6 +209,19 @@ function serializeReviewQueue({ preamble, entries }) {
|
|
|
209
209
|
// works because Active Threads is where transient observations
|
|
210
210
|
// belong by convention; the user can promote into a different
|
|
211
211
|
// section by editing MEMORY.md after promotion.
|
|
212
|
+
/**
|
|
213
|
+
* Pure-read list of pending review-queue entries (no mutation). Used by the MCP
|
|
214
|
+
* `mk_queue_list` tool so a "list" never rewrites the queue file — unlike
|
|
215
|
+
* resolveReviewQueue, which reserializes on every call. Returns `[]` when the
|
|
216
|
+
* queue file doesn't exist.
|
|
217
|
+
*/
|
|
218
|
+
export function listReviewQueue({ tier = 'P', projectRoot, userDir } = {}) {
|
|
219
|
+
const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
|
|
220
|
+
const queuePath = join(tierRoot, ...QUEUE_RELATIVE);
|
|
221
|
+
if (!existsSync(queuePath)) return [];
|
|
222
|
+
return parseReviewQueue(readFileSync(queuePath, 'utf8')).entries;
|
|
223
|
+
}
|
|
224
|
+
|
|
212
225
|
export async function resolveReviewQueue({
|
|
213
226
|
tier,
|
|
214
227
|
projectRoot,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Rich-fact body + slug shaping — the single source of truth for HOW a rich
|
|
2
|
+
// fact's file body and filename slug are built (Task 103).
|
|
3
|
+
//
|
|
4
|
+
// Extracted from subcommands.mjs so the TWO rich-capture paths build identical
|
|
5
|
+
// fact files (the shared-modules / no-drift rule, CLAUDE.md §1.3):
|
|
6
|
+
// 1. explicit — `cmk remember --why/--how` → runRememberRich (subcommands.mjs)
|
|
7
|
+
// 2. automatic — the Stop-hook auto-extract synthesizing rich facts on the
|
|
8
|
+
// native-immune path (auto-extract.mjs, Task 103)
|
|
9
|
+
// Both call writeFact() with a body produced here, so an auto-extracted fact
|
|
10
|
+
// reads the same as an explicitly-captured one.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a slug for a rich fact's filename from its title.
|
|
14
|
+
*
|
|
15
|
+
* Collapse every run of non-alphanumerics to a single '-' (so dashes are never
|
|
16
|
+
* doubled), cap at 60 chars, then trim a leading/trailing dash without a regex
|
|
17
|
+
* quantifier (static analysis flags trailing `-+$` as ReDoS-prone; a single
|
|
18
|
+
* dash is all that can remain after the collapse, so string ops suffice).
|
|
19
|
+
*
|
|
20
|
+
* @param {string} s - the source text (typically the fact title).
|
|
21
|
+
* @returns {string} a `[a-z0-9][a-z0-9_-]*`-safe slug, or 'fact' if empty.
|
|
22
|
+
*/
|
|
23
|
+
export function slugifyFact(s) {
|
|
24
|
+
let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 60);
|
|
25
|
+
if (base.startsWith('-')) base = base.slice(1);
|
|
26
|
+
if (base.endsWith('-')) base = base.slice(0, -1);
|
|
27
|
+
return base || 'fact';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Assemble the rich fact body in the v0.1.1 shape: headline + Why + How.
|
|
32
|
+
* The headline/body may itself be multi-line markdown (a structured breakdown);
|
|
33
|
+
* Why/How are appended as labelled blocks only when present.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} opts
|
|
36
|
+
* @param {string} opts.text - the headline / body (may be multi-line markdown).
|
|
37
|
+
* @param {string} [opts.why] - the rationale → `**Why:**` block.
|
|
38
|
+
* @param {string} [opts.how] - how to apply → `**How to apply:**` block.
|
|
39
|
+
* @returns {string} the assembled markdown body for writeFact().
|
|
40
|
+
*/
|
|
41
|
+
export function buildRichFactBody({ text, why, how }) {
|
|
42
|
+
const parts = [String(text).trim()];
|
|
43
|
+
if (why && String(why).trim()) parts.push(`**Why:** ${String(why).trim()}`);
|
|
44
|
+
if (how && String(how).trim()) parts.push(`**How to apply:** ${String(how).trim()}`);
|
|
45
|
+
return parts.join('\n\n');
|
|
46
|
+
}
|
package/src/settings-hooks.mjs
CHANGED
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
readFileSync,
|
|
57
57
|
writeFileSync,
|
|
58
58
|
} from 'node:fs';
|
|
59
|
-
import { dirname } from 'node:path';
|
|
59
|
+
import { dirname, join } from 'node:path';
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* Canonical npm-route hooks block. Shell form (no `args`), PATH-resolved
|
|
@@ -191,7 +191,17 @@ export function writeKitHooks(settingsPath) {
|
|
|
191
191
|
// returned one layer up once capture moved into the skill.
|
|
192
192
|
// Idempotent + over-mutation safe: preserve the user's existing allow entries;
|
|
193
193
|
// only append ours if absent.
|
|
194
|
-
|
|
194
|
+
// - `mcp__cmk__*` (Task 108b, R2 / D-80) — allow-lists the kit's MCP tools
|
|
195
|
+
// (mk_remember / mk_forget / mk_trust / …) so the model's memory ops run
|
|
196
|
+
// without a per-call approval prompt. This is the structural fix for the
|
|
197
|
+
// `cd`-compound bash-permission edge (D-80): the permissions doc confirms
|
|
198
|
+
// "Combining `cd` with `git` in one compound command always prompts" — and
|
|
199
|
+
// a `Bash(cmk:*)` rule can't cover a `cd … && cmk …` compound. Running the
|
|
200
|
+
// SAME memory op as an allow-listed MCP tool sidesteps the bash gate
|
|
201
|
+
// entirely. `mcp__cmk__*` is the documented server-wildcard form
|
|
202
|
+
// (code.claude.com/docs/en/permissions — MCP section). Pairs with the
|
|
203
|
+
// `.mcp.json` server registration written by writeKitMcpServer().
|
|
204
|
+
const KIT_ALLOW = ['Bash(cmk:*)', 'Skill(memory-write)', 'mcp__cmk__*'];
|
|
195
205
|
if (!settings.permissions || typeof settings.permissions !== 'object') {
|
|
196
206
|
settings.permissions = {};
|
|
197
207
|
}
|
|
@@ -214,3 +224,47 @@ export function writeKitHooks(settingsPath) {
|
|
|
214
224
|
|
|
215
225
|
return { changed, settingsPath, events };
|
|
216
226
|
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Read-merge-write the kit's MCP server registration into
|
|
230
|
+
* `<projectRoot>/.mcp.json` — the project-scoped, committed MCP config
|
|
231
|
+
* (code.claude.com/docs/en/mcp). This makes the kit's memory tools
|
|
232
|
+
* (mk_remember / mk_forget / mk_trust / mk_queue_* / …) available to the model
|
|
233
|
+
* in conversation, so the user never has to run `cmk` themselves (D-85). Pairs
|
|
234
|
+
* with the `mcp__cmk__*` allow rule writeKitHooks() adds (Task 108b, R2 / D-80).
|
|
235
|
+
*
|
|
236
|
+
* The server runs `cmk mcp serve` (PATH-resolved bare bin, matching the hooks);
|
|
237
|
+
* `cmk mcp serve` resolves the project root from CLAUDE_PROJECT_DIR (which Claude
|
|
238
|
+
* Code sets in the spawned server's environment) so it indexes the right project.
|
|
239
|
+
*
|
|
240
|
+
* Idempotent + non-destructive: preserves any OTHER `mcpServers` the user
|
|
241
|
+
* registered; only (re)writes the `cmk` entry. On a JSON parse error of an
|
|
242
|
+
* existing `.mcp.json`, returns `{changed:false, error}` (never clobbers).
|
|
243
|
+
*/
|
|
244
|
+
export function writeKitMcpServer(projectRoot) {
|
|
245
|
+
const mcpPath = join(projectRoot, '.mcp.json');
|
|
246
|
+
|
|
247
|
+
let config = {};
|
|
248
|
+
if (existsSync(mcpPath)) {
|
|
249
|
+
try {
|
|
250
|
+
config = JSON.parse(readFileSync(mcpPath, 'utf8'));
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return { changed: false, path: mcpPath, error: `${mcpPath} parse error: ${err?.message ?? err}` };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const before = JSON.stringify(config);
|
|
257
|
+
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
|
|
258
|
+
config.mcpServers = {};
|
|
259
|
+
}
|
|
260
|
+
config.mcpServers.cmk = { type: 'stdio', command: 'cmk', args: ['mcp', 'serve'] };
|
|
261
|
+
const after = JSON.stringify(config);
|
|
262
|
+
const changed = before !== after;
|
|
263
|
+
|
|
264
|
+
if (changed) {
|
|
265
|
+
mkdirSync(dirname(mcpPath), { recursive: true });
|
|
266
|
+
writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { changed, path: mcpPath };
|
|
270
|
+
}
|