@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.
Files changed (51) hide show
  1. package/README.md +12 -5
  2. package/bin/cmk-auto-extract.mjs +13 -0
  3. package/bin/cmk-compress-session.mjs +31 -17
  4. package/bin/cmk-inject-context.mjs +12 -2
  5. package/bin/cmk-weekly-curate.mjs +14 -2
  6. package/package.json +3 -2
  7. package/src/audit-log.mjs +6 -0
  8. package/src/auto-drain.mjs +59 -0
  9. package/src/auto-extract.mjs +117 -6
  10. package/src/auto-persona.mjs +544 -0
  11. package/src/bullet-lookup.mjs +59 -0
  12. package/src/capture-turn.mjs +54 -0
  13. package/src/compress-session.mjs +6 -8
  14. package/src/compressor.mjs +19 -4
  15. package/src/conflict-queue.mjs +8 -1
  16. package/src/daily-distill.mjs +19 -11
  17. package/src/doctor.mjs +74 -23
  18. package/src/forget.mjs +14 -0
  19. package/src/graduate-session.mjs +65 -0
  20. package/src/graduation.mjs +179 -0
  21. package/src/inject-context.mjs +206 -59
  22. package/src/install.mjs +52 -7
  23. package/src/lessons-promote.mjs +137 -0
  24. package/src/memory-write.mjs +2 -2
  25. package/src/native-memory.mjs +98 -0
  26. package/src/persona-portability.mjs +253 -0
  27. package/src/provenance.mjs +23 -5
  28. package/src/read-hook-stdin.mjs +47 -0
  29. package/src/register-crons.mjs +17 -8
  30. package/src/scratchpad.mjs +247 -19
  31. package/src/session-end-tasks.mjs +127 -0
  32. package/src/settings-hooks.mjs +33 -3
  33. package/src/subcommands.mjs +339 -16
  34. package/src/weekly-curate.mjs +53 -6
  35. package/src/write-fact.mjs +14 -0
  36. package/template/.claude/skills/memory-write/SKILL.md +47 -88
  37. package/template/.gitignore.fragment +6 -0
  38. package/template/CLAUDE.md.template +15 -9
  39. package/template/local/machine-paths.md.template +1 -12
  40. package/template/local/overrides.md.template +1 -11
  41. package/template/project/MEMORY.md.template +5 -26
  42. package/template/project/SOUL.md.template +1 -10
  43. package/template/user/fragments/INDEX.md.template +1 -1
  44. package/template/.claude/hooks/pre-tool-memory.js +0 -78
  45. package/template/.claude/hooks/transcript-capture.js +0 -69
  46. package/template/.claude/settings.json +0 -27
  47. package/template/support/scripts/auto-extract-memory.sh +0 -102
  48. package/template/support/scripts/refresh-distill-timestamp.py +0 -35
  49. package/template/support/scripts/register-crons.py +0 -242
  50. package/template/support/scripts/run-daily-distill.sh +0 -67
  51. package/template/support/scripts/run-weekly-curate.sh +0 -58
package/README.md CHANGED
@@ -4,9 +4,12 @@
4
4
 
5
5
  ## What it does
6
6
 
7
+ - **Cross-project persona — the wedge (v0.2)** — when you state how you work *everywhere* ("always use uv, never pip", "from now on run the linter before committing"), the per-turn auto-extract promotes it into your **user tier** (`~/.claude-memory-kit/`) **that turn**. So a brand-new project **cold-opens already knowing your style** — layered structure, your tooling, your testing discipline — with no hand-curation and no waiting. Carry it between your own machines with `cmk persona export`/`import`, or pin a single fact across projects with `cmk lessons promote`.
7
8
  - **Frozen snapshot at session start** — MEMORY.md + USER.md + SOUL.md + INDEX.md + today's session log inject once at the first tool call, so Claude sees your context every session without you re-telling it.
8
9
  - **Auto-extract on every assistant turn** — a background `claude --print` subagent reads each turn and saves durable facts (decisions, preferences, environment) to memory. No manual writes needed.
9
- - **`memory-write` skill** — say "remember this", "from now on", "we decided", or "forget X" and the skill dedups, caps, and writes silently.
10
+ - **Explicit capture when you want it** — say "remember this" / "from now on" / "we decided" / "forget X" (the `memory-write` skill), or run `cmk remember "<fact>"`. Both dedup, screen for secrets, abstract machine paths to `~`, and write silently.
11
+ - **Search + MCP** — `cmk search "<term>"` (keyword/hybrid over facts + scratchpads); `cmk mcp` exposes the same to Claude Code as tools.
12
+ - **Bounded by compression** — session → daily → weekly Haiku rollups (cron or lazy-on-read) keep the snapshot small as history grows.
10
13
  - **Per-project, in-repo** — `context/` lives inside your project and travels with `git clone`. Each project keeps its own memory.
11
14
  - **9 health checks** — `cmk doctor` validates install, hook wiring, distill freshness, INDEX consistency, cron registration, and stale locks.
12
15
 
@@ -19,11 +22,11 @@ Each route is complete on its own. **Don't run both** — they wire the same hoo
19
22
  ```bash
20
23
  npm install -g @lh8ppl/claude-memory-kit
21
24
  cd ~/my-project
22
- cmk install # scaffolds context/ AND wires the lifecycle hooks into .claude/settings.json
25
+ cmk install # scaffolds context/ + the memory-write skill AND wires the lifecycle hooks into .claude/settings.json
23
26
  cmk doctor # verify, then restart Claude Code
24
27
  ```
25
28
 
26
- `cmk install` is a complete entry point: it scaffolds `context/` and writes the 5 lifecycle hooks (PATH-resolved, cross-OS) into the project's `.claude/settings.json`. No separate `/plugin` step needed. Use `cmk install --no-hooks` for a scaffold-only install.
29
+ `cmk install` is a complete entry point: it scaffolds `context/`, drops the `memory-write` skill into `.claude/skills/` (committed — travels with `git clone`), and writes the 5 lifecycle hooks (PATH-resolved, cross-OS) into the project's `.claude/settings.json`. No separate `/plugin` step needed. Use `cmk install --no-hooks` for a scaffold-only install.
27
30
 
28
31
  > Installing the package globally adds the `cmk` CLI **and** the installer. It's the `cmk install` *subcommand* that wires the hooks — not the bare `npm install`.
29
32
 
@@ -44,13 +47,17 @@ Most-used commands (full list via `cmk --help`):
44
47
 
45
48
  | Command | Purpose |
46
49
  | --- | --- |
47
- | `cmk install` | Scaffold `context/` + `.gitignore` + CLAUDE.md block + wire hooks (`--no-hooks` for scaffold-only) |
50
+ | `cmk install` | Scaffold `context/` + the `memory-write` skill + `.gitignore` + CLAUDE.md block + wire hooks (`--no-hooks` for scaffold-only) |
48
51
  | `cmk doctor` | Run HC-1..HC-9 health checks, surface repair commands |
49
52
  | `cmk repair --hooks` / `--locks` / `--index` / `--all` | Idempotent self-repair |
50
53
  | `cmk search "<query>" [--mode keyword\|semantic\|hybrid]` | Search accumulated memory (keyword default) |
51
54
  | `cmk roll --scope now\|today\|recent` | Manually trigger a compression pipeline |
52
55
  | `cmk register-crons [--dry-run] [--unregister]` | Register daily + weekly jobs with cron / launchd / Task Scheduler |
53
56
  | `cmk forget <id>` | Tombstone a fact (preserves audit trail) |
57
+ | `cmk lessons promote <id> [--to USER.md\|HABITS.md]` | Promote one captured fact to your cross-project **user tier** (the safe path — sanitized, secret-screened, audited) so it applies in **every** project |
58
+ | `cmk disable-native-memory` / `enable-native-memory` | Opt out of Claude Code's built-in Auto Memory so the kit is your single, lean memory layer (committable — travels with `git clone`) |
59
+ | `cmk persona generate` | Run cross-project persona synthesis on demand (instead of waiting for the weekly pass) |
60
+ | `cmk persona export <file>` / `import <file>` | Carry your cross-project persona (the user tier) to another of **your** machines — export to one portable bundle, import on the other (overwrites with backup + rollback). The persona stays private (never committed to a project) |
54
61
  | `cmk import-anthropic-memory [--dry-run] [--yes]` | Merge bullets from Anthropic's native auto-memory into MEMORY.md |
55
62
 
56
63
  ## Requirements
@@ -74,4 +81,4 @@ Full docs, architecture, and design live in the repository:
74
81
 
75
82
  ## License
76
83
 
77
- MIT © Lior Hollander
84
+ MIT © the maintainer
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { dirname, join } from 'node:path';
17
17
  import { fileURLToPath, pathToFileURL } from 'node:url';
18
+ import { existsSync } from 'node:fs';
18
19
 
19
20
  const __filename = fileURLToPath(import.meta.url);
20
21
  const __dirname = dirname(__filename);
@@ -29,12 +30,15 @@ if (!turnFile) {
29
30
 
30
31
  const autoExtractModulePath = join(__dirname, '..', 'src', 'auto-extract.mjs');
31
32
  const compressorModulePath = join(__dirname, '..', 'src', 'compressor.mjs');
33
+ const tierPathsModulePath = join(__dirname, '..', 'src', 'tier-paths.mjs');
32
34
 
33
35
  let runAutoExtract;
34
36
  let HaikuViaAnthropicApi;
37
+ let resolveTierRoot;
35
38
  try {
36
39
  ({ runAutoExtract } = await import(pathToFileURL(autoExtractModulePath).href));
37
40
  ({ HaikuViaAnthropicApi } = await import(pathToFileURL(compressorModulePath).href));
41
+ ({ resolveTierRoot } = await import(pathToFileURL(tierPathsModulePath).href));
38
42
  } catch (err) {
39
43
  process.stderr.write(
40
44
  `cmk-auto-extract: failed to load modules: ${err?.message ?? err}\n`,
@@ -42,6 +46,14 @@ try {
42
46
  process.exit(0);
43
47
  }
44
48
 
49
+ // Task 61 — inline cross-project promotion: pass the user-tier dir so
50
+ // cross-project doctrine promotes immediately. Resolve the base via the
51
+ // shared tier-paths resolver (never re-derive ~/.claude-memory-kit inline —
52
+ // CLAUDE.md shared-modules rule), and only pass it when the user tier
53
+ // actually exists (skip gracefully on a fresh machine, like autoPersona).
54
+ const userDirBase = resolveTierRoot({ tier: 'U' });
55
+ const userDir = existsSync(userDirBase) ? userDirBase : undefined;
56
+
45
57
  try {
46
58
  const haikuBackend = new HaikuViaAnthropicApi();
47
59
  const r = await runAutoExtract({
@@ -49,6 +61,7 @@ try {
49
61
  projectRoot,
50
62
  haikuBackend,
51
63
  sessionId: process.env.CMK_SESSION_ID,
64
+ userDir,
52
65
  });
53
66
  process.stderr.write(
54
67
  `cmk-auto-extract: ${r.action} (observations: ${r.observation_count ?? 0}, ms: ${r.duration_ms ?? 0})\n`,
@@ -11,7 +11,7 @@
11
11
  // {"continue": true}. Always exit 0 — a crashed SessionEnd hook would
12
12
  // block the user from closing their terminal.
13
13
 
14
- import { readFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
17
 
@@ -19,24 +19,20 @@ function emitContinue() {
19
19
  process.stdout.write('{"continue": true}');
20
20
  }
21
21
 
22
- let rawInput = '';
23
- try {
24
- rawInput = readFileSync(0, 'utf8');
25
- } catch {
26
- // stdin not connected — fine; SessionEnd still proceeds.
27
- }
28
- void rawInput;
29
-
30
22
  const __filename = fileURLToPath(import.meta.url);
31
23
  const __dirname = dirname(__filename);
32
24
 
33
- const compressSessionModulePath = join(__dirname, '..', 'src', 'compress-session.mjs');
25
+ const readHookStdinPath = join(__dirname, '..', 'src', 'read-hook-stdin.mjs');
26
+ const sessionEndTasksModulePath = join(__dirname, '..', 'src', 'session-end-tasks.mjs');
34
27
  const compressorModulePath = join(__dirname, '..', 'src', 'compressor.mjs');
35
28
 
36
- let compressSession;
29
+ let readHookStdin;
30
+ let runSessionEndTasks;
31
+ let summarizeSessionEnd;
37
32
  let HaikuViaAnthropicApi;
38
33
  try {
39
- ({ compressSession } = await import(pathToFileURL(compressSessionModulePath).href));
34
+ ({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
35
+ ({ runSessionEndTasks, summarizeSessionEnd } = await import(pathToFileURL(sessionEndTasksModulePath).href));
40
36
  ({ HaikuViaAnthropicApi } = await import(pathToFileURL(compressorModulePath).href));
41
37
  } catch (err) {
42
38
  process.stderr.write(
@@ -46,15 +42,33 @@ try {
46
42
  process.exit(0);
47
43
  }
48
44
 
45
+ // Drain the hook payload so Claude Code's pipe closes cleanly — but NOT when
46
+ // stdin is an interactive TTY (a manual run): readFileSync(0) would block
47
+ // forever on a console that never sends EOF, hanging before any of the body
48
+ // runs (DECISION-LOG 2026-06-06). The payload is discarded; we read state from
49
+ // disk. readHookStdin returns '' for a TTY so a manual invocation finishes.
50
+ readHookStdin({ isTTY: process.stdin.isTTY });
51
+
49
52
  const projectRoot = process.env.CMK_PROJECT_DIR ?? process.cwd();
50
53
 
51
54
  try {
52
- const backend = new HaikuViaAnthropicApi();
53
- const r = await compressSession({ projectRoot, backend });
54
- process.stderr.write(
55
- `cmk-compress-session: ${r.action}${r.reason ? ` (${r.reason})` : ''}${r.bytesIn ? ` (in: ${r.bytesIn}b, out: ${r.bytesOut}b)` : ''} ms: ${r.duration_ms ?? 0}\n`,
56
- );
55
+ // Task 86b + D-42: run compressSession and the dedicated autoPersona classifier
56
+ // CONCURRENTLY (they have disjoint inputs/outputs, so they don't race), keeping
57
+ // the SessionEnd wall-clock at max(~50s) under the 60s hook ceiling instead of
58
+ // the sequential sum (~100s). See session-end-tasks.mjs for the full rationale.
59
+ const userDir = process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
60
+ const outcomes = await runSessionEndTasks({
61
+ projectRoot,
62
+ userDir,
63
+ makeBackend: () => new HaikuViaAnthropicApi(),
64
+ });
65
+ for (const line of summarizeSessionEnd(outcomes)) {
66
+ process.stderr.write(line);
67
+ }
57
68
  } catch (err) {
69
+ // Defensive backstop: runSessionEndTasks uses allSettled so it should never
70
+ // reject, but a synchronous throw (e.g. resolving userDir) must not block the
71
+ // user from closing their terminal.
58
72
  process.stderr.write(
59
73
  `cmk-compress-session: unexpected error: ${err?.message ?? err}\n`,
60
74
  );
@@ -14,7 +14,7 @@
14
14
  // unconditionally — a throwing SessionStart hook would interrupt
15
15
  // session start, worse than an empty additionalContext.
16
16
 
17
- import { readFileSync } from 'node:fs';
17
+ import { readFileSync, existsSync } from 'node:fs';
18
18
  import { dirname, join } from 'node:path';
19
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
20
20
 
@@ -29,6 +29,16 @@ const __filename = fileURLToPath(import.meta.url);
29
29
  const __dirname = dirname(__filename);
30
30
  const modulePath = join(__dirname, '..', 'src', 'inject-context.mjs');
31
31
 
32
+ // Resolve the sibling lazy-compress bin (ships in this same bin/ dir) so
33
+ // inject-context can spawn `node <path>` directly instead of the shell:true
34
+ // `.cmd` shim — the Windows console-popup fix (Task 81). Env override first;
35
+ // null in a corrupt install → graceful shell:true fallback in spawnLazyCompress.
36
+ const compressLazyPath =
37
+ process.env.CMK_COMPRESS_LAZY_PATH ??
38
+ (existsSync(join(__dirname, 'cmk-compress-lazy.mjs'))
39
+ ? join(__dirname, 'cmk-compress-lazy.mjs')
40
+ : null);
41
+
32
42
  let injectContext;
33
43
  try {
34
44
  ({ injectContext } = await import(pathToFileURL(modulePath).href));
@@ -50,7 +60,7 @@ try {
50
60
  }
51
61
 
52
62
  try {
53
- const r = injectContext({ cwd: process.cwd() });
63
+ const r = injectContext({ cwd: process.cwd(), compressLazyPath });
54
64
  process.stdout.write(JSON.stringify(r.hookOutput));
55
65
  process.exit(0);
56
66
  } catch (err) {
@@ -12,6 +12,7 @@
12
12
  // cooldown marker are the load-bearing observability surfaces.
13
13
 
14
14
  import { dirname, join } from 'node:path';
15
+ import { homedir } from 'node:os';
15
16
  import { fileURLToPath, pathToFileURL } from 'node:url';
16
17
 
17
18
  const __filename = fileURLToPath(import.meta.url);
@@ -41,11 +42,22 @@ const envRoot = process.env.CMK_PROJECT_DIR && process.env.CMK_PROJECT_DIR.lengt
41
42
  : null;
42
43
  const projectRoot = argvRoot ?? envRoot ?? process.cwd();
43
44
 
45
+ // User tier (cross-project) lives at ~/.claude-memory-kit. Passing it
46
+ // activates the Design-B auto-persona hook (Task 45): the weekly cycle
47
+ // synthesizes cross-project doctrine from the project's fact archive and
48
+ // auto-promotes it into the user tier. Without userDir the curate runs
49
+ // project-only (backward-compatible).
50
+ const userDir = join(homedir(), '.claude-memory-kit');
51
+
44
52
  try {
45
53
  const backend = new HaikuViaAnthropicApi();
46
- const r = await weeklyCurate({ projectRoot, backend });
54
+ const r = await weeklyCurate({ projectRoot, userDir, backend });
55
+ const p = r.persona;
56
+ const personaNote = p
57
+ ? ` | persona: ${p.action}${p.promoted?.length ? ` (+${p.promoted.length})` : ''}${p.superseded?.length ? ` (~${p.superseded.length})` : ''}`
58
+ : '';
47
59
  process.stderr.write(
48
- `cmk-weekly-curate: ${r.action}${r.reason ? ` (${r.reason})` : ''}${r.archivedDays ? ` (archived: ${r.archivedDays}d, current: ${r.currentDays}d, in: ${r.bytesIn}b, out: ${r.bytesOut}b)` : ''} ms: ${r.duration_ms ?? 0}\n`,
60
+ `cmk-weekly-curate: ${r.action}${r.reason ? ` (${r.reason})` : ''}${r.archivedDays ? ` (archived: ${r.archivedDays}d, current: ${r.currentDays}d, in: ${r.bytesIn}b, out: ${r.bytesOut}b)` : ''}${personaNote} ms: ${r.duration_ms ?? 0}\n`,
49
61
  );
50
62
  } catch (err) {
51
63
  process.stderr.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lh8ppl/claude-memory-kit",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "cmk — the CLI for claude-memory-kit. Per-project, in-repo memory system for Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "scripts": {
27
27
  "test": "vitest run",
28
+ "prepack": "node ../../scripts/prepublish-copy-template.mjs",
28
29
  "prepublishOnly": "node ../../scripts/prepublish-copy-template.mjs"
29
30
  },
30
31
  "dependencies": {
@@ -32,7 +33,7 @@
32
33
  "@modelcontextprotocol/sdk": "^1.29.0",
33
34
  "better-sqlite3": "^12.10.0",
34
35
  "chokidar": "^5.0.0",
35
- "commander": "^12.1.0",
36
+ "commander": "^15.0.0",
36
37
  "js-yaml": "^4.1.0",
37
38
  "zod": "^4.4.3"
38
39
  },
package/src/audit-log.mjs CHANGED
@@ -35,6 +35,8 @@ export const REASON_CODES = Object.freeze({
35
35
  USER_REQUESTED: 'user-requested', // forget: user-initiated tombstone
36
36
  CURATED_MERGE: 'curated-merge', // mergeFacts: explicit merge of A + B → C
37
37
  SCRATCHPAD_APPEND: 'scratchpad-append', // scratchpad: appendScratchpadBullet (Task 12)
38
+ SCRATCHPAD_GRADUATED: 'scratchpad-graduated', // graduation: high-trust bullet moved out to a fact file under cap pressure (Task 91.1)
39
+ SCRATCHPAD_EVICTED: 'scratchpad-evicted', // consolidate: stale low/medium bullet dropped under cap pressure, archived to memory/archive/evicted-bullets.md (Task 91.2)
38
40
  TRUST_CHANGE: 'trust-change', // trust: overrideTrust (Task 15)
39
41
  CONFLICT_QUEUED: 'conflict-queued', // conflict-queue: new write contradicts existing higher-trust fact, routed to queues/conflicts.md (Task 25, design §6.8)
40
42
  CONFLICT_RESOLVED: 'conflict-resolved', // conflict-queue: user resolved a pending conflict via cmk queue conflicts (keep-old / keep-new / merge-both)
@@ -46,6 +48,10 @@ export const REASON_CODES = Object.freeze({
46
48
  REPAIR_HOOKS_NOOP: 'repair-hooks-noop', // cmk repair --hooks: settings.json already canonical, no-op (Task 39)
47
49
  INSTALL_HOOKS_WIRED: 'install-hooks-wired', // cmk install: settings.json wired with npm-route hooks (Task 49). NOTE: no NOOP counterpart — install audits only on change, to keep re-runs byte-idempotent (the audit.log is append-only).
48
50
  REPAIR_LOCK_REMOVED: 'repair-lock-removed', // cmk repair --locks: stale lock unlinked (Task 39)
51
+ PERSONA_PROMOTED: 'persona-promoted', // auto-persona: cross-project doctrine auto-promoted into a user-tier scratchpad at trust:medium (Task 45, design §16.16)
52
+ PERSONA_SUPERSEDED: 'persona-superseded', // auto-persona: a promoted persona fact auto-superseded a contradicting existing one (Task 45.6, reuses Task 25 conflict detection)
53
+ PERSONA_SECTION_CREATED: 'persona-section-created', // auto-persona: a new `## ` section was created on a user-tier scratchpad to land a candidate (Task 64 / F2)
54
+ PERSONA_IMPORTED: 'persona-imported', // persona-portability: a user-tier persona bundle was imported onto this machine (Task 72)
49
55
  });
50
56
 
51
57
  export function nowIso() {
@@ -0,0 +1,59 @@
1
+ // Auto-drain the review + conflict queues (v0.2 Phase 2, decision-log D-6).
2
+ //
3
+ // The "i dont want to do anything, i want it to be automatic" posture: the
4
+ // daily-distill / weekly-curate maintenance passes resolve the queues with
5
+ // AUTOMATIC resolvers, instead of waiting for the user to run
6
+ // `cmk queue review` / `cmk queue conflicts`. Those manual verbs still work
7
+ // for anyone who wants explicit control — they are just no longer REQUIRED.
8
+ //
9
+ // Resolvers (optimistic):
10
+ // - Review queue — medium-trust auto-extractions awaiting blessing →
11
+ // **auto-promote**. Optimistic: trust the capture. Mistakes self-correct:
12
+ // a later contradicting fact auto-supersedes (the conflict path), and the
13
+ // 14-day medium-trust staleness drop + consolidation clean up noise.
14
+ // - Conflict queue — a new LOWER-trust write that contradicted an existing
15
+ // HIGHER-trust fact (that's the only thing that lands here; equal-or-higher
16
+ // trust auto-supersedes upstream and never reaches the queue) →
17
+ // **keep-old**. Protect the established/hand-curated higher-trust fact; the
18
+ // lower-trust contradiction is discarded (logged via the resolver's audit).
19
+ // Optimism never lets noise override an established fact.
20
+ //
21
+ // Per design §6.2 (review queue) + §6.8 (conflict queue) + §8.7 (cron passes).
22
+
23
+ import { resolveReviewQueue } from './review-queue.mjs';
24
+ import { resolveConflictQueue } from './conflict-queue.mjs';
25
+ import { mergeFacts } from './merge-facts.mjs';
26
+
27
+ // Stateless optimistic resolvers (no per-entry judgement — that's the point).
28
+ const AUTO_PROMOTE = async () => 'promote';
29
+ const KEEP_OLD = async () => 'keep-old';
30
+
31
+ /**
32
+ * Drain a tier's review + conflict queues with the optimistic auto-resolvers.
33
+ * Safe to call when the queues are absent/empty (the resolvers return
34
+ * zero-count results, not errors).
35
+ *
36
+ * @param {object} opts
37
+ * @param {'P'|'U'|'L'} [opts.tier='P']
38
+ * @param {string} [opts.projectRoot]
39
+ * @param {string} [opts.userDir]
40
+ * @param {string} [opts.scratchpad] review-queue promotion target (default MEMORY.md)
41
+ * @param {string} [opts.section] review-queue promotion section (default Active Threads)
42
+ * @returns {Promise<{review: object, conflict: object}>}
43
+ */
44
+ export async function autoDrainQueues({ tier = 'P', projectRoot, userDir, scratchpad, section } = {}) {
45
+ const reviewOpts = { tier, projectRoot, userDir, prompter: AUTO_PROMOTE };
46
+ if (scratchpad) reviewOpts.scratchpad = scratchpad;
47
+ if (section) reviewOpts.section = section;
48
+ const review = await resolveReviewQueue(reviewOpts);
49
+
50
+ const conflict = await resolveConflictQueue({
51
+ tier,
52
+ projectRoot,
53
+ userDir,
54
+ prompter: KEEP_OLD,
55
+ mergeFn: mergeFacts, // never invoked under KEEP_OLD; wired for correctness
56
+ });
57
+
58
+ return { review, conflict };
59
+ }
@@ -55,6 +55,11 @@ import { pidIsAlive } from './lock-discipline.mjs';
55
55
  import { nowIso } from './audit-log.mjs';
56
56
  import { ERROR_CATEGORIES } from './result-shapes.mjs';
57
57
  import { touchCooldownMarker } from './cooldown.mjs';
58
+ // Task 61 — inline cross-project promotion. Reuse auto-persona's classifier
59
+ // directive, parser, and promote-to-user-tier path so the SAME per-turn Haiku
60
+ // call that extracts project facts ALSO promotes cross-project doctrine to the
61
+ // user tier immediately (vs waiting for the weekly auto-persona janitor).
62
+ import { parsePersonaCandidates, promoteCandidatesToUserTier, PERSONA_CONFIDENCE_RULE } from './auto-persona.mjs';
58
63
 
59
64
  const LOCK_FILENAME = 'auto-extract.lock';
60
65
  const NOW_MD_RELATIVE = ['context', 'sessions', 'now.md'];
@@ -100,6 +105,12 @@ const TRUST_RANK = Object.freeze({
100
105
  discarded: 0,
101
106
  });
102
107
 
108
+ // Task 92 (G6): max chars of a discarded LOW candidate's text to record in the
109
+ // extract.log trace. Enough to identify what was dropped without bloating the
110
+ // log; the full turn still lives in transcripts/{date}.md if deeper recovery is
111
+ // needed.
112
+ const LOW_DISCARD_EXCERPT_MAX = 200;
113
+
103
114
  // --- Lock file primitives -------------------------------------------
104
115
 
105
116
  function acquireLock(lockPath) {
@@ -238,7 +249,7 @@ function parseTurnFile(rawTurn) {
238
249
  // Written from scratch per design §6.4 — no text copied from claude-
239
250
  // remember's prompts. Output format encodes origin so the routing
240
251
  // layer can apply the assistant-demotion rule (§6.4 amendment, 2026-05-26).
241
- function buildExtractionInstructions() {
252
+ export function buildExtractionInstructions() {
242
253
  return [
243
254
  'You are a memory-extraction agent for claude-memory-kit.',
244
255
  'You read a captured turn pair (the user prompt + the assistant response) and identify durable facts worth saving.',
@@ -249,8 +260,8 @@ function buildExtractionInstructions() {
249
260
  'Save when EITHER turn reveals any of the six writing triggers:',
250
261
  ' 1. User corrections — "don\'t do that again", "use this instead".',
251
262
  ' 2. Discovered preferences — patterns across multiple turns.',
252
- ' 3. Environment facts — tool versions, paths, configurations.',
253
- ' 4. Project conventions — discovered through code inspection.',
263
+ ' 3. Setup / configuration facts — concrete setup or configuration values the next session would otherwise have to re-derive to recover (the answer to "what\'s our setup / how is this configured"). **Capture these even when they surface from the WORK itself, not only from a stated preference** — a value recalled from memory is what saves the next session from re-deriving it.',
264
+ ' 4. Project conventions — patterns discovered while working in the project (in its files, structure, or materials).',
254
265
  ' 5. Completed complex workflows — 5+ tool calls; the approach is worth recording.',
255
266
  ' 6. Tool quirks and workarounds — non-obvious findings.',
256
267
  '',
@@ -272,6 +283,15 @@ function buildExtractionInstructions() {
272
283
  ' - If a previous-entry context is included below, do NOT re-emit facts already in it.',
273
284
  '',
274
285
  'Note: assistant-origin candidates are auto-demoted one trust level before routing (HIGH → MEDIUM → LOW → discarded). This is intentional — assistant inferences need user review. Emit your honest trust assessment; the routing layer handles demotion.',
286
+ '',
287
+ 'ALSO — cross-project doctrine. This is a REQUIRED, PER-FACT pass, separate from the TRUST_ lines above. Re-scan the SAME turn for EVERY fact that expresses how this user works in ALL their projects (tooling habits, how they structure their work, communication / process style — NOT specifics that belong to this ONE project, like a particular value, name, or detail that would not carry to their other projects). **For EACH such cross-project fact, emit its OWN PERSONA CANDIDATE line — one line per fact. If the turn states THREE cross-project rules, emit THREE PERSONA CANDIDATE lines. Never collapse several rules into one line, and never skip a rule because the turn is busy or already has TRUST_ lines.** Format (one line per cross-project fact):',
288
+ ' PERSONA CANDIDATE | target=<HABITS.md|LESSONS.md|USER.md> | section=<Section> | confidence=<high|medium|low> | <one-line restatement>',
289
+ ' - HABITS.md → sections: Iteration Cadence | Destructive Operations | Communication Style',
290
+ ' - LESSONS.md → sections: Tooling Lessons | Process Lessons | Anti-patterns',
291
+ ' - USER.md → sections: About | Preferences | Working Style',
292
+ ' PREFER an existing section above — route to the closest fit. Only if NONE genuinely fits may you name a new short Title-Case section (2-4 words, letters/spaces only, e.g. "Architecture Preferences"). Never invent a new section when an existing one fits.',
293
+ PERSONA_CONFIDENCE_RULE,
294
+ ' Emit no PERSONA CANDIDATE line if nothing is cross-project.',
275
295
  ].join('\n');
276
296
  }
277
297
 
@@ -460,6 +480,8 @@ export async function runAutoExtract({
460
480
  haikuBackend,
461
481
  now,
462
482
  sessionId,
483
+ userDir, // Task 61: when present, cross-project candidates promote to the user tier inline
484
+ settings,
463
485
  } = {}) {
464
486
  const ts = now ?? nowIso();
465
487
  const t0 = Date.now();
@@ -586,7 +608,14 @@ export async function runAutoExtract({
586
608
  instructions,
587
609
  maxOutputBytes: 2000,
588
610
  preserveCitationIds: false,
589
- timeoutMs: 25_000,
611
+ // 90s, not 25s: the real `claude --print` extraction (full turn +
612
+ // instructions) consistently exceeded a 25s ceiling on a live machine
613
+ // and was KILLED mid-call (extract.log: success:false, haiku_timeout,
614
+ // duration ≈ 25000ms = hitting the cap, not finishing) → automatic
615
+ // capture + persona promotion (F2) silently never ran. This call is
616
+ // DETACHED (fire-and-forget, never blocks the session), so a generous
617
+ // ceiling is free. Live-test finding (2026-06-01, lior-test-4 baseline).
618
+ timeoutMs: 90_000,
590
619
  });
591
620
  // Touch the cooldown marker IMMEDIATELY after the Haiku call
592
621
  // resolves — this is the "we spent the budget" signal that
@@ -639,9 +668,61 @@ export async function runAutoExtract({
639
668
  candidates = applyRetainOverride(candidates, retainSegments);
640
669
  candidates = dedupByCanonicalId(candidates);
641
670
 
642
- if (candidates.length === 0) {
671
+ // Task 61 — inline cross-project promotion. The SAME Haiku output may
672
+ // carry PERSONA CANDIDATE lines (cross-project doctrine); promote them to
673
+ // the user tier THIS run (vs the weekly auto-persona janitor). No second
674
+ // LLM call — same outputText. Runs BEFORE the project-empty check so a
675
+ // turn that is ONLY cross-project doctrine still promotes.
676
+ let persona = null;
677
+ if (userDir) {
678
+ // Inline persona promotion is SECONDARY to project extraction — a bug in
679
+ // the cross-project path must never take down the primary job (project
680
+ // facts + extract.log). Isolate it: on throw, record the error on the
681
+ // result and continue routing project candidates normally.
682
+ try {
683
+ const personaCandidates = parsePersonaCandidates(haikuResult.outputText);
684
+ if (personaCandidates.length > 0) {
685
+ // Task 78 — the wedge's AUTO half. Only confidence=high candidates
686
+ // clear the promote gate, and (post-Task-78 grading) confidence=high
687
+ // means the user EXPLICITLY STATED a standing rule THIS turn → it is
688
+ // user-attested, so promote at trust:high (durable; won't be clobbered
689
+ // by a later inferred-medium entry). Inferred (medium) candidates still
690
+ // queue to persona-review. The weekly janitor stays at the default
691
+ // medium (45.6) — it synthesizes from accumulated facts, not a fresh
692
+ // statement.
693
+ persona = promoteCandidatesToUserTier({
694
+ candidates: personaCandidates,
695
+ userDir,
696
+ now: ts,
697
+ settings,
698
+ trust: 'high',
699
+ source: 'user-explicit',
700
+ });
701
+ }
702
+ } catch (err) {
703
+ persona = { promoted: [], queued: [], superseded: [], conflicts: [], error: err?.message ?? String(err) };
704
+ }
705
+ }
706
+ const personaLanded =
707
+ !!persona && ((persona.promoted?.length ?? 0) + (persona.superseded?.length ?? 0)) > 0;
708
+ // Door 4 (observability): when the inline persona pass ran, the
709
+ // extract.log entry carries the promotion counts so a later debugger
710
+ // can see cross-project doctrine landed in the user tier without
711
+ // re-running the turn. Empty object when no userDir / no persona pass.
712
+ const personaLogFields = persona
713
+ ? {
714
+ persona_promoted: persona.promoted?.length ?? 0,
715
+ persona_superseded: persona.superseded?.length ?? 0,
716
+ persona_queued: persona.queued?.length ?? 0,
717
+ persona_conflicts: persona.conflicts?.length ?? 0,
718
+ ...(persona.error ? { persona_error: persona.error } : {}),
719
+ }
720
+ : {};
721
+
722
+ if (candidates.length === 0 && !personaLanded) {
643
723
  const entry = {
644
724
  ...baseEntry,
725
+ ...personaLogFields,
645
726
  success: true,
646
727
  skipped_reason: 'nothing_durable',
647
728
  duration_ms: Date.now() - t0,
@@ -654,6 +735,7 @@ export async function runAutoExtract({
654
735
  duration_ms: entry.duration_ms,
655
736
  logPath,
656
737
  candidates: [],
738
+ persona,
657
739
  };
658
740
  }
659
741
 
@@ -681,6 +763,27 @@ export async function runAutoExtract({
681
763
  writes.push({ ...candidate, written: 'review', result: r });
682
764
  } else {
683
765
  writes.push({ ...candidate, written: 'discarded' });
766
+ // Task 92 (G6): a LOW (or assistant-demoted-to-discarded) candidate is
767
+ // dropped from active memory, but leave a recoverable trace — the
768
+ // excerpt + reason — in extract.log, so a fact Haiku mis-graded LOW (or
769
+ // an assistant-origin fact demoted to LOW) is auditable, not silently
770
+ // vanished (the §6.5 "don't lose without trace" principle at the capture
771
+ // edge; MEDIUM already gets a review queue, LOW got nothing). Log-only
772
+ // by decision (92.1): NOT routed to the review queue — that would flood
773
+ // it with low-signal noise. One discrete NDJSON entry per drop (Door 4).
774
+ writeExtractLogEntry({
775
+ projectRoot,
776
+ ts,
777
+ entry: {
778
+ event: 'low_trust_discarded',
779
+ reason: 'low_trust_discarded',
780
+ trust: candidate.trust,
781
+ demoted_from: candidate.demotedFrom ?? null,
782
+ origin: candidate.origin ?? null,
783
+ excerpt: candidate.text.slice(0, LOW_DISCARD_EXCERPT_MAX),
784
+ excerpt_truncated: candidate.text.length > LOW_DISCARD_EXCERPT_MAX,
785
+ },
786
+ });
684
787
  }
685
788
  }
686
789
 
@@ -688,9 +791,14 @@ export async function runAutoExtract({
688
791
  (w) => w.written === 'memory' || w.written === 'review' || w.written === 'conflict',
689
792
  ).length;
690
793
 
691
- if (observation_count === 0) {
794
+ // Persona-only turn: no project candidate landed, but cross-project
795
+ // doctrine promoted to the user tier this run. That IS a durable
796
+ // extraction — fall through to the 'extracted' return (observation_count
797
+ // stays 0; `persona` carries the user-tier result + Door 4 log fields).
798
+ if (observation_count === 0 && !personaLanded) {
692
799
  const entry = {
693
800
  ...baseEntry,
801
+ ...personaLogFields,
694
802
  success: true,
695
803
  skipped_reason: 'nothing_durable',
696
804
  duration_ms: Date.now() - t0,
@@ -703,11 +811,13 @@ export async function runAutoExtract({
703
811
  duration_ms: entry.duration_ms,
704
812
  logPath,
705
813
  candidates: writes,
814
+ persona,
706
815
  };
707
816
  }
708
817
 
709
818
  const entry = {
710
819
  ...baseEntry,
820
+ ...personaLogFields,
711
821
  success: true,
712
822
  observation_count,
713
823
  duration_ms: Date.now() - t0,
@@ -719,6 +829,7 @@ export async function runAutoExtract({
719
829
  duration_ms: entry.duration_ms,
720
830
  logPath,
721
831
  candidates: writes,
832
+ persona,
722
833
  };
723
834
  } finally {
724
835
  // Cleanup order: turn-file FIRST (frees disk), lock LAST (releases