@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
package/src/subcommands.mjs
CHANGED
|
@@ -26,8 +26,9 @@ import { weeklyCurate } from './weekly-curate.mjs';
|
|
|
26
26
|
import { autoPersona } from './auto-persona.mjs';
|
|
27
27
|
import { exportPersona, importPersona } from './persona-portability.mjs';
|
|
28
28
|
import { setNativeAutoMemory, nativeMemoryInstallNote } from './native-memory.mjs';
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
29
|
+
import { rememberRich, richFactTitle, nonProjectTierNote } from './remember-core.mjs';
|
|
30
|
+
import { getObservations, citeLink, buildTimeline, recentActivity } from './read-core.mjs';
|
|
31
|
+
import { readHookStdin } from './read-hook-stdin.mjs';
|
|
31
32
|
import { runLazyCompress } from './lazy-compress.mjs';
|
|
32
33
|
import { runDoctor } from './doctor.mjs';
|
|
33
34
|
import { importAnthropicMemory } from './import-anthropic-memory.mjs';
|
|
@@ -60,7 +61,7 @@ import { resolveReviewQueue } from './review-queue.mjs';
|
|
|
60
61
|
import { createInterface } from 'node:readline';
|
|
61
62
|
import { resolve as resolvePath, join, basename } from 'node:path';
|
|
62
63
|
|
|
63
|
-
const NOTICE_PREFIX = 'not yet implemented
|
|
64
|
+
const NOTICE_PREFIX = 'not yet implemented';
|
|
64
65
|
|
|
65
66
|
/**
|
|
66
67
|
* Real `cmk install` action — wired in Task 3, extended in Task 4 with
|
|
@@ -322,6 +323,99 @@ function runSearch(queryParts, options) {
|
|
|
322
323
|
}
|
|
323
324
|
}
|
|
324
325
|
|
|
326
|
+
// --- Read verbs (Task 108b — CLI parity with the MCP read tools) ------
|
|
327
|
+
//
|
|
328
|
+
// `cmk get` / `timeline` / `cite` / `recent-activity` mirror the MCP tools
|
|
329
|
+
// mk_get / mk_timeline / mk_cite / mk_recent_activity by calling the SAME
|
|
330
|
+
// shared read cores (read-core.mjs) — identical results from CLI + MCP
|
|
331
|
+
// (ADR-0014). cite is pure (no DB); the rest open the index + reindex first
|
|
332
|
+
// (same fresh-install freshness guard as `cmk search`).
|
|
333
|
+
|
|
334
|
+
// `deps` (projectRoot / log / logError) are injection seams: production passes
|
|
335
|
+
// nothing (defaults to cwd + console), in-process tests pass a temp projectRoot
|
|
336
|
+
// + captured loggers so the glue is covered WITHOUT a subprocess (the D-86
|
|
337
|
+
// lesson — real-binary tests don't contribute line coverage). Exported for the
|
|
338
|
+
// unit tests.
|
|
339
|
+
|
|
340
|
+
/** Open the index DB, refresh it (best-effort), run `fn(db)`, always close. */
|
|
341
|
+
export function withReadDb(fn, deps = {}) {
|
|
342
|
+
const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
|
|
343
|
+
const userDir =
|
|
344
|
+
deps.userDir ?? process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
|
|
345
|
+
const logError = deps.logError ?? console.error;
|
|
346
|
+
const db = openIndexDb({ projectRoot });
|
|
347
|
+
try {
|
|
348
|
+
try {
|
|
349
|
+
reindexBoot({ projectRoot, userDir, db });
|
|
350
|
+
} catch (err) {
|
|
351
|
+
logError(`cmk: index refresh failed (${err?.message ?? err}); using the existing index.`);
|
|
352
|
+
}
|
|
353
|
+
return fn(db);
|
|
354
|
+
} finally {
|
|
355
|
+
db.close();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function runGet(ids, _options = {}, _command, deps = {}) {
|
|
360
|
+
const log = deps.log ?? console.log;
|
|
361
|
+
const list = Array.isArray(ids) ? ids : [ids];
|
|
362
|
+
const rows = withReadDb((db) => getObservations(db, list), deps);
|
|
363
|
+
log(JSON.stringify(rows, null, 2));
|
|
364
|
+
// All-missing/invalid → exit 2 (lets a script tell "nothing matched" from a hit).
|
|
365
|
+
if (rows.length > 0 && rows.every((r) => r.error)) process.exitCode = 2;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function runCite(id, _options = {}, _command, deps = {}) {
|
|
369
|
+
const log = deps.log ?? console.log;
|
|
370
|
+
const logError = deps.logError ?? console.error;
|
|
371
|
+
const r = citeLink(id);
|
|
372
|
+
if (!r.ok) {
|
|
373
|
+
logError(`cmk cite: ${r.error}`);
|
|
374
|
+
process.exitCode = 2;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
log(r.link);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function runTimeline(anchor, options = {}, _command, deps = {}) {
|
|
381
|
+
const log = deps.log ?? console.log;
|
|
382
|
+
const logError = deps.logError ?? console.error;
|
|
383
|
+
const r = withReadDb(
|
|
384
|
+
(db) =>
|
|
385
|
+
buildTimeline(db, {
|
|
386
|
+
anchor,
|
|
387
|
+
depthBefore: options.before !== undefined ? Number(options.before) : 5,
|
|
388
|
+
depthAfter: options.after !== undefined ? Number(options.after) : 5,
|
|
389
|
+
}),
|
|
390
|
+
deps,
|
|
391
|
+
);
|
|
392
|
+
if (!r.ok) {
|
|
393
|
+
logError(`cmk timeline: ${r.error}`);
|
|
394
|
+
process.exitCode = 2;
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
log(JSON.stringify(r.timeline, null, 2));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function runRecentActivity(options = {}, _command, deps = {}) {
|
|
401
|
+
const log = deps.log ?? console.log;
|
|
402
|
+
const logError = deps.logError ?? console.error;
|
|
403
|
+
const r = withReadDb(
|
|
404
|
+
(db) =>
|
|
405
|
+
recentActivity(db, {
|
|
406
|
+
window: options.window ?? '24h',
|
|
407
|
+
limit: options.limit !== undefined ? Number(options.limit) : 20,
|
|
408
|
+
}),
|
|
409
|
+
deps,
|
|
410
|
+
);
|
|
411
|
+
if (!r.ok) {
|
|
412
|
+
logError(`cmk recent-activity: ${r.error}`);
|
|
413
|
+
process.exitCode = 2;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
log(JSON.stringify(r.rows, null, 2));
|
|
417
|
+
}
|
|
418
|
+
|
|
325
419
|
/**
|
|
326
420
|
* `cmk reindex` — three modes.
|
|
327
421
|
*
|
|
@@ -356,25 +450,6 @@ function runSearch(queryParts, options) {
|
|
|
356
450
|
*/
|
|
357
451
|
// Task 63 (F1): a slug derived from the title — lowercased, non-alphanumerics
|
|
358
452
|
// collapsed to '-', trimmed, capped. Always passes writeFact's SLUG_PATTERN.
|
|
359
|
-
function slugifyFact(s) {
|
|
360
|
-
// Collapse every run of non-alphanumerics to a single '-' (so dashes are
|
|
361
|
-
// never doubled), cap, then trim a leading/trailing dash without a regex
|
|
362
|
-
// quantifier (static analysis flags trailing `-+$` as ReDoS-prone; a single
|
|
363
|
-
// dash is all that can remain after the collapse, so string ops suffice).
|
|
364
|
-
let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 60);
|
|
365
|
-
if (base.startsWith('-')) base = base.slice(1);
|
|
366
|
-
if (base.endsWith('-')) base = base.slice(0, -1);
|
|
367
|
-
return base || 'fact';
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Assemble the rich fact body in the v0.1.1 shape: headline + Why + How.
|
|
371
|
-
function buildRichFactBody({ text, why, how }) {
|
|
372
|
-
const parts = [String(text).trim()];
|
|
373
|
-
if (why && String(why).trim()) parts.push(`**Why:** ${String(why).trim()}`);
|
|
374
|
-
if (how && String(how).trim()) parts.push(`**How to apply:** ${String(how).trim()}`);
|
|
375
|
-
return parts.join('\n\n');
|
|
376
|
-
}
|
|
377
|
-
|
|
378
453
|
/**
|
|
379
454
|
* `cmk remember --why … --how … --type … --title …` (Task 63 / F1) — RICH
|
|
380
455
|
* capture. Writes a real granular fact file (frontmatter + Why/How/links) via
|
|
@@ -383,57 +458,27 @@ function buildRichFactBody({ text, why, how }) {
|
|
|
383
458
|
* carries injection seams for testing.
|
|
384
459
|
*/
|
|
385
460
|
export function runRememberRich(text, options = {}, deps = {}) {
|
|
386
|
-
const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
|
|
387
461
|
const log = deps.log ?? console.log;
|
|
388
462
|
const logError = deps.logError ?? console.error;
|
|
389
|
-
const write = deps.writeFact ?? writeFact;
|
|
390
463
|
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
// (a no-write surprise is worse than a captured-to-P note). Surface it so the
|
|
395
|
-
// divergence isn't silent.
|
|
464
|
+
// Non-P --tier: capture at the project tier (P) + surface the note (don't
|
|
465
|
+
// silently honor it, don't hard-error). ONE shared note across CLI + MCP so
|
|
466
|
+
// the message can't drift (D-102 / Task 121).
|
|
396
467
|
if (options.tier && options.tier !== 'P') {
|
|
397
|
-
log(
|
|
398
|
-
`cmk remember: --tier '${options.tier}' is v0.1.x — rich capture writes the project tier (P) for now.`,
|
|
399
|
-
);
|
|
468
|
+
log(`cmk remember: ${nonProjectTierNote(options.tier)}`);
|
|
400
469
|
}
|
|
401
470
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
? String(options.links).split(',').map((s) => s.trim()).filter(Boolean)
|
|
407
|
-
: undefined;
|
|
408
|
-
|
|
409
|
-
const r = write({
|
|
410
|
-
tier: 'P',
|
|
411
|
-
type: options.type ?? 'feedback',
|
|
412
|
-
slug: slugifyFact(title),
|
|
413
|
-
title,
|
|
414
|
-
body,
|
|
415
|
-
writeSource: 'user-explicit',
|
|
416
|
-
trust: options.trust ?? 'high',
|
|
417
|
-
sourceFile: 'user-explicit',
|
|
418
|
-
sourceLine: 1,
|
|
419
|
-
// Content fingerprint for provenance/dedup — NOT a security context.
|
|
420
|
-
// Matches the kit's sha1-of-content convention (memory-write.mjs,
|
|
421
|
-
// index-rebuild.mjs); writeFact dedups by content-addressed id, this is
|
|
422
|
-
// just the source_sha1 provenance field. // NOSONAR
|
|
423
|
-
sourceSha1: createHash('sha1').update(body).digest('hex'), // NOSONAR
|
|
424
|
-
|
|
425
|
-
related,
|
|
426
|
-
projectRoot,
|
|
427
|
-
});
|
|
471
|
+
// The write is the shared core (remember-core.rememberRich) — the SAME one the
|
|
472
|
+
// MCP `mk_remember` rich path calls, so both surfaces emit identical fact files
|
|
473
|
+
// (ADR-0014). This wrapper only formats the CLI's messages from the result.
|
|
474
|
+
const r = rememberRich(text, options, deps);
|
|
428
475
|
|
|
429
476
|
if (r.action === 'error') {
|
|
430
|
-
// M1: a collision means a fact file with this title (→ slug) already exists
|
|
431
|
-
// but with different content (different id). Give an actionable hint rather
|
|
432
|
-
// than the raw "refusing overwrite" — the user almost certainly wants to
|
|
433
|
-
// edit the existing fact or pick a new --title.
|
|
434
477
|
if (r.errorCategory === 'collision') {
|
|
478
|
+
// M1: a same-title / different-content collision — actionable hint over
|
|
479
|
+
// the raw "refusing overwrite".
|
|
435
480
|
logError(
|
|
436
|
-
`cmk remember: a fact titled "${
|
|
481
|
+
`cmk remember: a fact titled "${richFactTitle(text, options)}" already exists with different content. ` +
|
|
437
482
|
`Edit it directly, or capture under a new --title.`,
|
|
438
483
|
);
|
|
439
484
|
return r;
|
|
@@ -449,11 +494,128 @@ export function runRememberRich(text, options = {}, deps = {}) {
|
|
|
449
494
|
return r;
|
|
450
495
|
}
|
|
451
496
|
|
|
452
|
-
|
|
453
|
-
|
|
497
|
+
/**
|
|
498
|
+
* Task 108.2 (108a) — parse a structured fact from the off-shell input channel
|
|
499
|
+
* (`--from-file <path>` or `--json` stdin). PURE + dependency-injected (the CLI
|
|
500
|
+
* passes real fs readers; tests pass fakes) so every parse/validate/allowlist
|
|
501
|
+
* branch is covered IN-PROCESS — the real-binary subprocess tests prove the CLI
|
|
502
|
+
* wiring but don't contribute line coverage.
|
|
503
|
+
*
|
|
504
|
+
* @param {object} options - subcommand options (fromFile/json + the rich flags).
|
|
505
|
+
* @param {object} deps
|
|
506
|
+
* @param {(path:string)=>string} deps.readFile - read a file to a string (throws on error).
|
|
507
|
+
* @param {()=>string} deps.readStdin - read stdin to a string ('' for TTY/empty).
|
|
508
|
+
* @returns {{ok:true,channel:string,fields:object,ignored:string[]}
|
|
509
|
+
* | {ok:false,channel:string,error:string,ignored:string[]}}
|
|
510
|
+
*/
|
|
511
|
+
export function parseFactInput(options, { readFile, readStdin } = {}) {
|
|
512
|
+
const channel = options.fromFile ? '--from-file' : '--json';
|
|
513
|
+
// --from-file/--json are self-contained (the JSON is the whole fact); rich /
|
|
514
|
+
// terse flags passed alongside are ignored — surfaced so they aren't dropped silently.
|
|
515
|
+
const ignored = ['why', 'how', 'type', 'title', 'links', 'tier', 'trust', 'section']
|
|
516
|
+
.filter((k) => options[k] != null)
|
|
517
|
+
.map((k) => '--' + k);
|
|
518
|
+
const fail = (error) => ({ ok: false, channel, error, ignored });
|
|
519
|
+
|
|
520
|
+
let raw;
|
|
521
|
+
if (options.fromFile) {
|
|
522
|
+
try {
|
|
523
|
+
raw = readFile(options.fromFile);
|
|
524
|
+
} catch (e) {
|
|
525
|
+
return fail(`${channel} could not read ${options.fromFile}: ${e.message}`);
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
// '' = interactive TTY or empty pipe (read-hook-stdin returns '' for a TTY).
|
|
529
|
+
raw = readStdin();
|
|
530
|
+
if (!raw || !raw.trim()) {
|
|
531
|
+
return fail('--json expects a JSON object on stdin (pipe it in, or use --from-file).');
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Bound the input so a pathological file can't burn Poison_Guard regex time
|
|
536
|
+
// (the M1 concern that capped mk_remember). 64 KB is generous for one fact.
|
|
537
|
+
const MAX_INPUT_BYTES = 64 * 1024;
|
|
538
|
+
if (Buffer.byteLength(raw, 'utf8') > MAX_INPUT_BYTES) {
|
|
539
|
+
return fail(`${channel} fact is too large (max ${MAX_INPUT_BYTES / 1024} KB). Split it into smaller facts.`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
let parsed;
|
|
543
|
+
try {
|
|
544
|
+
parsed = JSON.parse(raw);
|
|
545
|
+
} catch (e) {
|
|
546
|
+
return fail(`${channel} could not parse JSON: ${e.message}`);
|
|
547
|
+
}
|
|
548
|
+
if (
|
|
549
|
+
!parsed ||
|
|
550
|
+
typeof parsed !== 'object' ||
|
|
551
|
+
Array.isArray(parsed) ||
|
|
552
|
+
typeof parsed.text !== 'string' ||
|
|
553
|
+
!parsed.text.trim()
|
|
554
|
+
) {
|
|
555
|
+
return fail(`${channel} JSON must be an object with a non-empty "text" field.`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Allowlist the honored fields — NEVER forward the raw parsed object. A crafted
|
|
559
|
+
// JSON must not reach a field runRememberRich might read; provenance
|
|
560
|
+
// (write_source / source_file) stays hardcoded user-explicit in runRememberRich.
|
|
561
|
+
return {
|
|
562
|
+
ok: true,
|
|
563
|
+
channel,
|
|
564
|
+
ignored,
|
|
565
|
+
fields: {
|
|
566
|
+
text: parsed.text,
|
|
567
|
+
why: parsed.why,
|
|
568
|
+
how: parsed.how,
|
|
569
|
+
type: parsed.type,
|
|
570
|
+
title: parsed.title,
|
|
571
|
+
links: parsed.links,
|
|
572
|
+
tier: parsed.tier,
|
|
573
|
+
trust: parsed.trust,
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function runRemember(textParts, options, deps = {}) {
|
|
579
|
+
const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
|
|
454
580
|
const userDir =
|
|
455
|
-
process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
|
|
581
|
+
deps.userDir ?? process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
|
|
582
|
+
const log = deps.log ?? console.log;
|
|
583
|
+
const logError = deps.logError ?? console.error;
|
|
584
|
+
|
|
585
|
+
// Task 108.2 (108a) — structured off-shell input. `--from-file`/`--json` carry
|
|
586
|
+
// the fact as a JSON object from a FILE or STDIN, so rich content (backticks,
|
|
587
|
+
// $(), quotes, newlines) never rides the shell command line — the D-81 fix.
|
|
588
|
+
// The parse/validate/allowlist lives in the pure parseFactInput() helper.
|
|
589
|
+
if (options?.fromFile || options?.json) {
|
|
590
|
+
const parsed = parseFactInput(options, {
|
|
591
|
+
readFile: (p) => readFileSync(p, 'utf8'),
|
|
592
|
+
readStdin: () => readHookStdin({ isTTY: process.stdin.isTTY }),
|
|
593
|
+
});
|
|
594
|
+
if (parsed.ignored.length) {
|
|
595
|
+
logError(
|
|
596
|
+
`cmk remember: ${parsed.channel} is self-contained — ignoring ${parsed.ignored.join(', ')} (put these in the JSON instead).`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
if (!parsed.ok) {
|
|
600
|
+
logError(`cmk remember: ${parsed.error}`);
|
|
601
|
+
process.exitCode = 2;
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
runRememberRich(parsed.fields.text, parsed.fields, { projectRoot, log, logError });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
456
608
|
const text = Array.isArray(textParts) ? textParts.join(' ') : textParts;
|
|
609
|
+
// Bare `cmk remember` — no positional text and no input channel. The positional
|
|
610
|
+
// arg is optional now (for --from-file/--json), so guard explicitly instead of
|
|
611
|
+
// falling through to a vague empty-text write error.
|
|
612
|
+
if (!text || !String(text).trim()) {
|
|
613
|
+
logError(
|
|
614
|
+
'cmk remember: provide a fact to remember, or use --from-file <json> / --json (stdin).',
|
|
615
|
+
);
|
|
616
|
+
process.exitCode = 2;
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
457
619
|
// Rich mode: any of --why/--how/--type/--title/--links → write a real fact
|
|
458
620
|
// file (the F1 fix) instead of a terse MEMORY.md bullet. M3: --trust and
|
|
459
621
|
// --section are intentionally NOT triggers — --trust is shared by both forms
|
|
@@ -463,15 +625,15 @@ function runRemember(textParts, options) {
|
|
|
463
625
|
runRememberRich(text, options, { projectRoot });
|
|
464
626
|
return;
|
|
465
627
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
return;
|
|
628
|
+
// Non-P --tier: capture at P + note (consistent with the rich path + the MCP
|
|
629
|
+
// tool — D-102). A fact becomes cross-project via `cmk lessons promote`, not a
|
|
630
|
+
// direct tier write (direct U/L routing is the deferred feature in design §16.40). We do NOT
|
|
631
|
+
// hard-error — losing the capture to an error is worse than landing it at P.
|
|
632
|
+
const requestedTier = options?.tier ?? 'P';
|
|
633
|
+
if (requestedTier !== 'P') {
|
|
634
|
+
log(`cmk remember: ${nonProjectTierNote(requestedTier)}`);
|
|
474
635
|
}
|
|
636
|
+
const tier = 'P';
|
|
475
637
|
const trust = options?.trust ?? 'high';
|
|
476
638
|
const section = options?.section ?? 'Active Threads';
|
|
477
639
|
const r = memoryWrite({
|
|
@@ -565,6 +727,11 @@ function runForget(idOrQuery, options /* , command */) {
|
|
|
565
727
|
const result = forgetAction({
|
|
566
728
|
idOrQuery,
|
|
567
729
|
projectRoot,
|
|
730
|
+
// Pass the resolved userDir (same source as `cmk search`) so forget's
|
|
731
|
+
// in-band reindex covers all three tiers and its orphan-prune fires
|
|
732
|
+
// IMMEDIATELY (Task 110) — without it the prune is skipped here and only
|
|
733
|
+
// self-heals on the next search. Also lets forget tombstone U-tier facts.
|
|
734
|
+
userDir: resolveUserDir(),
|
|
568
735
|
reason: options.reason,
|
|
569
736
|
deletedBy: options.deletedBy,
|
|
570
737
|
yes: true,
|
|
@@ -685,11 +852,19 @@ export async function runPersonaGenerate(opts = {}) {
|
|
|
685
852
|
try {
|
|
686
853
|
const backend =
|
|
687
854
|
opts.backend ?? new (await import('./compressor.mjs')).HaikuViaAnthropicApi();
|
|
688
|
-
|
|
855
|
+
// Task 111 (F-2): `cmk persona generate` is an explicit one-shot with NO outer
|
|
856
|
+
// hook ceiling (unlike the 60s-bounded SessionEnd path), so it gives the Haiku
|
|
857
|
+
// classifier generous headroom — the whole-project facts sweep is a heavier
|
|
858
|
+
// call than a session summary, and the user is willing to wait for the command
|
|
859
|
+
// they ran. The corpus is byte-capped (PERSONA_CORPUS_BYTES) so this can't run
|
|
860
|
+
// unbounded. Overridable via opts.timeoutMs.
|
|
861
|
+
const r = await autoPersona({ projectRoot, userDir, backend, timeoutMs: opts.timeoutMs ?? 120_000 });
|
|
689
862
|
if (r.action === 'error') {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
863
|
+
const detail = (r.errors && r.errors.length) ? `: ${r.errors.join('; ')}` : '';
|
|
864
|
+
const hint = /did not return within/.test(detail)
|
|
865
|
+
? ' — the Haiku classifier timed out; this is usually a transient API slowdown. Re-run `cmk persona generate` (the weekly curate pass also retries it automatically).'
|
|
866
|
+
: '';
|
|
867
|
+
logError(`cmk persona generate: error (${r.errorCategory ?? 'unknown'})${detail}${hint}`);
|
|
693
868
|
return;
|
|
694
869
|
}
|
|
695
870
|
const promoted = r.promoted?.length ?? 0;
|
|
@@ -813,9 +988,9 @@ function runRegisterCrons(options /* , command */) {
|
|
|
813
988
|
// Helper: quote a path for the platform's cron-line shell.
|
|
814
989
|
// Linux + macOS: double-quote (the cron line is single-quoted around the
|
|
815
990
|
// whole `echo '...'`; double-quotes inside are safe).
|
|
816
|
-
// Windows:
|
|
817
|
-
//
|
|
818
|
-
//
|
|
991
|
+
// Windows: registerCron execs schtasks with an args array (no shell), so this
|
|
992
|
+
// double-quoted /TR value is delivered verbatim — no escaping needed (Task 109
|
|
993
|
+
// / D-83). macOS strips these wrapping quotes before building the plist.
|
|
819
994
|
const quote = (s) => `"${s}"`;
|
|
820
995
|
|
|
821
996
|
const jobs = [
|
|
@@ -1006,40 +1181,44 @@ async function runRollCli(options /* , command */) {
|
|
|
1006
1181
|
}
|
|
1007
1182
|
}
|
|
1008
1183
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1184
|
+
// Task 114 (F-13): dep-injectable (projectRoot / harnessRoot / log / logError) so
|
|
1185
|
+
// the real-import CLI path is verifiable on real input WITHOUT touching the user's
|
|
1186
|
+
// ~/.claude. Defaults are unchanged for production. Returns the core result.
|
|
1187
|
+
export async function runImportAnthropicMemory(options = {}) {
|
|
1188
|
+
const projectRoot = options?.projectRoot ?? resolvePath(process.cwd());
|
|
1189
|
+
const log = options?.log ?? console.log;
|
|
1190
|
+
const logError = options?.logError ?? console.error;
|
|
1011
1191
|
const dryRun = options?.dryRun === true;
|
|
1012
1192
|
const acceptAll = options?.yes === true;
|
|
1193
|
+
// options.importFn is a test seam (default = the real core) so the error +
|
|
1194
|
+
// catch branches below — unreachable via normal input — are coverable.
|
|
1195
|
+
const importFn = options?.importFn ?? importAnthropicMemory;
|
|
1013
1196
|
try {
|
|
1014
|
-
|
|
1015
|
-
const r = await importAnthropicMemory({ projectRoot, dryRun, acceptAll });
|
|
1197
|
+
const r = await importFn({ projectRoot, dryRun, acceptAll, harnessRoot: options?.harnessRoot });
|
|
1016
1198
|
if (r.action === 'error') {
|
|
1017
|
-
|
|
1199
|
+
logError(`cmk import-anthropic-memory: error — ${(r.errors ?? []).join('; ')}`);
|
|
1018
1200
|
process.exitCode = 2;
|
|
1019
|
-
return;
|
|
1201
|
+
return r;
|
|
1020
1202
|
}
|
|
1021
1203
|
if (r.reason === 'no-source') {
|
|
1022
|
-
|
|
1023
|
-
return;
|
|
1204
|
+
log(`cmk import-anthropic-memory: no Anthropic auto-memory found at ${r.sourcePath}`);
|
|
1205
|
+
return r;
|
|
1024
1206
|
}
|
|
1025
1207
|
if (r.mode === 'dry-run') {
|
|
1026
|
-
|
|
1027
|
-
for (const p of r.proposals) {
|
|
1028
|
-
|
|
1029
|
-
}
|
|
1030
|
-
return;
|
|
1208
|
+
log(`cmk import-anthropic-memory: dry-run — ${r.proposals.length} proposal(s), ${r.skipped} duplicate(s) skipped`);
|
|
1209
|
+
for (const p of r.proposals) log(` + ${p.id}: ${p.text}`);
|
|
1210
|
+
return r;
|
|
1031
1211
|
}
|
|
1032
1212
|
if (r.mode === 'requires-confirmation') {
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
for (const p of r.proposals) {
|
|
1036
|
-
|
|
1037
|
-
}
|
|
1038
|
-
return;
|
|
1213
|
+
log(`cmk import-anthropic-memory: ${r.proposals.length} proposal(s) ready to apply.`);
|
|
1214
|
+
log(' Re-run with --yes to apply, or --dry-run to inspect.');
|
|
1215
|
+
for (const p of r.proposals) log(` + ${p.id}: ${p.text}`);
|
|
1216
|
+
return r;
|
|
1039
1217
|
}
|
|
1040
|
-
|
|
1218
|
+
log(`cmk import-anthropic-memory: applied ${r.accepted} proposal(s), skipped ${r.skipped} duplicate(s)`);
|
|
1219
|
+
return r;
|
|
1041
1220
|
} catch (err) {
|
|
1042
|
-
|
|
1221
|
+
logError(`cmk import-anthropic-memory: unexpected error: ${err?.message ?? err}`);
|
|
1043
1222
|
process.exitCode = 2;
|
|
1044
1223
|
}
|
|
1045
1224
|
}
|
|
@@ -1149,8 +1328,12 @@ async function runCompress(options /* , command */) {
|
|
|
1149
1328
|
|
|
1150
1329
|
async function runMcpDispatch(childName) {
|
|
1151
1330
|
if (childName === 'serve') {
|
|
1152
|
-
|
|
1153
|
-
|
|
1331
|
+
// Claude Code sets CLAUDE_PROJECT_DIR in the spawned MCP server's environment
|
|
1332
|
+
// to the project root (code.claude.com/docs/en/mcp). Prefer it over cwd so the
|
|
1333
|
+
// server indexes the right project even when Claude Code launches it with a
|
|
1334
|
+
// different working directory. Falls back to cwd for a manual `cmk mcp serve`.
|
|
1335
|
+
const projectRoot = resolvePath(process.env.CLAUDE_PROJECT_DIR ?? process.cwd());
|
|
1336
|
+
const userDir = process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
|
|
1154
1337
|
// ALL logs to stderr per design §10.1; stdout is reserved for
|
|
1155
1338
|
// JSON-RPC messages handled by the SDK's StdioServerTransport.
|
|
1156
1339
|
// Don't console.log() anything before/during the server's run.
|
|
@@ -1187,43 +1370,44 @@ async function runQueueDispatch(childName) {
|
|
|
1187
1370
|
* canonical kit usage). User-tier / language-tier conflicts queues
|
|
1188
1371
|
* can be added when the kit's CLI gains explicit `--tier` selection.
|
|
1189
1372
|
*/
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
proposedId,
|
|
1201
|
-
proposedText,
|
|
1202
|
-
proposedTrust,
|
|
1203
|
-
existingId,
|
|
1204
|
-
existingText,
|
|
1205
|
-
existingTrust,
|
|
1206
|
-
similarity,
|
|
1207
|
-
}) => {
|
|
1208
|
-
console.log('');
|
|
1209
|
-
console.log('─── pending conflict ──────────────────────────────────────');
|
|
1210
|
-
console.log(`existing (${existingId}, trust=${existingTrust}): ${existingText}`);
|
|
1211
|
-
console.log(`proposed (${proposedId}, trust=${proposedTrust}): ${proposedText}`);
|
|
1212
|
-
console.log(`similarity: ${Number(similarity).toFixed(4)}`);
|
|
1373
|
+
// Task 113 (F-9): conflict prompter LOGIC extracted as a factory over `ask`
|
|
1374
|
+
// (see buildReviewPrompter) so it's unit-testable without stdin.
|
|
1375
|
+
export function buildConflictPrompter({ ask, log }) {
|
|
1376
|
+
const VALID = new Set(['keep-old', 'keep-new', 'merge-both', 'skip']);
|
|
1377
|
+
return async ({ proposedId, proposedText, proposedTrust, existingId, existingText, existingTrust, similarity }) => {
|
|
1378
|
+
log('');
|
|
1379
|
+
log('─── pending conflict ──────────────────────────────────────');
|
|
1380
|
+
log(`existing (${existingId}, trust=${existingTrust}): ${existingText}`);
|
|
1381
|
+
log(`proposed (${proposedId}, trust=${proposedTrust}): ${proposedText}`);
|
|
1382
|
+
log(`similarity: ${Number(similarity).toFixed(4)}`);
|
|
1213
1383
|
let decision = '';
|
|
1214
|
-
while (!
|
|
1215
|
-
const answer = await
|
|
1216
|
-
` [keep-old / keep-new / merge-both / skip]: `,
|
|
1217
|
-
);
|
|
1384
|
+
while (!VALID.has(decision)) {
|
|
1385
|
+
const answer = await ask(` [keep-old / keep-new / merge-both / skip]: `);
|
|
1218
1386
|
decision = String(answer).trim();
|
|
1219
|
-
if (!
|
|
1220
|
-
|
|
1221
|
-
` unknown answer "${decision}" — please type one of: keep-old, keep-new, merge-both, skip`,
|
|
1222
|
-
);
|
|
1387
|
+
if (!VALID.has(decision)) {
|
|
1388
|
+
log(` unknown answer "${decision}" — please type one of: keep-old, keep-new, merge-both, skip`);
|
|
1223
1389
|
}
|
|
1224
1390
|
}
|
|
1225
1391
|
return decision;
|
|
1226
1392
|
};
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Task 113 (F-9): dep-injectable (see runQueueReview). Defaults unchanged for prod;
|
|
1396
|
+
// a test injects { projectRoot, prompter, log, logError } to drive a real keep-old
|
|
1397
|
+
// / keep-new / merge-both resolution end-to-end without stdin. Returns the result.
|
|
1398
|
+
export async function runQueueConflicts(opts = {}) {
|
|
1399
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
1400
|
+
const log = opts.log ?? console.log;
|
|
1401
|
+
const logError = opts.logError ?? console.error;
|
|
1402
|
+
|
|
1403
|
+
let rl = null;
|
|
1404
|
+
let prompter = opts.prompter;
|
|
1405
|
+
if (!prompter) {
|
|
1406
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1407
|
+
const askOnce = (q) =>
|
|
1408
|
+
new Promise((resolve) => rl.question(q, (answer) => resolve(answer)));
|
|
1409
|
+
prompter = buildConflictPrompter({ ask: askOnce, log });
|
|
1410
|
+
}
|
|
1227
1411
|
|
|
1228
1412
|
// merge-both wiring (Task 25b — closes Task 25's cross-layer
|
|
1229
1413
|
// composition gap). The proposed bullet from the conflict queue
|
|
@@ -1269,34 +1453,35 @@ async function runQueueConflicts() {
|
|
|
1269
1453
|
idB: proposedId,
|
|
1270
1454
|
});
|
|
1271
1455
|
if (result.action === 'error') {
|
|
1272
|
-
|
|
1456
|
+
logError(
|
|
1273
1457
|
`cmk queue conflicts: merge-both for ${existingId} + ${proposedId} failed: ${result.errors.join('; ')}`,
|
|
1274
1458
|
);
|
|
1275
1459
|
} else {
|
|
1276
|
-
|
|
1277
|
-
` merge-both → ${existingId} + ${proposedId} merged into ${result.id}`,
|
|
1278
|
-
);
|
|
1460
|
+
log(` merge-both → ${existingId} + ${proposedId} merged into ${result.id}`);
|
|
1279
1461
|
}
|
|
1280
1462
|
};
|
|
1281
1463
|
|
|
1464
|
+
// opts.resolve test seam (default = the real resolver) — see runQueueReview.
|
|
1465
|
+
const resolve = opts.resolve ?? resolveConflictQueue;
|
|
1282
1466
|
try {
|
|
1283
|
-
const result = await
|
|
1467
|
+
const result = await resolve({
|
|
1284
1468
|
tier: 'P',
|
|
1285
|
-
projectRoot
|
|
1469
|
+
projectRoot,
|
|
1286
1470
|
prompter,
|
|
1287
1471
|
mergeFn,
|
|
1288
1472
|
});
|
|
1289
1473
|
if (result.action === 'error') {
|
|
1290
|
-
for (const e of result.errors)
|
|
1474
|
+
for (const e of result.errors) logError(`cmk queue conflicts: ${e}`);
|
|
1291
1475
|
process.exitCode = 2;
|
|
1292
|
-
return;
|
|
1476
|
+
return result;
|
|
1293
1477
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1478
|
+
log('');
|
|
1479
|
+
log(
|
|
1296
1480
|
`cmk queue conflicts: ${result.resolved} resolved (${result.kept_old} kept-old, ${result.kept_new} kept-new, ${result.merged} merged), ${result.skipped} skipped`,
|
|
1297
1481
|
);
|
|
1482
|
+
return result;
|
|
1298
1483
|
} finally {
|
|
1299
|
-
rl.close();
|
|
1484
|
+
if (rl) rl.close();
|
|
1300
1485
|
}
|
|
1301
1486
|
}
|
|
1302
1487
|
|
|
@@ -1309,59 +1494,74 @@ async function runQueueConflicts() {
|
|
|
1309
1494
|
*
|
|
1310
1495
|
* Resolves the PROJECT tier's review queue (the canonical kit usage).
|
|
1311
1496
|
*/
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
console.log(`ts: ${ts}`);
|
|
1326
|
-
console.log(`text: ${text}`);
|
|
1327
|
-
if (provenance) console.log(`prov: ${provenance.trim()}`);
|
|
1497
|
+
// Task 113 (F-9): the interactive prompter LOGIC (formatting + the validate-retry
|
|
1498
|
+
// loop) extracted as a factory over an injectable `ask`, so it's unit-testable
|
|
1499
|
+
// without a real readline/stdin — only the 2-line createInterface wrapper in the
|
|
1500
|
+
// runner stays an uncovered shim.
|
|
1501
|
+
export function buildReviewPrompter({ ask, log }) {
|
|
1502
|
+
const VALID = new Set(['promote', 'discard', 'skip']);
|
|
1503
|
+
return async ({ id, text, ts, provenance }) => {
|
|
1504
|
+
log('');
|
|
1505
|
+
log('─── pending review ────────────────────────────────────────');
|
|
1506
|
+
log(`id: ${id}`);
|
|
1507
|
+
log(`ts: ${ts}`);
|
|
1508
|
+
log(`text: ${text}`);
|
|
1509
|
+
if (provenance) log(`prov: ${provenance.trim()}`);
|
|
1328
1510
|
let decision = '';
|
|
1329
|
-
while (!
|
|
1330
|
-
const answer = await
|
|
1511
|
+
while (!VALID.has(decision)) {
|
|
1512
|
+
const answer = await ask(` [promote / discard / skip]: `);
|
|
1331
1513
|
decision = String(answer).trim();
|
|
1332
|
-
if (!
|
|
1333
|
-
|
|
1334
|
-
` unknown answer "${decision}" — please type one of: promote, discard, skip`,
|
|
1335
|
-
);
|
|
1514
|
+
if (!VALID.has(decision)) {
|
|
1515
|
+
log(` unknown answer "${decision}" — please type one of: promote, discard, skip`);
|
|
1336
1516
|
}
|
|
1337
1517
|
}
|
|
1338
1518
|
return decision;
|
|
1339
1519
|
};
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// Task 113 (F-9): dep-injectable so the CLI resolution path is verifiable on REAL
|
|
1523
|
+
// queued items. Defaults (readline over stdin + cwd + console) are unchanged for
|
|
1524
|
+
// production; a test injects { projectRoot, prompter, log, logError } to drive a
|
|
1525
|
+
// real promote/discard end-to-end without stdin. Returns the resolver result.
|
|
1526
|
+
export async function runQueueReview(opts = {}) {
|
|
1527
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
1528
|
+
const log = opts.log ?? console.log;
|
|
1529
|
+
const logError = opts.logError ?? console.error;
|
|
1340
1530
|
|
|
1531
|
+
// Build the interactive prompter only when the caller didn't inject one — so a
|
|
1532
|
+
// test never opens a real readline on stdin.
|
|
1533
|
+
let rl = null;
|
|
1534
|
+
let prompter = opts.prompter;
|
|
1535
|
+
if (!prompter) {
|
|
1536
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1537
|
+
const askOnce = (q) =>
|
|
1538
|
+
new Promise((resolve) => rl.question(q, (answer) => resolve(answer)));
|
|
1539
|
+
prompter = buildReviewPrompter({ ask: askOnce, log });
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// opts.resolve is a test seam (default = the real resolver) so the error-
|
|
1543
|
+
// handling branches below — unreachable via normal input since tier/prompter
|
|
1544
|
+
// are always valid here — are coverable (Task 113).
|
|
1545
|
+
const resolve = opts.resolve ?? resolveReviewQueue;
|
|
1341
1546
|
try {
|
|
1342
|
-
const result = await
|
|
1343
|
-
tier: 'P',
|
|
1344
|
-
projectRoot: process.cwd(),
|
|
1345
|
-
prompter,
|
|
1346
|
-
});
|
|
1547
|
+
const result = await resolve({ tier: 'P', projectRoot, prompter });
|
|
1347
1548
|
if (result.action === 'error') {
|
|
1348
|
-
for (const e of result.errors)
|
|
1549
|
+
for (const e of result.errors) logError(`cmk queue review: ${e}`);
|
|
1349
1550
|
process.exitCode = 2;
|
|
1350
|
-
return;
|
|
1551
|
+
return result;
|
|
1351
1552
|
}
|
|
1352
|
-
|
|
1353
|
-
|
|
1553
|
+
log('');
|
|
1554
|
+
log(
|
|
1354
1555
|
`cmk queue review: ${result.promoted} promoted, ${result.discarded} discarded, ${result.skipped} skipped${result.errors && result.errors.length ? `, ${result.errors.length} errored` : ''}`,
|
|
1355
1556
|
);
|
|
1356
1557
|
if (result.errors && result.errors.length) {
|
|
1357
1558
|
for (const err of result.errors) {
|
|
1358
|
-
|
|
1359
|
-
` error on ${err.id} (${err.decision}): ${err.errors.join('; ')}`,
|
|
1360
|
-
);
|
|
1559
|
+
logError(` error on ${err.id} (${err.decision}): ${err.errors.join('; ')}`);
|
|
1361
1560
|
}
|
|
1362
1561
|
}
|
|
1562
|
+
return result;
|
|
1363
1563
|
} finally {
|
|
1364
|
-
rl.close();
|
|
1564
|
+
if (rl) rl.close();
|
|
1365
1565
|
}
|
|
1366
1566
|
}
|
|
1367
1567
|
|
|
@@ -1430,7 +1630,7 @@ export const subcommands = [
|
|
|
1430
1630
|
name: 'remember',
|
|
1431
1631
|
description: 'capture a durable fact (Poison_Guard + home-path abstraction). Terse → a MEMORY.md bullet; RICH (--why/--how/--type) → a granular fact file with rationale (the safe way to capture richly).',
|
|
1432
1632
|
milestone: 24,
|
|
1433
|
-
argSpec: [{ flags: '
|
|
1633
|
+
argSpec: [{ flags: '[text...]', description: 'the fact to remember (omit when using --from-file)' }],
|
|
1434
1634
|
optionSpec: [
|
|
1435
1635
|
{ flags: '--tier <tier>', description: 'P (default; U/L are v0.1.x)' },
|
|
1436
1636
|
{ flags: '--trust <level>', description: 'high | medium | low (default: high)' },
|
|
@@ -1441,6 +1641,8 @@ export const subcommands = [
|
|
|
1441
1641
|
{ flags: '--type <type>', description: 'rich: feedback | project | reference | user (default: feedback)' },
|
|
1442
1642
|
{ flags: '--title <text>', description: 'rich: a short title (also the fact-file slug)' },
|
|
1443
1643
|
{ flags: '--links <a,b>', description: 'rich: related fact names for [[cross-links]]' },
|
|
1644
|
+
{ flags: '--from-file <path>', description: 'rich: read the fact as a JSON object from a file — shell-safe (content never touches argv; the safe way to capture backtick/quote-heavy Why/How). JSON keys: text (required), why, how, type, title, links. Self-contained — other flags are ignored.' },
|
|
1645
|
+
{ flags: '--json', description: 'rich: read the fact as a JSON object from stdin (pipe-safe, shell-safe) — same JSON keys as --from-file' },
|
|
1444
1646
|
],
|
|
1445
1647
|
action: runRemember,
|
|
1446
1648
|
},
|
|
@@ -1459,6 +1661,41 @@ export const subcommands = [
|
|
|
1459
1661
|
],
|
|
1460
1662
|
action: runSearch,
|
|
1461
1663
|
},
|
|
1664
|
+
{
|
|
1665
|
+
name: 'get',
|
|
1666
|
+
description: 'fetch full observation bodies + provenance by ID (parity with the mk_get MCP tool)',
|
|
1667
|
+
milestone: 108,
|
|
1668
|
+
argSpec: [{ flags: '<ids...>', description: 'one or more citation IDs (e.g. P-S79MJHFN)' }],
|
|
1669
|
+
action: runGet,
|
|
1670
|
+
},
|
|
1671
|
+
{
|
|
1672
|
+
name: 'timeline',
|
|
1673
|
+
description: 'sequential context around an anchor observation — N before + N after (mk_timeline parity)',
|
|
1674
|
+
milestone: 108,
|
|
1675
|
+
argSpec: [{ flags: '<anchor>', description: 'citation ID to anchor the timeline on' }],
|
|
1676
|
+
optionSpec: [
|
|
1677
|
+
{ flags: '--before <n>', description: 'observations before the anchor (default: 5)' },
|
|
1678
|
+
{ flags: '--after <n>', description: 'observations after the anchor (default: 5)' },
|
|
1679
|
+
],
|
|
1680
|
+
action: runTimeline,
|
|
1681
|
+
},
|
|
1682
|
+
{
|
|
1683
|
+
name: 'cite',
|
|
1684
|
+
description: 'render the canonical Markdown citation link for an observation (mk_cite parity)',
|
|
1685
|
+
milestone: 108,
|
|
1686
|
+
argSpec: [{ flags: '<id>', description: 'citation ID' }],
|
|
1687
|
+
action: runCite,
|
|
1688
|
+
},
|
|
1689
|
+
{
|
|
1690
|
+
name: 'recent-activity',
|
|
1691
|
+
description: 'list recent observation changes within a time window (mk_recent_activity parity)',
|
|
1692
|
+
milestone: 108,
|
|
1693
|
+
optionSpec: [
|
|
1694
|
+
{ flags: '--window <w>', description: '1h | 24h | 7d (default: 24h)' },
|
|
1695
|
+
{ flags: '--limit <n>', description: 'max results (default: 20)' },
|
|
1696
|
+
],
|
|
1697
|
+
action: runRecentActivity,
|
|
1698
|
+
},
|
|
1462
1699
|
{
|
|
1463
1700
|
name: 'reindex',
|
|
1464
1701
|
description: 'rebuild the markdown INDEX.md pointer index for the project tier',
|