@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +87 -8
  3. package/agents/README.md +4 -4
  4. package/agents/guppi/agent.md +162 -0
  5. package/agents/guppi/claude/frontmatter.json +6 -0
  6. package/agents/guppi/pi/frontmatter.json +6 -0
  7. package/agents/plan/agent.md +6 -0
  8. package/agents/ralph/agent.md +17 -16
  9. package/agents/ralph/claude/wake.md +4 -4
  10. package/agents/ralph/pi/wake.md +15 -20
  11. package/agents/ralph-plan/agent.md +6 -0
  12. package/bin/agent-cortex.mjs +20 -1
  13. package/bin/installers/claude.mjs +1 -20
  14. package/bin/installers/pi.mjs +8 -2
  15. package/extensions/interrupt-bookmark/README.md +129 -0
  16. package/extensions/interrupt-bookmark/index.test.ts +717 -0
  17. package/extensions/interrupt-bookmark/index.ts +354 -0
  18. package/extensions/interrupt-bookmark/package.json +20 -0
  19. package/extensions/post-merge-watch/README.md +133 -0
  20. package/extensions/post-merge-watch/index.test.ts +247 -0
  21. package/extensions/post-merge-watch/index.ts +123 -0
  22. package/extensions/post-merge-watch/package.json +16 -0
  23. package/extensions/post-merge-watch/watch.test.ts +469 -0
  24. package/extensions/post-merge-watch/watch.ts +431 -0
  25. package/extensions/session-clear/README.md +74 -0
  26. package/extensions/session-clear/index.test.ts +524 -0
  27. package/extensions/session-clear/index.ts +159 -0
  28. package/extensions/session-clear/package.json +16 -0
  29. package/extensions/skill-stats/README.md +84 -3
  30. package/extensions/skill-stats/index.ts +62 -3
  31. package/extensions/skill-stats/package.json +1 -1
  32. package/extensions/skill-stats/rollup.test.ts +643 -0
  33. package/extensions/skill-stats/rollup.ts +415 -0
  34. package/extensions/skill-stats/timeline.ts +101 -0
  35. package/lib/cli.mjs +42 -1
  36. package/lib/self-update.mjs +553 -0
  37. package/package.json +1 -1
  38. package/scripts/lib/manifest.mjs +29 -0
  39. package/skills/engineering/implement-task/SKILL.md +124 -0
  40. package/skills/engineering/tdd/SKILL.md +2 -0
  41. package/skills/planning/create-task/REFERENCE.md +15 -6
  42. package/skills/planning/create-task/SKILL.md +1 -1
  43. package/skills/planning/create-task/pipeline.json +8 -0
  44. package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
  45. package/skills/planning/create-task/templates/code.md +8 -3
  46. package/skills/planning/create-task/templates/document.md +8 -3
  47. package/skills/planning/create-task/templates/fix.md +8 -3
  48. package/skills/planning/create-task/templates/housekeeping.md +8 -4
  49. package/skills/planning/create-task/templates/post-merge.md +67 -0
  50. package/skills/planning/create-task/templates/review.md +8 -3
  51. package/skills/planning/create-task/templates/verify.md +8 -3
  52. package/skills/productivity/bd-tool/SKILL.md +55 -0
  53. package/skills/review/review-defects/EXAMPLES.md +198 -0
  54. package/skills/review/review-defects/SKILL.md +121 -0
  55. package/skills/review/review-report/EXAMPLES.md +114 -0
  56. package/skills/review/review-report/SKILL.md +138 -0
  57. package/skills/review/review-report/scripts/report.mjs +1032 -0
  58. package/skills/style/style-tests/EXAMPLES.md +30 -0
  59. package/skills/style/style-tests/SKILL.md +10 -6
  60. package/skills/workflow/hitl-queue/SKILL.md +134 -0
  61. package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
  62. package/skills/workflow/plan/SKILL.md +27 -3
  63. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  64. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  65. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  66. package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
  67. package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
  68. package/skills/workflow/ralph/REFERENCE.md +124 -27
  69. package/skills/workflow/ralph/SKILL.md +24 -15
  70. package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
  71. package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
  72. package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
  73. package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
  74. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
  75. package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
  76. package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
  77. package/skills/workflow/run-pipeline-stage/playbooks/review.md +37 -3
  78. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
  79. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
  80. package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
  81. package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
  82. package/skills/workflow/score-bead/FORMAT.md +73 -0
  83. package/skills/workflow/score-bead/SKILL.md +112 -0
  84. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  85. package/skills/workflow/using-agent-skills/SKILL.md +21 -8
  86. package/token-map.README.md +27 -0
  87. package/token-map.json +16 -4
@@ -0,0 +1,415 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+
5
+ import type { RawEvent } from "./store.js";
6
+
7
+ // ── Long-range rollups ───────────────────────────────────────────────────────
8
+ //
9
+ // `store.ts` keeps per-skill counters and a 200-event ring: enough detail for
10
+ // the last few minutes, no history at all. These per-day, per-skill rollups are
11
+ // the low-fidelity half of the picture: one small record per skill per day,
12
+ // pruned on write to a stated retention window, so the file is bounded by
13
+ // construction rather than by hope.
14
+ //
15
+ // The rollups live in their own file so the high-frequency store stays small:
16
+ // the store is re-read and re-written on every tracked event, while this file
17
+ // is machine-read only (written minified) and read by the timeline view.
18
+
19
+ /** Per-skill counts for one calendar day (UTC). */
20
+ export interface DaySkillCounts {
21
+ loaded: number;
22
+ invoked: number;
23
+ read: number;
24
+ /** Distinct project paths seen that day, capped at `MAX_PROJECTS_PER_DAY`. */
25
+ projects: string[];
26
+ }
27
+
28
+ /**
29
+ * The last write that had to give up detail to stay under
30
+ * `MAX_ROLLUP_FILE_BYTES`, accumulated across writes.
31
+ *
32
+ * Shedding is permanent: the dropped project lists and days are gone from the
33
+ * file, so this record is kept for the life of the store and rendered by the
34
+ * timeline. Without it the user loses detail and is never told.
35
+ */
36
+ export interface ShedRecord {
37
+ /** The most recent day a write had to shed (`YYYY-MM-DD`, UTC). */
38
+ on: string;
39
+ /** Total skill-day project lists dropped by shedding, across writes. */
40
+ projectLists: number;
41
+ /** Total whole days dropped by shedding, across writes. */
42
+ days: number;
43
+ }
44
+
45
+ export interface RollupStore {
46
+ version: 1;
47
+ /**
48
+ * The first day a rollup is held for (`YYYY-MM-DD`, UTC), or null before the
49
+ * first one. Everything before it is *unavailable* history, never zero usage.
50
+ *
51
+ * It survives ordinary retention pruning on purpose: the day that aged out of
52
+ * the window was real history, and the rendered retention line explains the
53
+ * window. It does NOT survive a ceiling shed that drops days, because those
54
+ * days were inside the window and nothing explains their absence.
55
+ */
56
+ since: string | null;
57
+ /** Day key -> skill name -> counts. */
58
+ days: Record<string, Record<string, DaySkillCounts>>;
59
+ /**
60
+ * Present only after a write had to shed detail for the size ceiling. Absent
61
+ * from every file written before this field existed, which readers must
62
+ * tolerate: an absent record means "nothing was shed".
63
+ */
64
+ shed?: ShedRecord;
65
+ }
66
+
67
+ /** How long daily records are kept: one year. A documented default, not a rule. */
68
+ export const DEFAULT_RETENTION_DAYS = 365;
69
+
70
+ /**
71
+ * Environment override for the retention window, in days. A machine that wants
72
+ * a shorter window can set it without editing shipped code.
73
+ */
74
+ export const RETENTION_ENV = "AGENT_CORTEX_SKILL_STATS_RETENTION_DAYS";
75
+
76
+ /** Upper bound on stored project paths per skill-day, so one day stays small. */
77
+ export const MAX_PROJECTS_PER_DAY = 50;
78
+
79
+ /**
80
+ * Upper bound on one stored project path, measured in `String.length` units. A
81
+ * longer path is truncated with `truncateProject`, so a skill-day's record has a
82
+ * fixed maximum size whatever the working-directory depth of the machine that
83
+ * reports it. Two paths that share the truncated prefix count as one project.
84
+ */
85
+ export const MAX_PROJECT_PATH_CHARS = 120;
86
+
87
+ /** Bytes a single skill-day is allowed when the budget is sized: the three
88
+ * counts plus one realistic project path. */
89
+ const SIZE_MODEL_SKILL_DAY_BYTES = 200;
90
+
91
+ /** Skills per day assumed when the budget is sized. A sizing input, not a limit. */
92
+ const SIZE_MODEL_SKILLS = 40;
93
+
94
+ /**
95
+ * The hard ceiling on the rollup file: 365 days x 40 skills x 200 bytes per
96
+ * skill-day = 2,920,000 bytes. The 40 is how the budget was sized, not a bound
97
+ * on an install: `saveRollups` enforces this byte budget on every write, so an
98
+ * install with more skills, or with the maximum project detail on every day,
99
+ * loses detail rather than growing past it. Growth past the ceiling is
100
+ * impossible, not merely unexpected.
101
+ */
102
+ export const MAX_ROLLUP_FILE_BYTES =
103
+ DEFAULT_RETENTION_DAYS * SIZE_MODEL_SKILLS * SIZE_MODEL_SKILL_DAY_BYTES;
104
+
105
+ const DAY_MS = 24 * 60 * 60 * 1000;
106
+
107
+ /** The rollup file, beside the counters, in the global agent-cortex area. */
108
+ export function rollupFilePath(): string {
109
+ return path.join(os.homedir(), ".pi", "agent-cortex", "skill-usage-daily.json");
110
+ }
111
+
112
+ // ── Day keys ─────────────────────────────────────────────────────────────────
113
+
114
+ /** The UTC day key (`YYYY-MM-DD`) of a moment. */
115
+ export function dayKey(now: Date = new Date()): string {
116
+ return now.toISOString().slice(0, 10);
117
+ }
118
+
119
+ /** Shift a day key by whole days. Day keys sort lexicographically. */
120
+ export function shiftDay(day: string, days: number): string {
121
+ return new Date(Date.parse(`${day}T00:00:00.000Z`) + days * DAY_MS)
122
+ .toISOString()
123
+ .slice(0, 10);
124
+ }
125
+
126
+ /** The retention window in days: the environment override, else the default. */
127
+ export function retentionDays(): number {
128
+ const raw = process.env[RETENTION_ENV];
129
+ const parsed = raw === undefined || raw.trim() === "" ? Number.NaN : Number(raw);
130
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_RETENTION_DAYS;
131
+ }
132
+
133
+ // ── Recording ────────────────────────────────────────────────────────────────
134
+
135
+ /** Truncate a project path to `MAX_PROJECT_PATH_CHARS` so one record is fixed-size. */
136
+ function truncateProject(project: string): string {
137
+ return project.length > MAX_PROJECT_PATH_CHARS
138
+ ? project.slice(0, MAX_PROJECT_PATH_CHARS)
139
+ : project;
140
+ }
141
+
142
+ /**
143
+ * Add one usage event to the day's rollup for a skill. Counts stay fixed-size
144
+ * per skill-day, the project list is capped, and each stored path is truncated,
145
+ * so a single day cannot grow.
146
+ */
147
+ export function recordDailyUsage(
148
+ store: RollupStore,
149
+ skillName: string,
150
+ source: RawEvent["source"],
151
+ project: string,
152
+ now: Date = new Date(),
153
+ ): void {
154
+ const day = dayKey(now);
155
+ let bySkill = store.days[day];
156
+ if (!bySkill) {
157
+ bySkill = {};
158
+ store.days[day] = bySkill;
159
+ }
160
+
161
+ let counts = bySkill[skillName];
162
+ if (!counts) {
163
+ counts = { loaded: 0, invoked: 0, read: 0, projects: [] };
164
+ bySkill[skillName] = counts;
165
+ }
166
+
167
+ switch (source) {
168
+ case "loaded": counts.loaded++; break;
169
+ case "invoked": counts.invoked++; break;
170
+ case "read": counts.read++; break;
171
+ }
172
+
173
+ const stored = truncateProject(project);
174
+ if (
175
+ stored !== "" &&
176
+ counts.projects.length < MAX_PROJECTS_PER_DAY &&
177
+ !counts.projects.includes(stored)
178
+ ) {
179
+ counts.projects.push(stored);
180
+ }
181
+
182
+ if (store.since === null || day < store.since) store.since = day;
183
+ }
184
+
185
+ // ── Retention ────────────────────────────────────────────────────────────────
186
+
187
+ /** The oldest day a store may keep: the start of the retention window. */
188
+ export function retentionCutoff(now: Date = new Date(), retention = retentionDays()): string {
189
+ return shiftDay(dayKey(now), -(retention - 1));
190
+ }
191
+
192
+ /**
193
+ * Drop every day outside the retention window. Returns how many days were
194
+ * removed, so a caller can tell that pruning happened.
195
+ */
196
+ export function pruneRollups(
197
+ store: RollupStore,
198
+ now: Date = new Date(),
199
+ retention = retentionDays(),
200
+ ): number {
201
+ const cutoff = retentionCutoff(now, retention);
202
+ let removed = 0;
203
+ for (const day of Object.keys(store.days)) {
204
+ if (day < cutoff) {
205
+ delete store.days[day];
206
+ removed++;
207
+ }
208
+ }
209
+ return removed;
210
+ }
211
+
212
+ // ── Storage ──────────────────────────────────────────────────────────────────
213
+
214
+ /** The earliest day still recorded, or null for an empty store. */
215
+ export function oldestDay(store: RollupStore): string | null {
216
+ const days = Object.keys(store.days).sort();
217
+ return days.length > 0 ? days[0]! : null;
218
+ }
219
+
220
+ export function loadRollups(filePath?: string): RollupStore {
221
+ const target = filePath ?? rollupFilePath();
222
+ try {
223
+ const parsed = JSON.parse(fs.readFileSync(target, "utf-8")) as RollupStore;
224
+ if (parsed.version === 1 && typeof parsed.days === "object" && parsed.days !== null) {
225
+ // A store written before `since` existed reports its earliest real day
226
+ // rather than inventing a start date.
227
+ parsed.since = parsed.since ?? oldestDay(parsed);
228
+ return parsed;
229
+ }
230
+ } catch { /* missing or corrupt — start fresh */ }
231
+ return { version: 1, since: null, days: {} };
232
+ }
233
+
234
+ /** What a write had to give up to stay under `MAX_ROLLUP_FILE_BYTES`. */
235
+ export interface SaveRollupsResult {
236
+ /** Size of the file written, in bytes. Never above `MAX_ROLLUP_FILE_BYTES`. */
237
+ bytes: number;
238
+ /** Skill-days whose project list was dropped to stay under the ceiling. */
239
+ shedProjectLists: number;
240
+ /** Whole days dropped to stay under the ceiling, beyond the retention window. */
241
+ droppedDays: number;
242
+ }
243
+
244
+ /**
245
+ * Serialise the store, shedding detail until it fits `MAX_ROLLUP_FILE_BYTES`.
246
+ *
247
+ * The order is chosen so the least useful detail goes first:
248
+ * 1. the per-skill-day project lists, which the timeline never reads — the
249
+ * loaded / invoked / read counts and every day survive;
250
+ * 2. the oldest whole days, which continues the direction pruning already
251
+ * takes, if the counts alone exceed the budget.
252
+ * Step 1 is the only one a realistic install can reach.
253
+ *
254
+ * Every shed is recorded in `store.shed` and, when whole days go, `since` moves
255
+ * to the oldest day still held. Both are part of the bytes counted here, so the
256
+ * record cannot push the file past the ceiling.
257
+ */
258
+ function serialiseToBudget(
259
+ store: RollupStore,
260
+ on: string,
261
+ ): {
262
+ json: string;
263
+ shedProjectLists: number;
264
+ droppedDays: number;
265
+ } {
266
+ let json = JSON.stringify(store);
267
+ if (Buffer.byteLength(json, "utf-8") <= MAX_ROLLUP_FILE_BYTES) {
268
+ return { json, shedProjectLists: 0, droppedDays: 0 };
269
+ }
270
+
271
+ // Written from a snapshot of the counters below, so re-applying it after each
272
+ // pass replaces this write's contribution instead of counting it twice.
273
+ const previousShed = store.shed;
274
+ let shedProjectLists = 0;
275
+ let droppedDays = 0;
276
+ const recordShed = (): void => {
277
+ store.shed = {
278
+ on,
279
+ projectLists: (previousShed?.projectLists ?? 0) + shedProjectLists,
280
+ days: (previousShed?.days ?? 0) + droppedDays,
281
+ };
282
+ };
283
+
284
+ for (const bySkill of Object.values(store.days)) {
285
+ for (const counts of Object.values(bySkill)) {
286
+ if (counts.projects.length > 0) {
287
+ counts.projects = [];
288
+ shedProjectLists++;
289
+ }
290
+ }
291
+ }
292
+ if (shedProjectLists > 0) recordShed();
293
+ json = JSON.stringify(store);
294
+
295
+ while (Buffer.byteLength(json, "utf-8") > MAX_ROLLUP_FILE_BYTES) {
296
+ const dayKeys = Object.keys(store.days).sort();
297
+ if (dayKeys.length === 0) break;
298
+ const bytes = Buffer.byteLength(json, "utf-8");
299
+ // Drop roughly the excess, by average day size, so a pathological store
300
+ // converges in a few passes instead of one pass per day.
301
+ const excess = bytes - MAX_ROLLUP_FILE_BYTES;
302
+ const drop = Math.max(1, Math.ceil((excess / bytes) * dayKeys.length));
303
+ for (const day of dayKeys.slice(0, drop)) {
304
+ delete store.days[day];
305
+ droppedDays++;
306
+ }
307
+ // These days were inside the retention window, so `since` must not keep
308
+ // claiming them: move the retained start to the oldest day still held.
309
+ store.since = oldestDay(store);
310
+ recordShed();
311
+ json = JSON.stringify(store);
312
+ }
313
+
314
+ return { json, shedProjectLists, droppedDays };
315
+ }
316
+
317
+ /**
318
+ * Persist the rollups, pruning the retention window first. Pruning on write is
319
+ * what keeps the file bounded: a long-lived install can never accumulate more
320
+ * than the window's worth of days. The byte ceiling is enforced on the same
321
+ * write, by shedding detail (see `serialiseToBudget`), so the file can never
322
+ * grow past `MAX_ROLLUP_FILE_BYTES` even when every cap is filled.
323
+ *
324
+ * `now` is injectable so a caller (a test) can pin which day counts as today for
325
+ * the prune, instead of racing the wall clock.
326
+ */
327
+ export function saveRollups(
328
+ store: RollupStore,
329
+ filePath?: string,
330
+ now: Date = new Date(),
331
+ ): SaveRollupsResult {
332
+ const target = filePath ?? rollupFilePath();
333
+ const dir = path.dirname(target);
334
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
335
+
336
+ pruneRollups(store, now);
337
+ store.since = store.since ?? oldestDay(store);
338
+
339
+ const { json, shedProjectLists, droppedDays } = serialiseToBudget(store, dayKey(now));
340
+
341
+ // Minified: this file is read by the extension, not by a human. The README
342
+ // documents the shape, and fewer bytes means a smaller write per event.
343
+ const tmp = target + ".tmp";
344
+ fs.writeFileSync(tmp, json, "utf-8");
345
+ fs.renameSync(tmp, target);
346
+
347
+ return { bytes: Buffer.byteLength(json, "utf-8"), shedProjectLists, droppedDays };
348
+ }
349
+
350
+ // ── Timeline ─────────────────────────────────────────────────────────────────
351
+
352
+ export type Granularity = "day" | "week" | "month";
353
+
354
+ export interface TimelineBucket {
355
+ /** Day key, week-start day key (Monday), or `YYYY-MM` for a month. */
356
+ bucket: string;
357
+ loaded: number;
358
+ invoked: number;
359
+ read: number;
360
+ total: number;
361
+ /** Distinct skills seen anywhere inside the bucket. */
362
+ skills: number;
363
+ }
364
+
365
+ /** The bucket a day belongs to. Weeks start on Monday, so a bucket is one 7-day span. */
366
+ export function bucketKey(day: string, granularity: Granularity): string {
367
+ if (granularity === "day") return day;
368
+ if (granularity === "month") return day.slice(0, 7);
369
+ const weekday = (new Date(`${day}T00:00:00.000Z`).getUTCDay() + 6) % 7; // Monday = 0
370
+ return shiftDay(day, -weekday);
371
+ }
372
+
373
+ /**
374
+ * Aggregate the rollups into buckets, oldest first. `limit` keeps the most
375
+ * recent buckets only. Reads the rollup store and nothing else: no session
376
+ * transcripts are involved.
377
+ */
378
+ export function timeline(
379
+ store: RollupStore,
380
+ granularity: Granularity,
381
+ limit?: number,
382
+ ): TimelineBucket[] {
383
+ const buckets = new Map<
384
+ string,
385
+ { loaded: number; invoked: number; read: number; skills: Set<string> }
386
+ >();
387
+
388
+ for (const [day, bySkill] of Object.entries(store.days)) {
389
+ const key = bucketKey(day, granularity);
390
+ let bucket = buckets.get(key);
391
+ if (!bucket) {
392
+ bucket = { loaded: 0, invoked: 0, read: 0, skills: new Set() };
393
+ buckets.set(key, bucket);
394
+ }
395
+ for (const [skill, counts] of Object.entries(bySkill)) {
396
+ bucket.loaded += counts.loaded;
397
+ bucket.invoked += counts.invoked;
398
+ bucket.read += counts.read;
399
+ bucket.skills.add(skill);
400
+ }
401
+ }
402
+
403
+ const rows = [...buckets.entries()]
404
+ .sort(([a], [b]) => a.localeCompare(b))
405
+ .map(([key, b]) => ({
406
+ bucket: key,
407
+ loaded: b.loaded,
408
+ invoked: b.invoked,
409
+ read: b.read,
410
+ total: b.loaded + b.invoked + b.read,
411
+ skills: b.skills.size,
412
+ }));
413
+
414
+ return limit === undefined ? rows : rows.slice(-limit);
415
+ }
@@ -0,0 +1,101 @@
1
+ import { formatTable } from "./format.js";
2
+ import {
3
+ MAX_ROLLUP_FILE_BYTES,
4
+ retentionDays,
5
+ timeline,
6
+ type RollupStore,
7
+ type TimelineBucket,
8
+ } from "./rollup.js";
9
+
10
+ // ── Timeline rendering ───────────────────────────────────────────────────────
11
+ //
12
+ // Reads a rollup store and nothing else. Every number here comes from the
13
+ // per-day rollups, so the view works the same whether or not any session
14
+ // transcript still exists on disk.
15
+
16
+ /** How many buckets each section shows by default. */
17
+ export const DAY_BUCKETS = 14;
18
+ export const WEEK_BUCKETS = 8;
19
+ export const MONTH_BUCKETS = 12;
20
+
21
+ const TIMELINE_HEADER = ["Bucket", "Loaded", "Invoked", "Read", "Total", "Skills"];
22
+
23
+ function rows(buckets: TimelineBucket[]): string[][] {
24
+ return buckets.map((b) => [
25
+ b.bucket,
26
+ String(b.loaded),
27
+ String(b.invoked),
28
+ String(b.read),
29
+ String(b.total),
30
+ String(b.skills),
31
+ ]);
32
+ }
33
+
34
+ function section(title: string, buckets: TimelineBucket[]): string[] {
35
+ return [title, formatTable(TIMELINE_HEADER, rows(buckets))];
36
+ }
37
+
38
+ /**
39
+ * The line that tells the user a write gave up detail to stay under the size
40
+ * ceiling. Rendered from the persisted record, so it survives reloads and is
41
+ * shown where history is read, not only where it is written.
42
+ */
43
+ function shedNotice(store: RollupStore): string | null {
44
+ const shed = store.shed;
45
+ if (!shed) return null;
46
+ return (
47
+ `Detail shed on ${shed.on}: ${shed.projectLists} project lists and ${shed.days} days ` +
48
+ `dropped to stay under the ${MAX_ROLLUP_FILE_BYTES}-byte ceiling. ` +
49
+ `The counts for the days that remain are complete.`
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Render the timeline from the rollups alone. History that predates the first
55
+ * recorded rollup is reported as unavailable: it is never zero-filled, and no
56
+ * bucket appears for a period with no data.
57
+ */
58
+ export function formatTimeline(store: RollupStore): string {
59
+ const retention = retentionDays();
60
+ const lines: string[] = ["Skill usage timeline (from daily rollups)", ""];
61
+
62
+ if (store.since === null) {
63
+ lines.push(
64
+ "History unavailable: no rollup has been recorded yet.",
65
+ "The timeline starts with the first skill event recorded after this version was installed.",
66
+ "Older usage is not invented, and there is no backfill.",
67
+ "",
68
+ );
69
+ } else {
70
+ lines.push(
71
+ `Rollups since ${store.since}. History before ${store.since} is unavailable (no rollup was recorded).`,
72
+ `Retention: ${retention} days. Days older than that are pruned on every write.`,
73
+ "",
74
+ );
75
+ }
76
+
77
+ const notice = shedNotice(store);
78
+ if (notice !== null) lines.push(notice, "");
79
+
80
+ const days = timeline(store, "day", DAY_BUCKETS);
81
+ const weeks = timeline(store, "week", WEEK_BUCKETS);
82
+ const months = timeline(store, "month", MONTH_BUCKETS);
83
+
84
+ if (days.length === 0) {
85
+ return [...lines, "No rollup data to render."].join("\n");
86
+ }
87
+
88
+ lines.push(...section(`Per day (most recent ${DAY_BUCKETS}):`, days));
89
+ lines.push("");
90
+ lines.push(...section(`Per week (most recent ${WEEK_BUCKETS}, week starts Monday):`, weeks));
91
+ lines.push("");
92
+ lines.push(...section(`Per month (most recent ${MONTH_BUCKETS}):`, months));
93
+
94
+ return lines.join("\n");
95
+ }
96
+
97
+ /** True when the `/skill-stats` argument asks for the timeline view. */
98
+ export function isTimelineRequest(args: string | undefined): boolean {
99
+ const first = (args ?? "").trim().split(/\s+/)[0];
100
+ return first === "timeline";
101
+ }
package/lib/cli.mjs CHANGED
@@ -4,9 +4,10 @@ import { basename } from "node:path";
4
4
  * Result of parsing the CLI argv. Every branch returns `command`; the rest are
5
5
  * populated only for the commands that accept them.
6
6
  * @typedef {object} ParsedArgs
7
- * @property {"help" | "version" | "install" | "update" | "migrate" | "unknown"} command
7
+ * @property {"help" | "version" | "install" | "update" | "self-update" | "migrate" | "unknown"} command
8
8
  * @property {boolean} [report] `version` subcommand: report installed harnesses too
9
9
  * @property {boolean} [json] `version --json`: print the report as JSON
10
+ * @property {boolean} [check] `self-update --check`: report and stop
10
11
  * @property {string | null} [harness]
11
12
  * @property {string[]} [harnesses] `update`: the harnesses named on the command line
12
13
  * @property {boolean} [all] `update -a, --all`: every harness that is not at the CLI version
@@ -157,6 +158,21 @@ export function parseArgs(argv, programName = DEFAULT_PROGRAM_NAME) {
157
158
  return { command: "update", harnesses, all, force };
158
159
  }
159
160
 
161
+ if (command === "self-update") {
162
+ // A bare run acts; `--check` reports and stops. Nothing else is accepted,
163
+ // so a typo fails before the CLI touches the global install.
164
+ let check = false;
165
+ for (const arg of rest) {
166
+ if (arg === "--help" || arg === "-h") return { command: "help" };
167
+ if (arg === "--check") {
168
+ check = true;
169
+ continue;
170
+ }
171
+ return { command: "self-update", optionError: `Unknown option "${arg}". Run "${programName} --help" for usage.` };
172
+ }
173
+ return { command: "self-update", check };
174
+ }
175
+
160
176
  if (command === "migrate") {
161
177
  // Dry run is the default, so `--apply` is the only state-changing flag;
162
178
  // `--dry-run` stays accepted for scripts that pass it explicitly. `--dir`
@@ -285,6 +301,31 @@ Commands:
285
301
  -f, --force With --all: re-run every installer even
286
302
  when the version already matches
287
303
  --help, -h Show this help text
304
+ self-update Update the globally installed CLI through the manager that
305
+ owns this install, then refresh the harness installs with
306
+ the binary the update installed. A bare run acts; --check
307
+ only reports. The manager is inferred from the running
308
+ entry point's real path (pnpm's global/<v> or store link,
309
+ or npm's lib/node_modules), then confirmed by asking that
310
+ manager to list this package among its globals at the
311
+ version installed beside the entry point; a manager on
312
+ PATH is never used in its place. An owner that cannot be
313
+ inferred prints both managers' install commands and exits
314
+ 1; an inferred manager that does not confirm prints its own
315
+ command, says nothing was updated, and exits 1. Otherwise
316
+ it prints the CLI version before and after the install,
317
+ then runs the new binary's "update -a" with this terminal,
318
+ so the refresh's own output is visible, and exits with the
319
+ refresh's exit code. The refresh is skipped after --check,
320
+ after a failed update, and when the installed version
321
+ already matches the published one; that case installs
322
+ nothing and points at "update -a". A refresh that cannot
323
+ start prints the error and the command to run by hand, and
324
+ still exits non-zero.
325
+ Options:
326
+ (no option) Report, update, then refresh harnesses
327
+ --check Report the installed and published
328
+ versions and stop, writing nothing
288
329
  migrate Migrate the current beads workspace to the latest
289
330
  agent-cortex migration version, applying versioned steps
290
331
  in order (repo-agnostic — works in any beads workspace)