fapony 0.1.1 → 0.1.2
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/package.json +8 -7
- package/skill/plan-with-pony/SKILL.md +1 -1
- package/src/analyze.ts +9 -8
- package/src/conventions-seed.ts +10 -10
- package/src/db/index.ts +1 -1
- package/src/db/store.ts +4 -0
- package/src/debt.ts +26 -22
- package/src/digest/collect.ts +7 -7
- package/src/digest/html.ts +2 -2
- package/src/digest/text.ts +1 -1
- package/src/gate.ts +3 -3
- package/src/hook.ts +119 -21
- package/src/init-mem.ts +3 -3
- package/src/install/opencode.ts +95 -8
- package/src/install/types.ts +1 -1
- package/src/install.ts +5 -1
- package/src/lint-baseline.ts +9 -9
- package/src/mcp/tools/mem.ts +1 -1
- package/src/mcp/tools/stats.ts +2 -2
- package/src/mcp/tools/usage.ts +3 -3
- package/src/mcp/tools/verdict.ts +29 -16
- package/src/mcp/transport.ts +1 -1
- package/src/plan-seed.ts +23 -21
- package/src/price/fetch.ts +19 -19
- package/src/price/resolve.ts +24 -24
- package/src/review-seed.ts +1 -1
- package/src/stats/data.ts +8 -7
- package/src/stats/format.ts +6 -5
- package/src/usage/render.ts +6 -5
- package/templates/PLAN.md +1 -1
- package/templates/mem/commands/plan.ts +44 -44
- package/templates/mem/commands/read.ts +5 -5
- package/templates/mem/commands/rotate.ts +6 -6
- package/templates/mem/commands/selftest.ts +14 -14
- package/templates/mem/commands/write.ts +13 -13
- package/templates/mem/mem.ts +5 -5
- package/templates/mem/selectors.ts +18 -18
- package/templates/mem/store.ts +50 -50
|
@@ -7,17 +7,17 @@ import { KINDS, memCmd, nextId, put, root, rows } from "../store.js";
|
|
|
7
7
|
export const cmdAdd = async (a: string[]) => {
|
|
8
8
|
// mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [path/to/SPEC.md]
|
|
9
9
|
// mem add <kind> --stdin --files f1,f2 [spec.md] ← read text from stdin (avoids shell metachar issues)
|
|
10
|
-
// ponytail: kind
|
|
10
|
+
// ponytail: a wrong kind = that row silently vanishes from the view — better to die right here
|
|
11
11
|
if (!KINDS.includes(a[0] as WorkKind)) {
|
|
12
12
|
console.error(
|
|
13
13
|
`kind must be one of ${KINDS.join("|")} — got "${a[0] ?? ""}"`,
|
|
14
14
|
);
|
|
15
15
|
process.exit(1);
|
|
16
16
|
}
|
|
17
|
-
// hold
|
|
18
|
-
// PLAN-convention-debt chunk 3: files[]
|
|
19
|
-
// fill rate
|
|
20
|
-
// 48.1% —
|
|
17
|
+
// hold requires a spec — checked after parse (the spec may come before --files)
|
|
18
|
+
// PLAN-convention-debt chunk 3: files[] is required — a measured optional field
|
|
19
|
+
// had fill rate 0 (required+enum = 50/50) and recall on old logs without files[] caught only
|
|
20
|
+
// 48.1% — fix the write side, not the read side: a row that does not name the file = unfindable when you touch that file
|
|
21
21
|
const filesFlagIdx = a.indexOf("--files");
|
|
22
22
|
const filesVal = filesFlagIdx >= 0 ? a[filesFlagIdx + 1] : undefined;
|
|
23
23
|
if (!filesVal || filesVal.startsWith("--")) {
|
|
@@ -56,7 +56,7 @@ export const cmdAdd = async (a: string[]) => {
|
|
|
56
56
|
process.exit(1);
|
|
57
57
|
}
|
|
58
58
|
} else {
|
|
59
|
-
//
|
|
59
|
+
// text is required
|
|
60
60
|
if (!filtered.length || (filtered.length === 0 && !spec)) {
|
|
61
61
|
console.error(
|
|
62
62
|
`text is required — usage: ${memCmd} add <kind> "<text>" --files f1,f2 [spec.md]`,
|
|
@@ -67,7 +67,7 @@ export const cmdAdd = async (a: string[]) => {
|
|
|
67
67
|
}
|
|
68
68
|
// read once — cap check + id collision
|
|
69
69
|
const all = rows();
|
|
70
|
-
// ponytail:
|
|
70
|
+
// ponytail: a cap on open next/hold stops endless accumulation — forces triage of the old before opening new
|
|
71
71
|
const CAP = 15;
|
|
72
72
|
const CAP_HOLD = 10;
|
|
73
73
|
if (a[0] === "next" && !process.env.MEM_FORCE) {
|
|
@@ -88,14 +88,14 @@ export const cmdAdd = async (a: string[]) => {
|
|
|
88
88
|
process.exit(1);
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
-
// ponytail:
|
|
91
|
+
// ponytail: prevent id collisions — logic centralized in nextId (store.ts)
|
|
92
92
|
const id = nextId(all);
|
|
93
93
|
put({ id, kind: a[0] as WorkKind, text, spec, files });
|
|
94
94
|
console.log(id);
|
|
95
95
|
};
|
|
96
96
|
|
|
97
97
|
export const cmdClose = async (a: string[]) => {
|
|
98
|
-
// mem close <id> "
|
|
98
|
+
// mem close <id> "<what was done / commit>" — the tombstone voids the claim by itself
|
|
99
99
|
// mem close <id> --stdin ← read text from stdin
|
|
100
100
|
if (!a[0]) {
|
|
101
101
|
console.error(`id is required — usage: ${memCmd} close <id> "<text>"`);
|
|
@@ -117,7 +117,7 @@ export const cmdClose = async (a: string[]) => {
|
|
|
117
117
|
};
|
|
118
118
|
|
|
119
119
|
export const cmdClaim = (a: string[]) => {
|
|
120
|
-
// mem claim <id> —
|
|
120
|
+
// mem claim <id> — id must be real, open, kind=next|bug, with no active claim pending
|
|
121
121
|
if (!a[0]) {
|
|
122
122
|
console.error(`id is required — usage: ${memCmd} claim <id>`);
|
|
123
123
|
process.exit(1);
|
|
@@ -153,7 +153,7 @@ export const cmdClaim = (a: string[]) => {
|
|
|
153
153
|
};
|
|
154
154
|
|
|
155
155
|
export const cmdRelease = async (a: string[]) => {
|
|
156
|
-
// mem release <id> "
|
|
156
|
+
// mem release <id> "<reason>?"
|
|
157
157
|
// mem release <id> --stdin ← read text from stdin
|
|
158
158
|
if (!a[0]) {
|
|
159
159
|
console.error(`id is required — usage: ${memCmd} release <id> [reason]`);
|
|
@@ -179,7 +179,7 @@ export const cmdRelease = async (a: string[]) => {
|
|
|
179
179
|
};
|
|
180
180
|
|
|
181
181
|
export const cmdSynced = (a: string[]) => {
|
|
182
|
-
// mem synced [path.md ...] —
|
|
182
|
+
// mem synced [path.md ...] — declare the spec now matches the log (none given = every spec the log mentions)
|
|
183
183
|
const specs = a.length
|
|
184
184
|
? a
|
|
185
185
|
: [
|
|
@@ -195,7 +195,7 @@ export const cmdSynced = (a: string[]) => {
|
|
|
195
195
|
};
|
|
196
196
|
|
|
197
197
|
export const cmdHook = async () => {
|
|
198
|
-
// PostToolUse: stdin = {tool_input:{file_path}} →
|
|
198
|
+
// PostToolUse: stdin = {tool_input:{file_path}} → editing this app's spec is done = mark synced automatically
|
|
199
199
|
const j = (await Bun.stdin.json().catch(() => null)) as Record<
|
|
200
200
|
string,
|
|
201
201
|
unknown
|
package/templates/mem/mem.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
// append-only memory log.
|
|
4
|
-
// log
|
|
5
|
-
//
|
|
6
|
-
// (repo
|
|
3
|
+
// append-only memory log. Never edit old lines — close a task = close (tombstone), git = history
|
|
4
|
+
// the log always sits beside this file when placed by `fapony init` (<project>/.fapony/.memory/log.jsonl);
|
|
5
|
+
// the central copy in a monorepo (single code copy at the root) still splits logs per app: apps/<app>/.fapony/.memory/log.jsonl
|
|
6
|
+
// (a repo not yet migrated with an old log at apps/<app>/.memory/log.jsonl keeps reading that location)
|
|
7
7
|
//
|
|
8
8
|
// CLI entry point — all logic lives in:
|
|
9
9
|
// store.ts (types + config + rows/put)
|
|
@@ -63,6 +63,6 @@ if (cmd === "add") {
|
|
|
63
63
|
} else if (cmd === "rotate") {
|
|
64
64
|
cmdRotate(a);
|
|
65
65
|
} else {
|
|
66
|
-
// mem now (default) — next+bug+hold. decision/note
|
|
66
|
+
// mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
|
|
67
67
|
cmdNow();
|
|
68
68
|
}
|
|
@@ -8,8 +8,8 @@ import type {
|
|
|
8
8
|
WorkRow,
|
|
9
9
|
} from "./store.js";
|
|
10
10
|
|
|
11
|
-
// ponytail: reducer = filter + tombstone set. 50k
|
|
12
|
-
//
|
|
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
13
|
export const openRows = (all: LogRow[]): WorkRow[] => {
|
|
14
14
|
const dead = new Set(
|
|
15
15
|
all.filter((r): r is CloseRow => r.kind === "close").map((r) => r.ref),
|
|
@@ -25,8 +25,8 @@ export const openRows = (all: LogRow[]): WorkRow[] => {
|
|
|
25
25
|
);
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
// C) claimsOf:
|
|
29
|
-
// active =
|
|
28
|
+
// C) claimsOf: returns Map ref → latest claim row still active
|
|
29
|
+
// active = the latest claim|release row is a claim and ref is not in dead (close)
|
|
30
30
|
export const claimsOf = (all: LogRow[]): Map<string, ClaimRow> => {
|
|
31
31
|
const dead = new Set(
|
|
32
32
|
all.filter((r): r is CloseRow => r.kind === "close").map((r) => r.ref),
|
|
@@ -46,9 +46,9 @@ export const claimsOf = (all: LogRow[]): Map<string, ClaimRow> => {
|
|
|
46
46
|
return active;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
// decision
|
|
50
|
-
// + next/hold
|
|
51
|
-
// + active claim
|
|
49
|
+
// a decision newer than both the spec's latest commit and the synced marker = spec not updated to match
|
|
50
|
+
// + next/hold whose spec was edited after creation = may be done already but nobody closed it — real case: msbnndmt
|
|
51
|
+
// + an active claim older than 4h = the agent may have died mid-task
|
|
52
52
|
export const staleReport = (all: LogRow[]): string[] => {
|
|
53
53
|
const out: string[] = [];
|
|
54
54
|
const mark: Record<string, number> = {};
|
|
@@ -67,7 +67,7 @@ export const staleReport = (all: LogRow[]): string[] => {
|
|
|
67
67
|
}
|
|
68
68
|
const gitDates = new Map<string, number>();
|
|
69
69
|
for (const spec of specPaths) {
|
|
70
|
-
// --follow: spec path
|
|
70
|
+
// --follow: a spec path that was git mv'd (e.g. moved into plan/done/) can still follow its history
|
|
71
71
|
const git = Bun.spawnSync([
|
|
72
72
|
"git",
|
|
73
73
|
"log",
|
|
@@ -94,9 +94,9 @@ export const staleReport = (all: LogRow[]): string[] => {
|
|
|
94
94
|
for (const r of openRows(all)) {
|
|
95
95
|
if ((r.kind === "next" || r.kind === "hold") && r.spec) {
|
|
96
96
|
const gitDate = gitDates.get(r.spec);
|
|
97
|
-
// ponytail: grace
|
|
98
|
-
//
|
|
99
|
-
//
|
|
97
|
+
// ponytail: 24h grace — "log next then write/commit the plan the same day" is normal workflow
|
|
98
|
+
// not a signal that the work finished and was forgotten (the real case to catch, msbnndmt, is days apart) —
|
|
99
|
+
// without grace = SUSPECT fires on every freshly written plan, and everyone learns to skim past the whole stale block
|
|
100
100
|
if (gitDate && gitDate > Date.parse(r.ts) + 86_400_000)
|
|
101
101
|
out.push(
|
|
102
102
|
`SUSPECT [${r.id}] ${r.spec} changed after this ${r.kind} (${r.ts.slice(0, 10)}) — check whether it is already done`,
|
|
@@ -115,13 +115,13 @@ export const staleReport = (all: LogRow[]): string[] => {
|
|
|
115
115
|
return out;
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
-
// rotate:
|
|
119
|
-
// - next/bug/hold
|
|
120
|
-
// - decision/note
|
|
121
|
-
// spec
|
|
122
|
-
// (logic
|
|
123
|
-
// ponytail: decision/note
|
|
124
|
-
//
|
|
118
|
+
// rotate: rows that must carry over into the new log file after archiving
|
|
119
|
+
// - open next/bug/hold + still-active claims on those rows (close/claim of already-closed refs = discardable)
|
|
120
|
+
// - decision/note has no "close" of its own (permanent history by design) but resolves indirectly via synced:
|
|
121
|
+
// 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
|
|
122
|
+
// (same logic as staleReport's decision check — no spec or not yet synced by this row = still considered relevant, keep it)
|
|
123
|
+
// ponytail: a decision/note with no spec gives no way to know if it is resolved — keep it forever (the real ceiling is
|
|
124
|
+
// attaching a spec at log time if you want rotate to be able to drop it later, not rotate's own problem)
|
|
125
125
|
export const rotateKeep = (all: LogRow[]): LogRow[] => {
|
|
126
126
|
const open = openRows(all);
|
|
127
127
|
const workOpen = open.filter(
|
package/templates/mem/store.ts
CHANGED
|
@@ -60,43 +60,43 @@ type LogRow = WorkRow | CloseRow | ClaimRow | ReleaseRow | SyncedRow;
|
|
|
60
60
|
const root = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"])
|
|
61
61
|
.stdout.toString()
|
|
62
62
|
.trim();
|
|
63
|
-
// ponytail: worktree
|
|
64
|
-
// fapony template: repo
|
|
65
|
-
//
|
|
63
|
+
// ponytail: worktree named wt-<app> = monorepo scope (apps/<app>/.fapony/.memory).
|
|
64
|
+
// fapony template: single repo (no apps/) → fallback straight to .fapony/.memory at root
|
|
65
|
+
// no config/flag needed for either shape
|
|
66
66
|
const app = process.env.MEM_APP ?? basename(root).replace(/^wt-/, "");
|
|
67
|
-
//
|
|
68
|
-
// apps → packages → services
|
|
69
|
-
//
|
|
67
|
+
// app container folder found from {apps,packages,services}/<app>, first that exists — fixed order
|
|
68
|
+
// apps → packages → services, first hit wins (the order is the contract, not an accident)
|
|
69
|
+
// the `unknown app` guard below is still tied to apps/ as before — not widened this round
|
|
70
70
|
const appBase: string | undefined = ["apps", "packages", "services"]
|
|
71
71
|
.map((d) => `${root}/${d}/${app}`)
|
|
72
72
|
.find((p) => existsSync(p));
|
|
73
73
|
const monorepo = appBase !== undefined;
|
|
74
74
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// app —
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// .fapony/.memory
|
|
83
|
-
//
|
|
75
|
+
// The copy `fapony init` places lives in <project>/.fapony/.memory/ — its log and plan
|
|
76
|
+
// must key off their own folder, not the git root: the real broken case is apps/<x>/.fapony/.memory/ in a monorepo,
|
|
77
|
+
// where the heuristic below would point at apps/<worktree name>/.fapony/.memory = writing a log mixed into another project.
|
|
78
|
+
// But the central copy that was moved into .fapony/.memory at the monorepo root itself (single code copy, logs split per
|
|
79
|
+
// app — e.g. vela) must "not" count as scaffolded even though the path matches, because it still has to guess the app
|
|
80
|
+
// from the monorepo — the discriminator is "can the app be guessed" (`monorepo`), not "is there an app container folder":
|
|
81
|
+
// just having apps/ at the root does not make this copy the central one — `fapony init <monorepo root>` also places
|
|
82
|
+
// .fapony/.memory at the root, and it must key off its own folder, or it dies at the guard below
|
|
83
|
+
// from the very first command even though its plan sits right next to it.
|
|
84
84
|
const centralAtMonorepoRoot =
|
|
85
85
|
import.meta.dir === `${root}/.fapony/.memory` && monorepo;
|
|
86
86
|
const scaffolded =
|
|
87
87
|
import.meta.dir.includes("/.fapony/.memory") && !centralAtMonorepoRoot;
|
|
88
88
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
// (
|
|
89
|
+
// apps/ exists but apps/<app> does not = guessed the app wrong (worktree name mismatch / typo in MEM_APP) — die here
|
|
90
|
+
// rather than fall back silently and write a log at the root that becomes an orphan nobody reads
|
|
91
|
+
// (a copy scaffolded under apps/<x>/.fapony/.memory/ keys off its own folder, needs no guess, so it does not hit this)
|
|
92
92
|
if (!scaffolded && !monorepo && existsSync(`${root}/apps`)) {
|
|
93
93
|
console.error(
|
|
94
94
|
`unknown app (guessed "${app}" from ${basename(root)}) — pass MEM_APP=<app>`,
|
|
95
95
|
);
|
|
96
96
|
process.exit(1);
|
|
97
97
|
}
|
|
98
|
-
// default
|
|
99
|
-
//
|
|
98
|
+
// new default: log lives under .fapony/.memory — fall back to the old .memory/ only when an old log actually exists
|
|
99
|
+
// check for log.jsonl, not the dir: an empty folder someone accidentally mkdir'd must not lock the repo to the old layout
|
|
100
100
|
const newDir = appBase
|
|
101
101
|
? `${appBase}/.fapony/.memory`
|
|
102
102
|
: `${root}/.fapony/.memory`;
|
|
@@ -106,12 +106,12 @@ const dir = scaffolded
|
|
|
106
106
|
: existsSync(`${legacyDir}/log.jsonl`)
|
|
107
107
|
? legacyDir
|
|
108
108
|
: newDir;
|
|
109
|
-
//
|
|
109
|
+
// who wrote this row — the client can override with MEM_AGENT ("claude-code", "opencode")
|
|
110
110
|
const agent = process.env.MEM_AGENT || process.env.USER || "unknown";
|
|
111
111
|
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// git user.name
|
|
112
|
+
// filename = *person*, not client, deliberately different from agent above: naming files by MEM_AGENT
|
|
113
|
+
// would put two people running Claude Code back into the same log.claude-code.jsonl = same collision as before
|
|
114
|
+
// git user.name exists on every machine that can commit, so no env to set and no config to add
|
|
115
115
|
const person = (
|
|
116
116
|
Bun.spawnSync(["git", "config", "user.name"]).stdout.toString().trim() ||
|
|
117
117
|
process.env.USER ||
|
|
@@ -121,12 +121,12 @@ const person = (
|
|
|
121
121
|
.replace(/[^a-z0-9._-]+/g, "-")
|
|
122
122
|
.replace(/^-+|-+$/g, "");
|
|
123
123
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
124
|
+
// write your own file, read everyone's — two people never touch the same file = merge conflicts
|
|
125
|
+
// are structurally zero, no need for merge=union or GitHub behaving itself on PR merge
|
|
126
126
|
const LOG = `${dir}/log.${person || "unknown"}.jsonl`;
|
|
127
127
|
|
|
128
|
-
// log.jsonl =
|
|
129
|
-
//
|
|
128
|
+
// log.jsonl = the pre-split original (still read forever, no migration needed)
|
|
129
|
+
// skip the log.YYYY-MM-DD.jsonl that rotate creates, or rotate reduces nothing because they get read back in
|
|
130
130
|
const isLogFile = (f: string): boolean =>
|
|
131
131
|
f === "log.jsonl" ||
|
|
132
132
|
(/^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f) &&
|
|
@@ -140,17 +140,17 @@ const logFiles = (): string[] =>
|
|
|
140
140
|
.map((f) => join(dir, f))
|
|
141
141
|
: [];
|
|
142
142
|
|
|
143
|
-
//
|
|
144
|
-
// (
|
|
143
|
+
// the folder this project's plan/ and done/ live under — the single place these paths are assembled
|
|
144
|
+
// (previously commands/plan.ts hardcoded `apps/<app>/plan` in 10 places = dead on arrival for a single repo)
|
|
145
145
|
const planBase = scaffolded
|
|
146
146
|
? dirname(import.meta.dir) // <project>/.fapony
|
|
147
147
|
: (appBase ?? root);
|
|
148
148
|
|
|
149
|
-
// fapony.config.json
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// monorepo
|
|
149
|
+
// fapony.config.json is the *agreement* on where plan lives; planBase above is only a guess —
|
|
150
|
+
// whenever the file exists it must beat the guess (vela declares `apps/vela/plan` outright, which happens to match the guess
|
|
151
|
+
// but a repo that puts plan elsewhere breaks silently if this is not read)
|
|
152
|
+
// read only at the project level: a copy scaffolded in apps/<x>/.fapony/ must not pick up the root
|
|
153
|
+
// monorepo's config, because that is another project's path
|
|
154
154
|
const configDir = scaffolded ? dirname(planBase) : root;
|
|
155
155
|
|
|
156
156
|
const configPaths = ((): Record<string, string> => {
|
|
@@ -158,7 +158,7 @@ const configPaths = ((): Record<string, string> => {
|
|
|
158
158
|
const raw = readFileSync(`${configDir}/fapony.config.json`, "utf8");
|
|
159
159
|
return (JSON.parse(raw)?.paths ?? {}) as Record<string, string>;
|
|
160
160
|
} catch {
|
|
161
|
-
//
|
|
161
|
+
// no file / broken JSON → use the guessed values, not an error: memory must work without fapony
|
|
162
162
|
return {};
|
|
163
163
|
}
|
|
164
164
|
})();
|
|
@@ -168,27 +168,27 @@ const fromConfig = (key: string): string | null =>
|
|
|
168
168
|
? join(configDir, configPaths[key])
|
|
169
169
|
: null;
|
|
170
170
|
|
|
171
|
-
// app
|
|
172
|
-
//
|
|
171
|
+
// an app that moved plan into .fapony/ uses the new location; one that has not uses the old — so a monorepo migrates app by app
|
|
172
|
+
// without touching config (config has a single planDir, so declaring it points the other apps wrong too)
|
|
173
173
|
const base = existsSync(`${planBase}/.fapony`)
|
|
174
174
|
? `${planBase}/.fapony`
|
|
175
175
|
: planBase;
|
|
176
176
|
|
|
177
177
|
const planDir = fromConfig("planDir") ?? `${base}/plan`;
|
|
178
178
|
|
|
179
|
-
// done/
|
|
180
|
-
//
|
|
179
|
+
// done/ sits beside plan/ (same depth after the move, relative links in files survive) — a repo still on the old layout
|
|
180
|
+
// with plan/done/ keeps using it, no need to move before sweeping
|
|
181
181
|
const doneDir =
|
|
182
182
|
fromConfig("doneDir") ??
|
|
183
183
|
(!existsSync(`${base}/done`) && existsSync(`${planDir}/done`)
|
|
184
184
|
? `${planDir}/done`
|
|
185
185
|
: `${base}/done`);
|
|
186
186
|
|
|
187
|
-
// path
|
|
187
|
+
// path used for display/logging — always relative to repo root (`apps/vela/plan`, `.fapony/plan`)
|
|
188
188
|
const rel = (p: string) => relative(root, p) || ".";
|
|
189
189
|
|
|
190
|
-
//
|
|
191
|
-
//
|
|
190
|
+
// the command we tell the user to type must be the path of the mem.ts actually running, not a constant —
|
|
191
|
+
// the copy `fapony init` places lives in .fapony/.memory/, not the .memory/ the old help text hardcoded
|
|
192
192
|
const memCmd = `bun ${rel(dir)}/mem.ts`;
|
|
193
193
|
|
|
194
194
|
const KINDS: WorkKind[] = ["next", "bug", "decision", "note", "hold"];
|
|
@@ -205,7 +205,7 @@ const rows = (): LogRow[] =>
|
|
|
205
205
|
try {
|
|
206
206
|
return [JSON.parse(l) as LogRow];
|
|
207
207
|
} catch {
|
|
208
|
-
// ponytail:
|
|
208
|
+
// ponytail: one broken line (bad escape) must not make the whole log unreadable — skip it and warn
|
|
209
209
|
console.error(
|
|
210
210
|
`[mem] skipped ${basename(f)} line ${i + 1} (bad JSON)`,
|
|
211
211
|
);
|
|
@@ -213,7 +213,7 @@ const rows = (): LogRow[] =>
|
|
|
213
213
|
}
|
|
214
214
|
}),
|
|
215
215
|
)
|
|
216
|
-
//
|
|
216
|
+
// concatenating several files scrambles the time order — every selector reads top-down assuming ts order
|
|
217
217
|
.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
218
218
|
|
|
219
219
|
function put(r: Omit<WorkRow, "ts" | "agent">): void;
|
|
@@ -236,9 +236,9 @@ function put(
|
|
|
236
236
|
);
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
// ponytail:
|
|
240
|
-
//
|
|
241
|
-
//
|
|
239
|
+
// ponytail: prevent id collisions — base36 + increment per retry + random suffix
|
|
240
|
+
// shared everywhere a WorkRow.id must be generated (cmdAdd, ship-log in cmdPlanSweep)
|
|
241
|
+
// do not copy this loop elsewhere — change the scheme here only
|
|
242
242
|
function nextId(all: LogRow[]): string {
|
|
243
243
|
const used = new Set(all.map((r) => ("id" in r ? r.id : "")));
|
|
244
244
|
let base = Date.now();
|
|
@@ -250,8 +250,8 @@ function nextId(all: LogRow[]): string {
|
|
|
250
250
|
return id;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
//
|
|
254
|
-
//
|
|
253
|
+
// write a raw row (original ts/agent, no regeneration) — used when rotate moves old rows to a new file
|
|
254
|
+
// normally writing to the log must go through put(); this is the only exception
|
|
255
255
|
const appendRaw = (path: string, r: LogRow): void =>
|
|
256
256
|
appendFileSync(path, `${JSON.stringify(r)}\n`);
|
|
257
257
|
|