@lh8ppl/claude-memory-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/bin/cmk-compress-lazy.mjs +59 -0
  2. package/bin/cmk-daily-distill.mjs +67 -0
  3. package/bin/cmk-weekly-curate.mjs +56 -0
  4. package/bin/cmk.mjs +12 -0
  5. package/package.json +50 -0
  6. package/src/audit-log.mjs +103 -0
  7. package/src/auto-extract.mjs +742 -0
  8. package/src/capture-prompt.mjs +61 -0
  9. package/src/capture-turn.mjs +273 -0
  10. package/src/claude-md.mjs +212 -0
  11. package/src/compress-session.mjs +349 -0
  12. package/src/compressor.mjs +376 -0
  13. package/src/conflict-queue.mjs +796 -0
  14. package/src/cooldown.mjs +61 -0
  15. package/src/daily-distill.mjs +252 -0
  16. package/src/doctor.mjs +528 -0
  17. package/src/forget.mjs +335 -0
  18. package/src/frontmatter.mjs +73 -0
  19. package/src/import-anthropic-memory.mjs +266 -0
  20. package/src/index-db.mjs +154 -0
  21. package/src/index-rebuild.mjs +597 -0
  22. package/src/index.mjs +90 -0
  23. package/src/inject-context.mjs +484 -0
  24. package/src/install.mjs +327 -0
  25. package/src/lazy-compress.mjs +326 -0
  26. package/src/lock-discipline.mjs +166 -0
  27. package/src/mcp-server.mjs +498 -0
  28. package/src/memory-write.mjs +565 -0
  29. package/src/merge-facts.mjs +213 -0
  30. package/src/observe-edit.mjs +87 -0
  31. package/src/platform-commands.mjs +138 -0
  32. package/src/poison-guard.mjs +245 -0
  33. package/src/privacy.mjs +21 -0
  34. package/src/provenance.mjs +217 -0
  35. package/src/register-crons.mjs +354 -0
  36. package/src/reindex.mjs +134 -0
  37. package/src/repair.mjs +316 -0
  38. package/src/result-shapes.mjs +155 -0
  39. package/src/review-queue.mjs +345 -0
  40. package/src/roll.mjs +115 -0
  41. package/src/scratchpad.mjs +335 -0
  42. package/src/search.mjs +311 -0
  43. package/src/subcommands.mjs +1252 -0
  44. package/src/tier-paths.mjs +74 -0
  45. package/src/transcripts.mjs +234 -0
  46. package/src/trust.mjs +226 -0
  47. package/src/weekly-curate.mjs +454 -0
  48. package/src/write-fact.mjs +205 -0
  49. package/template/.claude/hooks/pre-tool-memory.js +78 -0
  50. package/template/.claude/hooks/transcript-capture.js +69 -0
  51. package/template/.claude/settings.json +27 -0
  52. package/template/.claude/skills/memory-write/SKILL.md +117 -0
  53. package/template/.gitignore.fragment +12 -0
  54. package/template/CLAUDE.md.template +49 -0
  55. package/template/docs/journey/journey-log.md.template +292 -0
  56. package/template/local/machine-paths.md.template +37 -0
  57. package/template/local/overrides.md.template +36 -0
  58. package/template/project/.index/.gitkeep +0 -0
  59. package/template/project/MEMORY.md.template +47 -0
  60. package/template/project/SOUL.md.template +35 -0
  61. package/template/project/memory/INDEX.md.template +47 -0
  62. package/template/project/memory/archive/superseded/.gitkeep +0 -0
  63. package/template/project/memory/archive/tombstones/.gitkeep +0 -0
  64. package/template/project/queues/.gitkeep +0 -0
  65. package/template/project/sessions/.gitkeep +0 -0
  66. package/template/project/transcripts/.gitkeep +0 -0
  67. package/template/support/cron-jobs/daily-memory-distill.md +15 -0
  68. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
  69. package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
  70. package/template/support/milvus-deploy/README.md +57 -0
  71. package/template/support/milvus-deploy/docker-compose.yml +66 -0
  72. package/template/support/scripts/auto-extract-memory.sh +102 -0
  73. package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
  74. package/template/support/scripts/refresh-distill-timestamp.py +35 -0
  75. package/template/support/scripts/register-crons.py +242 -0
  76. package/template/support/scripts/run-daily-distill.sh +67 -0
  77. package/template/support/scripts/run-weekly-curate.sh +58 -0
  78. package/template/user/HABITS.md.template +18 -0
  79. package/template/user/LESSONS.md.template +18 -0
  80. package/template/user/USER.md.template +18 -0
  81. package/template/user/fragments/INDEX.md.template +23 -0
@@ -0,0 +1,74 @@
1
+ // Shared tier-path resolution for every cmk module that touches the 3-tier
2
+ // filesystem. Per the Layer-2 review's I1 finding, this used to live in
3
+ // triplicated form inside write-fact / reindex / forget / merge-facts —
4
+ // any future change to the user-tier default path had to update four files.
5
+ //
6
+ // Public surface (used by all packages/cli/src/*.mjs that touch facts):
7
+ // VALID_TIERS — Set of tier prefixes (U, P, L)
8
+ // ID_PATTERN — RegExp for the kit's custom-alphabet citation ID format
9
+ // resolveTierRoot({tier, projectRoot, userDir}) → absolute path
10
+ // resolveFactDir(tier, tierRoot) → absolute path to <memory|fragments>
11
+
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ export const VALID_TIERS = new Set(['U', 'P', 'L']);
16
+
17
+ // Matches IDs produced by @lh8ppl/cmk-canonicalize.generateId(). Tier prefix +
18
+ // 8 chars from the custom 32-char base32 alphabet that excludes the six
19
+ // ambiguous chars (0, O, 1, l, I, 8). See design §3.1.
20
+ export const ID_PATTERN = /^[PUL]-[2345679ABCDEFGHJKLMNPQRSTUVWXYZa]{8}$/;
21
+
22
+ export function resolveTierRoot({ tier, projectRoot, userDir }) {
23
+ if (tier === 'P') return join(projectRoot ?? process.cwd(), 'context');
24
+ if (tier === 'L') return join(projectRoot ?? process.cwd(), 'context.local');
25
+ return (
26
+ userDir ??
27
+ process.env.MEMORY_KIT_USER_DIR ??
28
+ join(homedir(), '.claude-memory-kit')
29
+ );
30
+ }
31
+
32
+ export function resolveFactDir(tier, tierRoot) {
33
+ return tier === 'U' ? join(tierRoot, 'fragments') : join(tierRoot, 'memory');
34
+ }
35
+
36
+ // Scratchpads live at the tier root (no subdir). Filename is the scratchpad
37
+ // canonical name (e.g. 'MEMORY.md', 'USER.md'). Per design §1.1 + §2.1.
38
+ export function resolveScratchpadPath({ tier, scratchpad, projectRoot, userDir }) {
39
+ return join(resolveTierRoot({ tier, projectRoot, userDir }), scratchpad);
40
+ }
41
+
42
+ // Allow-list of scratchpads per tier, per design §1.1.
43
+ export const SCRATCHPADS_BY_TIER = Object.freeze({
44
+ P: new Set(['SOUL.md', 'MEMORY.md']),
45
+ L: new Set(['machine-paths.md', 'overrides.md']),
46
+ U: new Set(['USER.md', 'HABITS.md', 'LESSONS.md']),
47
+ });
48
+
49
+ // Hardcoded scratchpad cap defaults (chars). Tunable via settings.json per
50
+ // design §2.1; defaults are the fallback when no settings.json override exists.
51
+ export const DEFAULT_SCRATCHPAD_CAPS = Object.freeze({
52
+ 'SOUL.md': 1800,
53
+ 'MEMORY.md': 2500,
54
+ 'USER.md': 1375, // Hermes-verified
55
+ 'HABITS.md': 1800,
56
+ 'LESSONS.md': 1800,
57
+ 'machine-paths.md': 1500,
58
+ 'overrides.md': 1500,
59
+ });
60
+
61
+ // Canonical 3 fixed sections per scratchpad (Task 14 / design §2.1). Each seed
62
+ // template MUST emit exactly these `## <section>` headings; appendScratchpadBullet
63
+ // callers MUST pass one of these exact section names. The test in
64
+ // tests/cli-seed-templates.test.js asserts that every shipped seed contains
65
+ // all 3 documented sections.
66
+ export const SCRATCHPAD_DOCUMENTED_SECTIONS = Object.freeze({
67
+ 'SOUL.md': ['Tone and Disposition', 'Operating Defaults', 'Boundary Rules'],
68
+ 'MEMORY.md': ['Active Threads', 'Environment Notes', 'Pending Decisions'],
69
+ 'USER.md': ['About', 'Preferences', 'Working Style'],
70
+ 'HABITS.md': ['Iteration Cadence', 'Destructive Operations', 'Communication Style'],
71
+ 'LESSONS.md': ['Tooling Lessons', 'Process Lessons', 'Anti-patterns'],
72
+ 'machine-paths.md': ['Tool Paths', 'Project Paths', 'Misc Paths'],
73
+ 'overrides.md': ['Tool Overrides', 'Behavior Overrides', 'Path Overrides'],
74
+ });
@@ -0,0 +1,234 @@
1
+ // `cmk transcripts extract` (Task 38b, T-032).
2
+ //
3
+ // Two public boundaries:
4
+ //
5
+ // extractTranscript({inputPath, outputPath, includeThinking?})
6
+ // → {turnsKept, rawLines, outputSize, sessionStart, sessionEnd}
7
+ //
8
+ // discoverSessions({slug?, sessionUuidSuffix?, sinceIso?, harnessRoot?})
9
+ // → Array<{slug, sessionId, jsonlPath, mtimeMs}>
10
+ //
11
+ // Promotes the existing `scripts/extract-session-transcript.mjs` (kit-
12
+ // dev utility) to a user-facing CLI subcommand. Lets users mine months
13
+ // of pre-kit conversation history at
14
+ // `~/.claude/projects/<slug>/<uuid>.jsonl` into clean markdown corpora
15
+ // they can curate from.
16
+ //
17
+ // Filter contract (matches the scripts version + tasks.md 38.6):
18
+ // - Keep user + assistant text content
19
+ // - Drop tool_use / tool_result / image blocks
20
+ // - Drop thinking blocks UNLESS --include-thinking
21
+ // - Strip <system-reminder>, <command-name>, <command-message>,
22
+ // <command-args>, <ide_opened_file>, <ide_selection>,
23
+ // <local-command-stdout>, <local-command-stderr> from user text
24
+ //
25
+ // Per design §16.8 + tasks.md 38b (38.6–38.9).
26
+
27
+ import {
28
+ existsSync,
29
+ readFileSync,
30
+ readdirSync,
31
+ statSync,
32
+ writeFileSync,
33
+ mkdirSync,
34
+ } from 'node:fs';
35
+ import { homedir } from 'node:os';
36
+ import { dirname, join } from 'node:path';
37
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
38
+
39
+ const SYSTEM_REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
40
+ const COMMAND_NAME_RE = /<command-name>[\s\S]*?<\/command-name>/g;
41
+ const COMMAND_MESSAGE_RE = /<command-message>[\s\S]*?<\/command-message>/g;
42
+ const COMMAND_ARGS_RE = /<command-args>[\s\S]*?<\/command-args>/g;
43
+ const IDE_OPENED_RE = /<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g;
44
+ const IDE_SELECTION_RE = /<ide_selection>[\s\S]*?<\/ide_selection>/g;
45
+ const LOCAL_STDOUT_RE = /<local-command-stdout>[\s\S]*?<\/local-command-stdout>/g;
46
+ const LOCAL_STDERR_RE = /<local-command-stderr>[\s\S]*?<\/local-command-stderr>/g;
47
+
48
+ const UUID_RE = /^([0-9a-f-]{36})\.jsonl$/i;
49
+
50
+ function stripHarnessNoise(text) {
51
+ return String(text)
52
+ .replace(SYSTEM_REMINDER_RE, '')
53
+ .replace(COMMAND_NAME_RE, '')
54
+ .replace(COMMAND_MESSAGE_RE, '')
55
+ .replace(COMMAND_ARGS_RE, '')
56
+ .replace(IDE_OPENED_RE, '')
57
+ .replace(IDE_SELECTION_RE, '')
58
+ .replace(LOCAL_STDOUT_RE, '')
59
+ .replace(LOCAL_STDERR_RE, '')
60
+ .replace(/\n{3,}/g, '\n\n')
61
+ .trim();
62
+ }
63
+
64
+ function extractText(content, includeThinking) {
65
+ if (typeof content === 'string') return content;
66
+ if (!Array.isArray(content)) return '';
67
+ const parts = [];
68
+ for (const block of content) {
69
+ if (!block || typeof block !== 'object') continue;
70
+ if (block.type === 'text' && typeof block.text === 'string') {
71
+ parts.push(block.text);
72
+ } else if (
73
+ includeThinking &&
74
+ block.type === 'thinking' &&
75
+ typeof block.thinking === 'string'
76
+ ) {
77
+ parts.push(`\n[thinking]\n${block.thinking}\n[/thinking]\n`);
78
+ }
79
+ // Drop tool_use, tool_result, image, etc.
80
+ }
81
+ return parts.join('\n');
82
+ }
83
+
84
+ /**
85
+ * Extract a single session jsonl into a clean markdown transcript.
86
+ *
87
+ * @returns {object} {turnsKept, rawLines, outputSize, sessionStart, sessionEnd}
88
+ */
89
+ export function extractTranscript({
90
+ inputPath,
91
+ outputPath,
92
+ includeThinking = false,
93
+ } = {}) {
94
+ const errors = [];
95
+ if (!inputPath) errors.push('inputPath: required');
96
+ if (!outputPath) errors.push('outputPath: required');
97
+ if (errors.length > 0) {
98
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
99
+ }
100
+ if (!existsSync(inputPath)) {
101
+ return errorResult({
102
+ category: ERROR_CATEGORIES.NOT_FOUND,
103
+ errors: [`inputPath does not exist: ${inputPath}`],
104
+ });
105
+ }
106
+ const raw = readFileSync(inputPath, 'utf8');
107
+ const lines = raw.split('\n').filter(Boolean);
108
+
109
+ const turns = [];
110
+ let firstTimestamp = null;
111
+ let lastTimestamp = null;
112
+ let turnIndex = 0;
113
+
114
+ for (const line of lines) {
115
+ let obj;
116
+ try {
117
+ obj = JSON.parse(line);
118
+ } catch {
119
+ continue;
120
+ }
121
+ const t = obj.type;
122
+ if (t !== 'user' && t !== 'assistant') continue;
123
+ const msg = obj.message || {};
124
+ const role = msg.role || t;
125
+ const ts = obj.timestamp;
126
+ if (ts) {
127
+ if (!firstTimestamp) firstTimestamp = ts;
128
+ lastTimestamp = ts;
129
+ }
130
+ const text = stripHarnessNoise(extractText(msg.content, !!includeThinking));
131
+ if (!text) continue;
132
+ turnIndex += 1;
133
+ turns.push({ n: turnIndex, role, ts: ts || null, text });
134
+ }
135
+
136
+ const sessionStart = firstTimestamp ? new Date(firstTimestamp).toISOString() : null;
137
+ const sessionEnd = lastTimestamp ? new Date(lastTimestamp).toISOString() : null;
138
+
139
+ const out = [];
140
+ out.push('# Session transcript');
141
+ out.push('');
142
+ out.push(`- **Source jsonl**: \`${inputPath}\``);
143
+ out.push(`- **Spans**: ${sessionStart ?? '?'} → ${sessionEnd ?? '?'}`);
144
+ out.push(`- **Turns kept**: ${turns.length} (raw lines: ${lines.length})`);
145
+ out.push(
146
+ `- **Filters applied**: tool calls + tool results + thinking blocks + system reminders + IDE state + slash-command annotations removed${includeThinking ? ' (EXCEPT thinking, retained per --include-thinking)' : ''}`,
147
+ );
148
+ out.push('');
149
+ out.push('---');
150
+ out.push('');
151
+
152
+ for (const turn of turns) {
153
+ const tsLabel = turn.ts
154
+ ? new Date(turn.ts).toISOString().replace('T', ' ').replace(/\..+/, ' UTC')
155
+ : '';
156
+ out.push(`## Turn ${turn.n} — ${turn.role}${tsLabel ? ` — ${tsLabel}` : ''}`);
157
+ out.push('');
158
+ out.push(turn.text);
159
+ out.push('');
160
+ }
161
+
162
+ mkdirSync(dirname(outputPath), { recursive: true });
163
+ writeFileSync(outputPath, out.join('\n'), 'utf8');
164
+ const outputSize = statSync(outputPath).size;
165
+ return {
166
+ action: 'completed',
167
+ turnsKept: turns.length,
168
+ rawLines: lines.length,
169
+ outputSize,
170
+ sessionStart,
171
+ sessionEnd,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Discover Claude Code session jsonls under ~/.claude/projects/.
177
+ *
178
+ * @param {object} opts
179
+ * @param {string} [opts.slug] filter to this slug only
180
+ * @param {string} [opts.sessionUuidSuffix] match the session jsonl whose basename ends with this UUID suffix
181
+ * @param {string} [opts.sinceIso] filter by mtime >= this ISO date
182
+ * @param {string} [opts.harnessRoot] override (test injection) for ~/.claude/projects
183
+ * @returns {Array<{slug, sessionId, jsonlPath, mtimeMs}>}
184
+ */
185
+ export function discoverSessions({
186
+ slug,
187
+ sessionUuidSuffix,
188
+ sinceIso,
189
+ harnessRoot,
190
+ } = {}) {
191
+ const root = harnessRoot ?? join(homedir(), '.claude', 'projects');
192
+ if (!existsSync(root)) return [];
193
+ const slugsToScan = slug ? [slug] : safeReaddir(root).filter((s) => isDir(join(root, s)));
194
+ const sinceMs = sinceIso ? new Date(sinceIso).getTime() : null;
195
+ const results = [];
196
+ for (const s of slugsToScan) {
197
+ const dir = join(root, s);
198
+ if (!isDir(dir)) continue;
199
+ for (const name of safeReaddir(dir)) {
200
+ const m = UUID_RE.exec(name);
201
+ if (!m) continue;
202
+ const jsonlPath = join(dir, name);
203
+ let mtimeMs;
204
+ try {
205
+ mtimeMs = statSync(jsonlPath).mtimeMs;
206
+ } catch {
207
+ continue;
208
+ }
209
+ if (sinceMs !== null && mtimeMs < sinceMs) continue;
210
+ const sessionId = m[1];
211
+ if (sessionUuidSuffix && !sessionId.endsWith(sessionUuidSuffix)) continue;
212
+ results.push({ slug: s, sessionId, jsonlPath, mtimeMs });
213
+ }
214
+ }
215
+ // Sort newest first
216
+ results.sort((a, b) => b.mtimeMs - a.mtimeMs);
217
+ return results;
218
+ }
219
+
220
+ function safeReaddir(path) {
221
+ try {
222
+ return readdirSync(path);
223
+ } catch {
224
+ return [];
225
+ }
226
+ }
227
+
228
+ function isDir(path) {
229
+ try {
230
+ return statSync(path).isDirectory();
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
package/src/trust.mjs ADDED
@@ -0,0 +1,226 @@
1
+ // Trust override (Task 15, T-013). Last Layer 3 module.
2
+ //
3
+ // Public boundary: overrideTrust({id, level, ...}) → result.
4
+ // Locates an id in BOTH the granular per-fact archive (YAML frontmatter)
5
+ // AND scratchpad-bullet HTML-comment provenance lines, then updates the
6
+ // `trust:` field in every matched location. The two trust values are
7
+ // independent — a fact file and a scratchpad bullet sharing an id can
8
+ // drift; this command brings them back in sync at the new level.
9
+ //
10
+ // Uses shared modules per CLAUDE.md "Shared modules" rule:
11
+ // tier-paths.mjs — VALID_TIERS, ID_PATTERN, SCRATCHPADS_BY_TIER,
12
+ // resolveTierRoot, resolveFactDir
13
+ // frontmatter.mjs — parse + format (for fact-file YAML round-trip)
14
+ // audit-log.mjs — appendAuditEntry, nowIso, REASON_CODES.TRUST_CHANGE
15
+ // result-shapes.mjs — ERROR_CATEGORIES, errorResult, notFoundResult
16
+
17
+ import {
18
+ existsSync,
19
+ readdirSync,
20
+ readFileSync,
21
+ statSync,
22
+ writeFileSync,
23
+ } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import {
26
+ VALID_TIERS,
27
+ ID_PATTERN,
28
+ SCRATCHPADS_BY_TIER,
29
+ resolveTierRoot,
30
+ resolveFactDir,
31
+ } from './tier-paths.mjs';
32
+ import { parse, format } from './frontmatter.mjs';
33
+ import { readBullet, writeBullet } from './provenance.mjs';
34
+ import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
35
+ import {
36
+ ERROR_CATEGORIES,
37
+ errorResult,
38
+ notFoundResult,
39
+ } from './result-shapes.mjs';
40
+
41
+ const VALID_TRUST_LEVELS = new Set(['high', 'medium', 'low']);
42
+
43
+ function validateOptions(opts) {
44
+ const errors = [];
45
+ if (!opts.id || typeof opts.id !== 'string') {
46
+ errors.push('id: required, non-empty string');
47
+ } else if (!ID_PATTERN.test(opts.id)) {
48
+ errors.push(
49
+ `id: must match the kit's citation-ID format (got ${JSON.stringify(opts.id)})`,
50
+ );
51
+ }
52
+ if (!opts.level || typeof opts.level !== 'string') {
53
+ errors.push('level: required, one of high/medium/low');
54
+ } else if (!VALID_TRUST_LEVELS.has(opts.level)) {
55
+ errors.push(
56
+ `level: must be one of high/medium/low (got ${JSON.stringify(opts.level)})`,
57
+ );
58
+ }
59
+ return errors;
60
+ }
61
+
62
+ function listFactFiles(factDir) {
63
+ if (!existsSync(factDir)) return [];
64
+ const out = [];
65
+ for (const entry of readdirSync(factDir, { withFileTypes: true })) {
66
+ if (!entry.isFile()) continue;
67
+ if (!entry.name.endsWith('.md')) continue;
68
+ if (entry.name === 'INDEX.md') continue;
69
+ out.push(entry.name);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ function findFactFileById(factDir, id) {
75
+ for (const filename of listFactFiles(factDir)) {
76
+ const path = join(factDir, filename);
77
+ if (!statSync(path).isFile()) continue;
78
+ const { frontmatter } = parse(readFileSync(path, 'utf8'));
79
+ if (frontmatter?.id === id) {
80
+ return { path, frontmatter };
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function updateFactFileTrust(path, newLevel) {
87
+ const text = readFileSync(path, 'utf8');
88
+ const { frontmatter, body } = parse(text);
89
+ if (!frontmatter) return null; // shouldn't happen — caller already matched
90
+ const priorTrust = frontmatter.trust ?? null;
91
+ const updated = { ...frontmatter, trust: newLevel };
92
+ writeFileSync(path, format({ frontmatter: updated, body }), 'utf8');
93
+ return { priorTrust };
94
+ }
95
+
96
+ // Locate the bullet with matching leading id and rewrite both its lines via
97
+ // the canonical provenance.mjs reader+writer pair. PR-15 review finding:
98
+ // the prior `bulletLine.includes('(' + id + ')')` match was too loose — it
99
+ // also matched bullets whose BODY TEXT referenced another fact's id (e.g.
100
+ // "see also (P-XYZ)"). readBullet returns the leading id deterministically,
101
+ // so `parsed.id === id` only matches the actual target. Bonus: this closes
102
+ // the read/write-path asymmetry self-flagged in the PR description —
103
+ // overrideTrust now round-trips through the same provenance pair that
104
+ // appendScratchpadBullet uses.
105
+ function updateScratchpadBulletTrust(path, id, newLevel) {
106
+ const text = readFileSync(path, 'utf8');
107
+ const lines = text.split('\n');
108
+ for (let i = 0; i < lines.length - 1; i++) {
109
+ const parsed = readBullet({
110
+ bulletLine: lines[i],
111
+ commentLine: lines[i + 1],
112
+ });
113
+ if (!parsed || parsed.id !== id) continue;
114
+ const priorTrust = parsed.provenance.trust;
115
+ const result = writeBullet({
116
+ id: parsed.id,
117
+ text: parsed.text,
118
+ provenance: { ...parsed.provenance, trust: newLevel },
119
+ });
120
+ if (result.action !== 'formatted') {
121
+ throw new Error(
122
+ 'overrideTrust: writeBullet failed for re-formatted bullet — ' +
123
+ (result.errors?.join('; ') ?? 'unknown'),
124
+ );
125
+ }
126
+ const [newBullet, newComment] = result.lines.split('\n');
127
+ lines[i] = newBullet;
128
+ lines[i + 1] = newComment;
129
+ writeFileSync(path, lines.join('\n'), 'utf8');
130
+ return { priorTrust };
131
+ }
132
+ return null;
133
+ }
134
+
135
+ function listScratchpadsForTier(tier, tierRoot) {
136
+ const allowed = SCRATCHPADS_BY_TIER[tier];
137
+ const out = [];
138
+ for (const scratchpad of allowed) {
139
+ out.push({ name: scratchpad, path: join(tierRoot, scratchpad) });
140
+ }
141
+ return out;
142
+ }
143
+
144
+ export function overrideTrust(opts = {}) {
145
+ const errors = validateOptions(opts);
146
+ if (errors.length > 0) {
147
+ return errorResult({
148
+ category: ERROR_CATEGORIES.SCHEMA,
149
+ errors,
150
+ });
151
+ }
152
+
153
+ const { id, level, projectRoot, userDir, actor, now } = opts;
154
+ const tier = id[0];
155
+ if (!VALID_TIERS.has(tier)) {
156
+ return errorResult({
157
+ category: ERROR_CATEGORIES.SCHEMA,
158
+ errors: [`id tier prefix invalid: ${tier}`],
159
+ });
160
+ }
161
+
162
+ const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
163
+ const factDir = resolveFactDir(tier, tierRoot);
164
+ const updatedLocations = [];
165
+
166
+ // 1. Try fact file (granular archive)
167
+ const factMatch = findFactFileById(factDir, id);
168
+ if (factMatch) {
169
+ const r = updateFactFileTrust(factMatch.path, level);
170
+ if (r) {
171
+ updatedLocations.push({
172
+ type: 'fact',
173
+ path: factMatch.path,
174
+ priorTrust: r.priorTrust,
175
+ });
176
+ }
177
+ }
178
+
179
+ // 2. Try every scratchpad in the tier
180
+ for (const { path: scratchpadPath } of listScratchpadsForTier(tier, tierRoot)) {
181
+ if (!existsSync(scratchpadPath)) continue;
182
+ const r = updateScratchpadBulletTrust(scratchpadPath, id, level);
183
+ if (r) {
184
+ updatedLocations.push({
185
+ type: 'scratchpad',
186
+ path: scratchpadPath,
187
+ priorTrust: r.priorTrust,
188
+ });
189
+ }
190
+ }
191
+
192
+ if (updatedLocations.length === 0) {
193
+ return notFoundResult({
194
+ errors: [
195
+ `no matching id ${id} found in tier ${tier} (searched fact files + scratchpads)`,
196
+ ],
197
+ });
198
+ }
199
+
200
+ // 3. Audit log
201
+ const ts = now ?? nowIso();
202
+ appendAuditEntry(tierRoot, {
203
+ ts,
204
+ action: 'trust-changed',
205
+ tier,
206
+ id,
207
+ reasonCode: REASON_CODES.TRUST_CHANGE,
208
+ extra: {
209
+ actor: actor ?? 'user-explicit',
210
+ newTrust: level,
211
+ priorTrust: updatedLocations.map((l) => ({
212
+ type: l.type,
213
+ path: l.path,
214
+ value: l.priorTrust,
215
+ })),
216
+ },
217
+ });
218
+
219
+ return {
220
+ action: 'trust-updated',
221
+ id,
222
+ tier,
223
+ level,
224
+ updatedLocations,
225
+ };
226
+ }