@drewpayment/mink 0.8.0 → 0.9.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 (64) hide show
  1. package/dashboard/out/404.html +1 -1
  2. package/dashboard/out/action-log.html +1 -1
  3. package/dashboard/out/action-log.txt +1 -1
  4. package/dashboard/out/activity.html +1 -1
  5. package/dashboard/out/activity.txt +1 -1
  6. package/dashboard/out/bugs.html +1 -1
  7. package/dashboard/out/bugs.txt +1 -1
  8. package/dashboard/out/capture.html +1 -1
  9. package/dashboard/out/capture.txt +1 -1
  10. package/dashboard/out/config.html +1 -1
  11. package/dashboard/out/config.txt +1 -1
  12. package/dashboard/out/daemon.html +1 -1
  13. package/dashboard/out/daemon.txt +1 -1
  14. package/dashboard/out/design.html +1 -1
  15. package/dashboard/out/design.txt +1 -1
  16. package/dashboard/out/discord.html +1 -1
  17. package/dashboard/out/discord.txt +1 -1
  18. package/dashboard/out/file-index.html +1 -1
  19. package/dashboard/out/file-index.txt +1 -1
  20. package/dashboard/out/index.html +1 -1
  21. package/dashboard/out/index.txt +1 -1
  22. package/dashboard/out/insights.html +1 -1
  23. package/dashboard/out/insights.txt +1 -1
  24. package/dashboard/out/learning.html +1 -1
  25. package/dashboard/out/learning.txt +1 -1
  26. package/dashboard/out/overview.html +1 -1
  27. package/dashboard/out/overview.txt +1 -1
  28. package/dashboard/out/scheduler.html +1 -1
  29. package/dashboard/out/scheduler.txt +1 -1
  30. package/dashboard/out/sync.html +1 -1
  31. package/dashboard/out/sync.txt +1 -1
  32. package/dashboard/out/tokens.html +1 -1
  33. package/dashboard/out/tokens.txt +1 -1
  34. package/dashboard/out/waste.html +1 -1
  35. package/dashboard/out/waste.txt +1 -1
  36. package/dashboard/out/wiki.html +1 -1
  37. package/dashboard/out/wiki.txt +1 -1
  38. package/dist/cli.js +2105 -1068
  39. package/package.json +1 -1
  40. package/src/commands/bug-search.ts +3 -3
  41. package/src/commands/detect-waste.ts +34 -25
  42. package/src/commands/init.ts +21 -21
  43. package/src/commands/post-read.ts +6 -3
  44. package/src/commands/post-write.ts +6 -3
  45. package/src/commands/pre-read.ts +14 -10
  46. package/src/commands/pre-write.ts +8 -5
  47. package/src/commands/reflect.ts +12 -7
  48. package/src/commands/session-start.ts +34 -3
  49. package/src/commands/session-stop.ts +10 -6
  50. package/src/commands/status.ts +29 -17
  51. package/src/commands/sync-migrate.ts +330 -0
  52. package/src/commands/sync.ts +75 -1
  53. package/src/commands/update.ts +4 -9
  54. package/src/core/conflict-park.ts +84 -0
  55. package/src/core/dashboard-api.ts +12 -31
  56. package/src/core/note-writer.ts +52 -6
  57. package/src/core/paths.ts +66 -10
  58. package/src/core/state-aggregator.ts +304 -0
  59. package/src/core/state-counters.ts +46 -0
  60. package/src/core/sync-merge-drivers.ts +247 -0
  61. package/src/core/sync.ts +150 -68
  62. package/src/core/token-ledger.ts +19 -3
  63. /package/dashboard/out/_next/static/{EC-_8nIOf1GnPrIqZ7Mk3 → r7Xr9mrUpunsz4QtD3jh1}/_buildManifest.js +0 -0
  64. /package/dashboard/out/_next/static/{EC-_8nIOf1GnPrIqZ7Mk3 → r7Xr9mrUpunsz4QtD3jh1}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drewpayment/mink",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "A hidden presence that moves alongside the developer — token efficiency and cross-project wiki for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
- import { loadBugMemory, searchBugs } from "../core/bug-memory";
2
- import { bugMemoryPath } from "../core/paths";
1
+ import { searchBugs } from "../core/bug-memory";
2
+ import { aggregateBugMemory } from "../core/state-aggregator";
3
3
 
4
4
  export function bugSearch(cwd: string, query: string): void {
5
5
  if (!query) {
@@ -7,7 +7,7 @@ export function bugSearch(cwd: string, query: string): void {
7
7
  process.exit(1);
8
8
  }
9
9
 
10
- const memory = loadBugMemory(bugMemoryPath(cwd));
10
+ const memory = aggregateBugMemory(cwd);
11
11
  const results = searchBugs(memory, query);
12
12
 
13
13
  if (results.length === 0) {
@@ -1,36 +1,31 @@
1
1
  import { statSync } from "fs";
2
2
  import {
3
- tokenLedgerPath,
3
+ tokenLedgerShardPath,
4
4
  fileIndexPath,
5
- actionLogPath,
6
5
  learningMemoryPath,
7
6
  } from "../core/paths";
8
- import { createEmptyLedger, isTokenLedger, saveLedger } from "../core/token-ledger";
7
+ import { loadLedger, saveLedger } from "../core/token-ledger";
9
8
  import { isFileIndex, createEmptyIndex } from "../core/index-store";
10
- import { safeReadLog } from "../core/action-log";
9
+ import {
10
+ aggregateTokenLedger,
11
+ aggregateActionLog,
12
+ } from "../core/state-aggregator";
13
+ import { loadCounters } from "../core/state-counters";
11
14
  import { safeReadJson } from "../core/fs-utils";
12
15
  import { runDetection } from "../core/waste-detection";
16
+ import { getOrCreateDeviceId } from "../core/device";
13
17
  import type { TokenLedger } from "../types/token-ledger";
14
18
  import type { FileIndex } from "../types/file-index";
15
19
 
16
20
  export function detectWaste(cwd: string): void {
17
- const ledgerPath = tokenLedgerPath(cwd);
18
21
  const idxPath = fileIndexPath(cwd);
19
- const logPath = actionLogPath(cwd);
20
22
  const lmPath = learningMemoryPath(cwd);
21
23
 
22
- // Load and validate ledger distinguish empty vs corrupted
23
- const rawLedger = safeReadJson(ledgerPath);
24
- let ledger: TokenLedger;
25
-
26
- if (rawLedger === null) {
27
- ledger = createEmptyLedger();
28
- } else if (!isTokenLedger(rawLedger)) {
29
- console.warn("[mink] Warning: corrupt token ledger, skipping waste detection");
30
- return;
31
- } else {
32
- ledger = rawLedger;
33
- }
24
+ // Aggregated ledger (across all device shards + legacy). Aggregator returns
25
+ // an empty ledger when no sources exist, so the empty-vs-corrupt distinction
26
+ // collapses into "treat missing as empty" — corrupt files inside a shard are
27
+ // already logged by loadLedger.
28
+ const ledger: TokenLedger = aggregateTokenLedger(cwd);
34
29
 
35
30
  // Load file index
36
31
  const rawIndex = safeReadJson(idxPath);
@@ -41,8 +36,8 @@ export function detectWaste(cwd: string): void {
41
36
  fileIndex = createEmptyIndex();
42
37
  }
43
38
 
44
- // Load action log content
45
- const actionLogContent = safeReadLog(logPath);
39
+ // Aggregated action log content (across all device shards + legacy)
40
+ const actionLogContent = aggregateActionLog(cwd);
46
41
 
47
42
  // Get learning memory mtime
48
43
  let learningMemoryMtimeMs: number | null = null;
@@ -52,18 +47,32 @@ export function detectWaste(cwd: string): void {
52
47
  // File missing — will be flagged as stale
53
48
  }
54
49
 
55
- // Run detection
50
+ // Pull hit/miss telemetry from the per-device counter file, falling back to
51
+ // the legacy header counters when unmigrated. We feed runDetection a synthetic
52
+ // header so it works without knowing about the split.
53
+ const counters = loadCounters(cwd);
54
+ const headerForDetection = {
55
+ ...fileIndex.header,
56
+ lifetimeHits: counters.fileIndexHits || fileIndex.header.lifetimeHits,
57
+ lifetimeMisses: counters.fileIndexMisses || fileIndex.header.lifetimeMisses,
58
+ };
59
+
60
+ // Run detection on the aggregated cross-device view
56
61
  const flags = runDetection(
57
62
  ledger,
58
63
  fileIndex.entries,
59
- fileIndex.header,
64
+ headerForDetection,
60
65
  actionLogContent,
61
66
  learningMemoryMtimeMs
62
67
  );
63
68
 
64
- // Store flags in ledger (replaces previous)
65
- ledger.wasteFlags = flags;
66
- saveLedger(ledgerPath, ledger);
69
+ // Persist flags in THIS device's shard ledger so it's the only writer for
70
+ // that file. The aggregator unions wasteFlags across shards on read, so
71
+ // every device's view stays current without merge conflicts.
72
+ const shardLedgerPath = tokenLedgerShardPath(cwd, getOrCreateDeviceId());
73
+ const shardLedger = loadLedger(shardLedgerPath);
74
+ shardLedger.wasteFlags = flags;
75
+ saveLedger(shardLedgerPath, shardLedger);
67
76
 
68
77
  // Output summary
69
78
  if (flags.length === 0) {
@@ -53,18 +53,13 @@ export function resolveCliPath(): string {
53
53
  return resolve(selfDir, "../cli.ts");
54
54
  }
55
55
 
56
- export function buildHooksConfig(
57
- runtime: "bun" | "node",
58
- cliPath: string
59
- ): HooksConfig {
60
- // If using compiled JS, always use node (universally available)
61
- // If using .ts source, must use bun
56
+ export function buildHooksConfig(cliPath: string): HooksConfig {
57
+ // For installed packages emit the `mink` bin shim so the resulting
58
+ // .claude/settings.json is portable across machines, users, and runtimes
59
+ // when committed to git (issue #55). For source-dev mode (cli.ts) the shim
60
+ // isn't on PATH, so fall back to `bun run <abs path>`.
62
61
  const isTsSource = cliPath.endsWith(".ts");
63
- const prefix = isTsSource
64
- ? `bun run ${cliPath}`
65
- : runtime === "bun"
66
- ? `bun run ${cliPath}`
67
- : `node ${cliPath}`;
62
+ const prefix = isTsSource ? `bun run ${cliPath}` : "mink";
68
63
  const hook = (cmd: string): HookCommand[] => [{ type: "command", command: cmd }];
69
64
  return {
70
65
  SessionStart: [{ matcher: "", hooks: hook(`${prefix} session-start`) }],
@@ -83,15 +78,20 @@ export function buildHooksConfig(
83
78
  }
84
79
 
85
80
  function isMinkCommand(cmd: string): boolean {
86
- return (
87
- cmd.includes("cli") &&
88
- (cmd.includes("session-start") ||
89
- cmd.includes("session-stop") ||
90
- cmd.includes("pre-read") ||
91
- cmd.includes("post-read") ||
92
- cmd.includes("pre-write") ||
93
- cmd.includes("post-write"))
94
- );
81
+ const hasMinkSubcommand =
82
+ cmd.includes("session-start") ||
83
+ cmd.includes("session-stop") ||
84
+ cmd.includes("pre-read") ||
85
+ cmd.includes("post-read") ||
86
+ cmd.includes("pre-write") ||
87
+ cmd.includes("post-write");
88
+ if (!hasMinkSubcommand) return false;
89
+ // Match the new bin-shim format (`mink <subcmd>` or `/abs/path/to/mink <subcmd>`)
90
+ // as well as legacy formats (`bun run .../cli.js ...`, `node .../cli.js ...`,
91
+ // `bun run .../cli.ts ...`) so re-init replaces stale entries instead of
92
+ // duplicating them.
93
+ if (/(^|\/|\s)mink\s/.test(cmd)) return true;
94
+ return cmd.includes("cli.js") || cmd.includes("cli.ts");
95
95
  }
96
96
 
97
97
  function isMinkHook(entry: HookEntry | Record<string, unknown>): boolean {
@@ -159,7 +159,7 @@ function isExistingInstallation(cwd: string): boolean {
159
159
  export async function init(cwd: string): Promise<void> {
160
160
  const runtime = detectRuntime();
161
161
  const cliPath = resolveCliPath();
162
- const hooks = buildHooksConfig(runtime, cliPath);
162
+ const hooks = buildHooksConfig(cliPath);
163
163
  const settingsPath = resolve(cwd, ".claude", "settings.json");
164
164
  const dir = projectDir(cwd);
165
165
  const upgrading = isExistingInstallation(cwd);
@@ -1,11 +1,12 @@
1
1
  import { relative } from "path";
2
2
  import { readStdinJson } from "../core/stdin";
3
- import { sessionPath, fileIndexPath, actionLogPath } from "../core/paths";
3
+ import { sessionPath, fileIndexPath, actionLogShardPath } from "../core/paths";
4
4
  import { safeReadJson, atomicWriteJson } from "../core/fs-utils";
5
5
  import { createSessionState, isSessionState, recordRead } from "../core/session";
6
6
  import { isFileIndex, lookupEntry } from "../core/index-store";
7
7
  import { estimateTokens, isBinaryFile } from "../core/token-estimate";
8
8
  import { createActionLogWriter } from "../core/action-log";
9
+ import { getOrCreateDeviceId } from "../core/device";
9
10
  import type { SessionState } from "../types/session";
10
11
  import type { FileIndex } from "../types/file-index";
11
12
  import type { PostToolUseInput } from "../types/hook-input";
@@ -100,9 +101,11 @@ export async function postRead(cwd: string): Promise<void> {
100
101
  // Record the read in session state
101
102
  recordRead(state, filePath, result.estimatedTokens, result.indexHit);
102
103
 
103
- // Append read entry to action log
104
+ // Append read entry to this device's action log shard
104
105
  try {
105
- const logWriter = createActionLogWriter(actionLogPath(cwd));
106
+ const logWriter = createActionLogWriter(
107
+ actionLogShardPath(cwd, getOrCreateDeviceId())
108
+ );
106
109
  logWriter.appendReadEntry(
107
110
  new Date().toISOString(),
108
111
  filePath,
@@ -1,7 +1,8 @@
1
1
  import { relative } from "path";
2
2
  import { readFileSync } from "fs";
3
3
  import { readStdinJson } from "../core/stdin";
4
- import { sessionPath, fileIndexPath, actionLogPath } from "../core/paths";
4
+ import { sessionPath, fileIndexPath, actionLogShardPath } from "../core/paths";
5
+ import { getOrCreateDeviceId } from "../core/device";
5
6
  import { safeReadJson, atomicWriteJson } from "../core/fs-utils";
6
7
  import { createSessionState, isSessionState, recordWrite } from "../core/session";
7
8
  import {
@@ -129,9 +130,11 @@ export async function postWrite(cwd: string): Promise<void> {
129
130
  upsertEntry(index, result.indexEntry);
130
131
  }
131
132
 
132
- // 2. Action log entry
133
+ // 2. Action log entry — write to this device's shard
133
134
  try {
134
- const logWriter = createActionLogWriter(actionLogPath(cwd));
135
+ const logWriter = createActionLogWriter(
136
+ actionLogShardPath(cwd, getOrCreateDeviceId())
137
+ );
135
138
  logWriter.appendWriteEntry(
136
139
  new Date().toISOString(),
137
140
  filePath,
@@ -3,12 +3,11 @@ import { readStdinJson } from "../core/stdin";
3
3
  import { sessionPath, fileIndexPath } from "../core/paths";
4
4
  import { safeReadJson, atomicWriteJson } from "../core/fs-utils";
5
5
  import { createSessionState, isSessionState } from "../core/session";
6
+ import { isFileIndex, lookupEntry } from "../core/index-store";
6
7
  import {
7
- isFileIndex,
8
- lookupEntry,
9
- recordHit,
10
- recordMiss,
11
- } from "../core/index-store";
8
+ incrementFileIndexHit,
9
+ incrementFileIndexMiss,
10
+ } from "../core/state-counters";
12
11
  import type { SessionState } from "../types/session";
13
12
  import type { FileIndex, FileIndexEntry } from "../types/file-index";
14
13
  import type { PreToolUseInput } from "../types/hook-input";
@@ -40,17 +39,17 @@ export function analyzePreRead(
40
39
  state.counters.repeatedReadWarnings++;
41
40
  }
42
41
 
43
- // File index lookup
42
+ // File index lookup. Hit/miss telemetry is persisted by the caller via
43
+ // increment{Hit,Miss}, not by mutating the shared index — keeping the
44
+ // file-index.json content-addressed by filePath so a JSON union merge driver
45
+ // can resolve cross-device updates without conflict.
44
46
  if (index) {
45
47
  entry = lookupEntry(index, filePath);
46
48
  if (entry) {
47
49
  indexHit = true;
48
- recordHit(index);
49
50
  warnings.push(
50
51
  `[mink] ${filePath} — ${entry.description} (~${entry.estimatedTokens} tokens)`
51
52
  );
52
- } else {
53
- recordMiss(index);
54
53
  }
55
54
  }
56
55
 
@@ -99,7 +98,12 @@ export async function preRead(cwd: string): Promise<void> {
99
98
  // Persist state changes
100
99
  atomicWriteJson(sessionPath(cwd), state);
101
100
  if (index) {
102
- atomicWriteJson(fileIndexPath(cwd), index);
101
+ try {
102
+ if (result.indexHit) incrementFileIndexHit(cwd);
103
+ else incrementFileIndexMiss(cwd);
104
+ } catch {
105
+ // Counter file is best-effort telemetry — never block the read hook
106
+ }
103
107
  }
104
108
  } catch {
105
109
  // Never crash — exit silently
@@ -5,6 +5,10 @@ import { sessionPath, learningMemoryPath, bugMemoryPath } from "../core/paths";
5
5
  import { safeReadJson, atomicWriteJson } from "../core/fs-utils";
6
6
  import { createSessionState, isSessionState } from "../core/session";
7
7
  import { parseLearningMemory, getEntries } from "../core/learning-memory";
8
+ import {
9
+ aggregateLearningMemory,
10
+ aggregateBugMemory,
11
+ } from "../core/state-aggregator";
8
12
  import { extractPatterns, matchPatterns } from "../core/pattern-engine";
9
13
  import {
10
14
  loadBugMemory,
@@ -88,20 +92,19 @@ export async function preWrite(cwd: string): Promise<void> {
88
92
  const filePath = relative(cwd, absolutePath);
89
93
  const writeContent = extractWriteContent(input);
90
94
 
91
- // Load learning memory Do-Not-Repeat entries
95
+ // Load learning memory Do-Not-Repeat entries (canonical + sidecars)
92
96
  let doNotRepeatEntries: string[] = [];
93
97
  try {
94
- const markdown = readFileSync(learningMemoryPath(cwd), "utf-8");
95
- const mem = parseLearningMemory(markdown);
98
+ const mem = aggregateLearningMemory(cwd);
96
99
  doNotRepeatEntries = getEntries(mem, "Do-Not-Repeat");
97
100
  } catch {
98
101
  // Learning memory not found or corrupt — skip enforcement
99
102
  }
100
103
 
101
- // Load bug memory for this file
104
+ // Load bug memory for this file (aggregated across shards)
102
105
  let bugMemory: BugMemory | undefined;
103
106
  try {
104
- bugMemory = loadBugMemory(bugMemoryPath(cwd));
107
+ bugMemory = aggregateBugMemory(cwd);
105
108
  } catch {
106
109
  // Bug memory not found or corrupt — skip lookup
107
110
  }
@@ -1,5 +1,7 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { parseLearningMemory, serializeLearningMemory } from "../core/learning-memory";
1
+ import { existsSync } from "fs";
2
+ import { dirname } from "path";
3
+ import { serializeLearningMemory, totalEntryCount } from "../core/learning-memory";
4
+ import { aggregateLearningMemoryAt } from "../core/state-aggregator";
3
5
  import { reflectMemory } from "../core/reflection";
4
6
  import { atomicWriteText, safeReadJson } from "../core/fs-utils";
5
7
  import type { ReflectionResult } from "../types/learning-memory";
@@ -8,18 +10,21 @@ import type { ProjectConfig } from "../types/file-index";
8
10
  const DEFAULT_TOKEN_BUDGET = 2000;
9
11
 
10
12
  export function reflect(
11
- projectDir: string,
13
+ _cwd: string,
12
14
  memoryPath: string,
13
15
  configPath: string
14
16
  ): ReflectionResult | null {
15
- if (!existsSync(memoryPath)) {
17
+ // Aggregate canonical + every device's sidecar that sits next to memoryPath.
18
+ // Using dirname(memoryPath) keeps callers in control of where the project
19
+ // state lives — production passes projectDir(cwd)/learning-memory.md, tests
20
+ // pass arbitrary temp directories.
21
+ const projDir = dirname(memoryPath);
22
+ const mem = aggregateLearningMemoryAt(projDir);
23
+ if (totalEntryCount(mem) === 0 && !existsSync(memoryPath)) {
16
24
  console.log("[mink] no learning memory found");
17
25
  return null;
18
26
  }
19
27
 
20
- const markdown = readFileSync(memoryPath, "utf-8");
21
- const mem = parseLearningMemory(markdown);
22
-
23
28
  const config = safeReadJson(configPath) as ProjectConfig | null;
24
29
  const tokenBudget = config?.learningMemoryTokenBudget ?? DEFAULT_TOKEN_BUDGET;
25
30
 
@@ -1,8 +1,9 @@
1
1
  import { mkdirSync } from "fs";
2
2
  import { createSessionState } from "../core/session";
3
- import { projectDir, sessionPath, actionLogPath } from "../core/paths";
3
+ import { projectDir, sessionPath, actionLogShardPath } from "../core/paths";
4
4
  import { atomicWriteJson } from "../core/fs-utils";
5
5
  import { createActionLogWriter } from "../core/action-log";
6
+ import { getOrCreateDeviceId } from "../core/device";
6
7
  import { isWikiEnabled, isVaultInitialized, isInsideVault } from "../core/vault";
7
8
  import { loadVaultIndex } from "../core/note-index";
8
9
 
@@ -23,6 +24,17 @@ export function sessionStart(cwd: string): void {
23
24
  // Never crash hooks
24
25
  }
25
26
 
27
+ // One-shot migration to sync layout v2. Idempotent re-run is a no-op.
28
+ try {
29
+ const { readSyncVersion, MINK_SYNC_VERSION } = require("../core/sync");
30
+ if (readSyncVersion() < MINK_SYNC_VERSION) {
31
+ const { migrateSyncLayout } = require("./sync-migrate");
32
+ migrateSyncLayout();
33
+ }
34
+ } catch {
35
+ // Migration is best-effort; never block session-start
36
+ }
37
+
26
38
  // Sync pull before session begins (if enabled)
27
39
  try {
28
40
  const { isSyncInitialized, syncPull } = require("../core/sync");
@@ -39,9 +51,11 @@ export function sessionStart(cwd: string): void {
39
51
  const state = createSessionState();
40
52
  atomicWriteJson(sessionPath(cwd), state);
41
53
 
42
- // Append session header to action log
54
+ // Append session header to this device's action log shard
43
55
  try {
44
- const logWriter = createActionLogWriter(actionLogPath(cwd));
56
+ const logWriter = createActionLogWriter(
57
+ actionLogShardPath(cwd, getOrCreateDeviceId())
58
+ );
45
59
  logWriter.appendSessionHeader(state.startTimestamp);
46
60
  } catch {
47
61
  // Never crash hooks
@@ -55,6 +69,23 @@ export function sessionStart(cwd: string): void {
55
69
  (e) => e.category === "inbox"
56
70
  ).length;
57
71
 
72
+ // Regenerate the master index when missing — it's gitignored under sync
73
+ // v2 so freshly-cloned devices need it materialised before Obsidian can
74
+ // see the vault. updateMasterIndex is idempotent + cheap.
75
+ try {
76
+ const { join } = require("path");
77
+ const { existsSync } = require("fs");
78
+ const { resolveVaultPath } = require("../core/vault");
79
+ const { updateMasterIndex } = require("../core/note-linker");
80
+ const vaultPath = resolveVaultPath();
81
+ const masterIndexPath = join(vaultPath, "_index.md");
82
+ if (!existsSync(masterIndexPath)) {
83
+ updateMasterIndex(vaultPath);
84
+ }
85
+ } catch {
86
+ // Never crash hooks on regeneration failure
87
+ }
88
+
58
89
  if (inboxCount > 0) {
59
90
  console.error(
60
91
  `[mink] vault: ${inboxCount} notes in inbox need categorization`
@@ -4,8 +4,10 @@ import { safeReadJson, atomicWriteJson, atomicWriteText } from "../core/fs-utils
4
4
  import { isSessionState, buildSummary } from "../core/session";
5
5
  import { reflect } from "./reflect";
6
6
  import { createLedgerFinalizer } from "../core/token-ledger";
7
- import { loadBugMemory, hasBugForFileInSession } from "../core/bug-memory";
7
+ import { hasBugForFileInSession } from "../core/bug-memory";
8
+ import { aggregateBugMemoryAt } from "../core/state-aggregator";
8
9
  import { createActionLogWriter, consolidateLog } from "../core/action-log";
10
+ import { getOrCreateDeviceId } from "../core/device";
9
11
  import {
10
12
  isWikiEnabled,
11
13
  isVaultInitialized,
@@ -55,7 +57,8 @@ export function sessionStop(
55
57
  state.stopCount++;
56
58
 
57
59
  const projDir = dirname(sessionFile);
58
- const effectiveFinalizer = finalizer ?? createLedgerFinalizer(projDir);
60
+ const deviceId = getOrCreateDeviceId();
61
+ const effectiveFinalizer = finalizer ?? createLedgerFinalizer(projDir, deviceId);
59
62
 
60
63
  if (hasActivity(state)) {
61
64
  const summary = buildSummary(state);
@@ -66,9 +69,11 @@ export function sessionStop(
66
69
  effectiveFinalizer.updateSession(summary);
67
70
  }
68
71
 
69
- // Append session end to action log and run consolidation
72
+ // Append session end to action log and run consolidation. Both writes
73
+ // target THIS device's shard so concurrent sessions on other devices
74
+ // never collide on the action log file.
70
75
  try {
71
- const logPath = join(projDir, "action-log.md");
76
+ const logPath = join(projDir, "state", deviceId, "action-log.md");
72
77
  const logWriter = createActionLogWriter(logPath);
73
78
  logWriter.appendSessionEnd(summary);
74
79
 
@@ -84,8 +89,7 @@ export function sessionStop(
84
89
 
85
90
  // Check for files edited 3+ times without a corresponding bug entry
86
91
  const editCounts = getEditCounts(state);
87
- const bugMemoryFile = join(projDir, "bug-memory.json");
88
- const bugMemory = loadBugMemory(bugMemoryFile);
92
+ const bugMemory = aggregateBugMemoryAt(projDir);
89
93
 
90
94
  for (const [filePath, count] of Object.entries(editCounts)) {
91
95
  if (count >= 3) {
@@ -13,6 +13,12 @@ import { isFileIndex } from "../core/index-store";
13
13
  import { loadLedger } from "../core/token-ledger";
14
14
  import { parseLearningMemory, totalEntryCount } from "../core/learning-memory";
15
15
  import { loadBugMemory } from "../core/bug-memory";
16
+ import {
17
+ aggregateTokenLedger,
18
+ aggregateBugMemory,
19
+ aggregateLearningMemory,
20
+ } from "../core/state-aggregator";
21
+ import { loadCounters } from "../core/state-counters";
16
22
  import { getDaemonStatus } from "../core/daemon";
17
23
 
18
24
  interface FileCheck {
@@ -72,12 +78,17 @@ export function status(cwd: string): void {
72
78
  const raw = safeReadJson(fileIndexPath(cwd));
73
79
  if (raw && isFileIndex(raw)) {
74
80
  const h = raw.header;
75
- const total = h.lifetimeHits + h.lifetimeMisses;
76
- const ratio = total > 0 ? ((h.lifetimeHits / total) * 100).toFixed(1) : "N/A";
81
+ // Hit/miss counters live in the per-device counter file, fall back to
82
+ // legacy header counters for unmigrated repos.
83
+ const counters = loadCounters(cwd);
84
+ const hits = counters.fileIndexHits || h.lifetimeHits;
85
+ const misses = counters.fileIndexMisses || h.lifetimeMisses;
86
+ const total = hits + misses;
87
+ const ratio = total > 0 ? ((hits / total) * 100).toFixed(1) : "N/A";
77
88
  console.log(" File index:");
78
89
  console.log(` Files: ${h.totalFiles}`);
79
90
  console.log(` Last scan: ${h.lastScanTimestamp || "never"}`);
80
- console.log(` Hit/miss ratio: ${ratio}${total > 0 ? "%" : ""} (${h.lifetimeHits} hits, ${h.lifetimeMisses} misses)`);
91
+ console.log(` Hit/miss ratio: ${ratio}${total > 0 ? "%" : ""} (${hits} hits, ${misses} misses)`);
81
92
  } else {
82
93
  console.log(" File index: not available");
83
94
  }
@@ -86,9 +97,9 @@ export function status(cwd: string): void {
86
97
  }
87
98
  console.log();
88
99
 
89
- // Section 3: Token ledger
100
+ // Section 3: Token ledger (aggregated across all device shards + legacy)
90
101
  try {
91
- const ledger = loadLedger(tokenLedgerPath(cwd));
102
+ const ledger = aggregateTokenLedger(cwd);
92
103
  const lt = ledger.lifetime;
93
104
  console.log(" Token ledger:");
94
105
  console.log(` Sessions: ${lt.totalSessions}`);
@@ -100,32 +111,33 @@ export function status(cwd: string): void {
100
111
  }
101
112
  console.log();
102
113
 
103
- // Section 4: Learning memory
114
+ // Section 4: Learning memory (canonical + sidecars)
104
115
  try {
105
- const memPath = learningMemoryPath(cwd);
106
- if (existsSync(memPath)) {
107
- const content = readFileSync(memPath, "utf-8");
108
- const mem = parseLearningMemory(content);
109
- const total = totalEntryCount(mem);
110
- const mtime = statSync(memPath).mtime;
116
+ const mem = aggregateLearningMemory(cwd);
117
+ const total = totalEntryCount(mem);
118
+ if (total === 0 && mem.projectName === "unknown") {
119
+ console.log(" Learning memory: not initialized");
120
+ } else {
111
121
  console.log(" Learning memory:");
112
122
  console.log(` User Preferences: ${mem.sections["User Preferences"].length}`);
113
123
  console.log(` Key Learnings: ${mem.sections["Key Learnings"].length}`);
114
124
  console.log(` Do-Not-Repeat: ${mem.sections["Do-Not-Repeat"].length}`);
115
125
  console.log(` Decision Log: ${mem.sections["Decision Log"].length}`);
116
126
  console.log(` Total entries: ${total}`);
117
- console.log(` Last modified: ${mtime.toISOString()}`);
118
- } else {
119
- console.log(" Learning memory: not initialized");
127
+ const memPath = learningMemoryPath(cwd);
128
+ if (existsSync(memPath)) {
129
+ const mtime = statSync(memPath).mtime;
130
+ console.log(` Canonical last modified: ${mtime.toISOString()}`);
131
+ }
120
132
  }
121
133
  } catch {
122
134
  console.log(" Learning memory: error reading");
123
135
  }
124
136
  console.log();
125
137
 
126
- // Section 5: Bug log
138
+ // Section 5: Bug log (aggregated across shards)
127
139
  try {
128
- const bugs = loadBugMemory(bugMemoryPath(cwd));
140
+ const bugs = aggregateBugMemory(cwd);
129
141
  console.log(` Bug log: ${bugs.entries.length} entries`);
130
142
  } catch {
131
143
  console.log(" Bug log: error reading");