@jaybeeuu/agent-cortex 2.8.0 → 2.10.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 (52) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +30 -3
  3. package/agents/README.md +7 -2
  4. package/agents/guppi/agent.md +231 -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-plan/agent.md +6 -0
  9. package/bin/installers/claude.mjs +8 -23
  10. package/bin/installers/pi.mjs +8 -2
  11. package/extensions/interrupt-bookmark/README.md +129 -0
  12. package/extensions/interrupt-bookmark/index.test.ts +717 -0
  13. package/extensions/interrupt-bookmark/index.ts +354 -0
  14. package/extensions/interrupt-bookmark/package.json +20 -0
  15. package/extensions/skill-stats/README.md +84 -3
  16. package/extensions/skill-stats/index.ts +62 -3
  17. package/extensions/skill-stats/package.json +1 -1
  18. package/extensions/skill-stats/rollup.test.ts +643 -0
  19. package/extensions/skill-stats/rollup.ts +415 -0
  20. package/extensions/skill-stats/timeline.ts +101 -0
  21. package/package.json +1 -1
  22. package/scripts/lib/compose-agent.mjs +45 -7
  23. package/scripts/lib/manifest.mjs +29 -0
  24. package/skills/review/review-defects/EXAMPLES.md +80 -4
  25. package/skills/review/review-defects/SKILL.md +47 -16
  26. package/skills/review/review-report/EXAMPLES.md +118 -0
  27. package/skills/review/review-report/SKILL.md +138 -0
  28. package/skills/review/review-report/scripts/report.mjs +1032 -0
  29. package/skills/workflow/hitl-queue/BOUNDARY.md +122 -0
  30. package/skills/workflow/hitl-queue/DEADLINES.md +59 -0
  31. package/skills/workflow/hitl-queue/RECOMMEND.md +89 -0
  32. package/skills/workflow/hitl-queue/SCOPES.md +118 -0
  33. package/skills/workflow/hitl-queue/SKILL.md +150 -0
  34. package/skills/workflow/hitl-queue/scripts/boundary.mjs +493 -0
  35. package/skills/workflow/hitl-queue/scripts/queue.mjs +1012 -0
  36. package/skills/workflow/hitl-queue/scripts/recommend.mjs +492 -0
  37. package/skills/workflow/hitl-queue/scripts/scopes.mjs +479 -0
  38. package/skills/workflow/plan/SKILL.md +13 -0
  39. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  40. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  41. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  42. package/skills/workflow/prioritise-work/scripts/strategy.mjs +756 -0
  43. package/skills/workflow/prioritise-work/templates/strategy.md +87 -0
  44. package/skills/workflow/ralph/REFERENCE.md +6 -1
  45. package/skills/workflow/ralph/scripts/prune-worktrees.mjs +185 -10
  46. package/skills/workflow/run-pipeline-stage/playbooks/review.md +5 -2
  47. package/skills/workflow/score-bead/FORMAT.md +73 -0
  48. package/skills/workflow/score-bead/SKILL.md +112 -0
  49. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  50. package/skills/workflow/using-agent-skills/SKILL.md +6 -1
  51. package/token-map.README.md +14 -2
  52. package/token-map.json +17 -3
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -8,22 +8,25 @@
8
8
  // 1. {{SECTION:name}} is replaced with <dir>/<harness>/<name>.md content.
9
9
  // 2. {{TOOL:key}} is replaced with the harness column of token-map.json.
10
10
  // 3. {{PATH:arg}} with a named key resolves through token-map.json's paths
11
- // table; anything else is a relative path and stays verbatim.
11
+ // table; anything else is a relative path and stays verbatim, except that
12
+ // installers resolve it against the plugin root and map a `skills/…` row
13
+ // onto the tree that harness stages skills in (token-map.json `layouts`).
12
14
  // Substitution order is SECTION → TOOL → PATH (matches extensions/agent-modes).
13
15
  // Missing sections, unknown tool keys, or schema-invalid frontmatter are hard
14
16
  // errors: a committed generated file must never ship a literal token or a
15
17
  // silently dropped section.
16
18
  //
17
- // Options (the pi installer passes `dropNullTools`, `pluginRoot` and
18
- // `resolveRelativePaths`; the claude installer uses the strict defaults):
19
+ // Options (both installers pass `pluginRoot` and `resolveRelativePaths`; pi also
20
+ // passes `dropNullTools`):
19
21
  // dropNullTools replace null-mapped tool tokens with an empty string and
20
22
  // warn instead of throwing (pi's prose rule per the
21
23
  // token-map contract)
22
24
  // pluginRoot override the plugin_root used for resolved paths
23
25
  // (installers compute it from the actual install layout)
24
26
  // resolveRelativePaths non-keyed {{PATH:...}} args resolve to pluginRoot/arg
25
- // instead of staying verbatim (install time; without it
26
- // they stay bare for runtime resolution)
27
+ // (a `skills/…` row mapped onto the harness's staged
28
+ // skills shape) instead of staying verbatim (install
29
+ // time; without it they stay bare for runtime resolution)
27
30
  // warn callback for drop/omit warnings (default: no-op; the
28
31
  // pi installer collects them into its result)
29
32
  //
@@ -43,6 +46,7 @@ import { join } from "node:path";
43
46
  * @property {number} [version]
44
47
  * @property {Record<string, Record<string, string | null>>} tools
45
48
  * @property {Record<string, Record<string, string | PathSpec>>} paths
49
+ * @property {Record<string, Record<string, string>>} [layouts]
46
50
  */
47
51
 
48
52
  /**
@@ -100,6 +104,8 @@ function noop() {}
100
104
  * with a warning when `dropNullTools`, otherwise throw.
101
105
  * - PATH: named key → resolve through the paths table (plugin_root overridable);
102
106
  * non-keyed → join(pluginRoot, arg) when `resolveRelativePaths`, else verbatim.
107
+ * A non-keyed `skills/…` arg is mapped onto the harness's staged shape first
108
+ * (see stagedRelativePath).
103
109
  * - SECTION tokens are *not* handled here — the agent composer resolves them from
104
110
  * the harness's section files before calling this.
105
111
  *
@@ -231,6 +237,36 @@ async function parseFrontmatter(path, name, harness) {
231
237
 
232
238
  // ─── Path resolution ─────────────────────────────────────────────────────────
233
239
 
240
+ /**
241
+ * Map a repository-relative path onto the tree the harness stages it in.
242
+ *
243
+ * The repository nests every skill one group deep (skills/<group>/<name>/…), so
244
+ * an authored `skills/…` row is written in that grouped form. An installer that
245
+ * stages skills flat — Claude Code's plugin scan discovers skills/<name>/SKILL.md
246
+ * directly under skills/ — drops the group segment, and a row that kept it would
247
+ * name a directory the install does not have. The shape is a harness fact, read
248
+ * from token-map.json's layouts table, so a harness that stages skills
249
+ * differently needs no change here.
250
+ *
251
+ * @param {string} arg Non-keyed {{PATH:...}} argument
252
+ * @param {string} harness
253
+ * @param {TokenMap} tokenMap
254
+ * @returns {string}
255
+ */
256
+ function stagedRelativePath(arg, harness, tokenMap) {
257
+ const segments = arg.split("/");
258
+ if (segments[0] !== "skills") return arg;
259
+ // Every install ships a skill as a directory below skills/, so a shorter arg
260
+ // names nothing it can contain: fail now rather than render a dead row.
261
+ if (segments.length < 3) {
262
+ throw new Error(
263
+ `{{PATH:${arg}}} is not a staged path — a skill row is authored as skills/<group>/<name>/<file>`,
264
+ );
265
+ }
266
+ if (tokenMap.layouts?.skills?.[harness] !== "flat") return arg;
267
+ return [segments[0], segments[2], ...segments.slice(3)].join("/");
268
+ }
269
+
234
270
  /**
235
271
  * Resolve a {{PATH:...}} argument for a harness's generated output.
236
272
  * Named keys resolve through token-map.json's paths table (recursively for
@@ -238,7 +274,7 @@ async function parseFrontmatter(path, name, harness) {
238
274
  * the installer beads own precise runtime path resolution (see token-map.README.md).
239
275
  * With the installer options (`pluginRoot`, `resolveRelativePaths`) the
240
276
  * plugin_root base and bare relative paths resolve against the actual install
241
- * layout instead.
277
+ * layout instead, and a `skills/…` row is mapped onto that layout.
242
278
  * @param {string} arg
243
279
  * @param {string} harness
244
280
  * @param {TokenMap} tokenMap
@@ -248,7 +284,9 @@ async function parseFrontmatter(path, name, harness) {
248
284
  function resolvePath(arg, harness, tokenMap, opts = {}) {
249
285
  const entry = tokenMap.paths?.[arg];
250
286
  if (!entry) {
251
- if (opts.resolveRelativePaths && opts.pluginRoot) return join(opts.pluginRoot, arg);
287
+ if (opts.resolveRelativePaths && opts.pluginRoot) {
288
+ return join(opts.pluginRoot, stagedRelativePath(arg, harness, tokenMap));
289
+ }
252
290
  return arg;
253
291
  }
254
292
 
@@ -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
+ }