fapony 0.3.5 → 0.4.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 +16 -26
- package/package.json +2 -1
- package/skill/move-to-done/SKILL.md +13 -3
- package/skill/plan-with-pony/SKILL.md +6 -2
- package/src/adapters/hooks/bug-markers.ts +50 -0
- package/src/adapters/hooks/compute-hint-impact.ts +22 -5
- package/src/adapters/hooks/context-data.ts +16 -10
- package/src/adapters/hooks/index.ts +5 -0
- package/src/adapters/hooks/read-hint.ts +17 -1
- package/src/adapters/hooks/session-start.ts +17 -6
- package/src/adapters/hooks/stop.ts +3 -9
- package/src/adapters/mcp/tools/index.ts +6 -1
- package/src/adapters/mcp/tools/mem.ts +40 -121
- package/src/core/debt-format.ts +2 -2
- package/src/core/since.ts +26 -0
- package/src/debt/cli.ts +20 -6
- package/src/debt/load.ts +136 -2
- package/src/digest/collect.ts +1 -19
- package/src/hook.ts +3 -0
- package/src/install/opencode.ts +136 -177
- package/src/install.ts +15 -5
- package/src/mem/commands/plan.ts +272 -13
- package/src/mem/commands/read.ts +128 -24
- package/src/mem/commands/write.ts +26 -31
- package/src/mem/engine.ts +249 -0
- package/src/mem/index.ts +9 -4
- package/src/seed/review-seed.ts +34 -10
- package/src/update.ts +60 -3
package/src/mem/commands/plan.ts
CHANGED
|
@@ -31,6 +31,183 @@ import {
|
|
|
31
31
|
const SHIPPED = /^>\s*✅/m;
|
|
32
32
|
const FRONT = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
33
33
|
const HELD = /^status:\s*(blocked|superseded)\b/m;
|
|
34
|
+
const PLAN_REF_RE = /\bPLAN-[\w-]+\.md\b/g;
|
|
35
|
+
|
|
36
|
+
export type PlanFrontmatter = {
|
|
37
|
+
status: string | null;
|
|
38
|
+
kind: string | null;
|
|
39
|
+
blockedByRaw: string | null;
|
|
40
|
+
blocksRaw: string | null;
|
|
41
|
+
supersededBy: string | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// frontmatter is the only dep-graph source — no new schema, parse what agents
|
|
45
|
+
// already write per templates/PLAN.md (keys EN, values EN). Sentence values
|
|
46
|
+
// ("waiting on support email") carry no PLAN-*.md token and are skipped by
|
|
47
|
+
// the file checks, never flagged.
|
|
48
|
+
export const parsePlanFrontmatter = (file: string): PlanFrontmatter => {
|
|
49
|
+
const out: PlanFrontmatter = {
|
|
50
|
+
status: null,
|
|
51
|
+
kind: null,
|
|
52
|
+
blockedByRaw: null,
|
|
53
|
+
blocksRaw: null,
|
|
54
|
+
supersededBy: null,
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
const head = readFileSync(file, "utf8").slice(0, 4096);
|
|
58
|
+
const m = FRONT.exec(head);
|
|
59
|
+
if (!m) return out;
|
|
60
|
+
for (const line of m[1].split("\n")) {
|
|
61
|
+
const kv =
|
|
62
|
+
/^\s*(status|kind|blocked_by|blocks|superseded_by)\s*:\s*(.+?)\s*$/.exec(
|
|
63
|
+
line,
|
|
64
|
+
);
|
|
65
|
+
if (!kv) continue;
|
|
66
|
+
const v = kv[2].trim() || null;
|
|
67
|
+
if (kv[1] === "status") out.status = v;
|
|
68
|
+
else if (kv[1] === "kind") out.kind = v;
|
|
69
|
+
else if (kv[1] === "blocked_by") out.blockedByRaw = v;
|
|
70
|
+
else if (kv[1] === "blocks") out.blocksRaw = v;
|
|
71
|
+
else out.supersededBy = v;
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// unreadable file — callers treat nulls as "no frontmatter"
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// every PLAN-*.md token inside the raw value (comma list or a sentence that
|
|
80
|
+
// names a plan). Deduped basenames — resolution tries planDir then doneDir.
|
|
81
|
+
export const extractPlanRefs = (raw: string | null): string[] => {
|
|
82
|
+
if (!raw) return [];
|
|
83
|
+
return [...new Set(raw.match(PLAN_REF_RE) ?? [])].map((p) => basename(p));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const planLocation = (base: string): "plan" | "done" | null => {
|
|
87
|
+
try {
|
|
88
|
+
if (existsSync(join(planDir, base))) return "plan";
|
|
89
|
+
if (existsSync(join(doneDir, base))) return "done";
|
|
90
|
+
} catch {
|
|
91
|
+
// planDir/doneDir uninitialised in unit context — treat as unknown
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// checkbox tally of the first ## section only — same contract as kickoff's
|
|
97
|
+
// readPlanSectionItems (kept local: read.ts imports from this file, so an
|
|
98
|
+
// import back would be a cycle). Counts only, no text.
|
|
99
|
+
export const countFirstSection = (
|
|
100
|
+
file: string,
|
|
101
|
+
): { checked: number; unchecked: number } => {
|
|
102
|
+
try {
|
|
103
|
+
const text = readFileSync(file, "utf8");
|
|
104
|
+
const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
|
|
105
|
+
const start = body.search(/^##\s+/m);
|
|
106
|
+
if (start < 0) return { checked: 0, unchecked: 0 };
|
|
107
|
+
const rest = body.slice(start);
|
|
108
|
+
const next = rest.slice(3).search(/^##\s+/m);
|
|
109
|
+
const block = next < 0 ? rest : rest.slice(0, next + 3);
|
|
110
|
+
let checked = 0;
|
|
111
|
+
let unchecked = 0;
|
|
112
|
+
for (const line of block.split("\n")) {
|
|
113
|
+
if (/^\s*[-*]\s+\[\s\]\s+.+$/.test(line)) unchecked++;
|
|
114
|
+
else if (/^\s*[-*]\s+\[[xX]\]\s+.+$/.test(line)) checked++;
|
|
115
|
+
}
|
|
116
|
+
return { checked, unchecked };
|
|
117
|
+
} catch {
|
|
118
|
+
return { checked: 0, unchecked: 0 };
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// dep-graph issues over active plan files: dangling blocked_by/blocks refs,
|
|
123
|
+
// "blocker shipped but dependent still blocked", and cycles. Pure (no
|
|
124
|
+
// console/exit) so tests call it directly; cmdPlanCheck prints what it returns.
|
|
125
|
+
export const collectDepIssues = (active: string[]): string[] => {
|
|
126
|
+
const issues: string[] = [];
|
|
127
|
+
const activeBases = new Set(active.map((f) => basename(f)));
|
|
128
|
+
// waiter -> blocker edges among active files only (done/ blockers are the
|
|
129
|
+
// unblocked-ready case above, not a cycle).
|
|
130
|
+
const edges = new Map<string, string[]>();
|
|
131
|
+
|
|
132
|
+
for (const f of active) {
|
|
133
|
+
const relPath = relative(planBase, f);
|
|
134
|
+
const fm = parsePlanFrontmatter(f);
|
|
135
|
+
const base = basename(f);
|
|
136
|
+
|
|
137
|
+
for (const ref of extractPlanRefs(fm.blockedByRaw)) {
|
|
138
|
+
const loc = planLocation(ref);
|
|
139
|
+
if (!loc) {
|
|
140
|
+
issues.push(
|
|
141
|
+
`${relPath} — blocked_by points at ${ref} but no such file is in plan/ or done/\n fix: correct the filename or keep blocked_by as a plain sentence`,
|
|
142
|
+
);
|
|
143
|
+
} else if (loc === "done") {
|
|
144
|
+
issues.push(
|
|
145
|
+
`${relPath} — blocker ${ref} already shipped to done/ but this plan is still status:blocked\n fix: clear status:blocked or tick the remaining chunk`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (activeBases.has(ref)) {
|
|
149
|
+
if (!edges.has(base)) edges.set(base, []);
|
|
150
|
+
edges.get(base)?.push(ref);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const ref of extractPlanRefs(fm.blocksRaw)) {
|
|
155
|
+
const loc = planLocation(ref);
|
|
156
|
+
if (!loc) {
|
|
157
|
+
issues.push(
|
|
158
|
+
`${relPath} — blocks points at ${ref} but no such file is in plan/ or done/\n fix: correct the filename or drop it`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
// "F blocks G" = G waits on F → edge G -> F for cycle detection.
|
|
162
|
+
if (activeBases.has(ref)) {
|
|
163
|
+
if (!edges.has(ref)) edges.set(ref, []);
|
|
164
|
+
edges.get(ref)?.push(base);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// DFS cycle detection over the active-only edges.
|
|
170
|
+
const state = new Map<string, number>(); // 1 = on stack, 2 = done
|
|
171
|
+
const stack: string[] = [];
|
|
172
|
+
const visit = (n: string) => {
|
|
173
|
+
state.set(n, 1);
|
|
174
|
+
stack.push(n);
|
|
175
|
+
for (const t of edges.get(n) ?? []) {
|
|
176
|
+
if (state.get(t) === 1) {
|
|
177
|
+
const cyc = [...stack.slice(stack.indexOf(t)), t].join(" → ");
|
|
178
|
+
issues.push(
|
|
179
|
+
`cycle: ${cyc} — plans wait on each other, nothing can unblock\n fix: drop one side of the blocked_by/blocks link`,
|
|
180
|
+
);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!state.get(t)) visit(t);
|
|
184
|
+
}
|
|
185
|
+
stack.pop();
|
|
186
|
+
state.set(n, 2);
|
|
187
|
+
};
|
|
188
|
+
for (const n of edges.keys()) if (!state.get(n)) visit(n);
|
|
189
|
+
|
|
190
|
+
return issues;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// status:blocked with every first-section chunk ticked = deferred doc debt:
|
|
194
|
+
// the work reads done but the plan stays in plan/ forever (shippedNotMoved
|
|
195
|
+
// never lists it — HELD excludes it). Trackers never finish, so they are out.
|
|
196
|
+
export const collectBlockedTickedIssues = (active: string[]): string[] => {
|
|
197
|
+
const issues: string[] = [];
|
|
198
|
+
for (const f of active) {
|
|
199
|
+
const fm = parsePlanFrontmatter(f);
|
|
200
|
+
if (fm.status !== "blocked") continue;
|
|
201
|
+
if (fm.kind === "tracker") continue;
|
|
202
|
+
const { checked, unchecked } = countFirstSection(f);
|
|
203
|
+
if (checked > 0 && unchecked === 0) {
|
|
204
|
+
issues.push(
|
|
205
|
+
`${relative(planBase, f)} — status:blocked but all ${checked} chunk(s) ticked (deferred doc debt?)\n fix: ship via ${planSweepCmd} ${basename(f)} --apply or add the remaining chunk`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return issues;
|
|
210
|
+
};
|
|
34
211
|
|
|
35
212
|
// the ✅ shipped header is no longer on the first line — the current plan format starts with frontmatter
|
|
36
213
|
// then `# title` (see templates/PLAN.md); check the file's head rather than a single first line
|
|
@@ -172,25 +349,45 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
172
349
|
const apply = a.includes("--apply");
|
|
173
350
|
|
|
174
351
|
if (!target) {
|
|
175
|
-
|
|
352
|
+
const all = rows();
|
|
353
|
+
if (candidates.length) {
|
|
354
|
+
console.log(
|
|
355
|
+
`# plan-sweep — ${candidates.length} file(s) marked shipped but not archived\n`,
|
|
356
|
+
);
|
|
357
|
+
for (const name of candidates) {
|
|
358
|
+
const spec = `${rel(dir)}/${name}`;
|
|
359
|
+
const openN = openRows(all).filter((r) => r.spec === spec).length;
|
|
360
|
+
const warn = openN
|
|
361
|
+
? ` ⚠ ${openN} open row(s) (next/bug/hold/decision/note) — check before moving`
|
|
362
|
+
: "";
|
|
363
|
+
console.log(`- ${rel(dir)}/${name}${warn}`);
|
|
364
|
+
}
|
|
365
|
+
console.log(`\nmove: ${planSweepCmd} <file.md> --apply`);
|
|
366
|
+
} else {
|
|
176
367
|
console.log(
|
|
177
368
|
`no PLAN with a ✅ shipped header is sitting outside ${rel(doneDir)}/`,
|
|
178
369
|
);
|
|
179
|
-
return;
|
|
180
370
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
371
|
+
// blocked plans are never move candidates (HELD excludes them from
|
|
372
|
+
// shippedNotMoved) — list them with progress + open rows so the debt on
|
|
373
|
+
// a waiting plan is visible instead of silent.
|
|
374
|
+
const blocked = mdFiles(dir).filter(
|
|
375
|
+
(f) => parsePlanFrontmatter(f).status === "blocked",
|
|
184
376
|
);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
377
|
+
if (blocked.length) {
|
|
378
|
+
console.log(
|
|
379
|
+
`\n# blocked plans (${blocked.length}) — not move candidates\n`,
|
|
380
|
+
);
|
|
381
|
+
for (const f of blocked) {
|
|
382
|
+
const { checked, unchecked } = countFirstSection(f);
|
|
383
|
+
const spec = rel(f);
|
|
384
|
+
const openN = openRows(all).filter((r) => r.spec === spec).length;
|
|
385
|
+
const by = parsePlanFrontmatter(f).blockedByRaw ?? "?";
|
|
386
|
+
console.log(
|
|
387
|
+
`- ${spec} — ${checked}/${checked + unchecked} chunks · blocked_by: ${by}${openN ? ` · ⚠ ${openN} open row(s)` : ""}`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
192
390
|
}
|
|
193
|
-
console.log(`\nmove: ${planSweepCmd} <file.md> --apply`);
|
|
194
391
|
return;
|
|
195
392
|
}
|
|
196
393
|
|
|
@@ -210,6 +407,13 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
210
407
|
const srcDir = dirname(src);
|
|
211
408
|
const shipped = hasShippedHeader(src);
|
|
212
409
|
if (!apply) {
|
|
410
|
+
const fm = parsePlanFrontmatter(src);
|
|
411
|
+
if (fm.status === "blocked") {
|
|
412
|
+
console.log(
|
|
413
|
+
`${target}: status:blocked (blocked_by: ${fm.blockedByRaw ?? "?"}) — not a move candidate, stays in plan/`,
|
|
414
|
+
);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
213
417
|
console.log(
|
|
214
418
|
shipped
|
|
215
419
|
? `${target}: has a ✅ shipped header — ready to move (add --apply)`
|
|
@@ -289,6 +493,28 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
289
493
|
// immediately or staleReport shows "decision never made it into the spec" on every ship (seen in kickoff 2026-09-02)
|
|
290
494
|
put({ kind: "synced", spec: doneSpec });
|
|
291
495
|
|
|
496
|
+
// the ship may unblock waiting plans — the dep graph lives in frontmatter,
|
|
497
|
+
// so say which active plans name this file as their blocker (or were named
|
|
498
|
+
// in this file's own blocks:). Detect-only: the dependent keeps
|
|
499
|
+
// status:blocked until its owner clears it (plan-check flags it meanwhile).
|
|
500
|
+
const unblockedByName = mdFiles(dir).filter((f) =>
|
|
501
|
+
extractPlanRefs(parsePlanFrontmatter(f).blockedByRaw).includes(name),
|
|
502
|
+
);
|
|
503
|
+
const unblockedByOwnBlocks = extractPlanRefs(
|
|
504
|
+
parsePlanFrontmatter(dst).blocksRaw,
|
|
505
|
+
).filter((b) => existsSync(join(dir, b)));
|
|
506
|
+
const unblocked = [
|
|
507
|
+
...new Set([
|
|
508
|
+
...unblockedByName.map((f) => basename(f)),
|
|
509
|
+
...unblockedByOwnBlocks,
|
|
510
|
+
]),
|
|
511
|
+
];
|
|
512
|
+
if (unblocked.length) {
|
|
513
|
+
console.log(
|
|
514
|
+
`🔓 ${name} shipped — ${unblocked.join(", ")} list(s) it as blocker, clear status:blocked?`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
292
518
|
// plain-text mention detection (detect-only, no auto-fix)
|
|
293
519
|
let plainTextTotal = 0;
|
|
294
520
|
const plainTextFiles: string[] = [];
|
|
@@ -324,6 +550,8 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
324
550
|
|
|
325
551
|
// plan-check — list active PLANs + detect shipped-not-moved + broken links
|
|
326
552
|
// + verify the commits ticked chunks cite (chunk 8, PLAN-seed-and-surface)
|
|
553
|
+
// + dep-graph (blocked_by/blocks: dangling, shipped-but-still-blocked, cycles)
|
|
554
|
+
// + blocked-with-all-chunks-ticked (deferred doc debt) + blocked view
|
|
327
555
|
// exit 0 = clean, 1 = issues found
|
|
328
556
|
|
|
329
557
|
// A ticked chunk cites the commit that closed it — a sha git cannot find on
|
|
@@ -496,10 +724,41 @@ export const cmdPlanCheck = (a: string[]) => {
|
|
|
496
724
|
});
|
|
497
725
|
}
|
|
498
726
|
|
|
727
|
+
// 5) Dep-graph check — frontmatter blocked_by/blocks already states the
|
|
728
|
+
// order, so verify it: dangling refs, blocker shipped but dependent still
|
|
729
|
+
// blocked, and waiter cycles. Sentence values carry no PLAN-*.md token
|
|
730
|
+
// and are skipped, never flagged.
|
|
731
|
+
for (const issue of collectDepIssues(active)) issues.push(issue);
|
|
732
|
+
|
|
733
|
+
// 6) Blocked-but-ticked check — status:blocked with every first-section
|
|
734
|
+
// chunk ticked is deferred doc debt: shippedNotMoved never lists it
|
|
735
|
+
// (HELD excludes it), so without this flag it sits in plan/ silently.
|
|
736
|
+
for (const issue of collectBlockedTickedIssues(active)) issues.push(issue);
|
|
737
|
+
|
|
499
738
|
if (!quiet) {
|
|
500
739
|
console.log(
|
|
501
740
|
`closed chunks: ${closed} · citing a commit: ${citing} · verified: ${verified}`,
|
|
502
741
|
);
|
|
742
|
+
// blocked view: waiting plans are never move candidates, but their debt
|
|
743
|
+
// (progress + open mem rows) must be visible somewhere — plan-check is it.
|
|
744
|
+
const blocked = active.filter(
|
|
745
|
+
(f) => parsePlanFrontmatter(f).status === "blocked",
|
|
746
|
+
);
|
|
747
|
+
if (blocked.length) {
|
|
748
|
+
const all = rows();
|
|
749
|
+
console.log(
|
|
750
|
+
`\nblocked plans (${blocked.length}) — waiting, not candidates:`,
|
|
751
|
+
);
|
|
752
|
+
for (const f of blocked) {
|
|
753
|
+
const { checked, unchecked } = countFirstSection(f);
|
|
754
|
+
const spec = rel(f);
|
|
755
|
+
const openN = openRows(all).filter((r) => r.spec === spec).length;
|
|
756
|
+
const by = parsePlanFrontmatter(f).blockedByRaw ?? "?";
|
|
757
|
+
console.log(
|
|
758
|
+
`- ${spec} — ${checked}/${checked + unchecked} chunks · blocked_by: ${by}${openN ? ` · ⚠ ${openN} open row(s)` : ""}`,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
503
762
|
}
|
|
504
763
|
|
|
505
764
|
if (issues.length === 0) {
|
package/src/mem/commands/read.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
|
+
import { parseSince } from "../../core/since.js";
|
|
5
6
|
import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
|
|
7
|
+
import { CLI_FIND_EXCLUDE, engineFind } from "../engine.js";
|
|
6
8
|
import { doneLines, fmtClose, fmtRow } from "../render.js";
|
|
7
9
|
import { claimsOf, openRows, staleReport } from "../selectors.js";
|
|
8
10
|
import type { CloseRow, WorkRow } from "../store.js";
|
|
@@ -65,31 +67,133 @@ export const cmdStale = () => {
|
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
export const cmdFind = (a: string[]) => {
|
|
68
|
-
// mem find <
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
// mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--open]
|
|
71
|
+
// Query logic lives in the shared engine (../engine.ts) — this wrapper owns
|
|
72
|
+
// only argv parsing + the single-line print. MCP memFind calls the same
|
|
73
|
+
// engine with no kind default (contract); CLI keeps its legacy default of
|
|
74
|
+
// hiding bookkeeping rows unless --kind names them (PLAN-unify-mem-engine).
|
|
75
|
+
const flagVal = (flag: string, i: number): string | undefined => {
|
|
76
|
+
const t = a[i];
|
|
77
|
+
const eq = `${flag}=`;
|
|
78
|
+
if (t.startsWith(eq)) return t.slice(eq.length);
|
|
79
|
+
return a[i + 1];
|
|
80
|
+
};
|
|
81
|
+
const consumed = (flag: string, i: number): number => {
|
|
82
|
+
if (a[i].startsWith(`${flag}=`)) return 1;
|
|
83
|
+
return 2;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
let kind: string[] | undefined;
|
|
87
|
+
let files: string[] | undefined;
|
|
88
|
+
let sinceRaw: string | undefined;
|
|
89
|
+
let limit: number | undefined;
|
|
90
|
+
let open = false;
|
|
91
|
+
const positional: string[] = [];
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < a.length; ) {
|
|
94
|
+
const t = a[i];
|
|
95
|
+
if (t === "--kind" || t.startsWith("--kind=")) {
|
|
96
|
+
const v = flagVal("--kind", i);
|
|
97
|
+
if (!v || v.startsWith("--")) {
|
|
98
|
+
console.error(
|
|
99
|
+
`--kind needs a value — usage: ${memCmd} find "<text>" --kind bug,decision`,
|
|
100
|
+
);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
kind = v
|
|
104
|
+
.split(",")
|
|
105
|
+
.map((s) => s.trim())
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
i += consumed("--kind", i);
|
|
108
|
+
} else if (t === "--files" || t.startsWith("--files=")) {
|
|
109
|
+
const v = flagVal("--files", i);
|
|
110
|
+
if (!v || v.startsWith("--")) {
|
|
111
|
+
console.error(
|
|
112
|
+
`--files needs a value — usage: ${memCmd} find --files path/to/file.ts[,more]`,
|
|
113
|
+
);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
files = v
|
|
117
|
+
.split(",")
|
|
118
|
+
.map((s) => s.trim().replace(/^\.\//, ""))
|
|
119
|
+
.filter(Boolean);
|
|
120
|
+
i += consumed("--files", i);
|
|
121
|
+
} else if (t === "--since" || t.startsWith("--since=")) {
|
|
122
|
+
const v = flagVal("--since", i);
|
|
123
|
+
if (!v || v.startsWith("--")) {
|
|
124
|
+
console.error(`--since needs a value — use <N>d or YYYY-MM-DD`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
sinceRaw = v;
|
|
128
|
+
i += consumed("--since", i);
|
|
129
|
+
} else if (t === "--limit" || t.startsWith("--limit=")) {
|
|
130
|
+
const v = flagVal("--limit", i);
|
|
131
|
+
const n = Number(v);
|
|
132
|
+
if (!v || v.startsWith("--") || !Number.isInteger(n) || n < 0) {
|
|
133
|
+
console.error(
|
|
134
|
+
`--limit needs a non-negative integer — got "${v ?? ""}"`,
|
|
135
|
+
);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
limit = n;
|
|
139
|
+
i += consumed("--limit", i);
|
|
140
|
+
} else if (t === "--open") {
|
|
141
|
+
open = true;
|
|
142
|
+
i += 1;
|
|
143
|
+
} else {
|
|
144
|
+
positional.push(t);
|
|
145
|
+
i += 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const q = positional.join(" ").trim();
|
|
150
|
+
if (
|
|
151
|
+
!q &&
|
|
152
|
+
!kind?.length &&
|
|
153
|
+
!files?.length &&
|
|
154
|
+
!sinceRaw &&
|
|
155
|
+
limit === undefined &&
|
|
156
|
+
!open
|
|
157
|
+
) {
|
|
158
|
+
console.error(
|
|
159
|
+
`usage: ${memCmd} find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--open]`,
|
|
160
|
+
);
|
|
72
161
|
process.exit(1);
|
|
73
162
|
}
|
|
163
|
+
|
|
164
|
+
let sinceIso: string | undefined;
|
|
165
|
+
if (sinceRaw) {
|
|
166
|
+
try {
|
|
167
|
+
sinceIso = parseSince(sinceRaw).iso;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
74
174
|
// allRows, not rows: find is recall — rotated history counts
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
175
|
+
const { rows: newest } = engineFind(allRows(), {
|
|
176
|
+
text: q || undefined,
|
|
177
|
+
files,
|
|
178
|
+
kind,
|
|
179
|
+
excludeKind: kind?.length ? undefined : CLI_FIND_EXCLUDE,
|
|
180
|
+
sinceIso,
|
|
181
|
+
limit,
|
|
182
|
+
open: open || undefined,
|
|
183
|
+
});
|
|
184
|
+
// Legacy order: oldest first (engine returns newest first — same set, CLI print order unchanged)
|
|
185
|
+
const hits = [...newest].reverse();
|
|
186
|
+
for (const r of hits) {
|
|
187
|
+
if ("id" in r) {
|
|
188
|
+
console.log(
|
|
189
|
+
`- [${r.id}] ${r.ts.slice(0, 10)} ${r.kind} ${r.text ?? ""}${r.spec ? ` → ${r.spec}` : ""}`,
|
|
190
|
+
);
|
|
191
|
+
} else {
|
|
192
|
+
const label = "ref" in r && r.ref ? `${r.ref}(${r.kind})` : r.kind;
|
|
193
|
+
const body = ("text" in r && r.text) || ("spec" in r && r.spec) || "";
|
|
194
|
+
console.log(`- [${label}] ${r.ts.slice(0, 10)} ${r.kind} ${body}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
93
197
|
if (!hits.length) console.log("(no matches)");
|
|
94
198
|
};
|
|
95
199
|
|
|
@@ -332,7 +436,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
332
436
|
const uniqueRecent = [...new Set(recentFiles)].slice(0, GROUP_CAP);
|
|
333
437
|
if (uniqueRecent.length) {
|
|
334
438
|
suggestions.push({
|
|
335
|
-
text:
|
|
439
|
+
text: `Recently touched: ${uniqueRecent.join(", ")}`,
|
|
336
440
|
run: `fapony review-seed --files ${uniqueRecent.join(",")}`,
|
|
337
441
|
});
|
|
338
442
|
}
|
|
@@ -341,7 +445,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
341
445
|
try {
|
|
342
446
|
if (readEvidenceLintCmd(root) && !existsSync(baselinePath(root))) {
|
|
343
447
|
suggestions.push({
|
|
344
|
-
text: "lint baseline
|
|
448
|
+
text: "lint baseline not captured yet — pre-existing red will count as yours",
|
|
345
449
|
run: "fapony lint-baseline --capture",
|
|
346
450
|
});
|
|
347
451
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// commands/write.ts — mutating commands: add, close, claim, release, synced, hook
|
|
2
2
|
|
|
3
|
+
import { CapError, engineAdd, engineClose } from "../engine.js";
|
|
3
4
|
import { claimsOf, openRows } from "../selectors.js";
|
|
4
5
|
import type { WorkKind } from "../store.js";
|
|
5
|
-
import { KINDS, memCmd,
|
|
6
|
+
import { KINDS, memCmd, put, root, rows } from "../store.js";
|
|
6
7
|
|
|
7
8
|
export const cmdAdd = async (a: string[]) => {
|
|
8
9
|
// mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [path/to/SPEC.md]
|
|
@@ -65,33 +66,23 @@ export const cmdAdd = async (a: string[]) => {
|
|
|
65
66
|
}
|
|
66
67
|
text = filtered.join(" ");
|
|
67
68
|
}
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (a[0] === "hold" && !process.env.MEM_FORCE) {
|
|
83
|
-
const openHold = openRows(all).filter((r) => r.kind === "hold").length;
|
|
84
|
-
if (openHold >= CAP_HOLD) {
|
|
85
|
-
console.error(
|
|
86
|
-
`open hold ${openHold}/${CAP_HOLD} is full — close/release an old one first (or MEM_FORCE=1)`,
|
|
87
|
-
);
|
|
88
|
-
process.exit(1);
|
|
69
|
+
// Domain rules (caps, id) live in the shared engine — this wrapper owns
|
|
70
|
+
// only argv surface and the MEM_FORCE hint wording (PLAN-unify-mem-engine).
|
|
71
|
+
try {
|
|
72
|
+
const { id } = engineAdd({ kind: a[0], text, spec, files });
|
|
73
|
+
console.log(id);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (e instanceof CapError) {
|
|
76
|
+
const hint =
|
|
77
|
+
e.cap === "next"
|
|
78
|
+
? " (or MEM_FORCE=1 if you really must)"
|
|
79
|
+
: " (or MEM_FORCE=1)";
|
|
80
|
+
console.error(`${e.message}${hint}`);
|
|
81
|
+
} else {
|
|
82
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
89
83
|
}
|
|
84
|
+
process.exit(1);
|
|
90
85
|
}
|
|
91
|
-
// ponytail: prevent id collisions — logic centralized in nextId (store.ts)
|
|
92
|
-
const id = nextId(all);
|
|
93
|
-
put({ id, kind: a[0] as WorkKind, text, spec, files });
|
|
94
|
-
console.log(id);
|
|
95
86
|
};
|
|
96
87
|
|
|
97
88
|
export const cmdClose = async (a: string[]) => {
|
|
@@ -101,10 +92,6 @@ export const cmdClose = async (a: string[]) => {
|
|
|
101
92
|
console.error(`id is required — usage: ${memCmd} close <id> "<text>"`);
|
|
102
93
|
process.exit(1);
|
|
103
94
|
}
|
|
104
|
-
if (!rows().some((r) => "id" in r && r.id === a[0])) {
|
|
105
|
-
console.error(`no id "${a[0]}" in the log`);
|
|
106
|
-
process.exit(1);
|
|
107
|
-
}
|
|
108
95
|
const rest = a.slice(1);
|
|
109
96
|
const useStdin = rest.includes("--stdin");
|
|
110
97
|
let text: string;
|
|
@@ -113,7 +100,15 @@ export const cmdClose = async (a: string[]) => {
|
|
|
113
100
|
} else {
|
|
114
101
|
text = rest.join(" ");
|
|
115
102
|
}
|
|
116
|
-
|
|
103
|
+
// Tombstone rules (id must exist, text required) live in the shared engine —
|
|
104
|
+
// note this tightens CLI: an empty-text close now errors instead of writing
|
|
105
|
+
// a textless tombstone (intentional, PLAN-unify-mem-engine §4).
|
|
106
|
+
try {
|
|
107
|
+
engineClose({ id: a[0], text });
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
117
112
|
};
|
|
118
113
|
|
|
119
114
|
export const cmdClaim = (a: string[]) => {
|