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
package/src/price/resolve.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// src/price/resolve.ts — normalize model id +
|
|
1
|
+
// src/price/resolve.ts — normalize model id + compute list-price equivalent
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Hard rule: an unmappable model must be unpriced, never silently counted as 0 (the main failure mode
|
|
4
|
+
// of this feature) · free only applies to things that truly cost 0 (local / :free rows in the table)
|
|
5
5
|
|
|
6
6
|
import type { ModelBreakdown, PassiveUsageResult } from "../session/types.js";
|
|
7
7
|
import type { ModelRates, PriceTable } from "./fetch.js";
|
|
@@ -13,10 +13,10 @@ export interface PriceResolution {
|
|
|
13
13
|
rates: ModelRates | null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/** prefix
|
|
16
|
+
/** client prefix prepended to every model id — strip it, then look up again */
|
|
17
17
|
const CLIENT_PREFIXES = ["openrouter/", "opencode-go/", "opencode/"];
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/** OpenRouter's trailing tier — :free is a genuinely free endpoint, :batch is a discount */
|
|
20
20
|
function stripTier(id: string): string {
|
|
21
21
|
return id.endsWith(":free") || id.endsWith(":batch")
|
|
22
22
|
? id.slice(0, id.lastIndexOf(":"))
|
|
@@ -24,8 +24,8 @@ function stripTier(id: string): string {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
* (
|
|
27
|
+
* Candidate ids ordered by specificity: exact first, broad later
|
|
28
|
+
* (an exact match always wins — the bare slug is the broadest match and goes last)
|
|
29
29
|
*/
|
|
30
30
|
export function candidateIds(provider: string, model: string): string[] {
|
|
31
31
|
const full = provider ? `${provider}/${model}` : model;
|
|
@@ -38,14 +38,14 @@ export function candidateIds(provider: string, model: string): string[] {
|
|
|
38
38
|
break;
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
-
// opencode
|
|
41
|
+
// opencode appends -free to the slug of a free model (deepseek-v4-flash-free)
|
|
42
42
|
if (rest.endsWith("-free")) out.push(rest.slice(0, -"-free".length));
|
|
43
43
|
return out;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
* (local
|
|
47
|
+
* Find rates for one model — null only for local, which skips the table
|
|
48
|
+
* (local never had a price to begin with, it is not "not found")
|
|
49
49
|
*/
|
|
50
50
|
function isLocalProvider(provider: string): boolean {
|
|
51
51
|
return provider === "lmstudio_local";
|
|
@@ -59,14 +59,14 @@ export function resolvePrice(
|
|
|
59
59
|
if (!model || model === "(no model id)" || model === "(unknown)")
|
|
60
60
|
return { status: "unpriced", rates: null };
|
|
61
61
|
if (isLocalProvider(provider)) return { status: "free", rates: null };
|
|
62
|
-
//
|
|
63
|
-
//
|
|
62
|
+
// ends with :free or -free = used a free endpoint / the client's free model
|
|
63
|
+
// the real price is 0 (not list price), whether or not OpenRouter maps it
|
|
64
64
|
if (model.endsWith(":free") || model.endsWith("-free"))
|
|
65
65
|
return { status: "free", rates: null };
|
|
66
66
|
for (const id of candidateIds(provider, model)) {
|
|
67
67
|
const rates = table.models[id] ?? table.models[stripTier(id)];
|
|
68
68
|
if (rates) {
|
|
69
|
-
//
|
|
69
|
+
// an all-zero rate row (:free / free model) = genuinely free, not unpriced
|
|
70
70
|
if (
|
|
71
71
|
rates.input === 0 &&
|
|
72
72
|
rates.output === 0 &&
|
|
@@ -77,10 +77,10 @@ export function resolvePrice(
|
|
|
77
77
|
return { status: "priced", rates };
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
-
// zcode
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
80
|
+
// zcode stores only the slug with no vendor (GLM-5.3-Flash) — compare the suffix / exact
|
|
81
|
+
// case-insensitive match (exact, not fuzzy: the whole string must be equal in length)
|
|
82
|
+
// the table side strips the tier (:free/:batch) before comparing — so a bare slug hits the :free
|
|
83
|
+
// row that is truly 0 (e.g. ling-3.0-flash-fin) and becomes free, not unpriced
|
|
84
84
|
const slug = stripTier(
|
|
85
85
|
candidateIds(provider, model).at(-1) ?? "",
|
|
86
86
|
).toLowerCase();
|
|
@@ -114,11 +114,11 @@ export interface TokenCounts {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
*
|
|
117
|
+
* Pure costing: separate rates for input / cache-read / cache-write — never one rate for all
|
|
118
118
|
*
|
|
119
|
-
* reasoning
|
|
120
|
-
* (reader
|
|
121
|
-
* →
|
|
119
|
+
* reasoning is not multiplied separately: Anthropic-family thinking is already included in output
|
|
120
|
+
* (the reader keeps it separately just for visibility); multiplying separately = double counting · cache_write absent from the table
|
|
121
|
+
* → fall back to the input rate (writing cache costs more than or equal to a fresh read, never less)
|
|
122
122
|
*/
|
|
123
123
|
export function calcCost(t: TokenCounts, rates: ModelRates): number {
|
|
124
124
|
return (
|
|
@@ -138,7 +138,7 @@ export interface ImputedModel {
|
|
|
138
138
|
tokens_cache_read: number;
|
|
139
139
|
tokens_cache_write: number;
|
|
140
140
|
status: PriceStatus;
|
|
141
|
-
/**
|
|
141
|
+
/** dollars at list-price — 0 when free/unpriced (check status, do not read the number alone) */
|
|
142
142
|
imputed_cost: number;
|
|
143
143
|
}
|
|
144
144
|
|
|
@@ -152,8 +152,8 @@ export interface ImputeSummary {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
|
-
*
|
|
156
|
-
* (usage-web)
|
|
155
|
+
* Price an entire PassiveUsageResult — works on live results (stats/usage) or cache rows
|
|
156
|
+
* (usage-web) alike, because it only takes tokens per model
|
|
157
157
|
*/
|
|
158
158
|
export function imputeResult(
|
|
159
159
|
result: PassiveUsageResult,
|
package/src/review-seed.ts
CHANGED
|
@@ -544,7 +544,7 @@ function hasDynamicDispatch(absFile: string): boolean {
|
|
|
544
544
|
|
|
545
545
|
// --- --body: declaration slice (indent-out, no parser) ---
|
|
546
546
|
// extractBody lives in map.ts beside extractExports — the conventions seeder
|
|
547
|
-
// reuses the same slice for wrapper detection (one implementation,
|
|
547
|
+
// reuses the same slice for wrapper detection (one implementation, rule 1).
|
|
548
548
|
|
|
549
549
|
// --- --callers: symbol→symbol over importer files (identifier scan) ---
|
|
550
550
|
|
package/src/stats/data.ts
CHANGED
|
@@ -622,15 +622,16 @@ function addSessionTokens(
|
|
|
622
622
|
}
|
|
623
623
|
|
|
624
624
|
/**
|
|
625
|
-
* tokens/pass — retry tax
|
|
625
|
+
* tokens/pass — the visible retry tax (SPEC-cost-per-pass).
|
|
626
626
|
*
|
|
627
|
-
*
|
|
628
|
-
* "
|
|
629
|
-
*
|
|
627
|
+
* The divisor is a pass-family gate, not a run: bucket is already a gate, and
|
|
628
|
+
* work "finished" is a gate that passed · token is a per-session total (deduped),
|
|
629
|
+
* not per gate — one session can emit several gates, so summing per gate would
|
|
630
|
+
* multiply unevenly across models.
|
|
630
631
|
*
|
|
631
|
-
* `passes=0`
|
|
632
|
-
*
|
|
633
|
-
* (tokensInput
|
|
632
|
+
* `passes=0` or total token 0 → null (not Infinity/NaN/0): "unmeasurable" is not
|
|
633
|
+
* "free" and never divide by zero · no cost/pass in v1 — no cache split
|
|
634
|
+
* (tokensInput is fresh+cache_read+cache_write), so price needs a guess.
|
|
634
635
|
*/
|
|
635
636
|
function tokensPerPass(
|
|
636
637
|
passes: number,
|
package/src/stats/format.ts
CHANGED
|
@@ -51,9 +51,10 @@ function modelLine(
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* list-price equivalent line appended to each usage section — the one unit
|
|
55
|
+
* that compares across clients (clients that do not record cost get a price
|
|
56
|
+
* attached here). List price is not money paid · unpriced is broken out
|
|
57
|
+
* separately, never folded into 0.
|
|
57
58
|
*/
|
|
58
59
|
function imputedLines(
|
|
59
60
|
result: PassiveUsageResult,
|
|
@@ -103,8 +104,8 @@ export function formatStatsText(data: StatsData): string {
|
|
|
103
104
|
if (data.runs.total === 0) return "no runs yet";
|
|
104
105
|
|
|
105
106
|
const lines: string[] = [];
|
|
106
|
-
//
|
|
107
|
-
//
|
|
107
|
+
// list price from cache only — the query never fetches itself (works
|
|
108
|
+
// offline, missing file = shows — + hint, never throws)
|
|
108
109
|
const prices = loadPrices();
|
|
109
110
|
|
|
110
111
|
if (data.scope) {
|
package/src/usage/render.ts
CHANGED
|
@@ -304,8 +304,9 @@ function shareSection(
|
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
307
|
+
* Imputes list prices for rows where the client records no cost (0) — rows
|
|
308
|
+
* that already have a real price are left alone · returns a view to render
|
|
309
|
+
* plus a note for below the table (list-price label everywhere)
|
|
309
310
|
*/
|
|
310
311
|
function withImputed(
|
|
311
312
|
data: PassiveUsageResult | null,
|
|
@@ -332,7 +333,7 @@ function withImputed(
|
|
|
332
333
|
tokens_reasoning: real?.tokens_reasoning ?? 0,
|
|
333
334
|
tokens_cache_read: m.tokens_cache_read,
|
|
334
335
|
tokens_cache_write: m.tokens_cache_write,
|
|
335
|
-
//
|
|
336
|
+
// combine with the real per-model cost (OpenCode records its own) — whichever has a value wins
|
|
336
337
|
cost: real && real.cost > 0 ? real.cost : m.imputed_cost,
|
|
337
338
|
};
|
|
338
339
|
});
|
|
@@ -363,7 +364,7 @@ export function renderUsageHtml(
|
|
|
363
364
|
.filter((k) => k !== "__global__")
|
|
364
365
|
.sort();
|
|
365
366
|
|
|
366
|
-
//
|
|
367
|
+
// list price from cache only — serving never fetches itself (works offline)
|
|
367
368
|
const table = prices === undefined ? loadPrices() : prices;
|
|
368
369
|
|
|
369
370
|
const owner = ownerName?.trim() ? esc(ownerName.trim()) : "";
|
|
@@ -395,7 +396,7 @@ export function renderUsageHtml(
|
|
|
395
396
|
|
|
396
397
|
const heading = isGlobal ? "All projects" : shortWt(label);
|
|
397
398
|
|
|
398
|
-
//
|
|
399
|
+
// a card with no sessions has nothing to show — hide it instead of showing "no sessions"
|
|
399
400
|
const cards = [
|
|
400
401
|
["OpenCode", "var(--green)", pOc],
|
|
401
402
|
["ZCode", "var(--accent)", pZc],
|
package/templates/PLAN.md
CHANGED
|
@@ -24,7 +24,7 @@ spec: SPEC-<feature>.md # if any
|
|
|
24
24
|
- **Done when:** one line, testable
|
|
25
25
|
- **Order:** what this waits on / what it unblocks (mirrors the frontmatter)
|
|
26
26
|
- **Progress:**
|
|
27
|
-
- [x] chunk 1 — <what landed> `<short sha>` <YYYY-MM-DD>
|
|
27
|
+
- [x] chunk 1 — <what landed> `<short sha>` <YYYY-MM-DD> · verdict: <grade>
|
|
28
28
|
- [ ] chunk 2 — <what is next>
|
|
29
29
|
|
|
30
30
|
---
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// commands/plan.ts — plan-sweep:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// <file.md> =
|
|
5
|
-
// <file.md> --apply = git mv +
|
|
6
|
-
// +
|
|
1
|
+
// commands/plan.ts — plan-sweep: find PLAN-*.md whose header says shipped but not yet moved into done/
|
|
2
|
+
// rationale: moving by hand = chasing relative links yourself (in the file + files that link to it) → the step gets skipped often
|
|
3
|
+
// no arg = report only (safe, shows every kickoff/stale run)
|
|
4
|
+
// <file.md> = check a single file, is it ready to move
|
|
5
|
+
// <file.md> --apply = git mv + fix markdown links inside the file + fix inbound links from other files in plan/
|
|
6
|
+
// + warn about plain-text mentions (detect-only, no auto-fix)
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
existsSync,
|
|
@@ -21,13 +21,13 @@ const SHIPPED = /^>\s*✅/m;
|
|
|
21
21
|
const FRONT = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
22
22
|
const HELD = /^status:\s*(blocked|superseded)\b/m;
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
24
|
+
// the ✅ shipped header is no longer on the first line — the current plan format starts with frontmatter
|
|
25
|
+
// then `# title` (see templates/PLAN.md); check the file's head rather than a single first line
|
|
26
26
|
//
|
|
27
|
-
// frontmatter
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
27
|
+
// frontmatter always beats the ✅ header: a plan that shipped some chunks and is waiting on externals (VPS, users,
|
|
28
|
+
// a decision) writes `status: blocked` = it is meant to stay in plan/, not forgotten to move
|
|
29
|
+
// if this is not checked, such plans show "shipped but never archived" forever, and people stop reading
|
|
30
|
+
// the whole list — the same symptom that kept done/ from ever moving in the first place
|
|
31
31
|
export const hasShippedHeader = (file: string): boolean => {
|
|
32
32
|
const head = readFileSync(file, "utf8").slice(0, 2048);
|
|
33
33
|
if (!SHIPPED.test(head)) return false;
|
|
@@ -49,10 +49,10 @@ const mdFiles = (dir: string): string[] =>
|
|
|
49
49
|
|
|
50
50
|
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
// resolve
|
|
54
|
-
//
|
|
55
|
-
// pass 1 only —
|
|
52
|
+
// the file moved dir (same content) — an old markdown link meant the old path relative to oldDir, must re-relativize via newDir
|
|
53
|
+
// resolve from newDir (where the file is now) — if the target also moved to the same dir → plain filename,
|
|
54
|
+
// if the target stayed in oldDir → ../target (both correct)
|
|
55
|
+
// pass 1 only — fix only [text](target) markdown links, do not touch plain text
|
|
56
56
|
export const rewriteMovedFileLinks = (
|
|
57
57
|
file: string,
|
|
58
58
|
oldDir: string,
|
|
@@ -76,8 +76,8 @@ export const rewriteMovedFileLinks = (
|
|
|
76
76
|
return n;
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
//
|
|
80
|
-
// pass 1 only —
|
|
79
|
+
// other files whose links point at the old path (oldAbs) → repoint them at the new path (newAbs)
|
|
80
|
+
// pass 1 only — fix only [text](target) markdown links, do not touch plain text
|
|
81
81
|
export const rewriteMarkdownLinks = (
|
|
82
82
|
file: string,
|
|
83
83
|
oldAbs: string,
|
|
@@ -99,18 +99,18 @@ export const rewriteMarkdownLinks = (
|
|
|
99
99
|
return n;
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
-
//
|
|
103
|
-
// regex:
|
|
104
|
-
//
|
|
102
|
+
// count plain-text mentions of the target filename in a file (detect-only, writes nothing)
|
|
103
|
+
// regex: not a markdown link [text](url) — catches both with/without the .md extension
|
|
104
|
+
// covers: prose, backtick code span, code fence — every context that is not a markdown link
|
|
105
105
|
//
|
|
106
|
-
// Fix (2026-09-02):
|
|
107
|
-
// target —
|
|
108
|
-
// lookbehind
|
|
109
|
-
//
|
|
110
|
-
// `apps/vela/plan/PLAN-x.md`) — false-negative
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// plain-text mention —
|
|
106
|
+
// Fix (2026-09-02): strip the whole markdown link [text](target) first (both display text and
|
|
107
|
+
// target — not just target), then run the plain-text regex on the remainder, no longer needing
|
|
108
|
+
// lookbehind — the old lookbehind `(?<![/\[(])` was meant to stop markdown-link false-positives
|
|
109
|
+
// but its side effect was excluding every mention with a leading `/` too (e.g. `done/PLAN-x.md` or
|
|
110
|
+
// `apps/vela/plan/PLAN-x.md`) — the real false-negative that slipped through in 6e411042
|
|
111
|
+
// The first fix (stripping only `](target)`) missed — it left `[display-text]` unwrapped, making an already
|
|
112
|
+
// valid link like `[PLAN-x.md](../done/PLAN-x.md)` (the actual pattern throughout this file) get double-counted as a
|
|
113
|
+
// plain-text mention — strip the whole [..](..) block, not just the (..) part
|
|
114
114
|
export const countPlainTextMentions = (
|
|
115
115
|
file: string,
|
|
116
116
|
target: string,
|
|
@@ -128,7 +128,7 @@ export const countPlainTextMentions = (
|
|
|
128
128
|
return count;
|
|
129
129
|
};
|
|
130
130
|
|
|
131
|
-
//
|
|
131
|
+
// shared with the dashboard (now/kickoff) — plan/ files with a shipped header but not yet moved into done/
|
|
132
132
|
export const shippedNotMoved = (): string[] => {
|
|
133
133
|
const dir = planDir;
|
|
134
134
|
if (!existsSync(dir)) return [];
|
|
@@ -187,8 +187,8 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
187
187
|
return;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
// ponytail: --apply
|
|
191
|
-
//
|
|
190
|
+
// ponytail: the old --apply enforced neither of these two conditions — you could pass the dry-run message
|
|
191
|
+
// but then run --apply directly and skip everything → risky when an agent ships automatically with no human check, so hard block
|
|
192
192
|
if (!shipped && !process.env.MEM_FORCE) {
|
|
193
193
|
console.error(
|
|
194
194
|
`${target}: no ✅ shipped header at the top — refusing to move (MEM_FORCE=1 to override)`,
|
|
@@ -211,8 +211,8 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
211
211
|
process.exit(1);
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
// ponytail:
|
|
215
|
-
//
|
|
214
|
+
// ponytail: a file just written this round may not be git add'ed yet — `git mv` fails silently (exit 128, no throw)
|
|
215
|
+
// then the next code hits ENOENT reading a dst that does not exist — always stage first (no-op if already tracked)
|
|
216
216
|
mkdirSync(doneDir, { recursive: true });
|
|
217
217
|
Bun.spawnSync(["git", "add", src]);
|
|
218
218
|
const mv = Bun.spawnSync(["git", "mv", src, dst]);
|
|
@@ -221,8 +221,8 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
221
221
|
process.exit(1);
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
// done/
|
|
225
|
-
// layout
|
|
224
|
+
// done/ is a sibling of plan/ = same depth, links in the file still resolve, no need to touch
|
|
225
|
+
// the old layout (plan/done/) is one level deeper, so it does need re-relativizing
|
|
226
226
|
const nested = dirname(doneDir) !== dirname(dir);
|
|
227
227
|
const ownLinks = nested ? rewriteMovedFileLinks(dst, dir, doneDir) : 0;
|
|
228
228
|
|
|
@@ -247,7 +247,7 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
247
247
|
`inbound links rewritten: ${inbound} in ${inboundFiles} file(s) (scanned ${rel(dir)}/** only)`,
|
|
248
248
|
);
|
|
249
249
|
|
|
250
|
-
// log decision — record ship event (reuse existing kind,
|
|
250
|
+
// log decision — record ship event (reuse existing kind, no new schema)
|
|
251
251
|
const doneSpec = `${rel(dst)}`;
|
|
252
252
|
put({
|
|
253
253
|
id: nextId(rows()),
|
|
@@ -255,11 +255,11 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
255
255
|
text: `${target} shipped → ${rel(dst)}`,
|
|
256
256
|
spec: doneSpec,
|
|
257
257
|
});
|
|
258
|
-
// ponytail: decision
|
|
259
|
-
//
|
|
258
|
+
// ponytail: this decision *is* the move itself, nothing to write back into the spec — do not mark synced
|
|
259
|
+
// immediately or staleReport shows "decision never made it into the spec" on every ship (seen in kickoff 2026-09-02)
|
|
260
260
|
put({ kind: "synced", spec: doneSpec });
|
|
261
261
|
|
|
262
|
-
// plain-text mention detection (detect-only,
|
|
262
|
+
// plain-text mention detection (detect-only, no auto-fix)
|
|
263
263
|
let plainTextTotal = 0;
|
|
264
264
|
const plainTextFiles: string[] = [];
|
|
265
265
|
for (const f of mdFiles(dir)) {
|
|
@@ -275,14 +275,14 @@ export const cmdPlanSweep = (a: string[]) => {
|
|
|
275
275
|
);
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
//
|
|
278
|
+
// files outside plan/ that mention the target — detect-only
|
|
279
279
|
const grep = Bun.spawnSync([
|
|
280
280
|
"git",
|
|
281
281
|
"grep",
|
|
282
282
|
"-l",
|
|
283
283
|
target,
|
|
284
284
|
"--",
|
|
285
|
-
//
|
|
285
|
+
// the "files outside plan/" scope = the folder plan/ lives under (apps/vela, .fapony, …)
|
|
286
286
|
rel(dirname(planDir)),
|
|
287
287
|
`:!${rel(dir)}`,
|
|
288
288
|
])
|
|
@@ -324,8 +324,8 @@ export const cmdPlanCheck = (a: string[]) => {
|
|
|
324
324
|
// only check active files (not done/) — done/ files are historical snapshots with external refs
|
|
325
325
|
const linkRe = /\]\(([^)]+)\)/g;
|
|
326
326
|
for (const f of active) {
|
|
327
|
-
//
|
|
328
|
-
//
|
|
327
|
+
// strip fenced blocks + inline code first (replace with spaces to preserve line offset) —
|
|
328
|
+
// example links in code (e.g. this tool's own spec) must not be counted as real links
|
|
329
329
|
const src = readFileSync(f, "utf8")
|
|
330
330
|
.replace(/```[\s\S]*?```/g, (b) => b.replace(/[^\n]/g, " "))
|
|
331
331
|
.replace(/`[^`\n]*`/g, (b) => " ".repeat(b.length));
|
|
@@ -22,7 +22,7 @@ const planSweepLine = () => {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
export const cmdNow = () => {
|
|
25
|
-
// mem now (default) — next+bug+hold. decision/note
|
|
25
|
+
// mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
|
|
26
26
|
const all = rows();
|
|
27
27
|
console.log(`# ${app} — ${all.length} entries`);
|
|
28
28
|
printOpenRows(all, { showHold: true });
|
|
@@ -53,7 +53,7 @@ export const cmdStale = () => {
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
export const cmdFind = (a: string[]) => {
|
|
56
|
-
// mem find
|
|
56
|
+
// mem find <word> — grep text/spec case-insensitively, newest first, capped at 20 rows
|
|
57
57
|
const q = a.join(" ").toLowerCase();
|
|
58
58
|
if (!q) {
|
|
59
59
|
console.error(`usage: ${memCmd} find <word>`);
|
|
@@ -86,7 +86,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
86
86
|
const arg = a[0] ?? "";
|
|
87
87
|
|
|
88
88
|
if (!arg) {
|
|
89
|
-
//
|
|
89
|
+
// no args = now + a "recent" section = the last 10 closes
|
|
90
90
|
console.log(`# ${app} — ${all.length} entries`);
|
|
91
91
|
printOpenRows(all, { showHold: true });
|
|
92
92
|
console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
|
|
@@ -101,7 +101,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
101
101
|
const rotate = rotateLine(all.length);
|
|
102
102
|
if (rotate) console.log(rotate);
|
|
103
103
|
} else if (arg.endsWith(".md")) {
|
|
104
|
-
// spec.md = brief
|
|
104
|
+
// spec.md = a brief for that spec
|
|
105
105
|
const workAll = all.filter((r): r is WorkRow => "id" in r);
|
|
106
106
|
const byId = new Map(workAll.map((r) => [r.id, r] as const));
|
|
107
107
|
const open = openRows(all);
|
|
@@ -135,7 +135,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
135
135
|
console.log("(no entries for this spec)");
|
|
136
136
|
}
|
|
137
137
|
} else {
|
|
138
|
-
// id = brief
|
|
138
|
+
// id = a brief for that task
|
|
139
139
|
const workAll = all.filter((r): r is WorkRow => "id" in r);
|
|
140
140
|
const byId = new Map(workAll.map((r) => [r.id, r] as const));
|
|
141
141
|
const target = byId.get(arg);
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// commands/rotate.ts — compact log.jsonl once it grows past a row-count threshold
|
|
2
|
-
//
|
|
3
|
-
// git mv
|
|
2
|
+
// keep open work rows + active claims; the rest (close/release/synced of already-closed refs)
|
|
3
|
+
// is git mv'd to a separate archive file (not deleted) — old history remains, searchable via git log/git show
|
|
4
4
|
|
|
5
5
|
import { existsSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { rotateKeep } from "../selectors.js";
|
|
8
8
|
import { appendRaw, dir, LOG, rows } from "../store.js";
|
|
9
9
|
|
|
10
|
-
// ponytail: threshold =
|
|
11
|
-
// (view
|
|
12
|
-
// 3000
|
|
10
|
+
// ponytail: threshold = total row count, not just open — the fear is a bloated file / slow grep when many people use it at once
|
|
11
|
+
// (the view being unreadable is a separate problem the CAP in write.ts already handles)
|
|
12
|
+
// 3000 estimated from one solo week = ~2k rows — tune with MEM_ROTATE_THRESHOLD if the pace differs a lot from this
|
|
13
13
|
export const THRESHOLD = Number(process.env.MEM_ROTATE_THRESHOLD) || 3000;
|
|
14
14
|
|
|
15
15
|
export const cmdRotate = (a: string[]) => {
|
|
@@ -40,7 +40,7 @@ export const cmdRotate = (a: string[]) => {
|
|
|
40
40
|
process.exit(1);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
//
|
|
43
|
+
// like plan-sweep: stage first to stop git mv failing silently if the file is untracked (exit 128)
|
|
44
44
|
Bun.spawnSync(["git", "add", LOG]);
|
|
45
45
|
const mv = Bun.spawnSync(["git", "mv", LOG, archived]);
|
|
46
46
|
if (mv.exitCode !== 0) {
|
|
@@ -37,7 +37,7 @@ const runSelectorTests = () => {
|
|
|
37
37
|
{ id: "a2", kind: "next", text: "work B", ts: "", agent: "" },
|
|
38
38
|
{ kind: "close", ref: "a2", text: "done", ts: "", agent: "" },
|
|
39
39
|
{ kind: "claim", ref: "a1", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
|
|
40
|
-
// (1) claim
|
|
40
|
+
// (1) single claim → active
|
|
41
41
|
{ id: "a3", kind: "bug", text: "work C", ts: "", agent: "" },
|
|
42
42
|
{ kind: "claim", ref: "a3", ts: "2026-01-01T01:00:00Z", agent: "agent-1" },
|
|
43
43
|
{
|
|
@@ -55,7 +55,7 @@ const runSelectorTests = () => {
|
|
|
55
55
|
{ id: "a5", kind: "next", text: "work E", ts: "", agent: "" },
|
|
56
56
|
{ kind: "claim", ref: "a5", ts: "2026-01-01T04:00:00Z", agent: "agent-1" },
|
|
57
57
|
{ kind: "claim", ref: "a5", ts: "2026-01-01T05:00:00Z", agent: "agent-3" },
|
|
58
|
-
// (4) double claim →
|
|
58
|
+
// (4) double claim → the later one wins (agent-3)
|
|
59
59
|
];
|
|
60
60
|
|
|
61
61
|
const claims = claimsOf(t);
|
|
@@ -70,12 +70,12 @@ const runSelectorTests = () => {
|
|
|
70
70
|
assert(!claims.has("a3"), "release did not void the claim");
|
|
71
71
|
// (3) a4 close → inactive
|
|
72
72
|
assert(!claims.has("a4"), "close void claim");
|
|
73
|
-
// (4) a5 double claim → agent-3
|
|
73
|
+
// (4) a5 double claim → agent-3 wins
|
|
74
74
|
assert(
|
|
75
75
|
claims.has("a5") && claims.get("a5")?.agent === "agent-3",
|
|
76
76
|
"double claim after the winner",
|
|
77
77
|
);
|
|
78
|
-
// a2 closed →
|
|
78
|
+
// a2 closed → should not appear in open
|
|
79
79
|
assert(!open.some((r) => r.id === "a2"), "tombstone broken");
|
|
80
80
|
// open = a1, a3 (released but still open), a5
|
|
81
81
|
assert(
|
|
@@ -91,12 +91,12 @@ const runRotateTests = () => {
|
|
|
91
91
|
const t: LogRow[] = [
|
|
92
92
|
{ id: "r1", kind: "next", text: "keep me", ts: "", agent: "" },
|
|
93
93
|
{ kind: "claim", ref: "r1", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
|
|
94
|
-
// r1: open + claimed → work row
|
|
94
|
+
// r1: open + claimed → both the work row and the claim row must be kept
|
|
95
95
|
|
|
96
96
|
{ id: "r2", kind: "bug", text: "closed already", ts: "", agent: "" },
|
|
97
97
|
{ kind: "claim", ref: "r2", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
|
|
98
98
|
{ kind: "close", ref: "r2", text: "shipped", ts: "", agent: "" },
|
|
99
|
-
// r2:
|
|
99
|
+
// r2: closed → work row, claim row, close tombstone all dropped
|
|
100
100
|
|
|
101
101
|
{
|
|
102
102
|
id: "r3",
|
|
@@ -112,7 +112,7 @@ const runRotateTests = () => {
|
|
|
112
112
|
ts: "2026-01-02T00:00:00Z",
|
|
113
113
|
agent: "",
|
|
114
114
|
},
|
|
115
|
-
// r3: spec synced
|
|
115
|
+
// r3: spec synced *after* this decision → resolved, can be archived
|
|
116
116
|
|
|
117
117
|
{
|
|
118
118
|
id: "r4",
|
|
@@ -122,10 +122,10 @@ const runRotateTests = () => {
|
|
|
122
122
|
agent: "",
|
|
123
123
|
spec: "PLAN-a.md",
|
|
124
124
|
},
|
|
125
|
-
// r4: decision
|
|
125
|
+
// r4: decision newer than the latest synced → not resolved yet, keep it
|
|
126
126
|
|
|
127
127
|
{ id: "r5", kind: "note", text: "note no spec", ts: "", agent: "" },
|
|
128
|
-
// r5: note
|
|
128
|
+
// r5: a note with no spec → no way to know if resolved, always kept
|
|
129
129
|
];
|
|
130
130
|
|
|
131
131
|
const kept = rotateKeep(t);
|
|
@@ -167,17 +167,17 @@ const runPlanSweepTests = () => {
|
|
|
167
167
|
const tmpDir = mkdtempSync(join(tmpdir(), "mem-test-"));
|
|
168
168
|
try {
|
|
169
169
|
// === Case A: rewriteMarkdownLinks + countPlainTextMentions ===
|
|
170
|
-
//
|
|
170
|
+
// build fixture: a file that references plan/PLAN-page-style.md several ways
|
|
171
171
|
const planDir = join(tmpDir, "plan");
|
|
172
172
|
const doneDir = join(tmpDir, "plan", "done");
|
|
173
173
|
mkdirSync(planDir, { recursive: true });
|
|
174
174
|
mkdirSync(doneDir, { recursive: true });
|
|
175
175
|
|
|
176
|
-
// "old" file —
|
|
176
|
+
// "old" file — simulates apps/vela/plan/PLAN-page-style.md (touch only)
|
|
177
177
|
const oldFile = join(planDir, "PLAN-page-style.md");
|
|
178
178
|
writeFileSync(oldFile, "# PLAN-page-style\n");
|
|
179
179
|
|
|
180
|
-
// inbound file —
|
|
180
|
+
// inbound file — simulates apps/vela/plan/PLAN-people-style.md
|
|
181
181
|
const inbound = [
|
|
182
182
|
"# PLAN-people-style — refs",
|
|
183
183
|
"",
|
|
@@ -361,13 +361,13 @@ const runPlanCheckTests = () => {
|
|
|
361
361
|
);
|
|
362
362
|
writeFileSync(join(planDir, "PLAN-active.md"), "# active plan\n");
|
|
363
363
|
|
|
364
|
-
// plan
|
|
364
|
+
// a plan in the current format: frontmatter + title come before the ✅ shipped header
|
|
365
365
|
writeFileSync(
|
|
366
366
|
join(planDir, "PLAN-frontmatter.md"),
|
|
367
367
|
"---\nkind: unit\n---\n\n# shipped with frontmatter\n\n> ✅ **shipped 2026-09-13** (abc1234)\n",
|
|
368
368
|
);
|
|
369
369
|
|
|
370
|
-
//
|
|
370
|
+
// shipped some chunks and waiting on externals — frontmatter says it is meant to stay, do not count as forgotten
|
|
371
371
|
writeFileSync(
|
|
372
372
|
join(planDir, "PLAN-blocked.md"),
|
|
373
373
|
"---\nstatus: blocked\nblocked_by: VPS#2\n---\n\n# partly shipped\n\n> ✅ **chunk 1 shipped 2026-09-13** (abc1234)\n",
|