@jaybeeuu/agent-cortex 2.8.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 +173 -0
- package/README.md +26 -3
- package/agents/README.md +2 -2
- 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-plan/agent.md +6 -0
- 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/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/package.json +1 -1
- package/scripts/lib/manifest.mjs +29 -0
- package/skills/review/review-defects/EXAMPLES.md +80 -4
- package/skills/review/review-defects/SKILL.md +45 -15
- 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/workflow/hitl-queue/SKILL.md +134 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
- package/skills/workflow/plan/SKILL.md +13 -0
- 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/run-pipeline-stage/playbooks/review.md +5 -2
- 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 +6 -1
- package/token-map.json +8 -2
|
@@ -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/package.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Shared manifest helpers for the install-time generators.
|
|
2
|
+
//
|
|
3
|
+
// Both installs declare where this package came from, in their own manifest: the pi package's
|
|
4
|
+
// `package.json` (`repository`, npm's field) and the Claude plugin's `.claude-plugin/plugin.json`
|
|
5
|
+
// (`repository`, which Claude accepts only as a string). Both read the same value out of this
|
|
6
|
+
// checkout's `package.json`, so one normaliser decides it in both places rather than two copies
|
|
7
|
+
// drifting apart.
|
|
8
|
+
//
|
|
9
|
+
// A generated manifest is also what the shipped `review-report` skill reads to find its feedback
|
|
10
|
+
// repository, so the field is behaviour, not decoration: dropping it makes `--file` fail on every
|
|
11
|
+
// install of the artefact.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalise this package's `repository` field into the string a generated manifest declares.
|
|
15
|
+
*
|
|
16
|
+
* npm permits a string or an object (`{ type, url, directory }`); a value that is neither is
|
|
17
|
+
* dropped rather than emitted as junk, and the installer's caller keeps walking.
|
|
18
|
+
* @param {unknown} repository parsed package.json `repository` value
|
|
19
|
+
* @returns {string | undefined}
|
|
20
|
+
*/
|
|
21
|
+
export function normaliseRepository(repository) {
|
|
22
|
+
if (typeof repository === "string") return repository.trim() === "" ? undefined : repository;
|
|
23
|
+
if (repository === null || typeof repository !== "object") return undefined;
|
|
24
|
+
const { url, directory } = /** @type {{ url?: unknown, directory?: unknown }} */ (repository);
|
|
25
|
+
for (const candidate of [url, directory]) {
|
|
26
|
+
if (typeof candidate === "string" && candidate.trim() !== "") return candidate;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# Review defects — worked examples
|
|
2
2
|
|
|
3
|
-
One case per class. Each shows what was observed, the label, and the rejection text to write.
|
|
4
|
-
were observed in real review rounds
|
|
5
|
-
|
|
6
|
-
guessing at history.
|
|
3
|
+
One case per class. Each shows what was observed, the label, and the rejection text to write. Eleven
|
|
4
|
+
were observed in real review rounds; `scope-creep` is described as a situation, because no recorded
|
|
5
|
+
case exists, so you can match its shape without guessing at history.
|
|
7
6
|
|
|
8
7
|
## `test-unproven` — the name claims a settle-time cleanup
|
|
9
8
|
|
|
@@ -120,3 +119,80 @@ before writing, and cover the empty-source case with a test."
|
|
|
120
119
|
|
|
121
120
|
**Why this class.** The change may satisfy its own criteria fully. It also breaks behaviour nobody
|
|
122
121
|
asked to change, which is a separate fact from `criteria-unmet`.
|
|
122
|
+
|
|
123
|
+
## `unsafe-instruction` — a documented command that ran the reader's text
|
|
124
|
+
|
|
125
|
+
**Observed.** A shipped skill documented `bd note <bead> "<entry>"` and told the reader to embed a
|
|
126
|
+
free-text entry, including text the user had typed. Following it with an entry containing `$(…)`
|
|
127
|
+
ran the substitution when the line was pasted, and an entry containing a `"` ended the quoted
|
|
128
|
+
argument early and corrupted the note. The sentence had shipped for many releases and still
|
|
129
|
+
described the command correctly: the command does record a note.
|
|
130
|
+
|
|
131
|
+
**Label.** `defect:unsafe-instruction`
|
|
132
|
+
|
|
133
|
+
**Rejection text.** "The note command takes free text through a double-quoted shell argument, so a
|
|
134
|
+
`$(…)` in the entry executes and a `"` truncates the note. Write the entry with `--out` and pass it
|
|
135
|
+
with `bd note --file`, and say why. A reader on an arbitrary machine pastes this line as written."
|
|
136
|
+
|
|
137
|
+
**Why this class.** Not `docs-drift`: the text matches the command's behaviour. Not
|
|
138
|
+
`style-violation`: no convention was broken. The defect is the consequence of following the text —
|
|
139
|
+
data loss and code execution on a machine the author never sees.
|
|
140
|
+
|
|
141
|
+
## `ambiguous-rule` — two plausible readings of one rule
|
|
142
|
+
|
|
143
|
+
**Observed.** Two in one round. A bead described a runtime that supplies "mail, rigs and pane
|
|
144
|
+
conventions" and said the persona behaves the same in every scope, so a reader could take "rigs" as
|
|
145
|
+
true of personal work too; the real rule is that rigs are code-project containers and personal work
|
|
146
|
+
is a town-level scope with no rig. Separately, a shipped doc printed a live substitution token of
|
|
147
|
+
the `TOOL` kind — a full token, colon and key included — to illustrate the token grammar, and the
|
|
148
|
+
token guard necessarily rejects a literal token in a shipped doc: the line read both as an example
|
|
149
|
+
and as a token that must resolve.
|
|
150
|
+
|
|
151
|
+
**Label.** `defect:ambiguous-rule`
|
|
152
|
+
|
|
153
|
+
**Rejection text.** "Two readings are possible and the text picks neither. 'Mail, rigs and pane
|
|
154
|
+
conventions' reads as true of personal work; state that rigs are code-project containers and that
|
|
155
|
+
personal work is a town-level scope with no rig. The doc prints a literal substitution token of the
|
|
156
|
+
`TOOL` kind, which the guard reads as unresolved; show the grammar without a literal token, or mark
|
|
157
|
+
the line as an example the guard must skip."
|
|
158
|
+
|
|
159
|
+
**Why this class.** Not `docs-drift`: nothing was stale — each statement was true of the code
|
|
160
|
+
projects it described. The text can be read two ways, and the reader who takes the wrong one cannot
|
|
161
|
+
tell from the words.
|
|
162
|
+
|
|
163
|
+
## `silent-degradation` — a guard that reported success on the tree it never read
|
|
164
|
+
|
|
165
|
+
**Observed.** A materialiser check walked one harness's generated tree for leftover substitution
|
|
166
|
+
tokens and reported success, while the other harness's tree shipped a literal token to readers. The
|
|
167
|
+
guard was real and its assertion genuine; it never looked at half the surface, so it reported "no
|
|
168
|
+
offenders" for a tree it had not read. A second instance in the same area: a token row whose
|
|
169
|
+
second-harness column was empty dropped the token silently, leaving a sentence with a hole in it
|
|
170
|
+
and no warning.
|
|
171
|
+
|
|
172
|
+
**Label.** `defect:silent-degradation`
|
|
173
|
+
|
|
174
|
+
**Rejection text.** "The guard reads one harness's tree only; the other tree ships an unsubstituted
|
|
175
|
+
token and the check passes. Walk every generated tree, and fail on an empty token row rather than
|
|
176
|
+
dropping the token. A skipped check must not report success."
|
|
177
|
+
|
|
178
|
+
**Why this class.** Not `test-cannot-fail`: the assertion would fail for a token in the tree it
|
|
179
|
+
reads. The defect is the unread surface, plus a mapping that degrades to prose with no warning — a
|
|
180
|
+
path that fails quietly, which is wider than one test.
|
|
181
|
+
|
|
182
|
+
## `unclassified` — the escape hatch, before the class existed
|
|
183
|
+
|
|
184
|
+
**Observed.** The unsafe instruction above was rejected before `unsafe-instruction` existed. No
|
|
185
|
+
class fit, so the reviewer forced the nearest one and the finding went out as `defect:docs-drift`.
|
|
186
|
+
Nothing in the text was stale, the fix had nothing to do with documentation, and the aggregate
|
|
187
|
+
counted a documentation defect where the risk was a destructive command. That mislabel is the case
|
|
188
|
+
this section exists to correct.
|
|
189
|
+
|
|
190
|
+
**Label.** `defect:unclassified`
|
|
191
|
+
|
|
192
|
+
**Rejection text.** "No class fits: this is an instruction that can destroy data or run a command,
|
|
193
|
+
not stale text. Applied `defect:unclassified` alone and proposed an `unsafe-instruction` class in a
|
|
194
|
+
bead. Do not read this as a documentation defect."
|
|
195
|
+
|
|
196
|
+
**Why this class.** It is the escape hatch, and the obligation is the point: it was applied with a
|
|
197
|
+
gap bead proposing the missing class. Without that bead it is a miscount with a nicer name, and the
|
|
198
|
+
class is not a pass on the review.
|