fapony 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -46
- package/fapony.ts +35 -2
- package/package.json +3 -2
- package/skill/move-to-done/SKILL.md +18 -28
- package/skill/plan-with-pony/SKILL.md +8 -3
- package/src/analyze.ts +175 -5
- package/src/conventions-seed.ts +3 -3
- package/src/db/defaults.ts +13 -5
- package/src/db/getters.ts +11 -9
- package/src/db/load.ts +2 -2
- package/src/db/store.ts +0 -75
- package/src/db/types.ts +2 -4
- package/src/debt.ts +176 -36
- package/src/digest/collect.ts +19 -2
- package/src/digest/text.ts +25 -0
- package/src/hook.ts +665 -24
- package/src/init-mem.ts +123 -37
- package/src/init.ts +57 -39
- package/src/install/claude.ts +44 -8
- package/src/install/codex.ts +105 -13
- package/src/install/opencode.ts +169 -5
- package/src/install.ts +2 -1
- package/src/lint-baseline.ts +3 -8
- package/src/mcp/evidence.ts +15 -3
- package/src/mcp/tools/index.ts +97 -185
- package/src/mcp/tools/mem.ts +153 -11
- package/src/mcp/transport.ts +5 -13
- package/src/mem/commands/read.ts +448 -0
- package/src/mem/commands/where.ts +56 -0
- package/{templates → src}/mem/commands/write.ts +12 -5
- package/src/mem/index.ts +144 -0
- package/src/mem/store.ts +350 -0
- package/src/memory.ts +238 -40
- package/src/plan-seed.ts +26 -6
- package/src/review-seed.ts +19 -0
- package/src/setup.ts +7 -8
- package/src/stats/data.ts +40 -104
- package/src/stats/format.ts +8 -9
- package/src/stats/index.ts +0 -1
- package/templates/PLAN.md +1 -0
- package/src/mcp/tools/context.ts +0 -66
- package/src/mcp/tools/plans.ts +0 -255
- package/src/mcp/tools/stats.ts +0 -96
- package/templates/mem/commands/read.ts +0 -194
- package/templates/mem/commands/selftest.ts +0 -450
- package/templates/mem/mem.ts +0 -68
- package/templates/mem/store.ts +0 -285
- /package/{templates → src}/mem/commands/plan.ts +0 -0
- /package/{templates → src}/mem/commands/rotate.ts +0 -0
- /package/{templates → src}/mem/render.ts +0 -0
- /package/{templates → src}/mem/selectors.ts +0 -0
package/src/mcp/tools/plans.ts
DELETED
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
// src/mcp/tools/plans.ts — plan_list tool
|
|
2
|
-
//
|
|
3
|
-
// Answers "what's pending, what's blocked on what, and what should come
|
|
4
|
-
// next" — not a directory listing (an agent can `ls` on its own for that).
|
|
5
|
-
// Two joins give it that: filesystem plan files × real run history from
|
|
6
|
-
// `runs`/`events`, and the plan's own frontmatter × the plans it points at.
|
|
7
|
-
//
|
|
8
|
-
// The frontmatter is deliberately tiny (4 keys) because everything else is
|
|
9
|
-
// derivable: "never touched" comes from run history, ordering comes from the
|
|
10
|
-
// blocks/blocked_by edges. A plan with no frontmatter at all still lands in a
|
|
11
|
-
// sensible group, so an existing repo gets value before anyone annotates it.
|
|
12
|
-
|
|
13
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
import {
|
|
16
|
-
doneDir,
|
|
17
|
-
type Event,
|
|
18
|
-
loadConfig,
|
|
19
|
-
openDb,
|
|
20
|
-
planDir,
|
|
21
|
-
type Run,
|
|
22
|
-
} from "../../db/index.js";
|
|
23
|
-
import { getLastVerdictByPlan, resolveMaxRounds } from "../../stats/index.js";
|
|
24
|
-
import { errorResult, jsonResult, type ToolResult } from "../types.js";
|
|
25
|
-
|
|
26
|
-
type Front = {
|
|
27
|
-
kind?: string;
|
|
28
|
-
status?: string;
|
|
29
|
-
blocked_by?: string;
|
|
30
|
-
blocks: string[];
|
|
31
|
-
superseded_by?: string;
|
|
32
|
-
spec?: string;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
type Entry = {
|
|
36
|
-
file: string;
|
|
37
|
-
title: string;
|
|
38
|
-
runs: number;
|
|
39
|
-
last: string;
|
|
40
|
-
escalated: boolean;
|
|
41
|
-
progress?: string;
|
|
42
|
-
blocked_by?: string;
|
|
43
|
-
blocks?: string[];
|
|
44
|
-
superseded_by?: string;
|
|
45
|
-
spec?: string;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const EMPTY: Front = { blocks: [] };
|
|
49
|
-
|
|
50
|
-
/** Frontmatter without a YAML dependency: flat `key: value` lines only. */
|
|
51
|
-
export function parseFront(text: string): Front {
|
|
52
|
-
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
53
|
-
if (!m) return { blocks: [] };
|
|
54
|
-
const front: Front = { blocks: [] };
|
|
55
|
-
for (const line of m[1].split(/\r?\n/)) {
|
|
56
|
-
const kv = /^\s*([a-z_]+)\s*:\s*(.*?)\s*$/.exec(line);
|
|
57
|
-
if (!kv) continue;
|
|
58
|
-
// A trailing `# why` comment is prose for the human, not part of the value.
|
|
59
|
-
const value = kv[2].replace(/\s+#.*$/, "").trim();
|
|
60
|
-
if (!value) continue;
|
|
61
|
-
if (kv[1] === "blocks") {
|
|
62
|
-
front.blocks = value
|
|
63
|
-
.split(",")
|
|
64
|
-
.map((s) => s.trim())
|
|
65
|
-
.filter(Boolean);
|
|
66
|
-
} else if (kv[1] === "kind") front.kind = value;
|
|
67
|
-
else if (kv[1] === "spec") front.spec = value;
|
|
68
|
-
else if (kv[1] === "status") front.status = value;
|
|
69
|
-
else if (kv[1] === "blocked_by") front.blocked_by = value;
|
|
70
|
-
else if (kv[1] === "superseded_by") front.superseded_by = value;
|
|
71
|
-
}
|
|
72
|
-
return front;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Checkbox tally of the plan's summary block — the first `##` section after
|
|
77
|
-
* the title, whatever it is called. Anchoring on position instead of on the
|
|
78
|
-
* literal "TL;DR" keeps this working for plans written in any language; the
|
|
79
|
-
* deeper sections are skipped on purpose, because a step list halfway down a
|
|
80
|
-
* 140KB plan is detail, not status.
|
|
81
|
-
*/
|
|
82
|
-
function progressOf(text: string): string | undefined {
|
|
83
|
-
const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
|
|
84
|
-
const start = body.search(/^##\s+/m);
|
|
85
|
-
if (start < 0) return undefined;
|
|
86
|
-
const rest = body.slice(start);
|
|
87
|
-
const next = rest.slice(3).search(/^##\s+/m);
|
|
88
|
-
const block = next < 0 ? rest : rest.slice(0, next + 3);
|
|
89
|
-
const total = block.match(/^\s*[-*]\s+\[[ xX]\]/gm)?.length ?? 0;
|
|
90
|
-
if (!total) return undefined;
|
|
91
|
-
const done = block.match(/^\s*[-*]\s+\[[xX]\]/gm)?.length ?? 0;
|
|
92
|
-
return `${done}/${total}`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function read(path: string): {
|
|
96
|
-
title: string;
|
|
97
|
-
front: Front;
|
|
98
|
-
progress?: string;
|
|
99
|
-
} {
|
|
100
|
-
let text: string;
|
|
101
|
-
try {
|
|
102
|
-
text = readFileSync(path, "utf8");
|
|
103
|
-
} catch {
|
|
104
|
-
return { title: "(unreadable)", front: EMPTY };
|
|
105
|
-
}
|
|
106
|
-
const m = /^#\s+(.+)$/m.exec(text);
|
|
107
|
-
return {
|
|
108
|
-
title: m ? m[1].trim() : "(no title)",
|
|
109
|
-
front: parseFront(text),
|
|
110
|
-
progress: progressOf(text),
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
type Groups = {
|
|
115
|
-
active: Entry[];
|
|
116
|
-
blocked: Entry[];
|
|
117
|
-
untouched: Entry[];
|
|
118
|
-
superseded: Entry[];
|
|
119
|
-
trackers: Entry[];
|
|
120
|
-
done: number;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
function line(e: Entry): string {
|
|
124
|
-
const bits: string[] = [e.progress ?? e.last];
|
|
125
|
-
if (e.blocks?.length) bits.push(`unblocks ${e.blocks.join(", ")}`);
|
|
126
|
-
if (e.blocked_by) bits.push(`waiting: ${e.blocked_by}`);
|
|
127
|
-
if (e.superseded_by) bits.push(`replaced by ${e.superseded_by}`);
|
|
128
|
-
if (e.escalated) bits.push("escalated");
|
|
129
|
-
const box = e.progress?.match(/^(\d+)\/\1$/) ? "x" : " ";
|
|
130
|
-
return `- [${box}] ${e.file.replace(/\.md$/, "")} — ${bits.join(" · ")}`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* The hand-maintained master checklist, generated instead. Everything here
|
|
135
|
-
* already lives in the plans themselves (frontmatter edges + summary
|
|
136
|
-
* checkboxes), so a rendered view can never drift out of date the way a
|
|
137
|
-
* MASTER.md does.
|
|
138
|
-
*/
|
|
139
|
-
export function renderPlanList(g: Groups): string {
|
|
140
|
-
const out: string[] = [];
|
|
141
|
-
const section = (name: string, list: Entry[]) => {
|
|
142
|
-
if (!list.length) return;
|
|
143
|
-
out.push(`## ${name} (${list.length})`);
|
|
144
|
-
for (const e of list) out.push(line(e));
|
|
145
|
-
out.push("");
|
|
146
|
-
};
|
|
147
|
-
section("active — in order", g.active);
|
|
148
|
-
section("blocked", g.blocked);
|
|
149
|
-
section("untouched", g.untouched);
|
|
150
|
-
section("superseded — archive these", g.superseded);
|
|
151
|
-
section("trackers — not backlog", g.trackers);
|
|
152
|
-
out.push(`done: ${g.done} archived`);
|
|
153
|
-
return out.join("\n");
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function toolPlanList(args: Record<string, unknown>): ToolResult {
|
|
157
|
-
const worktree = typeof args.worktree === "string" ? args.worktree : "";
|
|
158
|
-
if (!worktree) return errorResult("worktree is required (absolute path)");
|
|
159
|
-
const markdown = args.format === "markdown";
|
|
160
|
-
|
|
161
|
-
// Path layout belongs to the worktree being listed, not to wherever the MCP
|
|
162
|
-
// server happened to start — a repo that keeps plans in apps/<app>/plan says
|
|
163
|
-
// so in its own fapony.config.json. Fall back to the server-global config
|
|
164
|
-
// (FAPONY_CONFIG or cwd) when the worktree has none.
|
|
165
|
-
const localConfig = join(worktree, "fapony.config.json");
|
|
166
|
-
const config = existsSync(localConfig)
|
|
167
|
-
? loadConfig(localConfig)
|
|
168
|
-
: loadConfig();
|
|
169
|
-
const dir = join(worktree, planDir(config));
|
|
170
|
-
// Archive lives beside plan/, so a plan keeps its depth (and its relative
|
|
171
|
-
// links) when it is archived. Repos from before that layout keep theirs:
|
|
172
|
-
// fall back to plan/done/ rather than silently reporting 0 archived.
|
|
173
|
-
const configured = join(worktree, doneDir(config));
|
|
174
|
-
const archive = existsSync(configured) ? configured : join(dir, "done");
|
|
175
|
-
const empty = {
|
|
176
|
-
active: [],
|
|
177
|
-
blocked: [],
|
|
178
|
-
untouched: [],
|
|
179
|
-
superseded: [],
|
|
180
|
-
trackers: [],
|
|
181
|
-
};
|
|
182
|
-
if (!existsSync(dir)) {
|
|
183
|
-
return jsonResult({ ...empty, done: 0, error: `no plan dir at ${dir}` });
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const pendingFiles = readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
187
|
-
const doneCount = existsSync(archive)
|
|
188
|
-
? readdirSync(archive).filter((f) => f.endsWith(".md")).length
|
|
189
|
-
: 0;
|
|
190
|
-
|
|
191
|
-
const db = openDb();
|
|
192
|
-
const runs = db.prepare("SELECT * FROM runs ORDER BY id").all() as Run[];
|
|
193
|
-
const events = db
|
|
194
|
-
.prepare("SELECT * FROM events ORDER BY run_id, id")
|
|
195
|
-
.all() as Event[];
|
|
196
|
-
const byPlan = getLastVerdictByPlan(runs, events, resolveMaxRounds());
|
|
197
|
-
|
|
198
|
-
const active: Entry[] = [];
|
|
199
|
-
const blocked: Entry[] = [];
|
|
200
|
-
const untouched: Entry[] = [];
|
|
201
|
-
const superseded: Entry[] = [];
|
|
202
|
-
const trackers: Entry[] = [];
|
|
203
|
-
|
|
204
|
-
for (const file of pendingFiles) {
|
|
205
|
-
const { title, front, progress } = read(join(dir, file));
|
|
206
|
-
// Runs record whatever plan string the caller passed (often a relative
|
|
207
|
-
// or absolute path) — match by filename suffix, not exact equality.
|
|
208
|
-
const match = byPlan.find((p) => p.plan.endsWith(file));
|
|
209
|
-
const entry: Entry = {
|
|
210
|
-
file,
|
|
211
|
-
title,
|
|
212
|
-
runs: match?.runs ?? 0,
|
|
213
|
-
last: match
|
|
214
|
-
? match.lastReasonCode
|
|
215
|
-
? `${match.lastVerdict}(${match.lastReasonCode})`
|
|
216
|
-
: match.lastVerdict
|
|
217
|
-
: "never attempted",
|
|
218
|
-
escalated: match?.escalated ?? false,
|
|
219
|
-
};
|
|
220
|
-
if (progress) entry.progress = progress;
|
|
221
|
-
if (front.spec) entry.spec = front.spec;
|
|
222
|
-
if (front.blocked_by) entry.blocked_by = front.blocked_by;
|
|
223
|
-
if (front.blocks.length) entry.blocks = front.blocks;
|
|
224
|
-
if (front.superseded_by) entry.superseded_by = front.superseded_by;
|
|
225
|
-
|
|
226
|
-
// A tracker is never a unit of work, so it never "finishes" and must not
|
|
227
|
-
// sit in the backlog shaming everyone — that's why done/ never moved.
|
|
228
|
-
if (front.kind === "tracker") trackers.push(entry);
|
|
229
|
-
else if (front.status === "superseded") superseded.push(entry);
|
|
230
|
-
else if (front.status === "blocked" || front.blocked_by)
|
|
231
|
-
blocked.push(entry);
|
|
232
|
-
else if (front.status === "active" || entry.runs > 0) active.push(entry);
|
|
233
|
-
else untouched.push(entry);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Ordering falls out of the blocks edges: what unblocks the most goes first.
|
|
237
|
-
// No priority number to argue with later.
|
|
238
|
-
active.sort(
|
|
239
|
-
(a, b) =>
|
|
240
|
-
(b.blocks?.length ?? 0) - (a.blocks?.length ?? 0) ||
|
|
241
|
-
a.file.localeCompare(b.file),
|
|
242
|
-
);
|
|
243
|
-
|
|
244
|
-
const groups: Groups = {
|
|
245
|
-
active,
|
|
246
|
-
blocked,
|
|
247
|
-
untouched,
|
|
248
|
-
superseded,
|
|
249
|
-
trackers,
|
|
250
|
-
done: doneCount,
|
|
251
|
-
};
|
|
252
|
-
return markdown
|
|
253
|
-
? { content: [{ type: "text", text: renderPlanList(groups) }] }
|
|
254
|
-
: jsonResult(groups);
|
|
255
|
-
}
|
package/src/mcp/tools/stats.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
// src/mcp/tools/stats.ts — fapony_stats tool
|
|
2
|
-
|
|
3
|
-
import type { Run } from "../../db/index.js";
|
|
4
|
-
import { openDb } from "../../db/index.js";
|
|
5
|
-
import {
|
|
6
|
-
currentWorktree,
|
|
7
|
-
formatStatsText,
|
|
8
|
-
formatVerdictText,
|
|
9
|
-
getPlanBreakdown,
|
|
10
|
-
getStatsData,
|
|
11
|
-
type PlanBreakdown,
|
|
12
|
-
resolveMaxRounds,
|
|
13
|
-
} from "../../stats/index.js";
|
|
14
|
-
import { errorResult, jsonResult, type ToolResult } from "../types.js";
|
|
15
|
-
|
|
16
|
-
export function toolFaponyStats(args: Record<string, unknown>): ToolResult {
|
|
17
|
-
// Same default as `fapony stats` (src/stats/cli.ts): the project you are
|
|
18
|
-
// standing in. A bare MCP call used to go global, so an agent asking about
|
|
19
|
-
// one repo got every repo averaged together — wrong answer AND the biggest
|
|
20
|
-
// payload the tool can return. `all:true` is the opt-in, mirroring `--all`.
|
|
21
|
-
const worktree =
|
|
22
|
-
typeof args.worktree === "string" && args.worktree
|
|
23
|
-
? args.worktree
|
|
24
|
-
: args.all === true
|
|
25
|
-
? undefined
|
|
26
|
-
: (currentWorktree() ?? undefined);
|
|
27
|
-
const data = getStatsData(worktree);
|
|
28
|
-
|
|
29
|
-
// group_by: top-N slice from real events (PLAN-project-health-context §2).
|
|
30
|
-
// Worktree-scoped when `worktree` is given, global otherwise.
|
|
31
|
-
const groupBy = args.group_by;
|
|
32
|
-
if (groupBy === "reason_code" || groupBy === "plan" || groupBy === "file") {
|
|
33
|
-
const top =
|
|
34
|
-
typeof args.top === "number" && args.top > 0 ? Math.floor(args.top) : 10;
|
|
35
|
-
if (groupBy === "file") {
|
|
36
|
-
const rows = (
|
|
37
|
-
worktree
|
|
38
|
-
? data.byFile.filter((f) => f.worktree === worktree)
|
|
39
|
-
: data.byFile
|
|
40
|
-
).slice(0, top);
|
|
41
|
-
return jsonResult({
|
|
42
|
-
group_by: groupBy,
|
|
43
|
-
worktree: worktree ?? null,
|
|
44
|
-
rows,
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
if (groupBy === "reason_code") {
|
|
48
|
-
const rows = (
|
|
49
|
-
worktree
|
|
50
|
-
? data.byReasonCode.filter((r) => r.worktree === worktree)
|
|
51
|
-
: data.byReasonCode
|
|
52
|
-
).slice(0, top);
|
|
53
|
-
return jsonResult({
|
|
54
|
-
group_by: groupBy,
|
|
55
|
-
worktree: worktree ?? null,
|
|
56
|
-
rows,
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
// plan grouping: recompute from scoped runs when worktree is given,
|
|
60
|
-
// so counts reflect only runs in that worktree (not global counts).
|
|
61
|
-
let rows: PlanBreakdown[];
|
|
62
|
-
if (worktree) {
|
|
63
|
-
const db = openDb();
|
|
64
|
-
try {
|
|
65
|
-
const scopedRuns = db
|
|
66
|
-
.prepare("SELECT * FROM runs WHERE worktree = ? ORDER BY id")
|
|
67
|
-
.all(worktree) as Run[];
|
|
68
|
-
rows = getPlanBreakdown(scopedRuns, resolveMaxRounds()).slice(0, top);
|
|
69
|
-
} finally {
|
|
70
|
-
db.close();
|
|
71
|
-
}
|
|
72
|
-
} else {
|
|
73
|
-
rows = data.byPlan.slice(0, top);
|
|
74
|
-
}
|
|
75
|
-
return jsonResult({ group_by: groupBy, worktree: worktree ?? null, rows });
|
|
76
|
-
}
|
|
77
|
-
if (typeof groupBy !== "undefined") {
|
|
78
|
-
return errorResult(`group_by must be one of: reason_code, plan, file`);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// mode: "verdict" → Pareto frontier of quality vs tokens/pass
|
|
82
|
-
if (args.mode === "verdict") {
|
|
83
|
-
const regime = typeof args.regime === "string" ? args.regime : undefined;
|
|
84
|
-
return {
|
|
85
|
-
content: [{ type: "text", text: formatVerdictText(data, regime) }],
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// json:true → pure StatsData (SPEC-verdict-stats) — no other text may be inserted
|
|
90
|
-
if (args.json === true) {
|
|
91
|
-
return jsonResult(data);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// json:false → the same text as `fapony stats` — same formatter, raw (not JSON-wrapped)
|
|
95
|
-
return { content: [{ type: "text", text: formatStatsText(data) }] };
|
|
96
|
-
}
|
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
// commands/read.ts — read-only commands: now (default), done, stale, find, kickoff
|
|
2
|
-
|
|
3
|
-
import { doneLines, fmtClose, fmtRow, printOpenRows } from "../render.js";
|
|
4
|
-
import { claimsOf, openRows, staleReport } from "../selectors.js";
|
|
5
|
-
import type { CloseRow, WorkRow } from "../store.js";
|
|
6
|
-
import { app, memCmd, rows } from "../store.js";
|
|
7
|
-
import { planSweepCmd, shippedNotMoved } from "./plan.js";
|
|
8
|
-
import { THRESHOLD } from "./rotate.js";
|
|
9
|
-
|
|
10
|
-
const rotateLine = (n: number) =>
|
|
11
|
-
n >= THRESHOLD
|
|
12
|
-
? `\n## 🗜 log ${n} rows (≥ ${THRESHOLD}) — run: ${memCmd} rotate --apply`
|
|
13
|
-
: "";
|
|
14
|
-
|
|
15
|
-
const planSweepLine = () => {
|
|
16
|
-
const pending = shippedNotMoved();
|
|
17
|
-
return pending.length
|
|
18
|
-
? `\n## 📦 shipped but not archived into done/ (${pending.length})\n` +
|
|
19
|
-
pending.map((n) => `- ${n}`).join("\n") +
|
|
20
|
-
`\n(move: ${planSweepCmd} <file.md> --apply)`
|
|
21
|
-
: "";
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export const cmdNow = () => {
|
|
25
|
-
// mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
|
|
26
|
-
const all = rows();
|
|
27
|
-
console.log(`# ${app} — ${all.length} entries`);
|
|
28
|
-
printOpenRows(all, { showHold: true });
|
|
29
|
-
const decisionN = openRows(all).filter((r) => r.kind === "decision").length;
|
|
30
|
-
const noteN = openRows(all).filter((r) => r.kind === "note").length;
|
|
31
|
-
console.log(
|
|
32
|
-
`\n## decision ${decisionN} · note ${noteN} — search: ${memCmd} find <word>`,
|
|
33
|
-
);
|
|
34
|
-
const stale = staleReport(all);
|
|
35
|
-
if (stale.length)
|
|
36
|
-
console.log(
|
|
37
|
-
`\n## ⚠ stale (${stale.length})\n` +
|
|
38
|
-
stale.map((l) => `- ${l}`).join("\n"),
|
|
39
|
-
);
|
|
40
|
-
const sweep = planSweepLine();
|
|
41
|
-
if (sweep) console.log(sweep);
|
|
42
|
-
const rotate = rotateLine(all.length);
|
|
43
|
-
if (rotate) console.log(rotate);
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
export const cmdDone = () => {
|
|
47
|
-
const all = rows();
|
|
48
|
-
for (const l of doneLines(all)) console.log(l);
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
export const cmdStale = () => {
|
|
52
|
-
for (const l of staleReport(rows())) console.log(l);
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export const cmdFind = (a: string[]) => {
|
|
56
|
-
// mem find <word> — grep text/spec case-insensitively, newest first, capped at 20 rows
|
|
57
|
-
const q = a.join(" ").toLowerCase();
|
|
58
|
-
if (!q) {
|
|
59
|
-
console.error(`usage: ${memCmd} find <word>`);
|
|
60
|
-
process.exit(1);
|
|
61
|
-
}
|
|
62
|
-
const hits = rows()
|
|
63
|
-
.filter(
|
|
64
|
-
(r): r is WorkRow =>
|
|
65
|
-
r.kind !== "close" &&
|
|
66
|
-
r.kind !== "synced" &&
|
|
67
|
-
r.kind !== "claim" &&
|
|
68
|
-
r.kind !== "release",
|
|
69
|
-
)
|
|
70
|
-
.filter(
|
|
71
|
-
(r) =>
|
|
72
|
-
r.text.toLowerCase().includes(q) ||
|
|
73
|
-
(r.spec ?? "").toLowerCase().includes(q),
|
|
74
|
-
)
|
|
75
|
-
.slice(-20);
|
|
76
|
-
for (const r of hits)
|
|
77
|
-
console.log(
|
|
78
|
-
`- [${r.id}] ${r.ts.slice(0, 10)} ${r.kind} ${r.text}${r.spec ? ` → ${r.spec}` : ""}`,
|
|
79
|
-
);
|
|
80
|
-
if (!hits.length) console.log("(no matches)");
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
export const cmdKickoff = (a: string[]) => {
|
|
84
|
-
// mem kickoff [id|spec.md]
|
|
85
|
-
const all = rows();
|
|
86
|
-
const arg = a[0] ?? "";
|
|
87
|
-
|
|
88
|
-
if (!arg) {
|
|
89
|
-
// no args = now + a "recent" section = the last 10 closes
|
|
90
|
-
console.log(`# ${app} — ${all.length} entries`);
|
|
91
|
-
printOpenRows(all, { showHold: true });
|
|
92
|
-
console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
|
|
93
|
-
const stale = staleReport(all);
|
|
94
|
-
if (stale.length)
|
|
95
|
-
console.log(
|
|
96
|
-
`\n## ⚠ stale (${stale.length})\n` +
|
|
97
|
-
stale.map((l) => `- ${l}`).join("\n"),
|
|
98
|
-
);
|
|
99
|
-
const sweep = planSweepLine();
|
|
100
|
-
if (sweep) console.log(sweep);
|
|
101
|
-
const rotate = rotateLine(all.length);
|
|
102
|
-
if (rotate) console.log(rotate);
|
|
103
|
-
} else if (arg.endsWith(".md")) {
|
|
104
|
-
// spec.md = a brief for that spec
|
|
105
|
-
const workAll = all.filter((r): r is WorkRow => "id" in r);
|
|
106
|
-
const byId = new Map(workAll.map((r) => [r.id, r] as const));
|
|
107
|
-
const open = openRows(all);
|
|
108
|
-
const specRows = open.filter((r) => r.spec === arg);
|
|
109
|
-
const specDecisions = all
|
|
110
|
-
.filter((r): r is WorkRow => r.kind === "decision" && r.spec === arg)
|
|
111
|
-
.slice(-5);
|
|
112
|
-
const specCloses = all
|
|
113
|
-
.filter((r): r is CloseRow => r.kind === "close")
|
|
114
|
-
.filter((r) => byId.get(r.ref)?.spec === arg)
|
|
115
|
-
.slice(-3);
|
|
116
|
-
|
|
117
|
-
console.log(`# ${arg} — ${specRows.length} open rows`);
|
|
118
|
-
if (specRows.length) {
|
|
119
|
-
console.log(`\n## open\n${specRows.map((r) => fmtRow(r)).join("\n")}`);
|
|
120
|
-
}
|
|
121
|
-
if (specDecisions.length) {
|
|
122
|
-
console.log(
|
|
123
|
-
`\n## decisions\n` +
|
|
124
|
-
specDecisions
|
|
125
|
-
.map((r) => `- ${r.ts.slice(0, 10)} ${r.text}`)
|
|
126
|
-
.join("\n"),
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
if (specCloses.length) {
|
|
130
|
-
console.log(
|
|
131
|
-
`\n## closes\n${specCloses.map((c) => fmtClose(c, byId)).join("\n")}`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
if (!specRows.length && !specDecisions.length && !specCloses.length) {
|
|
135
|
-
console.log("(no entries for this spec)");
|
|
136
|
-
}
|
|
137
|
-
} else {
|
|
138
|
-
// id = a brief for that task
|
|
139
|
-
const workAll = all.filter((r): r is WorkRow => "id" in r);
|
|
140
|
-
const byId = new Map(workAll.map((r) => [r.id, r] as const));
|
|
141
|
-
const target = byId.get(arg);
|
|
142
|
-
if (!target) {
|
|
143
|
-
console.error(`no id "${arg}" in the log`);
|
|
144
|
-
process.exit(1);
|
|
145
|
-
}
|
|
146
|
-
const claims = claimsOf(all);
|
|
147
|
-
const claimInfo = claims.has(arg)
|
|
148
|
-
? `\nClaimed by: ${claims.get(arg)?.agent} (${claims.get(arg)?.ts.slice(0, 10)})`
|
|
149
|
-
: "";
|
|
150
|
-
|
|
151
|
-
const spec = target.spec;
|
|
152
|
-
const open = spec
|
|
153
|
-
? openRows(all).filter((r) => r.spec === spec && r.id !== arg)
|
|
154
|
-
: [];
|
|
155
|
-
const specDecisions = spec
|
|
156
|
-
? all
|
|
157
|
-
.filter((r): r is WorkRow => r.kind === "decision" && r.spec === spec)
|
|
158
|
-
.slice(-5)
|
|
159
|
-
: [];
|
|
160
|
-
const specNotes = spec
|
|
161
|
-
? all
|
|
162
|
-
.filter((r): r is WorkRow => r.kind === "note" && r.spec === spec)
|
|
163
|
-
.slice(-5)
|
|
164
|
-
: [];
|
|
165
|
-
const specCloses = spec
|
|
166
|
-
? all
|
|
167
|
-
.filter((r): r is CloseRow => r.kind === "close")
|
|
168
|
-
.filter((r) => byId.get(r.ref)?.spec === spec)
|
|
169
|
-
.slice(-3)
|
|
170
|
-
: [];
|
|
171
|
-
|
|
172
|
-
console.log(
|
|
173
|
-
`# [${target.id}] ${target.kind} — ${target.text}${target.spec ? ` → ${target.spec}` : ""}${claimInfo}`,
|
|
174
|
-
);
|
|
175
|
-
if (open.length) {
|
|
176
|
-
console.log(
|
|
177
|
-
`\n## open (same spec)\n` +
|
|
178
|
-
open.map((r) => fmtRow(r, claims)).join("\n"),
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
if (specDecisions.length || specNotes.length) {
|
|
182
|
-
console.log(`\n## decisions & notes (spec)\n`);
|
|
183
|
-
for (const r of [...specDecisions, ...specNotes]) {
|
|
184
|
-
console.log(`- ${r.ts.slice(0, 10)} ${r.kind} ${r.text}`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
if (specCloses.length) {
|
|
188
|
-
console.log(
|
|
189
|
-
`\n## closes (spec)\n` +
|
|
190
|
-
specCloses.map((c) => fmtClose(c, byId)).join("\n"),
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
};
|