@jaybeeuu/agent-cortex 2.7.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +412 -0
- package/README.md +87 -8
- package/agents/README.md +4 -4
- package/agents/guppi/agent.md +162 -0
- package/agents/guppi/claude/frontmatter.json +6 -0
- package/agents/guppi/pi/frontmatter.json +6 -0
- package/agents/plan/agent.md +6 -0
- package/agents/ralph/agent.md +17 -16
- package/agents/ralph/claude/wake.md +4 -4
- package/agents/ralph/pi/wake.md +15 -20
- package/agents/ralph-plan/agent.md +6 -0
- package/bin/agent-cortex.mjs +20 -1
- package/bin/installers/claude.mjs +1 -20
- package/bin/installers/pi.mjs +8 -2
- package/extensions/interrupt-bookmark/README.md +129 -0
- package/extensions/interrupt-bookmark/index.test.ts +717 -0
- package/extensions/interrupt-bookmark/index.ts +354 -0
- package/extensions/interrupt-bookmark/package.json +20 -0
- package/extensions/post-merge-watch/README.md +133 -0
- package/extensions/post-merge-watch/index.test.ts +247 -0
- package/extensions/post-merge-watch/index.ts +123 -0
- package/extensions/post-merge-watch/package.json +16 -0
- package/extensions/post-merge-watch/watch.test.ts +469 -0
- package/extensions/post-merge-watch/watch.ts +431 -0
- package/extensions/session-clear/README.md +74 -0
- package/extensions/session-clear/index.test.ts +524 -0
- package/extensions/session-clear/index.ts +159 -0
- package/extensions/session-clear/package.json +16 -0
- package/extensions/skill-stats/README.md +84 -3
- package/extensions/skill-stats/index.ts +62 -3
- package/extensions/skill-stats/package.json +1 -1
- package/extensions/skill-stats/rollup.test.ts +643 -0
- package/extensions/skill-stats/rollup.ts +415 -0
- package/extensions/skill-stats/timeline.ts +101 -0
- package/lib/cli.mjs +42 -1
- package/lib/self-update.mjs +553 -0
- package/package.json +1 -1
- package/scripts/lib/manifest.mjs +29 -0
- package/skills/engineering/implement-task/SKILL.md +124 -0
- package/skills/engineering/tdd/SKILL.md +2 -0
- package/skills/planning/create-task/REFERENCE.md +15 -6
- package/skills/planning/create-task/SKILL.md +1 -1
- package/skills/planning/create-task/pipeline.json +8 -0
- package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
- package/skills/planning/create-task/templates/code.md +8 -3
- package/skills/planning/create-task/templates/document.md +8 -3
- package/skills/planning/create-task/templates/fix.md +8 -3
- package/skills/planning/create-task/templates/housekeeping.md +8 -4
- package/skills/planning/create-task/templates/post-merge.md +67 -0
- package/skills/planning/create-task/templates/review.md +8 -3
- package/skills/planning/create-task/templates/verify.md +8 -3
- package/skills/productivity/bd-tool/SKILL.md +55 -0
- package/skills/review/review-defects/EXAMPLES.md +198 -0
- package/skills/review/review-defects/SKILL.md +121 -0
- package/skills/review/review-report/EXAMPLES.md +114 -0
- package/skills/review/review-report/SKILL.md +138 -0
- package/skills/review/review-report/scripts/report.mjs +1032 -0
- package/skills/style/style-tests/EXAMPLES.md +30 -0
- package/skills/style/style-tests/SKILL.md +10 -6
- package/skills/workflow/hitl-queue/SKILL.md +134 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
- package/skills/workflow/plan/SKILL.md +27 -3
- package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
- package/skills/workflow/prioritise-work/SKILL.md +150 -0
- package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
- package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
- package/skills/workflow/ralph/REFERENCE.md +124 -27
- package/skills/workflow/ralph/SKILL.md +24 -15
- package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
- package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +37 -3
- package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
- package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
- package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
- package/skills/workflow/score-bead/FORMAT.md +73 -0
- package/skills/workflow/score-bead/SKILL.md +112 -0
- package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
- package/skills/workflow/using-agent-skills/SKILL.md +21 -8
- package/token-map.README.md +27 -0
- package/token-map.json +16 -4
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync, statSync } from "node:fs";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
import { createTempRoot } from "../../test/helpers/temp-root.mjs";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_RETENTION_DAYS,
|
|
12
|
+
MAX_PROJECT_PATH_CHARS,
|
|
13
|
+
MAX_PROJECTS_PER_DAY,
|
|
14
|
+
MAX_ROLLUP_FILE_BYTES,
|
|
15
|
+
RETENTION_ENV,
|
|
16
|
+
bucketKey,
|
|
17
|
+
dayKey,
|
|
18
|
+
loadRollups,
|
|
19
|
+
oldestDay,
|
|
20
|
+
pruneRollups,
|
|
21
|
+
recordDailyUsage,
|
|
22
|
+
retentionCutoff,
|
|
23
|
+
retentionDays,
|
|
24
|
+
saveRollups,
|
|
25
|
+
shiftDay,
|
|
26
|
+
timeline,
|
|
27
|
+
type RollupStore,
|
|
28
|
+
} from "./index.ts";
|
|
29
|
+
import { formatTimeline, isTimelineRequest } from "./timeline.ts";
|
|
30
|
+
|
|
31
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const tempRoot = createTempRoot("skill-stats-rollup-");
|
|
34
|
+
|
|
35
|
+
function freshRollups(): RollupStore {
|
|
36
|
+
return { version: 1, since: null, days: {} };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A moment inside a named UTC day. */
|
|
40
|
+
function at(day: string): Date {
|
|
41
|
+
return new Date(`${day}T12:00:00.000Z`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Five events across four days, two weeks and two months:
|
|
46
|
+
* 2026-06-01 bd-tool loaded
|
|
47
|
+
* 2026-06-02 bd-tool read, style-code invoked
|
|
48
|
+
* 2026-06-08 bd-tool read
|
|
49
|
+
* 2026-07-01 write-a-prd loaded
|
|
50
|
+
*/
|
|
51
|
+
function fixtureRollups(): RollupStore {
|
|
52
|
+
const store = freshRollups();
|
|
53
|
+
recordDailyUsage(store, "bd-tool", "loaded", "/proj/a", at("2026-06-01"));
|
|
54
|
+
recordDailyUsage(store, "bd-tool", "read", "/proj/a", at("2026-06-02"));
|
|
55
|
+
recordDailyUsage(store, "style-code", "invoked", "/proj/b", at("2026-06-02"));
|
|
56
|
+
recordDailyUsage(store, "bd-tool", "read", "/proj/a", at("2026-06-08"));
|
|
57
|
+
recordDailyUsage(store, "write-a-prd", "loaded", "/proj/a", at("2026-07-01"));
|
|
58
|
+
return store;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function withRetentionEnv<T>(value: string | undefined, run: () => Promise<T>): Promise<T> {
|
|
62
|
+
const previous = process.env[RETENTION_ENV];
|
|
63
|
+
if (value === undefined) delete process.env[RETENTION_ENV];
|
|
64
|
+
else process.env[RETENTION_ENV] = value;
|
|
65
|
+
try {
|
|
66
|
+
return await run();
|
|
67
|
+
} finally {
|
|
68
|
+
if (previous === undefined) delete process.env[RETENTION_ENV];
|
|
69
|
+
else process.env[RETENTION_ENV] = previous;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Recording ───────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe("recordDailyUsage", () => {
|
|
76
|
+
it("keeps one record per skill per day", () => {
|
|
77
|
+
const store = freshRollups();
|
|
78
|
+
recordDailyUsage(store, "bd-tool", "loaded", "/proj/a", at("2026-06-01"));
|
|
79
|
+
recordDailyUsage(store, "bd-tool", "loaded", "/proj/a", at("2026-06-01"));
|
|
80
|
+
recordDailyUsage(store, "bd-tool", "read", "/proj/a", at("2026-06-01"));
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(Object.keys(store.days), ["2026-06-01"]);
|
|
83
|
+
assert.deepEqual(Object.keys(store.days["2026-06-01"]!), ["bd-tool"]);
|
|
84
|
+
assert.equal(store.days["2026-06-01"]!["bd-tool"]!.loaded, 2);
|
|
85
|
+
assert.equal(store.days["2026-06-01"]!["bd-tool"]!.read, 1);
|
|
86
|
+
assert.equal(store.days["2026-06-01"]!["bd-tool"]!.invoked, 0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("separates the same skill into one record per day", () => {
|
|
90
|
+
const store = freshRollups();
|
|
91
|
+
recordDailyUsage(store, "bd-tool", "read", "/proj/a", at("2026-06-01"));
|
|
92
|
+
recordDailyUsage(store, "bd-tool", "read", "/proj/a", at("2026-06-02"));
|
|
93
|
+
|
|
94
|
+
assert.equal(store.days["2026-06-01"]!["bd-tool"]!.read, 1);
|
|
95
|
+
assert.equal(store.days["2026-06-02"]!["bd-tool"]!.read, 1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("counts each project once per skill-day", () => {
|
|
99
|
+
const store = freshRollups();
|
|
100
|
+
recordDailyUsage(store, "bd-tool", "read", "/proj/a", at("2026-06-01"));
|
|
101
|
+
recordDailyUsage(store, "bd-tool", "read", "/proj/a", at("2026-06-01"));
|
|
102
|
+
recordDailyUsage(store, "bd-tool", "read", "/proj/b", at("2026-06-01"));
|
|
103
|
+
|
|
104
|
+
assert.deepEqual(store.days["2026-06-01"]!["bd-tool"]!.projects, ["/proj/a", "/proj/b"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("caps the project list so one skill-day cannot grow", () => {
|
|
108
|
+
const store = freshRollups();
|
|
109
|
+
for (let i = 0; i < MAX_PROJECTS_PER_DAY + 20; i++) {
|
|
110
|
+
recordDailyUsage(store, "bd-tool", "read", `/proj/${i}`, at("2026-06-01"));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const counts = store.days["2026-06-01"]!["bd-tool"]!;
|
|
114
|
+
assert.equal(counts.projects.length, MAX_PROJECTS_PER_DAY);
|
|
115
|
+
assert.equal(counts.read, MAX_PROJECTS_PER_DAY + 20);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("truncates a project path, so one record has a fixed maximum size", () => {
|
|
119
|
+
const store = freshRollups();
|
|
120
|
+
const long = `/home/user/${"nested/".repeat(40)}project`;
|
|
121
|
+
assert.ok(long.length > MAX_PROJECT_PATH_CHARS, "the fixture must be longer than the cap");
|
|
122
|
+
|
|
123
|
+
recordDailyUsage(store, "bd-tool", "read", long, at("2026-06-01"));
|
|
124
|
+
|
|
125
|
+
const projects = store.days["2026-06-01"]!["bd-tool"]!.projects;
|
|
126
|
+
assert.equal(projects.length, 1);
|
|
127
|
+
assert.equal(projects[0]!.length, MAX_PROJECT_PATH_CHARS);
|
|
128
|
+
assert.ok(long.startsWith(projects[0]!), "the head of the path is kept");
|
|
129
|
+
|
|
130
|
+
// A longer path that shares the truncated prefix is the same project, not a
|
|
131
|
+
// second one: that collision is the cost of the fixed-size record.
|
|
132
|
+
recordDailyUsage(store, "bd-tool", "read", `${long}-other`, at("2026-06-01"));
|
|
133
|
+
assert.equal(store.days["2026-06-01"]!["bd-tool"]!.projects.length, 1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("records the first day as `since` and keeps the earliest", () => {
|
|
137
|
+
const store = freshRollups();
|
|
138
|
+
assert.equal(store.since, null);
|
|
139
|
+
|
|
140
|
+
recordDailyUsage(store, "bd-tool", "read", "/proj/a", at("2026-06-02"));
|
|
141
|
+
assert.equal(store.since, "2026-06-02");
|
|
142
|
+
|
|
143
|
+
recordDailyUsage(store, "bd-tool", "read", "/proj/a", at("2026-06-01"));
|
|
144
|
+
assert.equal(store.since, "2026-06-01");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ─── Retention ───────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
describe("retention", () => {
|
|
151
|
+
it("defaults to one year and honours the environment override", async () => {
|
|
152
|
+
await withRetentionEnv(undefined, async () => {
|
|
153
|
+
assert.equal(retentionDays(), DEFAULT_RETENTION_DAYS);
|
|
154
|
+
assert.equal(DEFAULT_RETENTION_DAYS, 365);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await withRetentionEnv("30", async () => {
|
|
158
|
+
assert.equal(retentionDays(), 30);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
for (const nonsense of ["", "0", "-5", "not-a-number", "2.5"]) {
|
|
162
|
+
await withRetentionEnv(nonsense, async () => {
|
|
163
|
+
assert.equal(retentionDays(), DEFAULT_RETENTION_DAYS, `${nonsense} is not a window`);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("computes the cutoff as the start of the window", () => {
|
|
169
|
+
const now = at("2026-06-30");
|
|
170
|
+
assert.equal(retentionCutoff(now, 1), "2026-06-30");
|
|
171
|
+
assert.equal(retentionCutoff(now, 30), "2026-06-01");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("prunes days older than the window and reports how many went", () => {
|
|
175
|
+
const store = freshRollups();
|
|
176
|
+
for (const day of ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04"]) {
|
|
177
|
+
recordDailyUsage(store, "bd-tool", "read", "/proj/a", at(day));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const removed = pruneRollups(store, at("2026-06-04"), 2);
|
|
181
|
+
|
|
182
|
+
assert.equal(removed, 2);
|
|
183
|
+
assert.deepEqual(Object.keys(store.days).sort(), ["2026-06-03", "2026-06-04"]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("enforces the window on write, so the file cannot grow past it, and the header keeps the first-ever start", async () => {
|
|
187
|
+
await withRetentionEnv("30", async () => {
|
|
188
|
+
const workspace = await tempRoot.createWorkspace();
|
|
189
|
+
const file = join(workspace, "skill-usage-daily.json");
|
|
190
|
+
const today = dayKey();
|
|
191
|
+
const store = freshRollups();
|
|
192
|
+
|
|
193
|
+
for (let offset = 0; offset < 40; offset++) {
|
|
194
|
+
recordDailyUsage(store, "bd-tool", "read", "/proj/a", at(shiftDay(today, -offset)));
|
|
195
|
+
}
|
|
196
|
+
assert.equal(Object.keys(store.days).length, 40, "fixture spans 40 days");
|
|
197
|
+
|
|
198
|
+
saveRollups(store, file);
|
|
199
|
+
const loaded = loadRollups(file);
|
|
200
|
+
|
|
201
|
+
assert.equal(Object.keys(loaded.days).length, 30, "only the window is written");
|
|
202
|
+
assert.equal(oldestDay(loaded), shiftDay(today, -29));
|
|
203
|
+
assert.equal(loaded.since, shiftDay(today, -39), "the recorded start survives pruning");
|
|
204
|
+
|
|
205
|
+
// The days that aged out of the window were real history, so the header
|
|
206
|
+
// still reports the first-ever day rather than the retained one.
|
|
207
|
+
const output = formatTimeline(loaded);
|
|
208
|
+
assert.match(output, new RegExp(`Rollups since ${shiftDay(today, -39)}\\.`));
|
|
209
|
+
assert.ok(
|
|
210
|
+
!output.includes("Detail shed"),
|
|
211
|
+
"ordinary pruning sheds nothing, so no shed notice is rendered",
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("prunes against the injected moment, not the wall clock", async () => {
|
|
217
|
+
const workspace = await tempRoot.createWorkspace();
|
|
218
|
+
const anchored = join(workspace, "anchored.json");
|
|
219
|
+
const drifted = join(workspace, "drifted.json");
|
|
220
|
+
|
|
221
|
+
saveRollups(fixtureRollups(), anchored, at("2026-07-02"));
|
|
222
|
+
saveRollups(fixtureRollups(), drifted, at("2027-07-02"));
|
|
223
|
+
|
|
224
|
+
assert.equal(
|
|
225
|
+
Object.keys(loadRollups(anchored).days).length,
|
|
226
|
+
4,
|
|
227
|
+
"the fixture is current when the save is anchored to it",
|
|
228
|
+
);
|
|
229
|
+
assert.equal(
|
|
230
|
+
Object.keys(loadRollups(drifted).days).length,
|
|
231
|
+
0,
|
|
232
|
+
"a year later the same fixture is outside the window",
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("keeps the file bounded: a full window for 40 skills stays under the ceiling and stops growing", async () => {
|
|
237
|
+
await withRetentionEnv(undefined, async () => {
|
|
238
|
+
const workspace = await tempRoot.createWorkspace();
|
|
239
|
+
const file = join(workspace, "skill-usage-daily.json");
|
|
240
|
+
const today = dayKey();
|
|
241
|
+
const skills = Array.from({ length: 40 }, (_, i) => `skill-${i}`);
|
|
242
|
+
const store = freshRollups();
|
|
243
|
+
|
|
244
|
+
const fill = (from: number, to: number) => {
|
|
245
|
+
for (let offset = from; offset < to; offset++) {
|
|
246
|
+
const day = shiftDay(today, -offset);
|
|
247
|
+
for (const skill of skills) {
|
|
248
|
+
recordDailyUsage(store, skill, "loaded", "/proj/a", at(day));
|
|
249
|
+
recordDailyUsage(store, skill, "read", "/proj/a", at(day));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
fill(0, DEFAULT_RETENTION_DAYS);
|
|
255
|
+
saveRollups(store, file);
|
|
256
|
+
|
|
257
|
+
const size = statSync(file).size;
|
|
258
|
+
assert.ok(
|
|
259
|
+
size <= MAX_ROLLUP_FILE_BYTES,
|
|
260
|
+
`${size} bytes exceeds the ${MAX_ROLLUP_FILE_BYTES}-byte ceiling`,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// Another full year, all older than the window: pruned as fast as it is
|
|
264
|
+
// written, so the file does not grow at all. This is the bound that binds:
|
|
265
|
+
// without pruning it doubles.
|
|
266
|
+
fill(DEFAULT_RETENTION_DAYS, DEFAULT_RETENTION_DAYS * 2);
|
|
267
|
+
saveRollups(store, file);
|
|
268
|
+
|
|
269
|
+
const grown = statSync(file).size;
|
|
270
|
+
assert.equal(grown, size, `file grew from ${size} to ${grown} bytes`);
|
|
271
|
+
assert.equal(Object.keys(loadRollups(file).days).length, DEFAULT_RETENTION_DAYS);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("holds the ceiling when every skill-day carries the maximum project detail", async () => {
|
|
276
|
+
await withRetentionEnv(undefined, async () => {
|
|
277
|
+
const workspace = await tempRoot.createWorkspace();
|
|
278
|
+
const file = join(workspace, "skill-usage-daily.json");
|
|
279
|
+
const today = dayKey();
|
|
280
|
+
const skills = Array.from({ length: 40 }, (_, i) => `skill-${i}`);
|
|
281
|
+
// Realistic-length paths in every permitted project slot. This is the
|
|
282
|
+
// dimension the other bounded test leaves empty, and the one that
|
|
283
|
+
// dominates the file: it multiplies the raw size by `MAX_PROJECTS_PER_DAY`.
|
|
284
|
+
const projects = Array.from({ length: MAX_PROJECTS_PER_DAY }, (_, i) =>
|
|
285
|
+
`/home/user/projects/app-${String(i).padStart(2, "0")}`,
|
|
286
|
+
);
|
|
287
|
+
const store = freshRollups();
|
|
288
|
+
|
|
289
|
+
for (let offset = 0; offset < DEFAULT_RETENTION_DAYS; offset++) {
|
|
290
|
+
const day = shiftDay(today, -offset);
|
|
291
|
+
for (const skill of skills) {
|
|
292
|
+
for (const project of projects) {
|
|
293
|
+
recordDailyUsage(store, skill, "read", project, at(day));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const raw = Buffer.byteLength(JSON.stringify(store), "utf-8");
|
|
299
|
+
assert.ok(
|
|
300
|
+
raw > MAX_ROLLUP_FILE_BYTES,
|
|
301
|
+
`the fixture must exceed the ceiling to prove it binds: ${raw} bytes`,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const result = saveRollups(store, file, at(today));
|
|
305
|
+
const size = statSync(file).size;
|
|
306
|
+
|
|
307
|
+
assert.ok(
|
|
308
|
+
size <= MAX_ROLLUP_FILE_BYTES,
|
|
309
|
+
`raw ${raw} bytes was written as ${size}, over the ${MAX_ROLLUP_FILE_BYTES} ceiling`,
|
|
310
|
+
);
|
|
311
|
+
assert.equal(result.bytes, size);
|
|
312
|
+
assert.ok(result.shedProjectLists > 0, "project detail was shed to hold the ceiling");
|
|
313
|
+
assert.equal(result.droppedDays, 0, "the counts alone fit, so no day is dropped");
|
|
314
|
+
|
|
315
|
+
const loaded = loadRollups(file);
|
|
316
|
+
assert.equal(Object.keys(loaded.days).length, DEFAULT_RETENTION_DAYS, "every day survives");
|
|
317
|
+
assert.deepEqual(loaded.days[today]!["skill-0"]!.projects, [], "the shed removed the lists");
|
|
318
|
+
assert.equal(
|
|
319
|
+
loaded.days[today]!["skill-0"]!.read,
|
|
320
|
+
MAX_PROJECTS_PER_DAY,
|
|
321
|
+
"counts survive the shed",
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// The timeline reads counts only, so shedding changes no rendered number.
|
|
325
|
+
const row = timeline(loaded, "day").find((r) => r.bucket === today);
|
|
326
|
+
assert.equal(row?.total, 40 * MAX_PROJECTS_PER_DAY);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("drops the oldest days only when the counts alone exceed the ceiling", async () => {
|
|
331
|
+
const workspace = await tempRoot.createWorkspace();
|
|
332
|
+
const file = join(workspace, "skill-usage-daily.json");
|
|
333
|
+
const today = dayKey();
|
|
334
|
+
// No project paths at all, so shedding them frees nothing: 200 skills used
|
|
335
|
+
// every day of the window is several times the ceiling in counts alone.
|
|
336
|
+
const skills = Array.from({ length: 200 }, (_, i) => `skill-${i}`);
|
|
337
|
+
const oldest = shiftDay(today, -(DEFAULT_RETENTION_DAYS - 1));
|
|
338
|
+
const store = freshRollups();
|
|
339
|
+
|
|
340
|
+
for (let offset = 0; offset < DEFAULT_RETENTION_DAYS; offset++) {
|
|
341
|
+
const day = shiftDay(today, -offset);
|
|
342
|
+
for (const skill of skills) recordDailyUsage(store, skill, "read", "", at(day));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const result = saveRollups(store, file, at(today));
|
|
346
|
+
|
|
347
|
+
assert.ok(
|
|
348
|
+
statSync(file).size <= MAX_ROLLUP_FILE_BYTES,
|
|
349
|
+
`the counts alone must still fit the ${MAX_ROLLUP_FILE_BYTES}-byte ceiling`,
|
|
350
|
+
);
|
|
351
|
+
assert.equal(result.shedProjectLists, 0, "there was no project detail to shed");
|
|
352
|
+
assert.ok(result.droppedDays > 0, "the oldest days were dropped");
|
|
353
|
+
|
|
354
|
+
const loaded = loadRollups(file);
|
|
355
|
+
assert.equal(Object.keys(loaded.days).length, DEFAULT_RETENTION_DAYS - result.droppedDays);
|
|
356
|
+
assert.ok(loaded.days[today], "the newest day survives");
|
|
357
|
+
assert.ok(!loaded.days[oldest], "the oldest day went first");
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ─── Bucketing ───────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
describe("bucketKey", () => {
|
|
364
|
+
it("uses the day itself for days", () => {
|
|
365
|
+
assert.equal(bucketKey("2026-06-02", "day"), "2026-06-02");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("buckets a week from its Monday", () => {
|
|
369
|
+
// 2026-06-01 is a Monday; the rest of that week shares its key.
|
|
370
|
+
assert.equal(bucketKey("2026-06-01", "week"), "2026-06-01");
|
|
371
|
+
assert.equal(bucketKey("2026-06-02", "week"), "2026-06-01");
|
|
372
|
+
assert.equal(bucketKey("2026-06-07", "week"), "2026-06-01");
|
|
373
|
+
assert.equal(bucketKey("2026-06-08", "week"), "2026-06-08");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("buckets a month from its year and month", () => {
|
|
377
|
+
assert.equal(bucketKey("2026-06-30", "month"), "2026-06");
|
|
378
|
+
assert.equal(bucketKey("2026-07-01", "month"), "2026-07");
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("timeline", () => {
|
|
383
|
+
it("aggregates per day, including distinct skills per day", () => {
|
|
384
|
+
const rows = timeline(fixtureRollups(), "day");
|
|
385
|
+
|
|
386
|
+
assert.deepEqual(
|
|
387
|
+
rows.map((r) => [r.bucket, r.total, r.skills]),
|
|
388
|
+
[
|
|
389
|
+
["2026-06-01", 1, 1],
|
|
390
|
+
["2026-06-02", 2, 2],
|
|
391
|
+
["2026-06-08", 1, 1],
|
|
392
|
+
["2026-07-01", 1, 1],
|
|
393
|
+
],
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("aggregates per week, counting a skill once per bucket", () => {
|
|
398
|
+
const rows = timeline(fixtureRollups(), "week");
|
|
399
|
+
|
|
400
|
+
assert.deepEqual(
|
|
401
|
+
rows.map((r) => [r.bucket, r.total, r.skills]),
|
|
402
|
+
[
|
|
403
|
+
["2026-06-01", 3, 2],
|
|
404
|
+
["2026-06-08", 1, 1],
|
|
405
|
+
["2026-06-29", 1, 1],
|
|
406
|
+
],
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("aggregates per month", () => {
|
|
411
|
+
const rows = timeline(fixtureRollups(), "month");
|
|
412
|
+
|
|
413
|
+
assert.deepEqual(
|
|
414
|
+
rows.map((r) => [r.bucket, r.loaded, r.invoked, r.read, r.total, r.skills]),
|
|
415
|
+
[
|
|
416
|
+
["2026-06", 1, 1, 2, 4, 2],
|
|
417
|
+
["2026-07", 1, 0, 0, 1, 1],
|
|
418
|
+
],
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("keeps only the most recent buckets when limited", () => {
|
|
423
|
+
const rows = timeline(fixtureRollups(), "day", 2);
|
|
424
|
+
assert.deepEqual(rows.map((r) => r.bucket), ["2026-06-08", "2026-07-01"]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("returns nothing for an empty store rather than zero-filled buckets", () => {
|
|
428
|
+
assert.deepEqual(timeline(freshRollups(), "day"), []);
|
|
429
|
+
assert.deepEqual(timeline(freshRollups(), "week"), []);
|
|
430
|
+
assert.deepEqual(timeline(freshRollups(), "month"), []);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// ─── Rendering ───────────────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
describe("formatTimeline", () => {
|
|
437
|
+
it("renders per-day, per-week and per-month sections from the rollups", () => {
|
|
438
|
+
const output = formatTimeline(fixtureRollups());
|
|
439
|
+
|
|
440
|
+
assert.match(output, /Per day/);
|
|
441
|
+
assert.match(output, /Per week/);
|
|
442
|
+
assert.match(output, /Per month/);
|
|
443
|
+
assert.match(output, /2026-06-02/);
|
|
444
|
+
assert.match(output, /2026-06-29/);
|
|
445
|
+
assert.match(output, /2026-07/);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("states the retention window and where the history starts", () => {
|
|
449
|
+
const output = formatTimeline(fixtureRollups());
|
|
450
|
+
|
|
451
|
+
assert.match(output, /Rollups since 2026-06-01/);
|
|
452
|
+
assert.match(output, /History before 2026-06-01 is unavailable/);
|
|
453
|
+
assert.match(output, new RegExp(`Retention: ${DEFAULT_RETENTION_DAYS} days`));
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("reports missing history as unavailable and renders no rows for it", () => {
|
|
457
|
+
const output = formatTimeline(freshRollups());
|
|
458
|
+
|
|
459
|
+
assert.match(output, /History unavailable/);
|
|
460
|
+
assert.match(output, /no backfill/);
|
|
461
|
+
assert.match(output, /No rollup data to render/);
|
|
462
|
+
assert.ok(!output.includes("Bucket"), "an empty store renders no table at all");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("renders no rows when the window has pruned everything", () => {
|
|
466
|
+
const store = fixtureRollups();
|
|
467
|
+
pruneRollups(store, at("2027-06-01"), 30);
|
|
468
|
+
|
|
469
|
+
const output = formatTimeline(store);
|
|
470
|
+
assert.match(output, /No rollup data to render/);
|
|
471
|
+
assert.ok(!output.includes("Bucket"), "no table without rows");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("is requested only by the `timeline` subcommand", () => {
|
|
475
|
+
assert.equal(isTimelineRequest("timeline"), true);
|
|
476
|
+
assert.equal(isTimelineRequest(" timeline "), true);
|
|
477
|
+
assert.equal(isTimelineRequest("timeline --project /p"), true);
|
|
478
|
+
assert.equal(isTimelineRequest(""), false);
|
|
479
|
+
assert.equal(isTimelineRequest(undefined), false);
|
|
480
|
+
assert.equal(isTimelineRequest("--project timeline"), false);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("renders the shed detail a ceiling write persisted, naming the counts and the day", async () => {
|
|
484
|
+
const workspace = await tempRoot.createWorkspace();
|
|
485
|
+
const file = join(workspace, "skill-usage-daily.json");
|
|
486
|
+
const today = dayKey();
|
|
487
|
+
// Long paths in every permitted project slot, so the project detail alone
|
|
488
|
+
// pushes the file past the ceiling while the counts keep it well inside.
|
|
489
|
+
const skills = 10;
|
|
490
|
+
const days = 60;
|
|
491
|
+
const projects = Array.from({ length: MAX_PROJECTS_PER_DAY }, (_, i) =>
|
|
492
|
+
`/${String(i).padStart(2, "0")}/${"x".repeat(MAX_PROJECT_PATH_CHARS - 4)}`,
|
|
493
|
+
);
|
|
494
|
+
const store = freshRollups();
|
|
495
|
+
|
|
496
|
+
for (let offset = 0; offset < days; offset++) {
|
|
497
|
+
const day = shiftDay(today, -offset);
|
|
498
|
+
for (let s = 0; s < skills; s++) {
|
|
499
|
+
for (const project of projects) {
|
|
500
|
+
recordDailyUsage(store, `skill-${s}`, "read", project, at(day));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
assert.equal(
|
|
505
|
+
projects[0]!.length,
|
|
506
|
+
MAX_PROJECT_PATH_CHARS,
|
|
507
|
+
"the fixture fills the path cap, so the file is at its largest",
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
const result = saveRollups(store, file, at(today));
|
|
511
|
+
assert.equal(result.shedProjectLists, skills * days, "one list per skill-day was shed");
|
|
512
|
+
assert.equal(result.droppedDays, 0, "the counts alone fit, so no day was dropped");
|
|
513
|
+
|
|
514
|
+
const output = formatTimeline(loadRollups(file));
|
|
515
|
+
|
|
516
|
+
assert.match(output, new RegExp(`Detail shed on ${today}`), "the notice names the write's day");
|
|
517
|
+
assert.ok(
|
|
518
|
+
output.includes(`${result.shedProjectLists} project lists`),
|
|
519
|
+
`the notice names the ${result.shedProjectLists} dropped lists:\n${output}`,
|
|
520
|
+
);
|
|
521
|
+
assert.ok(
|
|
522
|
+
output.includes(`${MAX_ROLLUP_FILE_BYTES}-byte ceiling`),
|
|
523
|
+
"the notice names the ceiling that forced the shed",
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("moves the retained start when a ceiling shed drops days, so the header cannot claim them", async () => {
|
|
528
|
+
const workspace = await tempRoot.createWorkspace();
|
|
529
|
+
const file = join(workspace, "skill-usage-daily.json");
|
|
530
|
+
const today = dayKey();
|
|
531
|
+
const firstEver = shiftDay(today, -(DEFAULT_RETENTION_DAYS - 1));
|
|
532
|
+
// No project paths at all, so only whole days can free the bytes: enough
|
|
533
|
+
// skills per day that the counts alone exceed the ceiling.
|
|
534
|
+
const skills = Array.from({ length: 140 }, (_, i) => `skill-${i}`);
|
|
535
|
+
const store = freshRollups();
|
|
536
|
+
|
|
537
|
+
for (let offset = 0; offset < DEFAULT_RETENTION_DAYS; offset++) {
|
|
538
|
+
const day = shiftDay(today, -offset);
|
|
539
|
+
for (const skill of skills) recordDailyUsage(store, skill, "read", "", at(day));
|
|
540
|
+
}
|
|
541
|
+
assert.equal(store.since, firstEver, "the fixture starts at the oldest day of the window");
|
|
542
|
+
|
|
543
|
+
const result = saveRollups(store, file, at(today));
|
|
544
|
+
assert.ok(result.droppedDays > 0, "the counts alone exceeded the ceiling, so days were dropped");
|
|
545
|
+
|
|
546
|
+
const loaded = loadRollups(file);
|
|
547
|
+
const retained = oldestDay(loaded);
|
|
548
|
+
assert.ok(retained !== null && retained > firstEver, "the fixture lost its oldest days");
|
|
549
|
+
assert.equal(loaded.since, retained, "the retained start moved to the oldest day held");
|
|
550
|
+
|
|
551
|
+
const output = formatTimeline(loaded);
|
|
552
|
+
assert.ok(
|
|
553
|
+
output.includes(`Rollups since ${retained}.`),
|
|
554
|
+
`the header names the oldest retained day ${retained}:\n${output}`,
|
|
555
|
+
);
|
|
556
|
+
assert.ok(
|
|
557
|
+
!output.includes(`Rollups since ${firstEver}.`),
|
|
558
|
+
`the header must not claim ${firstEver}, which the file no longer holds`,
|
|
559
|
+
);
|
|
560
|
+
assert.ok(
|
|
561
|
+
output.includes(`${result.droppedDays} days`),
|
|
562
|
+
"the notice names the days the header no longer accounts for",
|
|
563
|
+
);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("loads and renders a rollup file written before the shed record existed", async () => {
|
|
567
|
+
const workspace = await tempRoot.createWorkspace();
|
|
568
|
+
const file = join(workspace, "skill-usage-daily.json");
|
|
569
|
+
// Exactly the shape this version wrote before `shed` existed: no such key.
|
|
570
|
+
await fs.promises.writeFile(
|
|
571
|
+
file,
|
|
572
|
+
JSON.stringify({
|
|
573
|
+
version: 1,
|
|
574
|
+
since: "2026-06-01",
|
|
575
|
+
days: {
|
|
576
|
+
"2026-06-02": { "bd-tool": { loaded: 1, invoked: 0, read: 2, projects: ["/proj/a"] } },
|
|
577
|
+
},
|
|
578
|
+
}),
|
|
579
|
+
"utf-8",
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
const loaded = loadRollups(file);
|
|
583
|
+
assert.equal(loaded.shed, undefined, "an older file has no shed record");
|
|
584
|
+
assert.equal(loaded.days["2026-06-02"]!["bd-tool"]!.read, 2, "the days load unchanged");
|
|
585
|
+
|
|
586
|
+
const output = formatTimeline(loaded);
|
|
587
|
+
assert.match(output, /Rollups since 2026-06-01/);
|
|
588
|
+
assert.match(output, /2026-06-02/);
|
|
589
|
+
assert.ok(!output.includes("Detail shed"), "no shed notice is invented for an older file");
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// ─── No transcript scan ──────────────────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
describe("the timeline path", () => {
|
|
596
|
+
it("renders from the store it is given, reading no file or directory", () => {
|
|
597
|
+
const calls: string[] = [];
|
|
598
|
+
const originalRead = fs.readFileSync;
|
|
599
|
+
const originalReaddir = fs.readdirSync;
|
|
600
|
+
|
|
601
|
+
// A transcript scan would have to open a file or walk a directory. Neither
|
|
602
|
+
// happens on the render path: every number comes from the rollup store.
|
|
603
|
+
fs.readFileSync = ((file: fs.PathOrFileDescriptor) => {
|
|
604
|
+
calls.push(String(file));
|
|
605
|
+
return "";
|
|
606
|
+
}) as typeof fs.readFileSync;
|
|
607
|
+
fs.readdirSync = ((target: fs.PathLike) => {
|
|
608
|
+
calls.push(String(target));
|
|
609
|
+
return [];
|
|
610
|
+
}) as typeof fs.readdirSync;
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
assert.match(formatTimeline(fixtureRollups()), /Per day/);
|
|
614
|
+
} finally {
|
|
615
|
+
fs.readFileSync = originalRead;
|
|
616
|
+
fs.readdirSync = originalReaddir;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
assert.deepEqual(calls, []);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("never names a transcript file", () => {
|
|
623
|
+
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
624
|
+
|
|
625
|
+
for (const name of ["rollup.ts", "timeline.ts", "index.ts"]) {
|
|
626
|
+
const source = readFileSync(join(here, name), "utf-8");
|
|
627
|
+
assert.ok(!/\.jsonl/i.test(source), `${name} must not read a .jsonl transcript`);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("renders from a rollup file alone", async () => {
|
|
632
|
+
const workspace = await tempRoot.createWorkspace();
|
|
633
|
+
const file = join(workspace, "skill-usage-daily.json");
|
|
634
|
+
// Anchored to the fixture's own window: the save prunes against this moment,
|
|
635
|
+
// so the 2026 fixture never falls out of the window as wall time advances.
|
|
636
|
+
saveRollups(fixtureRollups(), file, at("2026-07-02"));
|
|
637
|
+
|
|
638
|
+
const output = formatTimeline(loadRollups(file));
|
|
639
|
+
|
|
640
|
+
assert.match(output, /Per day/);
|
|
641
|
+
assert.match(output, /2026-06-02/);
|
|
642
|
+
});
|
|
643
|
+
});
|