@lh8ppl/claude-memory-kit 0.1.2 → 0.2.1
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 +12 -5
- package/bin/cmk-auto-extract.mjs +13 -0
- package/bin/cmk-compress-session.mjs +31 -17
- package/bin/cmk-inject-context.mjs +12 -2
- package/bin/cmk-weekly-curate.mjs +14 -2
- package/package.json +3 -2
- package/src/audit-log.mjs +6 -0
- package/src/auto-drain.mjs +59 -0
- package/src/auto-extract.mjs +117 -6
- package/src/auto-persona.mjs +544 -0
- package/src/bullet-lookup.mjs +59 -0
- package/src/capture-turn.mjs +54 -0
- package/src/compress-session.mjs +6 -8
- package/src/compressor.mjs +19 -4
- package/src/conflict-queue.mjs +8 -1
- package/src/daily-distill.mjs +19 -11
- package/src/doctor.mjs +74 -23
- package/src/forget.mjs +14 -0
- package/src/graduate-session.mjs +65 -0
- package/src/graduation.mjs +179 -0
- package/src/inject-context.mjs +206 -59
- package/src/install.mjs +52 -7
- package/src/lessons-promote.mjs +137 -0
- package/src/memory-write.mjs +2 -2
- package/src/native-memory.mjs +98 -0
- package/src/persona-portability.mjs +253 -0
- package/src/provenance.mjs +23 -5
- package/src/read-hook-stdin.mjs +47 -0
- package/src/register-crons.mjs +17 -8
- package/src/scratchpad.mjs +247 -19
- package/src/session-end-tasks.mjs +127 -0
- package/src/settings-hooks.mjs +33 -3
- package/src/subcommands.mjs +339 -16
- package/src/weekly-curate.mjs +53 -6
- package/src/write-fact.mjs +14 -0
- package/template/.claude/skills/memory-write/SKILL.md +47 -88
- package/template/.gitignore.fragment +6 -0
- package/template/CLAUDE.md.template +15 -9
- package/template/local/machine-paths.md.template +1 -12
- package/template/local/overrides.md.template +1 -11
- package/template/project/MEMORY.md.template +5 -26
- package/template/project/SOUL.md.template +1 -10
- package/template/user/fragments/INDEX.md.template +1 -1
- package/template/.claude/hooks/pre-tool-memory.js +0 -78
- package/template/.claude/hooks/transcript-capture.js +0 -69
- package/template/.claude/settings.json +0 -27
- package/template/support/scripts/auto-extract-memory.sh +0 -102
- package/template/support/scripts/refresh-distill-timestamp.py +0 -35
- package/template/support/scripts/register-crons.py +0 -242
- package/template/support/scripts/run-daily-distill.sh +0 -67
- package/template/support/scripts/run-weekly-curate.sh +0 -58
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// lessons-promote.mjs — `cmk lessons promote <id>`: move a project-tier fact
|
|
2
|
+
// into the user tier (LESSONS.md by default) through the SAFE promote path.
|
|
3
|
+
//
|
|
4
|
+
// This is the EXPLICIT half of the wedge (D-27/D-30): a project observation the
|
|
5
|
+
// user wants to carry across ALL their projects. Before this, the subcommand
|
|
6
|
+
// was a stub and the memory-write skill hand-edited LESSONS.md — bypassing
|
|
7
|
+
// home-path sanitization, Poison_Guard, dedup, and the audit trail.
|
|
8
|
+
//
|
|
9
|
+
// It routes through promoteCandidatesToUserTier (D-13) at confidence:'high'
|
|
10
|
+
// (an explicit user action is the highest-trust signal there is, so it promotes
|
|
11
|
+
// rather than queuing). NEVER hand-edit ~/.claude-memory-kit/*.md.
|
|
12
|
+
//
|
|
13
|
+
// Composes on: forget.resolveFact (read a project fact by id) +
|
|
14
|
+
// auto-persona.promoteCandidatesToUserTier (safe user-tier write).
|
|
15
|
+
|
|
16
|
+
import { resolveFact } from './forget.mjs';
|
|
17
|
+
import { promoteCandidatesToUserTier } from './auto-persona.mjs';
|
|
18
|
+
import { findBulletScratchpad } from './bullet-lookup.mjs';
|
|
19
|
+
import { errorResult, notFoundResult } from './result-shapes.mjs';
|
|
20
|
+
|
|
21
|
+
const VALID_TARGETS = new Set(['USER.md', 'HABITS.md', 'LESSONS.md']);
|
|
22
|
+
|
|
23
|
+
// Sensible default landing section per target. Each name passes
|
|
24
|
+
// auto-persona's SAFE_SECTION_NAME guard; ensureSectionExists creates it if the
|
|
25
|
+
// user's scaffold doesn't already have it.
|
|
26
|
+
const DEFAULT_SECTION = Object.freeze({
|
|
27
|
+
'LESSONS.md': 'Cross-Project Lessons',
|
|
28
|
+
'HABITS.md': 'Working Style',
|
|
29
|
+
'USER.md': 'Profile',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Promote a project-tier fact to the user tier through the safe path.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} opts
|
|
36
|
+
* @param {string} opts.id citation id of the project fact (e.g. P-XXXXXXXX)
|
|
37
|
+
* @param {string} opts.projectRoot project root (for resolving the source fact)
|
|
38
|
+
* @param {string} opts.userDir user-tier dir (~/.claude-memory-kit)
|
|
39
|
+
* @param {string} [opts.to] target user-tier file (default LESSONS.md)
|
|
40
|
+
* @param {string} [opts.section] landing section (default per-target)
|
|
41
|
+
* @param {string} [opts.now] ISO timestamp override (tests)
|
|
42
|
+
* @returns {{action:string, id?:string, target?:string, section?:string, ...}}
|
|
43
|
+
*/
|
|
44
|
+
export function lessonsPromote({ id, projectRoot, userDir, to = 'LESSONS.md', section, now } = {}) {
|
|
45
|
+
if (!userDir) {
|
|
46
|
+
return errorResult({ category: 'schema', errors: ['userDir is required (lessons promote writes to the user tier)'] });
|
|
47
|
+
}
|
|
48
|
+
if (!VALID_TARGETS.has(to)) {
|
|
49
|
+
return errorResult({ category: 'schema', errors: [`invalid target '${to}' (expected USER.md | HABITS.md | LESSONS.md)`] });
|
|
50
|
+
}
|
|
51
|
+
// `lessons promote` carries a PROJECT observation to the user tier. Reject a
|
|
52
|
+
// U-tier id (already user-tier — nothing to promote) and an L-tier id (local
|
|
53
|
+
// is gitignored/machine-specific on purpose — promoting it to the
|
|
54
|
+
// machine-global user tier would surface deliberately-unshared content in
|
|
55
|
+
// every project's persona). Source must be the committed project tier.
|
|
56
|
+
if (typeof id === 'string' && (id[0] === 'U' || id[0] === 'L')) {
|
|
57
|
+
return errorResult({
|
|
58
|
+
category: 'schema',
|
|
59
|
+
errors: [`lessons promote moves a PROJECT-tier (P-) fact; got a ${id[0]}-tier id '${id}'`],
|
|
60
|
+
id,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const found = resolveFact({ id, projectRoot, userDir });
|
|
65
|
+
if (found.state === 'not-found') {
|
|
66
|
+
// The id might be a scratchpad BULLET (the common `cmk search` mix-up):
|
|
67
|
+
// search surfaces bullet ids too, but promote carries FACTS. Say so.
|
|
68
|
+
const bulletIn = findBulletScratchpad(id, { projectRoot, userDir });
|
|
69
|
+
if (bulletIn) {
|
|
70
|
+
return notFoundResult({
|
|
71
|
+
errors: [
|
|
72
|
+
`'${id}' is a scratchpad bullet in ${bulletIn}, not a graduated fact — \`cmk lessons promote\` carries facts (in context/memory/) to the user tier. In \`cmk search\` output, pick an id whose location is a context/memory/*.md file, not a ${bulletIn}:NN bullet.`,
|
|
73
|
+
],
|
|
74
|
+
id,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return notFoundResult({ errors: [`no fact with id '${id}'`], id });
|
|
78
|
+
}
|
|
79
|
+
if (found.state === 'tombstoned') {
|
|
80
|
+
return notFoundResult({ errors: [`fact '${id}' is tombstoned (forgotten); cannot promote`], id });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A scratchpad bullet is single-line (the provenance HTML-comment must sit on
|
|
84
|
+
// the very next line). A RICH fact body is multi-line — `headline\n\n**Why:**
|
|
85
|
+
// …\n\n**How to apply:** …` — which writeBullet rejects outright (newlines
|
|
86
|
+
// break the 2-line bullet+comment shape). Flatten all whitespace to single
|
|
87
|
+
// spaces so the rule + its rationale promote as one well-formed bullet (the
|
|
88
|
+
// primary wedge case: an explicitly-captured rich architecture rule). The
|
|
89
|
+
// scratchpad byte cap still applies downstream via memoryWrite.
|
|
90
|
+
const text = (found.body ?? '').replace(/\s+/g, ' ').trim();
|
|
91
|
+
if (!text) {
|
|
92
|
+
return errorResult({ category: 'schema', errors: [`fact '${id}' has no body to promote`], id });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const candidate = {
|
|
96
|
+
target: to,
|
|
97
|
+
section: section || DEFAULT_SECTION[to],
|
|
98
|
+
text,
|
|
99
|
+
confidence: 'high', // explicit user action → clears the confidence gate (promotes, not queued)
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// trust:'high' + source:'user-explicit' — a user-attested promotion is durable
|
|
103
|
+
// (never aged out / auto-superseded by the maintenance passes — the 45.4
|
|
104
|
+
// invariant). The auto path leaves these at the default medium.
|
|
105
|
+
const res = promoteCandidatesToUserTier({
|
|
106
|
+
candidates: [candidate],
|
|
107
|
+
userDir,
|
|
108
|
+
now,
|
|
109
|
+
trust: 'high',
|
|
110
|
+
source: 'user-explicit',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const promotedHit = res.promoted.find((p) => p.target === to);
|
|
114
|
+
if (promotedHit) {
|
|
115
|
+
return { action: 'promoted', id, target: to, section: candidate.section, newId: promotedHit.id ?? null };
|
|
116
|
+
}
|
|
117
|
+
// A supersede is ALSO success: the promotion replaced an existing same-topic
|
|
118
|
+
// lesson with this updated one (common when the user re-promotes a refined rule).
|
|
119
|
+
const supersededHit = res.superseded.find((s) => s.target === to);
|
|
120
|
+
if (supersededHit) {
|
|
121
|
+
return { action: 'promoted', id, target: to, section: candidate.section, newId: supersededHit.newId, superseded: supersededHit.oldId };
|
|
122
|
+
}
|
|
123
|
+
// Routed to the conflict queue (e.g. it clashes with a hand-curated entry the
|
|
124
|
+
// kit won't silently overwrite) or otherwise didn't land — surface honestly.
|
|
125
|
+
const conflictHit = res.conflicts.find((q) => q.target === to);
|
|
126
|
+
if (conflictHit) {
|
|
127
|
+
return { action: 'queued', id, target: to, section: candidate.section, reason: 'conflict' };
|
|
128
|
+
}
|
|
129
|
+
const queuedHit = res.queued.find((q) => q.target === to);
|
|
130
|
+
return {
|
|
131
|
+
action: 'queued',
|
|
132
|
+
id,
|
|
133
|
+
target: to,
|
|
134
|
+
section: candidate.section,
|
|
135
|
+
reason: queuedHit?.reason ?? 'not-promoted',
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/memory-write.mjs
CHANGED
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
import { nowIso, appendAuditEntry, REASON_CODES } from './audit-log.mjs';
|
|
55
55
|
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
56
56
|
import { appendScratchpadBullet } from './scratchpad.mjs';
|
|
57
|
-
import { parseBulletProvenance } from './provenance.mjs';
|
|
57
|
+
import { parseBulletProvenance, isProvenanceCommentLine } from './provenance.mjs';
|
|
58
58
|
import { checkPoisonGuard, logPoisonGuardRejection } from './poison-guard.mjs';
|
|
59
59
|
import { detectConflicts, writeConflictEntry } from './conflict-queue.mjs';
|
|
60
60
|
import { sanitizeHomePaths } from './sanitize.mjs';
|
|
@@ -190,7 +190,7 @@ function findMatchingBullet({ lines, substring, sectionTitle }) {
|
|
|
190
190
|
const [, tier, idShort, bulletText] = m;
|
|
191
191
|
if (!bulletText.includes(substring)) continue;
|
|
192
192
|
const commentLine = lines[i + 1];
|
|
193
|
-
if (!
|
|
193
|
+
if (!isProvenanceCommentLine(commentLine)) continue;
|
|
194
194
|
return {
|
|
195
195
|
bulletIdx: i,
|
|
196
196
|
commentIdx: i + 1,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Native Anthropic Auto Memory coexistence (Task 60, ADR-0011).
|
|
2
|
+
//
|
|
3
|
+
// Claude Code ships its own Auto Memory (v2.1.59+, ON by default), writing
|
|
4
|
+
// machine-local `~/.claude/projects/<slug>/memory/` in the same shape the kit
|
|
5
|
+
// uses in-repo. With the kit installed BOTH inject at session start → context
|
|
6
|
+
// bloat. Per ADR-0011 the kit is ADDITIVE, not enforcing: the default is
|
|
7
|
+
// coexist (we never touch the user's setting); `cmk disable-native-memory`
|
|
8
|
+
// is a one-command, committable opt-in that writes `autoMemoryEnabled: false`
|
|
9
|
+
// into the project's `.claude/settings.json` (which travels with `git clone`,
|
|
10
|
+
// unlike the user-only `autoMemoryDirectory`). `cmk enable-native-memory`
|
|
11
|
+
// reverses it (explicit `true`).
|
|
12
|
+
//
|
|
13
|
+
// Public boundary:
|
|
14
|
+
// setNativeAutoMemory({ projectRoot, enabled })
|
|
15
|
+
// → { action: 'written' | 'unchanged', settingsPath, enabled }
|
|
16
|
+
// → errorResult({ category: SCHEMA }) when the existing file is unparseable
|
|
17
|
+
// (NEVER clobber a hand-broken file — surface it).
|
|
18
|
+
// getNativeAutoMemoryState({ projectRoot })
|
|
19
|
+
// → { state: 'enabled' | 'disabled' | 'default' | 'unknown', settingsPath }
|
|
20
|
+
// (`default` = key absent ⇒ Anthropic's default, which is ON.)
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
23
|
+
import { join, dirname } from 'node:path';
|
|
24
|
+
import { errorResult, ERROR_CATEGORIES } from './result-shapes.mjs';
|
|
25
|
+
|
|
26
|
+
const SETTINGS_REL = ['.claude', 'settings.json'];
|
|
27
|
+
|
|
28
|
+
export function nativeMemorySettingsPath(projectRoot) {
|
|
29
|
+
return join(projectRoot, ...SETTINGS_REL);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readSettings(settingsPath) {
|
|
33
|
+
if (!existsSync(settingsPath)) return { settings: {}, existed: false };
|
|
34
|
+
const raw = readFileSync(settingsPath, 'utf8');
|
|
35
|
+
return { settings: JSON.parse(raw), existed: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read the project's `.claude/settings.json` and report the native-memory
|
|
40
|
+
* state. `default` means the user has not set `autoMemoryEnabled` at all, so
|
|
41
|
+
* Anthropic's default (enabled) applies.
|
|
42
|
+
*/
|
|
43
|
+
export function getNativeAutoMemoryState({ projectRoot }) {
|
|
44
|
+
const settingsPath = nativeMemorySettingsPath(projectRoot);
|
|
45
|
+
if (!existsSync(settingsPath)) return { state: 'default', settingsPath };
|
|
46
|
+
let settings;
|
|
47
|
+
try {
|
|
48
|
+
({ settings } = readSettings(settingsPath));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { state: 'unknown', settingsPath, error: err?.message ?? String(err) };
|
|
51
|
+
}
|
|
52
|
+
const v = settings?.autoMemoryEnabled;
|
|
53
|
+
if (v === false) return { state: 'disabled', settingsPath };
|
|
54
|
+
if (v === true) return { state: 'enabled', settingsPath };
|
|
55
|
+
return { state: 'default', settingsPath };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The one-line `cmk install` heads-up about native-vs-kit coexistence
|
|
60
|
+
* (ADR-0011). Returns the note string when the heads-up is relevant (the user
|
|
61
|
+
* has NOT already opted out), or `null` when they've disabled native memory
|
|
62
|
+
* (no point nagging). Pure + trivially testable; runInstall just prints it.
|
|
63
|
+
*/
|
|
64
|
+
export function nativeMemoryInstallNote(projectRoot) {
|
|
65
|
+
if (getNativeAutoMemoryState({ projectRoot }).state === 'disabled') return null;
|
|
66
|
+
return " Note: Claude Code's native Auto Memory keeps running alongside the kit (both fill over time). For one lean memory layer, run `cmk disable-native-memory`.";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Write `autoMemoryEnabled: <enabled>` into the project's committable
|
|
71
|
+
* `.claude/settings.json`. Idempotent (a no-op write reports `unchanged` and
|
|
72
|
+
* leaves the file byte-identical). Preserves every sibling key. On a parse
|
|
73
|
+
* error of an existing file, returns an error WITHOUT overwriting.
|
|
74
|
+
*/
|
|
75
|
+
export function setNativeAutoMemory({ projectRoot, enabled }) {
|
|
76
|
+
const settingsPath = nativeMemorySettingsPath(projectRoot);
|
|
77
|
+
|
|
78
|
+
let settings = {};
|
|
79
|
+
if (existsSync(settingsPath)) {
|
|
80
|
+
try {
|
|
81
|
+
({ settings } = readSettings(settingsPath));
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return errorResult({
|
|
84
|
+
category: ERROR_CATEGORIES.SCHEMA,
|
|
85
|
+
errors: [`${settingsPath} parse error: ${err?.message ?? err}`],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (settings.autoMemoryEnabled === enabled) {
|
|
91
|
+
return { action: 'unchanged', settingsPath, enabled };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
settings.autoMemoryEnabled = enabled;
|
|
95
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
96
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
97
|
+
return { action: 'written', settingsPath, enabled };
|
|
98
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// persona-portability.mjs — Task 72. `cmk persona export` / `cmk persona import`.
|
|
2
|
+
//
|
|
3
|
+
// The persona (the user tier — USER/HABITS/LESSONS + fragments/) follows the
|
|
4
|
+
// HUMAN, not the repo (design §1.1, D-27): it lives machine-local at
|
|
5
|
+
// ~/.claude-memory-kit and is deliberately OUT of any project repo, because
|
|
6
|
+
// committing it would leak your working-style to teammates who clone. So
|
|
7
|
+
// portability across YOUR machines is per-human, not per-repo: export the user
|
|
8
|
+
// tier to one OS-agnostic bundle file, carry it (USB / private repo / Dropbox),
|
|
9
|
+
// import it on the other machine.
|
|
10
|
+
//
|
|
11
|
+
// This is the EXPLICIT primitive (decided in Task 72): no merge, no collision
|
|
12
|
+
// control. Import OVERWRITES, backing up anything it would replace so nothing is
|
|
13
|
+
// lost. The seamless auto-merge path (`cmk persona sync <git-url>`, Task 72.2)
|
|
14
|
+
// is deferred — git handles transport + conflicts there.
|
|
15
|
+
//
|
|
16
|
+
// Bundle format: a single self-describing JSON file (no tar/zip dependency, and
|
|
17
|
+
// human-inspectable). `{ kind, version, exportedAt, fileCount, files: { relpath:
|
|
18
|
+
// content } }`.
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
existsSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
readdirSync,
|
|
26
|
+
statSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
unlinkSync,
|
|
29
|
+
} from 'node:fs';
|
|
30
|
+
import { join, dirname } from 'node:path';
|
|
31
|
+
import { reindex } from './reindex.mjs';
|
|
32
|
+
import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
33
|
+
|
|
34
|
+
const BUNDLE_KIND = 'cmk-persona-bundle';
|
|
35
|
+
const BUNDLE_VERSION = 1;
|
|
36
|
+
|
|
37
|
+
// The persona surface to bundle: the 3 user-tier scratchpads + a settings
|
|
38
|
+
// override, plus the fact-store / queue subdirs (walked recursively). Everything
|
|
39
|
+
// else under the user tier is machine-local + regenerable and is NEVER bundled —
|
|
40
|
+
// runtime locks/audit (.locks/), the FTS cache (.index/), and prior import
|
|
41
|
+
// backups (.import-backups/). Using an explicit allow-list (rather than
|
|
42
|
+
// "everything minus excludes") guarantees a new runtime dir can't leak in later.
|
|
43
|
+
const TOP_LEVEL_FILES = ['USER.md', 'HABITS.md', 'LESSONS.md', 'settings.json'];
|
|
44
|
+
const SUBDIRS = ['fragments', 'queues'];
|
|
45
|
+
|
|
46
|
+
function walkFiles(absDir, relPrefix, out) {
|
|
47
|
+
for (const name of readdirSync(absDir)) {
|
|
48
|
+
const abs = join(absDir, name);
|
|
49
|
+
const rel = relPrefix ? `${relPrefix}/${name}` : name;
|
|
50
|
+
if (statSync(abs).isDirectory()) walkFiles(abs, rel, out);
|
|
51
|
+
else out.push({ rel, abs });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Export the user tier to a portable bundle file.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} opts
|
|
59
|
+
* @param {string} opts.userDir - the user-tier root to export.
|
|
60
|
+
* @param {string} opts.outFile - where to write the bundle.
|
|
61
|
+
* @param {string} [opts.now] - ISO timestamp override (tests).
|
|
62
|
+
* @returns {{action:'exported'|'error', path?, fileCount?, bytes?, errorCategory?, errors?}}
|
|
63
|
+
*/
|
|
64
|
+
export function exportPersona({ userDir, outFile, now } = {}) {
|
|
65
|
+
if (!userDir || !existsSync(userDir)) {
|
|
66
|
+
return {
|
|
67
|
+
action: 'error',
|
|
68
|
+
errorCategory: 'not-found',
|
|
69
|
+
errors: [`user tier not found at ${userDir} — run \`cmk init-user-tier\` first`],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (!outFile) {
|
|
73
|
+
return { action: 'error', errorCategory: 'schema', errors: ['no output file given'] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const files = {};
|
|
77
|
+
for (const f of TOP_LEVEL_FILES) {
|
|
78
|
+
const abs = join(userDir, f);
|
|
79
|
+
if (existsSync(abs) && statSync(abs).isFile()) {
|
|
80
|
+
files[f] = readFileSync(abs, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const sub of SUBDIRS) {
|
|
84
|
+
const absSub = join(userDir, sub);
|
|
85
|
+
if (existsSync(absSub) && statSync(absSub).isDirectory()) {
|
|
86
|
+
const collected = [];
|
|
87
|
+
walkFiles(absSub, sub, collected);
|
|
88
|
+
for (const { rel, abs } of collected) files[rel] = readFileSync(abs, 'utf8');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const bundle = {
|
|
93
|
+
kind: BUNDLE_KIND,
|
|
94
|
+
version: BUNDLE_VERSION,
|
|
95
|
+
exportedAt: now ?? nowIso(),
|
|
96
|
+
fileCount: Object.keys(files).length,
|
|
97
|
+
files,
|
|
98
|
+
};
|
|
99
|
+
const json = JSON.stringify(bundle, null, 2);
|
|
100
|
+
mkdirSync(dirname(outFile), { recursive: true });
|
|
101
|
+
writeFileSync(outFile, json, 'utf8');
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
action: 'exported',
|
|
105
|
+
path: outFile,
|
|
106
|
+
fileCount: bundle.fileCount,
|
|
107
|
+
bytes: Buffer.byteLength(json, 'utf8'),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Read + validate a bundle file. Returns { bundle } on success, or { error: <the
|
|
112
|
+
// error result> } on any problem. Kept separate so importPersona stays simple.
|
|
113
|
+
function readAndValidateBundle(inFile) {
|
|
114
|
+
const err = (msg, cat = 'schema') => ({ error: { action: 'error', errorCategory: cat, errors: [msg] } });
|
|
115
|
+
if (!inFile || !existsSync(inFile)) return err(`bundle not found at ${inFile}`, 'not-found');
|
|
116
|
+
let bundle;
|
|
117
|
+
try {
|
|
118
|
+
bundle = JSON.parse(readFileSync(inFile, 'utf8'));
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return err(`bundle is not valid JSON: ${e.message}`);
|
|
121
|
+
}
|
|
122
|
+
if (bundle?.kind !== BUNDLE_KIND) return err(`not a cmk persona bundle (kind: ${bundle?.kind ?? 'missing'})`);
|
|
123
|
+
if (bundle.version !== BUNDLE_VERSION) {
|
|
124
|
+
return err(`unsupported bundle version ${bundle.version} (this cmk supports v${BUNDLE_VERSION})`);
|
|
125
|
+
}
|
|
126
|
+
if (!bundle.files || typeof bundle.files !== 'object') return err('bundle carries no files');
|
|
127
|
+
return { bundle };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Undo a partial import: remove the files we created, restore the ones we moved
|
|
131
|
+
// aside. Best-effort per item — a leaked backup is recoverable; a clobbered live
|
|
132
|
+
// file is not, so we always try to put the originals back.
|
|
133
|
+
function rollbackImport(created, renamed) {
|
|
134
|
+
for (const dest of created) {
|
|
135
|
+
try {
|
|
136
|
+
if (existsSync(dest)) unlinkSync(dest);
|
|
137
|
+
} catch {
|
|
138
|
+
/* best-effort */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const { dest, bkp } of renamed) {
|
|
142
|
+
try {
|
|
143
|
+
if (existsSync(bkp)) {
|
|
144
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
145
|
+
renameSync(bkp, dest);
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
/* best-effort — the backup copy still exists for manual recovery */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Apply the bundle's files TRANSACTIONALLY (the Task-91 rollback discipline):
|
|
154
|
+
// back up every existing target first, then write all files, and if ANY write
|
|
155
|
+
// fails partway, roll the whole thing back so a mid-import disk/permission error
|
|
156
|
+
// never leaves the persona half-applied. Returns the count of backed-up files;
|
|
157
|
+
// throws on unrecoverable failure (after rolling back).
|
|
158
|
+
function applyBundleAtomic(userDir, files, backupRoot) {
|
|
159
|
+
const renamed = []; // {dest, bkp} — existing files moved aside
|
|
160
|
+
const created = []; // dest — files that did NOT exist before (new this import)
|
|
161
|
+
try {
|
|
162
|
+
for (const rel of Object.keys(files)) {
|
|
163
|
+
const dest = join(userDir, ...rel.split('/'));
|
|
164
|
+
if (existsSync(dest)) {
|
|
165
|
+
const bkp = join(backupRoot, ...rel.split('/'));
|
|
166
|
+
mkdirSync(dirname(bkp), { recursive: true });
|
|
167
|
+
renameSync(dest, bkp);
|
|
168
|
+
renamed.push({ dest, bkp });
|
|
169
|
+
} else {
|
|
170
|
+
created.push(dest);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
174
|
+
const dest = join(userDir, ...rel.split('/'));
|
|
175
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
176
|
+
writeFileSync(dest, content, 'utf8');
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
rollbackImport(created, renamed);
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
return renamed.length;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Best-effort user-tier reindex — `cmk search` works immediately after import;
|
|
186
|
+
// `cmk reindex` can rebuild later if this throws.
|
|
187
|
+
function tryReindexUserTier(userDir) {
|
|
188
|
+
try {
|
|
189
|
+
reindex({ tier: 'U', userDir, warn: () => {} });
|
|
190
|
+
return true;
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Door 4: one operational audit entry (the user tier was bulk-rewritten). The
|
|
197
|
+
// individual facts keep their own provenance inside the bundled fact files; this
|
|
198
|
+
// records the import event + where overwritten files were backed up. Best-effort.
|
|
199
|
+
function writeImportAudit(userDir, { ts, fileCount, backedUp, backupRoot, inFile }) {
|
|
200
|
+
try {
|
|
201
|
+
appendAuditEntry(userDir, {
|
|
202
|
+
ts,
|
|
203
|
+
action: 'persona-imported',
|
|
204
|
+
tier: 'U',
|
|
205
|
+
id: 'persona-bundle',
|
|
206
|
+
reasonCode: REASON_CODES.PERSONA_IMPORTED,
|
|
207
|
+
paths: backedUp > 0 ? { archive: backupRoot } : undefined,
|
|
208
|
+
extra: { fileCount, backedUp, source: inFile },
|
|
209
|
+
});
|
|
210
|
+
} catch {
|
|
211
|
+
/* never fail the import because the audit write failed */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Import a persona bundle onto this machine's user tier. OVERWRITES, backing up
|
|
217
|
+
* any file it would replace to <userDir>/.import-backups/<ts>/ first (no data
|
|
218
|
+
* loss; transactional — rolls back on a mid-import failure). Rebuilds the
|
|
219
|
+
* user-tier search index from the imported fragments.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} opts
|
|
222
|
+
* @param {string} opts.userDir - the target user-tier root.
|
|
223
|
+
* @param {string} opts.inFile - the bundle to import.
|
|
224
|
+
* @param {string} [opts.now] - ISO timestamp override (tests).
|
|
225
|
+
* @returns {{action:'imported'|'error', fileCount?, backedUp?, backupPath?, reindexed?, errorCategory?, errors?}}
|
|
226
|
+
*/
|
|
227
|
+
export function importPersona({ userDir, inFile, now } = {}) {
|
|
228
|
+
const { bundle, error } = readAndValidateBundle(inFile);
|
|
229
|
+
if (error) return error;
|
|
230
|
+
|
|
231
|
+
const ts = now ?? nowIso();
|
|
232
|
+
mkdirSync(userDir, { recursive: true });
|
|
233
|
+
const backupRoot = join(userDir, '.import-backups', ts.replace(/[:.]/g, '-'));
|
|
234
|
+
|
|
235
|
+
let backedUp;
|
|
236
|
+
try {
|
|
237
|
+
backedUp = applyBundleAtomic(userDir, bundle.files, backupRoot);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return { action: 'error', errorCategory: 'io', errors: [`import failed and was rolled back: ${err?.message ?? err}`] };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const fileCount = Object.keys(bundle.files).length;
|
|
243
|
+
const reindexed = tryReindexUserTier(userDir);
|
|
244
|
+
writeImportAudit(userDir, { ts, fileCount, backedUp, backupRoot, inFile });
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
action: 'imported',
|
|
248
|
+
fileCount,
|
|
249
|
+
backedUp,
|
|
250
|
+
backupPath: backedUp > 0 ? backupRoot : null,
|
|
251
|
+
reindexed,
|
|
252
|
+
};
|
|
253
|
+
}
|
package/src/provenance.mjs
CHANGED
|
@@ -75,8 +75,27 @@ const BULLET_RE = new RegExp(
|
|
|
75
75
|
`^- \\((${ID_PATTERN.source.replace(/^\^/, '').replace(/\$$/, '')})\\)\\s+(.+)$`,
|
|
76
76
|
);
|
|
77
77
|
|
|
78
|
-
//
|
|
79
|
-
|
|
78
|
+
// Is `line` a single-line HTML comment (the shape the kit writes provenance
|
|
79
|
+
// in: ` <!-- source: …, trust: … -->`), tolerant of leading indentation?
|
|
80
|
+
// String-scanning, NOT a regex, on purpose: a `/<!--.*-->/` regex trips
|
|
81
|
+
// CodeQL js/bad-tag-filter (`.` skips newlines; ignores the `--!>` end-tag
|
|
82
|
+
// variant). Our provenance comments are always single-line, so a literal
|
|
83
|
+
// prefix/suffix check is equivalent AND clears the alert (the PR #72
|
|
84
|
+
// pattern). Shared so scratchpad / memory-write / inject-context don't each
|
|
85
|
+
// re-roll the flagged regex.
|
|
86
|
+
export function isProvenanceCommentLine(line) {
|
|
87
|
+
if (typeof line !== 'string') return false;
|
|
88
|
+
const t = line.trim();
|
|
89
|
+
return t.length >= 7 && t.startsWith('<!--') && t.endsWith('-->');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Strip the `<!--` (4 chars) / `-->` (3 chars) delimiters from a line already
|
|
93
|
+
// confirmed by isProvenanceCommentLine. Slicing, not a regex, for the same
|
|
94
|
+
// js/bad-tag-filter reason.
|
|
95
|
+
function stripCommentDelimiters(line) {
|
|
96
|
+
const t = line.trim();
|
|
97
|
+
return t.slice(4, t.length - 3);
|
|
98
|
+
}
|
|
80
99
|
|
|
81
100
|
function validateBulletInput({ id, text, provenance }) {
|
|
82
101
|
const errors = [];
|
|
@@ -183,10 +202,9 @@ export function writeBullet(opts = {}) {
|
|
|
183
202
|
}
|
|
184
203
|
|
|
185
204
|
export function parseBulletProvenance(line) {
|
|
186
|
-
if (
|
|
187
|
-
if (!COMMENT_RE.test(line)) return null;
|
|
205
|
+
if (!isProvenanceCommentLine(line)) return null;
|
|
188
206
|
|
|
189
|
-
const inner = line
|
|
207
|
+
const inner = stripCommentDelimiters(line);
|
|
190
208
|
const fields = {};
|
|
191
209
|
for (const part of inner.split(',')) {
|
|
192
210
|
const idx = part.indexOf(':');
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Hook-stdin drain (Task: cmk-compress-session manual-invocation hang fix).
|
|
2
|
+
//
|
|
3
|
+
// The SessionEnd hook bins (npm: packages/cli/bin/; plugin: plugin/bin/) drain
|
|
4
|
+
// stdin so Claude Code's hook pipe closes cleanly. The PAYLOAD is discarded —
|
|
5
|
+
// the bins read their state from disk (sessions/now.md, the fact corpus), not
|
|
6
|
+
// from the hook JSON — so the read exists ONLY to drain the pipe.
|
|
7
|
+
//
|
|
8
|
+
// The hazard this module fixes: `readFileSync(0, 'utf8')` BLOCKS until stdin
|
|
9
|
+
// reaches EOF. When the bin is run as a real hook, Claude Code pipes the JSON
|
|
10
|
+
// payload and closes the pipe → EOF arrives → the read returns instantly. But
|
|
11
|
+
// when the bin is run MANUALLY without redirecting stdin (e.g. the v0.2.0
|
|
12
|
+
// cut-gate B7 probe: `cmk-compress-session | Out-Null` pipes stdout but leaves
|
|
13
|
+
// stdin on the interactive console), the console never sends EOF, so the read
|
|
14
|
+
// blocks forever — before ANY of the bin's body runs. The 60s SessionEnd hook
|
|
15
|
+
// ceiling then kills it (exit 124, zero stderr), which looked for days like a
|
|
16
|
+
// graduation/Haiku/lock hang but was the wrapper never executing. See
|
|
17
|
+
// DECISION-LOG 2026-06-06 FIX/RESOLVED.
|
|
18
|
+
//
|
|
19
|
+
// Boundary: readHookStdin({ isTTY }) → string. When stdin is an interactive TTY
|
|
20
|
+
// (no piped payload to drain, and reading would block), return '' without
|
|
21
|
+
// touching the fd. Otherwise drain the fd as before. isTTY is INJECTED by the
|
|
22
|
+
// caller (`process.stdin.isTTY`) so the function is pure and unit-testable
|
|
23
|
+
// without a real terminal.
|
|
24
|
+
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Drain the hook payload from stdin without blocking an interactive console.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} [opts]
|
|
31
|
+
* @param {boolean} [opts.isTTY] - the caller's `process.stdin.isTTY`. When
|
|
32
|
+
* truthy, stdin is an interactive terminal: there is no piped payload and a
|
|
33
|
+
* blocking read would hang, so we return '' and never touch the fd.
|
|
34
|
+
* @param {number} [opts.fd=0] - the stdin file descriptor (override for tests).
|
|
35
|
+
* @returns {string} the drained payload, or '' for a TTY / unconnected stdin.
|
|
36
|
+
*/
|
|
37
|
+
export function readHookStdin({ isTTY, fd = 0 } = {}) {
|
|
38
|
+
// Interactive console → no payload to drain and readFileSync(fd) would block
|
|
39
|
+
// waiting for an EOF the terminal never sends. Treat as empty.
|
|
40
|
+
if (isTTY) return '';
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(fd, 'utf8');
|
|
43
|
+
} catch {
|
|
44
|
+
// stdin not connected (e.g. fd closed) — fine; the hook still proceeds.
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/register-crons.mjs
CHANGED
|
@@ -121,13 +121,17 @@ function buildWindowsSchtasks({ command, entryName, hour, minute, dayOfWeek }) {
|
|
|
121
121
|
// already exists (idempotency primitive). /RL LIMITED (not HIGHEST)
|
|
122
122
|
// because daily distill doesn't need admin.
|
|
123
123
|
const time = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
124
|
+
// The command is wrapped in `/TR "${command}"`. cmd.exe/schtasks treats
|
|
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.)
|
|
131
135
|
// Task 34: /SC WEEKLY /D <SUN|MON|...> for weekly cadence; /SC DAILY otherwise.
|
|
132
136
|
let scheduleFlags;
|
|
133
137
|
if (dayOfWeek !== undefined && dayOfWeek !== null) {
|
|
@@ -139,7 +143,7 @@ function buildWindowsSchtasks({ command, entryName, hour, minute, dayOfWeek }) {
|
|
|
139
143
|
} else {
|
|
140
144
|
scheduleFlags = '/SC DAILY';
|
|
141
145
|
}
|
|
142
|
-
return `schtasks /Create /TN "${entryName}" ${scheduleFlags} /ST ${time} /TR "${
|
|
146
|
+
return `schtasks /Create /TN "${entryName}" ${scheduleFlags} /ST ${time} /TR "${command}" /RL LIMITED /F`;
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
/**
|
|
@@ -165,6 +169,11 @@ export function registerCron(opts = {}) {
|
|
|
165
169
|
// cron command needs to either escape POSIX-style ('\'') or
|
|
166
170
|
// we extend this helper with a sanitizer (v0.1.x candidate).
|
|
167
171
|
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)');
|
|
168
177
|
}
|
|
169
178
|
const entryName = opts.entryName ?? CRON_ENTRY_NAME;
|
|
170
179
|
if (!entryName || typeof entryName !== 'string' || !/^[a-zA-Z0-9_.-]+$/.test(entryName)) {
|