fapony 0.3.6 → 0.5.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.
@@ -6,8 +6,8 @@ import { parseSince } from "../../core/since.js";
6
6
  import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
7
7
  import { CLI_FIND_EXCLUDE, engineFind } from "../engine.js";
8
8
  import { doneLines, fmtClose, fmtRow } from "../render.js";
9
- import { claimsOf, openRows, staleReport } from "../selectors.js";
10
- import type { CloseRow, WorkRow } from "../store.js";
9
+ import { claimsOf, openKeys, openRows, staleReport } from "../selectors.js";
10
+ import type { CloseRow, LogRow, WorkRow } from "../store.js";
11
11
  import { allRows, app, memCmd, planDir, root, rows } from "../store.js";
12
12
  import { checkTickedLine, planSweepCmd, shippedNotMoved } from "./plan.js";
13
13
  import { THRESHOLD } from "./rotate.js";
@@ -49,6 +49,21 @@ const BUGS_LIMIT = 10;
49
49
  const BRANCH_LIMIT = 10;
50
50
  const RECENT_OPEN_LIMIT = 10;
51
51
  const RECENT_OPEN_TEXT = 160;
52
+ const OPEN_KEYS_LIMIT = 8;
53
+
54
+ // Important-index line (PLAN-mem-keys chunk 3): one line under the kickoff
55
+ // header listing keys that still have open rows — silent when none. Placed
56
+ // before every tier so the 4KB session-start cap can never drop it (only
57
+ // ## recent is cut first). Counts are open rows per key, not total rows.
58
+ const openKeysLine = (all: LogRow[]): string => {
59
+ const keys = openKeys(all);
60
+ if (!keys.length) return "";
61
+ const shown = keys.slice(0, OPEN_KEYS_LIMIT);
62
+ const body = shown.map(({ key, open }) => `${key}(${open})`).join(", ");
63
+ const more =
64
+ keys.length > OPEN_KEYS_LIMIT ? ` +${keys.length - OPEN_KEYS_LIMIT}` : "";
65
+ return `open keys: ${body}${more} — ${memCmd} find --key <key>`;
66
+ };
52
67
 
53
68
  const shortText = (text: string, max = RECENT_OPEN_TEXT): string => {
54
69
  if (text.length <= max) return text;
@@ -67,7 +82,7 @@ export const cmdStale = () => {
67
82
  };
68
83
 
69
84
  export const cmdFind = (a: string[]) => {
70
- // mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--open]
85
+ // mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--key k] [--open]
71
86
  // Query logic lives in the shared engine (../engine.ts) — this wrapper owns
72
87
  // only argv parsing + the single-line print. MCP memFind calls the same
73
88
  // engine with no kind default (contract); CLI keeps its legacy default of
@@ -83,11 +98,15 @@ export const cmdFind = (a: string[]) => {
83
98
  return 2;
84
99
  };
85
100
 
86
- let kind: string[] | undefined;
87
- let files: string[] | undefined;
101
+ // Accumulators, not reassignments: a repeated list flag must grow the set,
102
+ // never last-win (PLAN-comma-x chunk 2). Exported as string[] | undefined
103
+ // after the loop so every downstream check reads the same shape.
104
+ const kindAcc: string[] = [];
105
+ const filesAcc: string[] = [];
88
106
  let sinceRaw: string | undefined;
89
107
  let limit: number | undefined;
90
108
  let open = false;
109
+ let key: string | undefined;
91
110
  const positional: string[] = [];
92
111
 
93
112
  for (let i = 0; i < a.length; ) {
@@ -100,10 +119,12 @@ export const cmdFind = (a: string[]) => {
100
119
  );
101
120
  process.exit(1);
102
121
  }
103
- kind = v
104
- .split(",")
105
- .map((s) => s.trim())
106
- .filter(Boolean);
122
+ kindAcc.push(
123
+ ...v
124
+ .split(",")
125
+ .map((s) => s.trim())
126
+ .filter(Boolean),
127
+ );
107
128
  i += consumed("--kind", i);
108
129
  } else if (t === "--files" || t.startsWith("--files=")) {
109
130
  const v = flagVal("--files", i);
@@ -113,11 +134,25 @@ export const cmdFind = (a: string[]) => {
113
134
  );
114
135
  process.exit(1);
115
136
  }
116
- files = v
117
- .split(",")
118
- .map((s) => s.trim().replace(/^\.\//, ""))
119
- .filter(Boolean);
137
+ filesAcc.push(
138
+ ...v
139
+ .split(",")
140
+ .map((s) => s.trim().replace(/^\.\//, ""))
141
+ .filter(Boolean),
142
+ );
120
143
  i += consumed("--files", i);
144
+ } else if (t === "--key" || t.startsWith("--key=")) {
145
+ const v = flagVal("--key", i);
146
+ if (!v || v.startsWith("--")) {
147
+ console.error(
148
+ `--key needs a value — usage: ${memCmd} find --key fix-stop-dedupe`,
149
+ );
150
+ process.exit(1);
151
+ }
152
+ // No KEY_RE validation on the read side — a wrong-pattern key never
153
+ // exists in the log, so the engine answers with knownKeys instead.
154
+ key = v;
155
+ i += consumed("--key", i);
121
156
  } else if (t === "--since" || t.startsWith("--since=")) {
122
157
  const v = flagVal("--since", i);
123
158
  if (!v || v.startsWith("--")) {
@@ -146,17 +181,20 @@ export const cmdFind = (a: string[]) => {
146
181
  }
147
182
  }
148
183
 
184
+ const kind = kindAcc.length ? kindAcc : undefined;
185
+ const files = filesAcc.length ? filesAcc : undefined;
149
186
  const q = positional.join(" ").trim();
150
187
  if (
151
188
  !q &&
152
189
  !kind?.length &&
153
190
  !files?.length &&
191
+ !key &&
154
192
  !sinceRaw &&
155
193
  limit === undefined &&
156
194
  !open
157
195
  ) {
158
196
  console.error(
159
- `usage: ${memCmd} find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--open]`,
197
+ `usage: ${memCmd} find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--key k] [--open]`,
160
198
  );
161
199
  process.exit(1);
162
200
  }
@@ -172,21 +210,24 @@ export const cmdFind = (a: string[]) => {
172
210
  }
173
211
 
174
212
  // allRows, not rows: find is recall — rotated history counts
175
- const { rows: newest } = engineFind(allRows(), {
213
+ const result = engineFind(allRows(), {
176
214
  text: q || undefined,
177
215
  files,
178
216
  kind,
179
217
  excludeKind: kind?.length ? undefined : CLI_FIND_EXCLUDE,
180
218
  sinceIso,
219
+ key,
181
220
  limit,
182
221
  open: open || undefined,
183
222
  });
184
223
  // Legacy order: oldest first (engine returns newest first — same set, CLI print order unchanged)
185
- const hits = [...newest].reverse();
224
+ const hits = [...result.rows].reverse();
186
225
  for (const r of hits) {
187
226
  if ("id" in r) {
227
+ // #key only when present — rows without one keep the old shape
228
+ const keyTag = r.key ? ` #${r.key}` : "";
188
229
  console.log(
189
- `- [${r.id}] ${r.ts.slice(0, 10)} ${r.kind} ${r.text ?? ""}${r.spec ? ` → ${r.spec}` : ""}`,
230
+ `- [${r.id}] ${r.ts.slice(0, 10)} ${r.kind}${keyTag} ${r.text ?? ""}${r.spec ? ` → ${r.spec}` : ""}`,
190
231
  );
191
232
  } else {
192
233
  const label = "ref" in r && r.ref ? `${r.ref}(${r.kind})` : r.kind;
@@ -194,7 +235,19 @@ export const cmdFind = (a: string[]) => {
194
235
  console.log(`- [${label}] ${r.ts.slice(0, 10)} ${r.kind} ${body}`);
195
236
  }
196
237
  }
197
- if (!hits.length) console.log("(no matches)");
238
+ if (!hits.length) {
239
+ // A wrong key answers with the real ones (SPEC fail example) — a silent
240
+ // empty would read as "this problem never happened" (rule 9).
241
+ if (key && result.knownKeys) {
242
+ console.log(
243
+ `no key ${key}; known keys: ${
244
+ result.knownKeys.length ? result.knownKeys.join(", ") : "(none)"
245
+ }`,
246
+ );
247
+ } else {
248
+ console.log("(no matches)");
249
+ }
250
+ }
198
251
  };
199
252
 
200
253
  /** Read a plan file and extract checked + unchecked items from the first ## section. */
@@ -500,6 +553,8 @@ export const cmdKickoff = (a: string[]) => {
500
553
  if (!arg && !planFile) {
501
554
  // no args = ranked open rows + next up + recent closes
502
555
  console.log(`# ${app} — ${all.length} entries`);
556
+ const keys = openKeysLine(all);
557
+ if (keys) console.log(keys);
503
558
 
504
559
  const open = openRows(all);
505
560
  const claims = claimsOf(all);
@@ -19,30 +19,87 @@ export const cmdAdd = async (a: string[]) => {
19
19
  // PLAN-convention-debt chunk 3: files[] is required — a measured optional field
20
20
  // had fill rate 0 (required+enum = 50/50) and recall on old logs without files[] caught only
21
21
  // 48.1% — fix the write side, not the read side: a row that does not name the file = unfindable when you touch that file
22
- const filesFlagIdx = a.indexOf("--files");
23
- const filesVal = filesFlagIdx >= 0 ? a[filesFlagIdx + 1] : undefined;
24
- if (!filesVal || filesVal.startsWith("--")) {
22
+
23
+ // Positional reparse (PLAN-comma-x chunk 2): walk argv once, consume every
24
+ // known flag occurrence, keep the rest as positional = text + optional
25
+ // trailing spec.md. The old filter removed only the first --files value by
26
+ // identity, so `--files a --files b` left the stray `b` in the row text
27
+ // (live corruption, row muds6zg5) — and a text word equal to the files
28
+ // value was eaten instead. Flag strip stays positional, never by value:
29
+ // a key like "fix" may legitimately sit inside the text.
30
+ const positional: string[] = [];
31
+ const rawFiles: string[] = [];
32
+ let filesValueMissing = false;
33
+ let useStdin = false;
34
+ let sawKey = false;
35
+ let keyVal: string | undefined;
36
+ const rest = a.slice(1);
37
+ for (let i = 0; i < rest.length; i++) {
38
+ const t = rest[i];
39
+ if (t === "--stdin") {
40
+ useStdin = true;
41
+ continue;
42
+ }
43
+ if (t === "--files") {
44
+ const v = rest[i + 1];
45
+ if (v !== undefined && !v.startsWith("--")) {
46
+ rawFiles.push(v);
47
+ i++;
48
+ } else {
49
+ filesValueMissing = true; // dies in validation below
50
+ }
51
+ continue;
52
+ }
53
+ if (t === "--key" || t.startsWith("--key=")) {
54
+ const eq = t.startsWith("--key=");
55
+ const v = eq ? t.slice("--key=".length) : rest[i + 1];
56
+ if (!sawKey) {
57
+ sawKey = true;
58
+ keyVal = v; // first occurrence wins — validated below
59
+ }
60
+ // Consume the value token on every occurrence so a stray repeat never
61
+ // leaks into the text (same leak class as a repeated --files).
62
+ if (!eq && v !== undefined && !v.startsWith("--")) i++;
63
+ continue;
64
+ }
65
+ positional.push(t);
66
+ }
67
+
68
+ // Validation order unchanged from the old parser: files → key → hold → text.
69
+ if (filesValueMissing || rawFiles.length === 0) {
25
70
  console.error(
26
71
  `--files is required — usage: ${memCmd} add ${a[0]} "<text>" --files path/to/file.ts[,more] [spec.md]`,
27
72
  );
28
73
  process.exit(1);
29
74
  }
30
- const files = filesVal
31
- .split(",")
32
- .map((s) => s.trim().replace(/^\.\//, ""))
33
- .filter(Boolean);
34
- if (files.length === 0) {
75
+ const files: string[] = [];
76
+ for (const v of rawFiles) {
77
+ const parts = v
78
+ .split(",")
79
+ .map((s) => s.trim().replace(/^\.\//, ""))
80
+ .filter(Boolean);
81
+ if (parts.length === 0) {
82
+ console.error(
83
+ `--files needs at least one path — usage: ${memCmd} add ${a[0]} "<text>" --files path/to/file.ts[,more]`,
84
+ );
85
+ process.exit(1);
86
+ }
87
+ files.push(...parts);
88
+ }
89
+ // --key is optional; pattern validation lives in engineAdd (one checker,
90
+ // both surfaces — PLAN-mem-keys chunk 1). argv layer only extracts it.
91
+ // Accepts `--key value` and `--key=value` alike (same as cmdFind) — an
92
+ // exact-match lookup for "--key" would leave a `--key=x` token inside the
93
+ // text and write a keyless row with exit 0 (silent drop, never allowed).
94
+ if (sawKey && (!keyVal || keyVal.startsWith("--"))) {
35
95
  console.error(
36
- `--files needs at least one path — usage: ${memCmd} add ${a[0]} "<text>" --files path/to/file.ts[,more]`,
96
+ `--key needs a value — usage: ${memCmd} add ${a[0]} "<text>" --files f1,f2 --key fix-stop-dedupe`,
37
97
  );
38
98
  process.exit(1);
39
99
  }
40
- const arg = a.slice(1);
41
- const useStdin = arg.includes("--stdin");
42
- const filtered = arg.filter(
43
- (x) => x !== "--stdin" && x !== "--files" && x !== filesVal,
44
- );
45
- const spec = filtered.at(-1)?.endsWith(".md") ? filtered.pop() : undefined;
100
+ const spec = positional.at(-1)?.endsWith(".md")
101
+ ? positional.pop()
102
+ : undefined;
46
103
  if (a[0] === "hold" && !spec) {
47
104
  console.error(
48
105
  `hold requires a spec — usage: ${memCmd} add hold "..." --files f1,f2 <spec.md>`,
@@ -58,18 +115,18 @@ export const cmdAdd = async (a: string[]) => {
58
115
  }
59
116
  } else {
60
117
  // text is required
61
- if (!filtered.length || (filtered.length === 0 && !spec)) {
118
+ if (positional.length === 0) {
62
119
  console.error(
63
120
  `text is required — usage: ${memCmd} add <kind> "<text>" --files f1,f2 [spec.md]`,
64
121
  );
65
122
  process.exit(1);
66
123
  }
67
- text = filtered.join(" ");
124
+ text = positional.join(" ");
68
125
  }
69
126
  // Domain rules (caps, id) live in the shared engine — this wrapper owns
70
127
  // only argv surface and the MEM_FORCE hint wording (PLAN-unify-mem-engine).
71
128
  try {
72
- const { id } = engineAdd({ kind: a[0], text, spec, files });
129
+ const { id } = engineAdd({ kind: a[0], text, spec, files, key: keyVal });
73
130
  console.log(id);
74
131
  } catch (e) {
75
132
  if (e instanceof CapError) {
package/src/mem/engine.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // does it at dispatch, the MCP wrapper does it per call with its worktree.
11
11
  // (PLAN-unify-mem-engine chunk 1)
12
12
 
13
+ import { KEY_RE } from "../core/mem-log.js";
13
14
  import { openRows } from "./selectors.js";
14
15
  import { KINDS, nextId, put, rows, type WorkKind } from "./store.js";
15
16
 
@@ -35,6 +36,8 @@ export interface EngineAddArgs {
35
36
  text: string;
36
37
  files: string[];
37
38
  spec?: string;
39
+ /** Problem identity — optional, but validated against KEY_RE whenever present. */
40
+ key?: string;
38
41
  }
39
42
 
40
43
  export interface EngineAddResult {
@@ -43,6 +46,7 @@ export interface EngineAddResult {
43
46
  text: string;
44
47
  files: string[];
45
48
  spec?: string;
49
+ key?: string;
46
50
  ts: string;
47
51
  }
48
52
 
@@ -60,6 +64,13 @@ export function engineAdd(a: EngineAddArgs): EngineAddResult {
60
64
  if (a.kind === "hold" && !a.spec) {
61
65
  throw new Error("hold requires a spec — pass spec: <path/to/SPEC.md>");
62
66
  }
67
+ // One validator for both surfaces (CLI argv reaches here too) — reject loudly
68
+ // with a usable example, never silently drop the key (SPEC-mem-keys §Validation).
69
+ if (a.key !== undefined && !KEY_RE.test(a.key)) {
70
+ throw new Error(
71
+ `key must match [a-z0-9-]{3,40} — e.g. "fix-stop-dedupe", got "${a.key}"`,
72
+ );
73
+ }
63
74
 
64
75
  const all = rows();
65
76
  if (a.kind === "next" && !process.env.MEM_FORCE) {
@@ -83,14 +94,27 @@ export function engineAdd(a: EngineAddArgs): EngineAddResult {
83
94
 
84
95
  const id = nextId(all);
85
96
  const ts = new Date().toISOString();
97
+ // Every row written from here on is v:2 — key optional, but the version
98
+ // stamps the schema so a reader can tell new rows from v:1 legacy ones.
99
+ // JSON.stringify drops the undefined key, so keyless rows carry only v.
86
100
  put({
87
101
  id,
88
102
  kind: a.kind as WorkKind,
89
103
  text: a.text,
90
104
  spec: a.spec,
91
105
  files: a.files,
106
+ key: a.key,
107
+ v: 2,
92
108
  });
93
- return { id, kind: a.kind, text: a.text, files: a.files, spec: a.spec, ts };
109
+ return {
110
+ id,
111
+ kind: a.kind,
112
+ text: a.text,
113
+ files: a.files,
114
+ spec: a.spec,
115
+ key: a.key,
116
+ ts,
117
+ };
94
118
  }
95
119
 
96
120
  export interface EngineCloseArgs {
@@ -165,11 +189,20 @@ export interface EngineFindArgs {
165
189
  * qualify. Default false — recall shows closed rows too.
166
190
  */
167
191
  open?: boolean;
192
+ /**
193
+ * Exact problem-identity match — rows whose effective key differs fall out
194
+ * (v:1 rows included; they stay reachable via files/text). A close row
195
+ * matches through the key of the work row its ref points at (derived at
196
+ * read time, never stored). A pure miss also returns knownKeys.
197
+ */
198
+ key?: string;
168
199
  }
169
200
 
170
201
  export interface EngineFindResult<T> {
171
202
  rows: T[];
172
203
  total: number;
204
+ /** Present only on a pure key miss — every distinct key in `all`, sorted. */
205
+ knownKeys?: string[];
173
206
  }
174
207
 
175
208
  export type FindableRow = {
@@ -178,6 +211,7 @@ export type FindableRow = {
178
211
  spec?: string;
179
212
  ref?: string;
180
213
  files?: string[];
214
+ key?: string;
181
215
  ts: string;
182
216
  };
183
217
 
@@ -214,6 +248,34 @@ export function engineFind<T extends FindableRow>(
214
248
  out = out.filter((r) => !drop.has(r.kind));
215
249
  }
216
250
 
251
+ // Exact key match, before text/files — a wrong key must never fall through
252
+ // to substring luck. Close rows derive their key from the ref'd work row
253
+ // (read-time derive: match only; the row itself stays keyless). knownKeys
254
+ // fires only on a pure key miss — answer a wrong guess with the real list.
255
+ let knownKeys: string[] | undefined;
256
+ if (a.key) {
257
+ const want = a.key;
258
+ const idKey = new Map<string, string>();
259
+ for (const r of all) {
260
+ if ("id" in r && typeof r.id === "string" && r.key) {
261
+ idKey.set(r.id, r.key);
262
+ }
263
+ }
264
+ const eff = (r: T): string | undefined =>
265
+ r.key ??
266
+ (r.kind === "close" && typeof r.ref === "string"
267
+ ? idKey.get(r.ref)
268
+ : undefined);
269
+ out = out.filter((r) => eff(r) === want);
270
+ if (!all.some((r) => eff(r) === want)) {
271
+ const distinct = new Set<string>();
272
+ for (const r of all) {
273
+ if (r.key) distinct.add(r.key);
274
+ }
275
+ knownKeys = [...distinct].sort();
276
+ }
277
+ }
278
+
217
279
  if (a.text?.trim()) {
218
280
  const needle = a.text.toLowerCase();
219
281
  out = out.filter((r) =>
@@ -245,5 +307,9 @@ export function engineFind<T extends FindableRow>(
245
307
 
246
308
  const total = out.length;
247
309
  const limit = Math.max(0, a.limit ?? FIND_DEFAULT_LIMIT);
248
- return { rows: out.slice(0, limit), total };
310
+ return {
311
+ rows: out.slice(0, limit),
312
+ total,
313
+ ...(knownKeys ? { knownKeys } : {}),
314
+ };
249
315
  }
package/src/mem/index.ts CHANGED
@@ -25,9 +25,9 @@ const MEM_HELP = `usage: fapony mem [--mem-dir <path>] <sub> [args]
25
25
  subcommands:
26
26
  where show the resolved mem dir and which step won
27
27
  kickoff [<plan.md>] [--pick <n>] open a session + a next-up list
28
- add <kind> "<text>" --files f1,f2 [spec.md]
28
+ add <kind> "<text>" --files f1,f2 [--key k] [spec.md]
29
29
  close <id> "<msg>" close a bug
30
- find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n]
30
+ find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--key k]
31
31
  substring-search every row (archives included)
32
32
  done | stale views
33
33
  claim <id> | release <id> | synced bookkeeping
@@ -49,19 +49,21 @@ open a session and print a "next up" list (priority plans, unchecked chunks, ope
49
49
  <plan.md> plan whose first unchecked chunk is offered as a context line
50
50
  --pick <n> run suggestion [n] non-interactively; refuses context/template items
51
51
  example: fapony mem kickoff .fapony/plan/PLAN-x.md`,
52
- add: `usage: fapony mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [spec.md]
52
+ add: `usage: fapony mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [--key k] [spec.md]
53
53
  append one row to the mem log
54
54
  --files f1,f2 repo-relative paths this row is about (required)
55
+ --key k problem identity, [a-z0-9-]{3,40} — same problem = same key (optional)
55
56
  --stdin read <text> from stdin (avoids shell metachar)
56
- example: fapony mem add decision "chose X because Y" --files src/a.ts,src/b.ts`,
57
+ example: fapony mem add decision "chose X because Y" --files src/a.ts,src/b.ts --key unify-mem-engine`,
57
58
  close: `usage: fapony mem close <id> "<what was done | commit>"
58
59
  tombstone a bug so it stops showing as open work
59
60
  --stdin read the message from stdin
60
61
  example: fapony mem close mt14 "fixed in a2c6beb"`,
61
- find: `usage: fapony mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n]
62
+ find: `usage: fapony mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--key k]
62
63
  substring search over every row's text/spec/ref (rotated archives included) — bookkeeping kinds (close/synced/claim/release) hidden unless --kind names them
63
64
  --kind a,b include only these kinds (overrides the bookkeeping default)
64
65
  --files f1,f2 rows about these paths (stored files[] first, text/spec/ref fallback)
66
+ --key k exact problem-identity match — a miss prints the known keys
65
67
  --since 7d only rows at or after this time (<N>d or YYYY-MM-DD)
66
68
  --limit n max rows returned (default 20, newest first)
67
69
  example: fapony mem find "usage-web" --kind bug,decision --limit 5`,
package/src/mem/render.ts CHANGED
@@ -6,7 +6,10 @@ import type { ClaimRow, CloseRow, LogRow, WorkRow } from "./store.js";
6
6
  export const fmtRow = (r: WorkRow, claims?: Map<string, ClaimRow>) => {
7
7
  let s = `- [${r.id}]`;
8
8
  if (claims?.has(r.id)) s += ` (${claims.get(r.id)?.agent})`;
9
- s += ` ${r.kind} ${r.text}`;
9
+ s += ` ${r.kind}`;
10
+ // key only when present — rows without one stay byte-identical
11
+ if (r.key) s += ` #${r.key}`;
12
+ s += ` ${r.text}`;
10
13
  if (r.spec) s += ` → ${r.spec}`;
11
14
  return s;
12
15
  };
@@ -25,6 +25,19 @@ export const openRows = (all: LogRow[]): WorkRow[] => {
25
25
  );
26
26
  };
27
27
 
28
+ // Distinct keys on open work rows, sorted — the important-index ingredient
29
+ // (PLAN-mem-keys chunk 3). A key is open while ≥1 keyed row survives the
30
+ // tombstone set; computed live like every other selector, never persisted.
31
+ export const openKeys = (all: LogRow[]): { key: string; open: number }[] => {
32
+ const counts = new Map<string, number>();
33
+ for (const r of openRows(all)) {
34
+ if (r.key) counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
35
+ }
36
+ return [...counts.entries()]
37
+ .map(([key, open]) => ({ key, open }))
38
+ .sort((a, b) => a.key.localeCompare(b.key));
39
+ };
40
+
28
41
  // C) claimsOf: returns Map ref → latest claim row still active
29
42
  // active = the latest claim|release row is a claim and ref is not in dead (close)
30
43
  export const claimsOf = (all: LogRow[]): Map<string, ClaimRow> => {
package/src/mem/store.ts CHANGED
@@ -34,6 +34,8 @@ type WorkRow = {
34
34
  text: string;
35
35
  spec?: string;
36
36
  files?: string[];
37
+ key?: string;
38
+ v?: number;
37
39
  };
38
40
 
39
41
  type CloseRow = {