fapony 0.6.2 → 0.7.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 +144 -156
- package/package.json +5 -6
- package/skill/define-convention/SKILL.md +3 -3
- package/skill/lookup-before-edit/SKILL.md +2 -1
- package/skill/move-to-done/SKILL.md +18 -22
- package/skill/plan-with-pony/SKILL.md +4 -4
- package/skill/review-pony/SKILL.md +10 -12
- package/src/adapters/cli.ts +11 -52
- package/src/adapters/hooks/index.ts +0 -67
- package/src/adapters/hooks/mv-guard.ts +2 -2
- package/src/analyze/discover.ts +1 -1
- package/src/analyze/index.ts +0 -1
- package/src/commands.ts +8 -39
- package/src/core/config.ts +0 -6
- package/src/core/fapony-dir.ts +71 -0
- package/src/core/hook-helpers.ts +1 -2
- package/src/debt/load.ts +3 -5
- package/src/debt/promotion.ts +2 -2
- package/src/debt/scan.ts +1 -1
- package/src/digest/collect.ts +6 -7
- package/src/fael.ts +139 -0
- package/src/hook.ts +1 -81
- package/src/init.ts +30 -90
- package/src/install/antigravity.ts +8 -99
- package/src/install/claude.ts +66 -200
- package/src/install/codex.ts +6 -170
- package/src/install/detect.ts +4 -14
- package/src/install/opencode.ts +50 -486
- package/src/install/types.ts +1 -39
- package/src/install/zcode.ts +5 -91
- package/src/install.ts +9 -35
- package/src/memory.ts +10 -69
- package/src/plan/index.ts +34 -0
- package/src/plan/next.ts +181 -0
- package/src/plan/store.ts +41 -0
- package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
- package/src/seed/plan-seed.ts +10 -9
- package/src/setup.ts +4 -22
- package/src/update.ts +1 -1
- package/templates/PLAN.md +1 -1
- package/src/adapters/hooks/bug-markers.ts +0 -60
- package/src/adapters/hooks/context-data.ts +0 -139
- package/src/adapters/hooks/edit-hint.ts +0 -215
- package/src/adapters/hooks/read-hint.ts +0 -410
- package/src/adapters/hooks/session-start.ts +0 -112
- package/src/adapters/hooks/stop.ts +0 -768
- package/src/adapters/mcp/tools/index.ts +0 -149
- package/src/adapters/mcp/tools/mem.ts +0 -252
- package/src/adapters/mcp/transport.ts +0 -147
- package/src/analyze/cache.ts +0 -162
- package/src/core/mem-log.ts +0 -375
- package/src/init-mem.ts +0 -143
- package/src/install/cursor.ts +0 -167
- package/src/install/utils.ts +0 -29
- package/src/mem/commands/read.ts +0 -738
- package/src/mem/commands/rotate.ts +0 -59
- package/src/mem/commands/where.ts +0 -56
- package/src/mem/commands/write.ts +0 -273
- package/src/mem/engine.ts +0 -315
- package/src/mem/index.ts +0 -201
- package/src/mem/render.ts +0 -66
- package/src/mem/selectors.ts +0 -160
- package/src/mem/store.ts +0 -356
package/src/mem/index.ts
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
// src/mem/index.ts — CLI dispatch for `fapony mem <sub>`
|
|
2
|
-
//
|
|
3
|
-
// Moved from templates/mem/mem.ts (2026-09-19) as part of PLAN-agent-one-call chunk 1.
|
|
4
|
-
// Now an export function called by fapony.ts, not a standalone script.
|
|
5
|
-
|
|
6
|
-
import { existsSync } from "node:fs";
|
|
7
|
-
import { basename, join } from "node:path";
|
|
8
|
-
import { levenshtein } from "../commands.js";
|
|
9
|
-
import { cmdPlanCheck, cmdPlanSweep } from "./commands/plan.js";
|
|
10
|
-
import { cmdDone, cmdFind, cmdKickoff, cmdStale } from "./commands/read.js";
|
|
11
|
-
import { cmdRotate } from "./commands/rotate.js";
|
|
12
|
-
import { cmdWhere } from "./commands/where.js";
|
|
13
|
-
import {
|
|
14
|
-
cmdAdd,
|
|
15
|
-
cmdClaim,
|
|
16
|
-
cmdClose,
|
|
17
|
-
cmdHook,
|
|
18
|
-
cmdRelease,
|
|
19
|
-
cmdSynced,
|
|
20
|
-
} from "./commands/write.js";
|
|
21
|
-
import { doneDir, planDir } from "./store.js";
|
|
22
|
-
|
|
23
|
-
const MEM_HELP = `usage: fapony mem [--mem-dir <path>] <sub> [args]
|
|
24
|
-
|
|
25
|
-
subcommands:
|
|
26
|
-
where show the resolved mem dir and which step won
|
|
27
|
-
kickoff [<plan.md>] [--pick <n>] open a session + a next-up list
|
|
28
|
-
add <kind> "<text>" --files f1,f2 [--key k] [spec.md]
|
|
29
|
-
close <id> "<msg>" close a bug
|
|
30
|
-
find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--key k]
|
|
31
|
-
substring-search every row (archives included)
|
|
32
|
-
done | stale views
|
|
33
|
-
claim <id> | release <id> | synced bookkeeping
|
|
34
|
-
plan-sweep [<plan.md> [--apply]] move shipped plans + fix links
|
|
35
|
-
plan-check <plan.md> validate a plan's frontmatter/sections
|
|
36
|
-
rotate archive the live log when it grows
|
|
37
|
-
hook Stop-hook payload reader (installed, not run by hand)
|
|
38
|
-
|
|
39
|
-
--mem-dir <path> wins over config and the walk-up (global to every sub)
|
|
40
|
-
example: fapony mem add note "decided X because Y" --files src/a.ts`;
|
|
41
|
-
|
|
42
|
-
const HELP: Record<string, string> = {
|
|
43
|
-
where: `usage: fapony mem where [--from <dir>]
|
|
44
|
-
show the .fapony/.memory dir the resolver picks and which step won — writes nothing
|
|
45
|
-
--from <dir> resolve as if cwd were here
|
|
46
|
-
example: fapony mem where`,
|
|
47
|
-
kickoff: `usage: fapony mem kickoff [<plan.md>] [--pick <n>]
|
|
48
|
-
open a session and print a "next up" list (priority plans, unchecked chunks, open bugs)
|
|
49
|
-
<plan.md> plan whose first unchecked chunk is offered as a context line
|
|
50
|
-
--pick <n> run suggestion [n] non-interactively; refuses context/template items
|
|
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 [--key k] [spec.md]
|
|
53
|
-
append one row to the mem log
|
|
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)
|
|
56
|
-
--stdin read <text> from stdin (avoids shell metachar)
|
|
57
|
-
example: fapony mem add decision "chose X because Y" --files src/a.ts,src/b.ts --key unify-mem-engine`,
|
|
58
|
-
close: `usage: fapony mem close <id> "<what was done | commit>"
|
|
59
|
-
tombstone a bug so it stops showing as open work
|
|
60
|
-
--stdin read the message from stdin
|
|
61
|
-
example: fapony mem close mt14 "fixed in a2c6beb"`,
|
|
62
|
-
find: `usage: fapony mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--key k]
|
|
63
|
-
substring search over every row's text/spec/ref (rotated archives included) — bookkeeping kinds (close/synced/claim/release) hidden unless --kind names them
|
|
64
|
-
--kind a,b include only these kinds (overrides the bookkeeping default)
|
|
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
|
|
67
|
-
--since 7d only rows at or after this time (<N>d or YYYY-MM-DD)
|
|
68
|
-
--limit n max rows returned (default 20, newest first)
|
|
69
|
-
example: fapony mem find "usage-web" --kind bug,decision --limit 5`,
|
|
70
|
-
done: `usage: fapony mem done
|
|
71
|
-
closed rows with their tombstone message
|
|
72
|
-
example: fapony mem done`,
|
|
73
|
-
stale: `usage: fapony mem stale
|
|
74
|
-
decisions that never made it into their spec
|
|
75
|
-
example: fapony mem stale`,
|
|
76
|
-
claim: `usage: fapony mem claim <id>
|
|
77
|
-
claim an open next|bug before working it
|
|
78
|
-
example: fapony mem claim mt14`,
|
|
79
|
-
release: `usage: fapony mem release <id> [--stdin]
|
|
80
|
-
give back a claimed row without closing it
|
|
81
|
-
example: fapony mem release mt14`,
|
|
82
|
-
synced: `usage: fapony mem synced [<spec.md>]
|
|
83
|
-
mark a spec as carried into the log
|
|
84
|
-
example: fapony mem synced .fapony/spec/SPEC-x.md`,
|
|
85
|
-
"plan-sweep": `usage: fapony mem plan-sweep [<plan.md>] [--apply]
|
|
86
|
-
find shipped plans (or move one) and fix the links that point at it
|
|
87
|
-
--apply perform the git mv + link fixes (default = dry run)
|
|
88
|
-
example: fapony mem plan-sweep .fapony/plan/PLAN-x.md --apply`,
|
|
89
|
-
"plan-check": `usage: fapony mem plan-check <plan.md>
|
|
90
|
-
validate a plan's frontmatter and required sections
|
|
91
|
-
example: fapony mem plan-check .fapony/plan/PLAN-x.md`,
|
|
92
|
-
rotate: `usage: fapony mem rotate
|
|
93
|
-
archive the live log to log.YYYY-MM-DD.jsonl once it crosses the threshold
|
|
94
|
-
example: fapony mem rotate`,
|
|
95
|
-
hook: `usage: fapony mem hook
|
|
96
|
-
reads the Stop-hook payload on stdin — installed by fapony, not run by hand
|
|
97
|
-
example: (installed hook only)`,
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
// Subcommand names for top-level did-you-mean (src/commands.ts) — derived
|
|
101
|
-
// from HELP keys so the list cannot drift from the dispatch below.
|
|
102
|
-
export const MEM_SUBCOMMANDS: string[] = Object.keys(HELP);
|
|
103
|
-
|
|
104
|
-
// Chunk 7 (PLAN-seed-and-surface, bug muc85pml): `fapony mem now` used to fall
|
|
105
|
-
// through to kickoff and print an overview ("N entries") instead of an error —
|
|
106
|
-
// a typo that looks like success. Three rules, in order:
|
|
107
|
-
// 1. a known subcommand → dispatch (unchanged)
|
|
108
|
-
// 2. looks like a plan path (.md suffix, a slash, or a file with that name
|
|
109
|
-
// in planDir/doneDir) → kickoff — this is `fapony mem PLAN-x.md`, which
|
|
110
|
-
// must keep working without the `kickoff` word
|
|
111
|
-
// 3. anything else → error, exit 1 (a typo is never a kickoff)
|
|
112
|
-
//
|
|
113
|
-
// `exists` is injectable so tests hit this directly without a store on disk —
|
|
114
|
-
// production passes nothing and reads planDir/doneDir live.
|
|
115
|
-
export function classifyMemArg(
|
|
116
|
-
arg: string,
|
|
117
|
-
exists?: (name: string) => boolean,
|
|
118
|
-
): "subcommand" | "plan" | "unknown" {
|
|
119
|
-
// hasOwn, not `in` or indexing — prototype props ("toString") are not
|
|
120
|
-
// subcommands.
|
|
121
|
-
if (Object.hasOwn(HELP, arg)) return "subcommand";
|
|
122
|
-
if (arg.endsWith(".md") || arg.includes("/")) return "plan";
|
|
123
|
-
const has =
|
|
124
|
-
exists ??
|
|
125
|
-
((name: string): boolean => {
|
|
126
|
-
const base = basename(name);
|
|
127
|
-
const candidates = [name, base, `${name}.md`, `${base}.md`];
|
|
128
|
-
return candidates.some(
|
|
129
|
-
(c) => existsSync(join(planDir, c)) || existsSync(join(doneDir, c)),
|
|
130
|
-
);
|
|
131
|
-
});
|
|
132
|
-
if (has(arg)) return "plan";
|
|
133
|
-
return "unknown";
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export async function cmdMem(a: string[], memDir?: string): Promise<void> {
|
|
137
|
-
const [cmd, ...rest] = a;
|
|
138
|
-
|
|
139
|
-
// SPEC §2: every subcommand answers --help with signature + flags + one runnable
|
|
140
|
-
// example, because the reader is an agent that gets one call to get it right.
|
|
141
|
-
if (cmd === "-h" || cmd === "--help") {
|
|
142
|
-
console.log(MEM_HELP);
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
if (rest.includes("-h") || rest.includes("--help")) {
|
|
146
|
-
console.log(HELP[cmd] ?? MEM_HELP);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (cmd === "add") {
|
|
151
|
-
await cmdAdd(rest);
|
|
152
|
-
} else if (cmd === "close") {
|
|
153
|
-
await cmdClose(rest);
|
|
154
|
-
} else if (cmd === "claim") {
|
|
155
|
-
cmdClaim(rest);
|
|
156
|
-
} else if (cmd === "release") {
|
|
157
|
-
await cmdRelease(rest);
|
|
158
|
-
} else if (cmd === "synced") {
|
|
159
|
-
cmdSynced(rest);
|
|
160
|
-
} else if (cmd === "hook") {
|
|
161
|
-
await cmdHook();
|
|
162
|
-
} else if (cmd === "done") {
|
|
163
|
-
cmdDone();
|
|
164
|
-
} else if (cmd === "stale") {
|
|
165
|
-
cmdStale();
|
|
166
|
-
} else if (cmd === "find") {
|
|
167
|
-
cmdFind(rest);
|
|
168
|
-
} else if (cmd === "kickoff") {
|
|
169
|
-
cmdKickoff(rest);
|
|
170
|
-
} else if (cmd === "where") {
|
|
171
|
-
cmdWhere(rest, memDir);
|
|
172
|
-
} else if (cmd === "plan-sweep") {
|
|
173
|
-
cmdPlanSweep(rest);
|
|
174
|
-
} else if (cmd === "plan-check") {
|
|
175
|
-
cmdPlanCheck(rest);
|
|
176
|
-
} else if (cmd === "rotate") {
|
|
177
|
-
cmdRotate(rest);
|
|
178
|
-
} else if (cmd === undefined) {
|
|
179
|
-
// bare `fapony mem` → kickoff (ranked session overview)
|
|
180
|
-
cmdKickoff(rest);
|
|
181
|
-
} else if (classifyMemArg(cmd) === "plan") {
|
|
182
|
-
// `fapony mem PLAN-x.md` — the plan arg rides along (the old else-branch
|
|
183
|
-
// dropped it and printed the no-arg overview instead).
|
|
184
|
-
cmdKickoff([cmd, ...rest]);
|
|
185
|
-
} else {
|
|
186
|
-
console.error(`fapony mem: unknown subcommand "${cmd}"`);
|
|
187
|
-
const scored = MEM_SUBCOMMANDS.map((s) => ({
|
|
188
|
-
s,
|
|
189
|
-
d: levenshtein(cmd, s),
|
|
190
|
-
}))
|
|
191
|
-
.filter((x) => x.d <= 2)
|
|
192
|
-
.sort((x, y) => x.d - y.d || (x.s < y.s ? -1 : 1))
|
|
193
|
-
.slice(0, 3)
|
|
194
|
-
.map((x) => `"fapony mem ${x.s}"`);
|
|
195
|
-
if (scored.length > 0) {
|
|
196
|
-
console.error(`did you mean ${scored.join(" or ")}?`);
|
|
197
|
-
}
|
|
198
|
-
console.error(`subcommands: ${MEM_SUBCOMMANDS.join(" ")}`);
|
|
199
|
-
process.exit(1);
|
|
200
|
-
}
|
|
201
|
-
}
|
package/src/mem/render.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
// render.ts — formatting and display helpers for the memory log
|
|
2
|
-
|
|
3
|
-
import { claimsOf, openRows } from "./selectors.js";
|
|
4
|
-
import type { ClaimRow, CloseRow, LogRow, WorkRow } from "./store.js";
|
|
5
|
-
|
|
6
|
-
export const fmtRow = (r: WorkRow, claims?: Map<string, ClaimRow>) => {
|
|
7
|
-
let s = `- [${r.id}]`;
|
|
8
|
-
if (claims?.has(r.id)) s += ` (${claims.get(r.id)?.agent})`;
|
|
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}`;
|
|
13
|
-
if (r.spec) s += ` → ${r.spec}`;
|
|
14
|
-
return s;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export const printOpenRows = (all: LogRow[], opts?: { showHold?: boolean }) => {
|
|
18
|
-
const open = openRows(all);
|
|
19
|
-
const claims = claimsOf(all);
|
|
20
|
-
|
|
21
|
-
// in-progress (claimed items)
|
|
22
|
-
const inProg = open.filter(
|
|
23
|
-
(r) => claims.has(r.id) && (r.kind === "next" || r.kind === "bug"),
|
|
24
|
-
);
|
|
25
|
-
if (inProg.length) {
|
|
26
|
-
console.log(
|
|
27
|
-
`\n## in-progress\n${inProg.map((r) => fmtRow(r, claims)).join("\n")}`,
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// next (unclaimed only)
|
|
32
|
-
const nexts = open.filter((r) => r.kind === "next" && !claims.has(r.id));
|
|
33
|
-
if (nexts.length) {
|
|
34
|
-
console.log(`\n## next\n${nexts.map((r) => fmtRow(r)).join("\n")}`);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// bug (unclaimed only)
|
|
38
|
-
const bugs = open.filter((r) => r.kind === "bug" && !claims.has(r.id));
|
|
39
|
-
if (bugs.length) {
|
|
40
|
-
console.log(`\n## bug\n${bugs.map((r) => fmtRow(r)).join("\n")}`);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// hold — sorted by spec for per-plan visibility
|
|
44
|
-
if (opts?.showHold) {
|
|
45
|
-
const holds = open
|
|
46
|
-
.filter((r) => r.kind === "hold")
|
|
47
|
-
.sort((a, b) => (a.spec ?? "").localeCompare(b.spec ?? ""));
|
|
48
|
-
if (holds.length) {
|
|
49
|
-
console.log(`\n## hold\n${holds.map((r) => fmtRow(r)).join("\n")}`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
export const fmtClose = (c: CloseRow, byId: Map<string, WorkRow>) => {
|
|
55
|
-
const o = byId.get(c.ref);
|
|
56
|
-
const agentStr = c.agent && c.agent !== "unknown" ? ` (${c.agent})` : "";
|
|
57
|
-
return `- ${c.ts.slice(0, 10)}${agentStr} ${o && "text" in o ? o.text : c.ref} → ${c.text}`;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export const doneLines = (all: LogRow[], limit?: number) => {
|
|
61
|
-
const workAll = all.filter((r): r is WorkRow => "id" in r);
|
|
62
|
-
const byId = new Map(workAll.map((r) => [r.id, r] as const));
|
|
63
|
-
const closes = all.filter((r): r is CloseRow => r.kind === "close").reverse();
|
|
64
|
-
const slice = limit ? closes.slice(0, limit) : closes;
|
|
65
|
-
return slice.map((c) => fmtClose(c, byId));
|
|
66
|
-
};
|
package/src/mem/selectors.ts
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
// selectors.ts — derive open/claimed/stale views from the append-only log
|
|
2
|
-
|
|
3
|
-
import type {
|
|
4
|
-
ClaimRow,
|
|
5
|
-
CloseRow,
|
|
6
|
-
LogRow,
|
|
7
|
-
ReleaseRow,
|
|
8
|
-
WorkRow,
|
|
9
|
-
} from "./store.js";
|
|
10
|
-
|
|
11
|
-
// ponytail: reducer = filter + tombstone set. 50k lines = 9.6MB/26ms — the file is not the bottleneck
|
|
12
|
-
// the real ceiling = too many open rows for the view to make sense → rotate then: git mv + append openRows(old) into a new file
|
|
13
|
-
export const openRows = (all: LogRow[]): WorkRow[] => {
|
|
14
|
-
const dead = new Set(
|
|
15
|
-
all.filter((r): r is CloseRow => r.kind === "close").map((r) => r.ref),
|
|
16
|
-
);
|
|
17
|
-
return all.filter(
|
|
18
|
-
(r): r is WorkRow =>
|
|
19
|
-
r.kind !== "close" &&
|
|
20
|
-
r.kind !== "claim" &&
|
|
21
|
-
r.kind !== "release" &&
|
|
22
|
-
r.kind !== "synced" &&
|
|
23
|
-
"id" in r &&
|
|
24
|
-
!dead.has(r.id),
|
|
25
|
-
);
|
|
26
|
-
};
|
|
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
|
-
// Decisions are skipped: nobody closes them, so a keyed decision would sit in
|
|
32
|
-
// the index forever as a fake "open problem".
|
|
33
|
-
export const openKeys = (all: LogRow[]): { key: string; open: number }[] => {
|
|
34
|
-
const counts = new Map<string, number>();
|
|
35
|
-
for (const r of openRows(all)) {
|
|
36
|
-
if (r.key && r.kind !== "decision")
|
|
37
|
-
counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
|
|
38
|
-
}
|
|
39
|
-
return [...counts.entries()]
|
|
40
|
-
.map(([key, open]) => ({ key, open }))
|
|
41
|
-
.sort((a, b) => a.key.localeCompare(b.key));
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
// C) claimsOf: returns Map ref → latest claim row still active
|
|
45
|
-
// active = the latest claim|release row is a claim and ref is not in dead (close)
|
|
46
|
-
export const claimsOf = (all: LogRow[]): Map<string, ClaimRow> => {
|
|
47
|
-
const dead = new Set(
|
|
48
|
-
all.filter((r): r is CloseRow => r.kind === "close").map((r) => r.ref),
|
|
49
|
-
);
|
|
50
|
-
const latest = new Map<string, ClaimRow | ReleaseRow>();
|
|
51
|
-
for (const r of all) {
|
|
52
|
-
if (r.kind === "claim" || r.kind === "release") {
|
|
53
|
-
latest.set(r.ref, r);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const active = new Map<string, ClaimRow>();
|
|
57
|
-
for (const [ref, r] of latest) {
|
|
58
|
-
if (r.kind === "claim" && !dead.has(ref)) {
|
|
59
|
-
active.set(ref, r);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return active;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
// a decision newer than both the spec's latest commit and the synced marker = spec not updated to match
|
|
66
|
-
// + next/hold whose spec was edited after creation = may be done already but nobody closed it — real case: msbnndmt
|
|
67
|
-
// + an active claim older than 4h = the agent may have died mid-task
|
|
68
|
-
export const staleReport = (all: LogRow[]): string[] => {
|
|
69
|
-
const out: string[] = [];
|
|
70
|
-
const mark: Record<string, number> = {};
|
|
71
|
-
for (const r of all) {
|
|
72
|
-
if (r.kind === "synced") mark[r.spec] = Date.parse(r.ts);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// batch: collect unique spec paths, spawn one git per path
|
|
76
|
-
const specPaths = new Set<string>();
|
|
77
|
-
for (const r of all) {
|
|
78
|
-
if (r.kind === "decision" && r.spec) specPaths.add(r.spec);
|
|
79
|
-
}
|
|
80
|
-
for (const r of openRows(all)) {
|
|
81
|
-
if ((r.kind === "next" || r.kind === "hold") && r.spec)
|
|
82
|
-
specPaths.add(r.spec);
|
|
83
|
-
}
|
|
84
|
-
const gitDates = new Map<string, number>();
|
|
85
|
-
for (const spec of specPaths) {
|
|
86
|
-
// --follow: a spec path that was git mv'd (e.g. moved into plan/done/) can still follow its history
|
|
87
|
-
const git = Bun.spawnSync([
|
|
88
|
-
"git",
|
|
89
|
-
"log",
|
|
90
|
-
"-1",
|
|
91
|
-
"--follow",
|
|
92
|
-
"--format=%cI",
|
|
93
|
-
"--",
|
|
94
|
-
spec,
|
|
95
|
-
])
|
|
96
|
-
.stdout.toString()
|
|
97
|
-
.trim();
|
|
98
|
-
if (git) gitDates.set(spec, Date.parse(git));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
for (const r of all) {
|
|
102
|
-
if (r.kind === "decision" && r.spec) {
|
|
103
|
-
const base = Math.max(gitDates.get(r.spec) ?? 0, mark[r.spec] ?? 0);
|
|
104
|
-
if (base < Date.parse(r.ts))
|
|
105
|
-
out.push(
|
|
106
|
-
`STALE ${r.spec}: decision ${r.ts.slice(0, 10)} never made it into the spec — "${r.text}"`,
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
for (const r of openRows(all)) {
|
|
111
|
-
if ((r.kind === "next" || r.kind === "hold") && r.spec) {
|
|
112
|
-
const gitDate = gitDates.get(r.spec);
|
|
113
|
-
// ponytail: 24h grace — "log next then write/commit the plan the same day" is normal workflow
|
|
114
|
-
// not a signal that the work finished and was forgotten (the real case to catch, msbnndmt, is days apart) —
|
|
115
|
-
// without grace = SUSPECT fires on every freshly written plan, and everyone learns to skim past the whole stale block
|
|
116
|
-
if (gitDate && gitDate > Date.parse(r.ts) + 86_400_000)
|
|
117
|
-
out.push(
|
|
118
|
-
`SUSPECT [${r.id}] ${r.spec} changed after this ${r.kind} (${r.ts.slice(0, 10)}) — check whether it is already done`,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
const claims = claimsOf(all);
|
|
123
|
-
for (const [ref, c] of claims) {
|
|
124
|
-
const ageH = (Date.now() - Date.parse(c.ts)) / 3_600_000;
|
|
125
|
-
if (ageH > 4) {
|
|
126
|
-
out.push(
|
|
127
|
-
`SUSPECT claim [${ref}] by ${c.agent} ${c.ts.slice(0, 10)} never closed/released — the agent may have died mid-task`,
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return out;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
// rotate: rows that must carry over into the new log file after archiving
|
|
135
|
-
// - open next/bug/hold + still-active claims on those rows (close/claim of already-closed refs = discardable)
|
|
136
|
-
// - decision/note has no "close" of its own (permanent history by design) but resolves indirectly via synced:
|
|
137
|
-
// spec was synced *after* this row = the info really made it into the spec, archive (git) is enough, no need to carry it in the hot file
|
|
138
|
-
// (same logic as staleReport's decision check — no spec or not yet synced by this row = still considered relevant, keep it)
|
|
139
|
-
// ponytail: a decision/note with no spec gives no way to know if it is resolved — keep it forever (the real ceiling is
|
|
140
|
-
// attaching a spec at log time if you want rotate to be able to drop it later, not rotate's own problem)
|
|
141
|
-
export const rotateKeep = (all: LogRow[]): LogRow[] => {
|
|
142
|
-
const open = openRows(all);
|
|
143
|
-
const workOpen = open.filter(
|
|
144
|
-
(r) => r.kind === "next" || r.kind === "bug" || r.kind === "hold",
|
|
145
|
-
);
|
|
146
|
-
const claims = claimsOf(all);
|
|
147
|
-
const openIds = new Set(workOpen.map((r) => r.id));
|
|
148
|
-
const keepClaims = [...claims.values()].filter((c) => openIds.has(c.ref));
|
|
149
|
-
|
|
150
|
-
const syncedAt: Record<string, number> = {};
|
|
151
|
-
for (const r of all)
|
|
152
|
-
if (r.kind === "synced") syncedAt[r.spec] = Date.parse(r.ts);
|
|
153
|
-
const keepDecisionNote = open.filter(
|
|
154
|
-
(r) =>
|
|
155
|
-
(r.kind === "decision" || r.kind === "note") &&
|
|
156
|
-
(!r.spec || !(r.spec in syncedAt) || syncedAt[r.spec] < Date.parse(r.ts)),
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
return [...workOpen, ...keepDecisionNote, ...keepClaims];
|
|
160
|
-
};
|