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.
- package/README.md +181 -427
- package/package.json +2 -1
- package/skill/lookup-before-edit/SKILL.md +2 -2
- package/skill/review-pony/SKILL.md +2 -2
- package/src/adapters/hooks/compute-hint-impact.ts +12 -1
- package/src/adapters/hooks/context-data.ts +31 -5
- package/src/adapters/hooks/edit-hint.ts +10 -0
- package/src/adapters/hooks/index.ts +15 -0
- package/src/adapters/hooks/read-hint.ts +10 -0
- package/src/adapters/hooks/stop.ts +347 -7
- package/src/adapters/mcp/tools/index.ts +13 -1
- package/src/adapters/mcp/tools/mem.ts +30 -5
- package/src/adapters/mcp/transport.ts +1 -1
- package/src/core/hint-log.ts +16 -1
- package/src/core/mem-log.ts +18 -0
- package/src/debt/cli.ts +25 -10
- package/src/hook.ts +15 -0
- package/src/install/antigravity.ts +18 -7
- package/src/install/detect.ts +8 -5
- package/src/install/opencode.ts +142 -177
- package/src/install.ts +20 -8
- package/src/mem/commands/plan.ts +70 -0
- package/src/mem/commands/read.ts +73 -18
- package/src/mem/commands/write.ts +75 -18
- package/src/mem/engine.ts +68 -2
- package/src/mem/index.ts +7 -5
- package/src/mem/render.ts +4 -1
- package/src/mem/selectors.ts +13 -0
- package/src/mem/store.ts +2 -0
- package/src/seed/plan-seed.ts +139 -10
- package/src/seed/review-seed.ts +141 -54
- package/src/update.ts +60 -3
package/src/mem/commands/read.ts
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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 = [...
|
|
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)
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
`--
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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 (
|
|
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 =
|
|
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 {
|
|
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 {
|
|
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}
|
|
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
|
};
|
package/src/mem/selectors.ts
CHANGED
|
@@ -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> => {
|