@jaybeeuu/agent-cortex 1.20.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 (154) hide show
  1. package/.beads/README.md +81 -0
  2. package/.beads/config.yaml +1 -0
  3. package/.beads/issues.jsonl +236 -0
  4. package/.beads/metadata.json +7 -0
  5. package/.claude-plugin/marketplace.json +16 -0
  6. package/.github/workflows/ci.test.ts +217 -0
  7. package/.github/workflows/ci.yml +35 -0
  8. package/.github/workflows/publish.yml +36 -0
  9. package/.junie/guidelines.md +87 -0
  10. package/.junie/mcp/mcp.json +10 -0
  11. package/AGENTS.md +102 -0
  12. package/CHANGELOG.md +705 -0
  13. package/README.md +209 -0
  14. package/agents/README.md +235 -0
  15. package/agents/plan.agent.md +56 -0
  16. package/agents/ralph-plan.agent.md +119 -0
  17. package/agents/ralph.agent.md +305 -0
  18. package/agents/strategy.agent.md +109 -0
  19. package/agents-native/ralph.md +193 -0
  20. package/bin/agent-cortex.mjs +27 -0
  21. package/claude/.claude-plugin/plugin.json +11 -0
  22. package/claude/.mcp.json +18 -0
  23. package/claude/agents/plan.md +56 -0
  24. package/claude/agents/ralph-plan.md +119 -0
  25. package/claude/agents/ralph.md +193 -0
  26. package/claude/agents/strategy.md +109 -0
  27. package/claude/hooks.json +23 -0
  28. package/claude/scripts/statusline-command.sh +124 -0
  29. package/docs/ideas/auto-model-selection.md +75 -0
  30. package/docs/ideas/improve-pi-system-prompt.md +34 -0
  31. package/docs/ideas/model-selector.md +40 -0
  32. package/docs/ideas/pi-task-memory.md +34 -0
  33. package/docs/ideas/qa-extension.md +43 -0
  34. package/docs/inspirations.md +33 -0
  35. package/docs/research/gas-city-research.md +256 -0
  36. package/docs/research/skill-design-research.md +286 -0
  37. package/docs/research/skill-improvements-analysis.md +117 -0
  38. package/docs/research/skill-patterns-research.md +24 -0
  39. package/docs/skills/skill-anatomy.md +149 -0
  40. package/docs/user-preferences.md +17 -0
  41. package/extensions/agent-modes/index.ts +507 -0
  42. package/extensions/auto-name.ts +107 -0
  43. package/extensions/context-loader.md +35 -0
  44. package/extensions/context-loader.ts +65 -0
  45. package/extensions/lib/tiny-model.ts +212 -0
  46. package/extensions/notify/index.ts +293 -0
  47. package/extensions/session-start.ts +44 -0
  48. package/extensions/skill-stats/README.md +74 -0
  49. package/extensions/skill-stats/context-loader.test.ts +107 -0
  50. package/extensions/skill-stats/discover.ts +119 -0
  51. package/extensions/skill-stats/format.ts +13 -0
  52. package/extensions/skill-stats/index.test.ts +452 -0
  53. package/extensions/skill-stats/index.ts +222 -0
  54. package/extensions/skill-stats/package.json +19 -0
  55. package/extensions/skill-stats/pnpm-lock.yaml +329 -0
  56. package/extensions/skill-stats/record.ts +48 -0
  57. package/extensions/skill-stats/store.ts +104 -0
  58. package/extensions/skill-stats/tsconfig.json +12 -0
  59. package/extensions/subagent/README.md +175 -0
  60. package/extensions/subagent/agents/planner.md +37 -0
  61. package/extensions/subagent/agents/reviewer.md +35 -0
  62. package/extensions/subagent/agents/scout.md +50 -0
  63. package/extensions/subagent/agents/worker.md +24 -0
  64. package/extensions/subagent/agents.ts +126 -0
  65. package/extensions/subagent/index.ts +1138 -0
  66. package/extensions/subagent/prompts/implement-and-review.md +10 -0
  67. package/extensions/subagent/prompts/implement.md +10 -0
  68. package/extensions/subagent/prompts/scout-and-plan.md +9 -0
  69. package/hooks.json +10 -0
  70. package/lib/cli.mjs +37 -0
  71. package/package.json +39 -0
  72. package/pi/keybindings.json +3 -0
  73. package/pi/settings.json +29 -0
  74. package/plugin.json +35 -0
  75. package/pnpm-workspace.yaml +7 -0
  76. package/scripts/build-claude-agents.mjs +179 -0
  77. package/scripts/install-instructions.sh +82 -0
  78. package/skills/engineering/design-an-interface/SKILL.md +94 -0
  79. package/skills/engineering/grill-with-docs/SKILL.md +88 -0
  80. package/skills/engineering/improve-codebase-architecture/REFERENCE.md +78 -0
  81. package/skills/engineering/improve-codebase-architecture/SKILL.md +81 -0
  82. package/skills/engineering/tdd/SKILL.md +150 -0
  83. package/skills/engineering/tdd/deep-modules.md +33 -0
  84. package/skills/engineering/tdd/interface-design.md +31 -0
  85. package/skills/engineering/tdd/mocking.md +59 -0
  86. package/skills/engineering/tdd/refactoring.md +10 -0
  87. package/skills/engineering/tdd/tests.md +61 -0
  88. package/skills/engineering/technical-direction/SKILL.md +86 -0
  89. package/skills/planning/classify-bead/SKILL.md +67 -0
  90. package/skills/planning/create-task/SKILL.md +96 -0
  91. package/skills/planning/create-task/pipeline.json +33 -0
  92. package/skills/planning/create-task/scripts/create-chores.ts +112 -0
  93. package/skills/planning/create-task/scripts/package.json +14 -0
  94. package/skills/planning/create-task/scripts/pnpm-lock.yaml +342 -0
  95. package/skills/planning/create-task/scripts/tsconfig.json +12 -0
  96. package/skills/planning/create-task/templates/code.md +40 -0
  97. package/skills/planning/create-task/templates/document.md +40 -0
  98. package/skills/planning/create-task/templates/fix.md +27 -0
  99. package/skills/planning/create-task/templates/review.md +36 -0
  100. package/skills/planning/create-task/templates/verify.md +43 -0
  101. package/skills/planning/prd-to-tasks/SKILL.md +227 -0
  102. package/skills/planning/record-idea/SKILL.md +62 -0
  103. package/skills/planning/record-idea/scripts/new-idea.sh +89 -0
  104. package/skills/planning/request-refactor-plan/SKILL.md +73 -0
  105. package/skills/planning/write-a-prd/SKILL.md +74 -0
  106. package/skills/planning/write-a-ticket/SKILL.md +97 -0
  107. package/skills/productivity/bd-tool/SKILL.md +90 -0
  108. package/skills/productivity/grill-me/SKILL.md +10 -0
  109. package/skills/productivity/handoff/SKILL.md +15 -0
  110. package/skills/productivity/init-beads/SKILL.md +93 -0
  111. package/skills/productivity/teach/GLOSSARY-FORMAT.md +35 -0
  112. package/skills/productivity/teach/LEARNING-RECORD-FORMAT.md +46 -0
  113. package/skills/productivity/teach/MISSION-FORMAT.md +31 -0
  114. package/skills/productivity/teach/RESOURCES-FORMAT.md +32 -0
  115. package/skills/productivity/teach/SKILL.md +109 -0
  116. package/skills/productivity/validate-agent-dir/scripts/package.json +15 -0
  117. package/skills/productivity/validate-agent-dir/scripts/pnpm-lock.yaml +329 -0
  118. package/skills/productivity/validate-agent-dir/scripts/pnpm-workspace.yaml +2 -0
  119. package/skills/productivity/validate-agent-dir/scripts/tsconfig.json +19 -0
  120. package/skills/productivity/validate-agent-dir/scripts/validate-agent-dir.test.ts +317 -0
  121. package/skills/productivity/validate-agent-dir/scripts/validate-agent-dir.ts +127 -0
  122. package/skills/productivity/write-a-skill/SKILL.md +74 -0
  123. package/skills/review/maintain-agent-docs/REFERENCE.md +246 -0
  124. package/skills/review/maintain-agent-docs/SKILL.md +83 -0
  125. package/skills/review/refactor-skill/EXAMPLES.md +20 -0
  126. package/skills/review/refactor-skill/SKILL.md +148 -0
  127. package/skills/review/review-security/SKILL.md +107 -0
  128. package/skills/review/review-security/scripts/install-gitleaks.sh +43 -0
  129. package/skills/style/style-code/REFERENCE.md +38 -0
  130. package/skills/style/style-code/SKILL.md +113 -0
  131. package/skills/style/style-comms/SKILL.md +93 -0
  132. package/skills/style/style-documentation/SKILL.md +112 -0
  133. package/skills/style/style-tests/SKILL.md +97 -0
  134. package/skills/workflow/git-workflow/SKILL.md +85 -0
  135. package/skills/workflow/hitl-collab/SKILL.md +29 -0
  136. package/skills/workflow/plan/SKILL.md +272 -0
  137. package/skills/workflow/ralph/REFERENCE.md +202 -0
  138. package/skills/workflow/ralph/SKILL.md +105 -0
  139. package/skills/workflow/run-pipeline-stage/SKILL.md +151 -0
  140. package/skills/workflow/run-pipeline-stage/playbooks/code.md +17 -0
  141. package/skills/workflow/run-pipeline-stage/playbooks/document.md +18 -0
  142. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +17 -0
  143. package/skills/workflow/run-pipeline-stage/playbooks/review.md +19 -0
  144. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +14 -0
  145. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +20 -0
  146. package/skills/workflow/run-pipeline-stage/scripts/generate-progress.test.ts +783 -0
  147. package/skills/workflow/run-pipeline-stage/scripts/generate-progress.ts +586 -0
  148. package/skills/workflow/run-pipeline-stage/scripts/package.json +15 -0
  149. package/skills/workflow/run-pipeline-stage/scripts/pnpm-lock.yaml +342 -0
  150. package/skills/workflow/run-pipeline-stage/scripts/pnpm-workspace.yaml +2 -0
  151. package/skills/workflow/run-pipeline-stage/scripts/tsconfig.json +12 -0
  152. package/skills/workflow/using-agent-skills/SKILL.md +109 -0
  153. package/skills/workflow/using-agent-skills/scripts/validate-skill-structure.test.ts +73 -0
  154. package/test/cli.test.mjs +149 -0
@@ -0,0 +1,107 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ import {
8
+ loadContextFile,
9
+ loadSessionContext,
10
+ } from "../context-loader.ts";
11
+
12
+ // ─── loadContextFile ─────────────────────────────────────────────────────────
13
+
14
+ describe("loadContextFile", () => {
15
+ let tmpDir: string;
16
+
17
+ before(() => {
18
+ tmpDir = mkdtempSync(join(tmpdir(), "context-loader-test-"));
19
+ });
20
+
21
+ after(() => {
22
+ rmSync(tmpDir, { recursive: true, force: true });
23
+ });
24
+
25
+ it("returns file content when path exists", () => {
26
+ const file = join(tmpDir, "AGENTS.md");
27
+ writeFileSync(file, "# AGENTS\n\nProject instructions.", "utf-8");
28
+
29
+ const result = loadContextFile(file);
30
+ assert.ok(result);
31
+ assert.equal(result.path, file);
32
+ assert.equal(result.content, "# AGENTS\n\nProject instructions.");
33
+ });
34
+
35
+ it("returns null for non-existent file", () => {
36
+ const result = loadContextFile(join(tmpDir, "nope.md"));
37
+ assert.equal(result, null);
38
+ });
39
+
40
+ it("returns null for empty path", () => {
41
+ const result = loadContextFile("");
42
+ assert.equal(result, null);
43
+ });
44
+
45
+ it("strips leading and trailing whitespace from content", () => {
46
+ const file = join(tmpDir, "padded.md");
47
+ writeFileSync(file, "\n\n # Title\n\nBody\n\n", "utf-8");
48
+
49
+ const result = loadContextFile(file);
50
+ assert.ok(result);
51
+ assert.equal(result.content, "# Title\n\nBody");
52
+ });
53
+ });
54
+
55
+ // ─── loadSessionContext ──────────────────────────────────────────────────────
56
+
57
+ describe("loadSessionContext", () => {
58
+ let tmpDir: string;
59
+
60
+ before(() => {
61
+ tmpDir = mkdtempSync(join(tmpdir(), "session-context-test-"));
62
+ mkdirSync(join(tmpDir, "docs"), { recursive: true });
63
+ });
64
+
65
+ after(() => {
66
+ rmSync(tmpDir, { recursive: true, force: true });
67
+ });
68
+
69
+ it("loads all existing files from default list", () => {
70
+ writeFileSync(join(tmpDir, "AGENTS.md"), "# Agents", "utf-8");
71
+ writeFileSync(join(tmpDir, "docs", "user-preferences.md"), "# Prefs", "utf-8");
72
+
73
+ const result = loadSessionContext(tmpDir);
74
+ assert.ok(result.includes("# Agents"));
75
+ assert.ok(result.includes("# Prefs"));
76
+ });
77
+
78
+ it("skips missing files silently", () => {
79
+ const result = loadSessionContext(tmpDir);
80
+ // Should not throw — missing files are just omitted
81
+ assert.equal(typeof result, "string");
82
+ });
83
+
84
+ it("accepts custom file list", () => {
85
+ const custom = join(tmpDir, "custom.md");
86
+ writeFileSync(custom, "# Custom", "utf-8");
87
+
88
+ const result = loadSessionContext(tmpDir, [custom]);
89
+ assert.ok(result.includes("# Custom"));
90
+ });
91
+
92
+ it("returns empty string when no files exist", () => {
93
+ const result = loadSessionContext("/nonexistent/path");
94
+ assert.equal(typeof result, "string");
95
+ assert.equal(result.length, 0);
96
+ });
97
+
98
+ it("formats files with headers", () => {
99
+ const file = join(tmpDir, "test.md");
100
+ writeFileSync(file, "Hello world", "utf-8");
101
+
102
+ const result = loadSessionContext(tmpDir, [file]);
103
+ assert.ok(result.includes("---"));
104
+ assert.ok(result.includes("test.md"));
105
+ assert.ok(result.includes("Hello world"));
106
+ });
107
+ });
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+
5
+ // ── Types ────────────────────────────────────────────────────────────────────
6
+
7
+ export interface SkillRecord {
8
+ name: string;
9
+ skillMdPath: string;
10
+ }
11
+
12
+ // ── Globals ──────────────────────────────────────────────────────────────────
13
+
14
+ /** Resolve the home directory for global skill locations. */
15
+ const HOME = os.homedir();
16
+
17
+ // ── Directory discovery ─────────────────────────────────────────────────────
18
+
19
+ /** Known global skill base directories. */
20
+ export function globalSkillDirs(): string[] {
21
+ return [
22
+ path.join(HOME, ".pi", "agent", "skills"),
23
+ ].filter((d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } });
24
+ }
25
+
26
+ // ── Scanning ─────────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Recursively scan a base directory for skill subdirectories containing
30
+ * SKILL.md. Handles both flat layouts (`skills/<name>/SKILL.md`) and
31
+ * domain-grouped layouts (`skills/<domain>/<name>/SKILL.md`). Does not
32
+ * follow symlinks (avoids cycles). Mutates `map` in place.
33
+ */
34
+ export function scanSkills(baseDir: string, map: Map<string, SkillRecord>): void {
35
+ let entries: fs.Dirent[];
36
+ try { entries = fs.readdirSync(baseDir, { withFileTypes: true }); } catch { return; }
37
+
38
+ for (const entry of entries) {
39
+ if (!entry.isDirectory()) continue; // excludes symlinks
40
+
41
+ const full = path.join(baseDir, entry.name);
42
+
43
+ const skillMd = path.join(full, "SKILL.md");
44
+ if (fs.existsSync(skillMd)) {
45
+ const name = extractSkillName(skillMd) ?? entry.name;
46
+ if (!map.has(name)) {
47
+ map.set(name, { name, skillMdPath: skillMd });
48
+ }
49
+ }
50
+
51
+ // Plain subdirectory (e.g. a domain group) — recurse into it.
52
+ scanSkills(full, map);
53
+ }
54
+ }
55
+
56
+ /** Extract the `name` field from a skill's YAML frontmatter. */
57
+ export function extractSkillName(skillMdPath: string): string | null {
58
+ try {
59
+ const content = fs.readFileSync(skillMdPath, "utf-8");
60
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
61
+ if (match) {
62
+ const fm = match[1];
63
+ const nameMatch = fm.match(/^name:\s*(.+)$/m);
64
+ if (nameMatch) return nameMatch[1].trim();
65
+ }
66
+ } catch { /* unreadable file */ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Lazily discover skills from the current project directory.
72
+ * Called on startup and on `/reload`.
73
+ */
74
+ export function discoverProjectSkills(cwd: string, map: Map<string, SkillRecord>): void {
75
+ // Project-local skills directories
76
+ const projectDirs = [
77
+ path.join(cwd, "skills"),
78
+ path.join(cwd, ".pi", "skills"),
79
+ ].filter((d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } });
80
+
81
+ for (const d of projectDirs) scanSkills(d, map);
82
+ }
83
+
84
+ // ── Read-path resolution ─────────────────────────────────────────────────────
85
+
86
+ /**
87
+ * Given a file path from a `read` tool call, determine whether it's
88
+ * reading a skill's SKILL.md and return the skill name.
89
+ *
90
+ * Strategy:
91
+ * 1. Exact match against pre-discovered skill SKILL.md paths
92
+ * 2. Path heuristic: `<anything>/skills/[.../]<name>/SKILL.md` → `<name>`
93
+ * (the `[.../]` allows domain-grouped layouts like
94
+ * `skills/workflow/run-pipeline-stage/SKILL.md`)
95
+ */
96
+ export function resolveSkillFromPath(
97
+ filePath: string,
98
+ knownSkills: Map<string, SkillRecord>,
99
+ ): string | null {
100
+ const abs = path.resolve(filePath);
101
+
102
+ // 1. Exact match
103
+ for (const [name, rec] of knownSkills) {
104
+ if (abs === rec.skillMdPath) return name;
105
+ }
106
+
107
+ // 2. Path heuristic — includes both project-local and global skill dirs
108
+ const normalized = abs.replace(/\\/g, "/");
109
+
110
+ // Match .../skills/<name>/SKILL.md and .../skills/<domain>/<name>/SKILL.md
111
+ let m = normalized.match(/\/skills\/(?:[^/]+\/)*([^/]+)\/SKILL\.md$/);
112
+ if (m) return m[1];
113
+
114
+ // Match .../<skill-dir>/<name>/SKILL.md for well-known dirs
115
+ m = normalized.match(/\/(?:\.pi\/skills)\/([^/]+)\/SKILL\.md$/);
116
+ if (m) return m[1];
117
+
118
+ return null;
119
+ }
@@ -0,0 +1,13 @@
1
+ // ── Rendering helpers ────────────────────────────────────────────────────────
2
+
3
+ export function formatTable(header: string[], rows: string[][]): string {
4
+ const colWidths = header.map((h, i) =>
5
+ Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)),
6
+ );
7
+ const sep = "─".repeat(Math.max(1, colWidths.reduce((a, b) => a + b + 3, 1) - 1));
8
+
9
+ const fmtRow = (cells: string[]) =>
10
+ " " + cells.map((c, i) => c.padEnd(colWidths[i])).join(" │ ");
11
+
12
+ return [fmtRow(header), sep, ...rows.map(fmtRow)].join("\n");
13
+ }
@@ -0,0 +1,452 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ import {
8
+ type UsageStore,
9
+ type SkillRecord,
10
+ recordUsage,
11
+ resolveSkillFromPath,
12
+ formatTable,
13
+ extractSkillName,
14
+ scanSkills,
15
+ loadStore,
16
+ saveStore,
17
+ ageOutStore,
18
+ } from "./index.ts";
19
+
20
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
21
+
22
+ function freshStore(): UsageStore {
23
+ return {
24
+ version: 1,
25
+ skills: {},
26
+ totalTurns: 0,
27
+ lastUpdated: "2026-06-07T00:00:00.000Z",
28
+ recentEvents: [],
29
+ };
30
+ }
31
+
32
+ // ─── recordUsage ─────────────────────────────────────────────────────────────
33
+
34
+ describe("recordUsage", () => {
35
+ it("records a 'loaded' event for a new skill", () => {
36
+ const store = freshStore();
37
+ recordUsage(store, "bd-tool", "loaded", "/proj/a");
38
+
39
+ assert.equal(store.skills["bd-tool"].loadedCount, 1);
40
+ assert.equal(store.skills["bd-tool"].invokedCount, 0);
41
+ assert.equal(store.skills["bd-tool"].readCount, 0);
42
+ assert.ok(store.skills["bd-tool"].lastUsed);
43
+
44
+ // Project breakdown
45
+ const proj = store.skills["bd-tool"].byProject["/proj/a"];
46
+ assert.ok(proj);
47
+ assert.equal(proj.loadedCount, 1);
48
+ });
49
+
50
+ it("increments counts for the same skill and same project", () => {
51
+ const store = freshStore();
52
+ recordUsage(store, "bd-tool", "loaded", "/proj/a");
53
+ recordUsage(store, "bd-tool", "loaded", "/proj/a");
54
+ recordUsage(store, "bd-tool", "invoked", "/proj/a");
55
+
56
+ const s = store.skills["bd-tool"];
57
+ assert.equal(s.loadedCount, 2);
58
+ assert.equal(s.invokedCount, 1);
59
+ assert.equal(s.readCount, 0);
60
+
61
+ const p = s.byProject["/proj/a"];
62
+ assert.equal(p.loadedCount, 2);
63
+ assert.equal(p.invokedCount, 1);
64
+ assert.equal(p.readCount, 0);
65
+ });
66
+
67
+ it("tracks separate counts per project", () => {
68
+ const store = freshStore();
69
+ recordUsage(store, "style-code", "read", "/proj/a");
70
+ recordUsage(store, "style-code", "read", "/proj/b");
71
+
72
+ const s = store.skills["style-code"];
73
+ assert.equal(s.readCount, 2); // global total
74
+
75
+ assert.equal(s.byProject["/proj/a"].readCount, 1);
76
+ assert.equal(s.byProject["/proj/b"].readCount, 1);
77
+ });
78
+
79
+ it("appends a recent event entry each time", () => {
80
+ const store = freshStore();
81
+ recordUsage(store, "bd-tool", "loaded", "/proj/a");
82
+ recordUsage(store, "bd-tool", "invoked", "/proj/a");
83
+
84
+ assert.equal(store.recentEvents.length, 2);
85
+ assert.equal(store.recentEvents[0].source, "loaded");
86
+ assert.equal(store.recentEvents[1].source, "invoked");
87
+ });
88
+
89
+ it("updates lastUsed on every call", async () => {
90
+ const store = freshStore();
91
+ recordUsage(store, "bd-tool", "loaded", "/proj/a");
92
+
93
+ const first = store.skills["bd-tool"].lastUsed;
94
+
95
+ // Small delay so timestamps differ
96
+ await new Promise((r) => setTimeout(r, 5));
97
+ recordUsage(store, "bd-tool", "read", "/proj/a");
98
+
99
+ const second = store.skills["bd-tool"].lastUsed;
100
+ assert.notEqual(first, second);
101
+ });
102
+ });
103
+
104
+ // ─── resolveSkillFromPath ────────────────────────────────────────────────────
105
+
106
+ describe("resolveSkillFromPath", () => {
107
+ const known = new Map<string, SkillRecord>([
108
+ ["bd-tool", { name: "bd-tool", skillMdPath: "/home/user/.pi/agent/skills/bd-tool/SKILL.md" }],
109
+ ]);
110
+
111
+ it("matches an exact pre-discovered SKILL.md path", () => {
112
+ const result = resolveSkillFromPath(
113
+ "/home/user/.pi/agent/skills/bd-tool/SKILL.md",
114
+ known,
115
+ );
116
+ assert.equal(result, "bd-tool");
117
+ });
118
+
119
+ it("resolves a project-local path via heuristic", () => {
120
+ const result = resolveSkillFromPath(
121
+ "/home/user/projects/agent-cortex/skills/workflow/run-pipeline-stage/SKILL.md",
122
+ new Map(), // empty known -> heuristic only
123
+ );
124
+ assert.equal(result, "run-pipeline-stage");
125
+ });
126
+
127
+ it("resolves a .pi/skills path via heuristic", () => {
128
+ const result = resolveSkillFromPath(
129
+ "/home/user/projects/my-app/.pi/skills/my-helper/SKILL.md",
130
+ new Map(),
131
+ );
132
+ assert.equal(result, "my-helper");
133
+ });
134
+
135
+ it("resolves a deeply nested skills/ path via heuristic", () => {
136
+ const result = resolveSkillFromPath(
137
+ "/home/user/projects/my-app/.agents/skills/deploy/SKILL.md",
138
+ new Map(),
139
+ );
140
+ assert.equal(result, "deploy");
141
+ });
142
+
143
+ it("returns null for a non-skill path", () => {
144
+ const result = resolveSkillFromPath(
145
+ "/home/user/projects/agent-cortex/src/index.ts",
146
+ known,
147
+ );
148
+ assert.equal(result, null);
149
+ });
150
+
151
+ it("returns null for a path that looks like a skill but isn't in a known dir", () => {
152
+ const result = resolveSkillFromPath(
153
+ "/home/user/random/SKILL.md",
154
+ known,
155
+ );
156
+ assert.equal(result, null);
157
+ });
158
+
159
+ it("normalises Windows backslashes", () => {
160
+ const result = resolveSkillFromPath(
161
+ "C:\\Users\\me\\projects\\app\\skills\\my-skill\\SKILL.md",
162
+ new Map(),
163
+ );
164
+ assert.equal(result, "my-skill");
165
+ });
166
+ });
167
+
168
+ // ─── scanSkills ──────────────────────────────────────────────────────────────
169
+
170
+ describe("scanSkills", () => {
171
+ it("discovers skills in a domain-grouped (nested) layout", () => {
172
+ const root = mkdtempSync(join(tmpdir(), "skill-stats-scan-"));
173
+ try {
174
+ const skillDir = join(root, "skills", "workflow", "run-pipeline-stage");
175
+ mkdirSync(skillDir, { recursive: true });
176
+ writeFileSync(
177
+ join(skillDir, "SKILL.md"),
178
+ "---\nname: run-pipeline-stage\ndescription: Runs a pipeline stage\n---\n",
179
+ "utf-8",
180
+ );
181
+
182
+ // A non-skill subdir must not produce false positives
183
+ mkdirSync(join(root, "skills", "docs"), { recursive: true });
184
+ writeFileSync(join(root, "skills", "docs", "notes.md"), "hi", "utf-8");
185
+
186
+ const map = new Map<string, SkillRecord>();
187
+ scanSkills(join(root, "skills"), map);
188
+
189
+ assert.equal(map.size, 1);
190
+ assert.equal(
191
+ map.get("run-pipeline-stage")?.skillMdPath,
192
+ join(skillDir, "SKILL.md"),
193
+ );
194
+ } finally {
195
+ rmSync(root, { recursive: true, force: true });
196
+ }
197
+ });
198
+
199
+ it("keeps flat layout discovery working", () => {
200
+ const root = mkdtempSync(join(tmpdir(), "skill-stats-scan-flat-"));
201
+ try {
202
+ const skillDir = join(root, "skills", "bd-tool");
203
+ mkdirSync(skillDir, { recursive: true });
204
+ writeFileSync(join(skillDir, "SKILL.md"), "---\nname: bd-tool\n---\n", "utf-8");
205
+
206
+ const map = new Map<string, SkillRecord>();
207
+ scanSkills(join(root, "skills"), map);
208
+
209
+ assert.equal(map.get("bd-tool")?.skillMdPath, join(skillDir, "SKILL.md"));
210
+ } finally {
211
+ rmSync(root, { recursive: true, force: true });
212
+ }
213
+ });
214
+ });
215
+
216
+ // ─── formatTable ─────────────────────────────────────────────────────────────
217
+
218
+ describe("formatTable", () => {
219
+ it("renders a table with header and rows", () => {
220
+ const header = ["Skill", "Count", "Status"];
221
+ const rows = [
222
+ ["bd-tool", "42", "active"],
223
+ ["style-code", "7", "active"],
224
+ ];
225
+
226
+ const output = formatTable(header, rows);
227
+ const lines = output.split("\n");
228
+
229
+ // Header present
230
+ assert.ok(lines[0].includes("Skill"));
231
+ assert.ok(lines[0].includes("Count"));
232
+
233
+ // Separator line
234
+ assert.ok(lines[1].includes("─"));
235
+
236
+ // Data rows
237
+ assert.ok(lines[2].includes("bd-tool"));
238
+ assert.ok(lines[2].includes("42"));
239
+ assert.ok(lines[3].includes("style-code"));
240
+ assert.ok(lines[3].includes("7"));
241
+ });
242
+
243
+ it("pads columns to align them", () => {
244
+ const header = ["A", "BB", "CCC"];
245
+ const rows = [["x", "yy", "zzz"]];
246
+
247
+ const output = formatTable(header, rows);
248
+ const line = output.split("\n")[2]; // data row
249
+
250
+ // Each column should be at least as wide as its header
251
+ const parts = line.split("│");
252
+ assert.ok(parts[0]!.length >= " A".length);
253
+ assert.ok(parts[1]!.length >= " BB".length);
254
+ });
255
+
256
+ it("handles a single row", () => {
257
+ const header = ["Name"];
258
+ const rows = [["test"]];
259
+
260
+ const output = formatTable(header, rows);
261
+ assert.ok(output.includes("test"));
262
+ });
263
+
264
+ it("handles empty rows gracefully", () => {
265
+ const header = ["A", "B"];
266
+ const rows: string[][] = [];
267
+
268
+ const output = formatTable(header, rows);
269
+ assert.ok(output.includes("A"));
270
+ assert.ok(output.includes("─"));
271
+ });
272
+ });
273
+
274
+ // ─── extractSkillName ────────────────────────────────────────────────────────
275
+
276
+ describe("extractSkillName", () => {
277
+ it("extracts name from frontmatter", () => {
278
+ const content = `---
279
+ name: my-skill
280
+ description: Does things
281
+ ---
282
+
283
+ # My Skill
284
+ `;
285
+ const tmp = join(tmpdir(), "skill-stats-test-extract-" + Date.now());
286
+ writeFileSync(tmp, content, "utf-8");
287
+ try {
288
+ assert.equal(extractSkillName(tmp), "my-skill");
289
+ } finally {
290
+ rmSync(tmp);
291
+ }
292
+ });
293
+
294
+ it("returns null for file with no frontmatter", () => {
295
+ const content = `# My Skill\nNo YAML frontmatter here.\n`;
296
+ const tmp = join(tmpdir(), "skill-stats-test-no-fm-" + Date.now());
297
+ writeFileSync(tmp, content, "utf-8");
298
+ try {
299
+ assert.equal(extractSkillName(tmp), null);
300
+ } finally {
301
+ rmSync(tmp);
302
+ }
303
+ });
304
+
305
+ it("returns null for a non-existent file", () => {
306
+ assert.equal(extractSkillName("/nonexistent/path/SKILL.md"), null);
307
+ });
308
+ });
309
+
310
+ // ─── loadStore / saveStore ────────────────────────────────────────────────────
311
+
312
+ describe("loadStore / saveStore", () => {
313
+ let tmpDir: string;
314
+
315
+ before(() => {
316
+ tmpDir = mkdtempSync(join(tmpdir(), "skill-stats-test-"));
317
+ });
318
+
319
+ after(() => {
320
+ rmSync(tmpDir, { recursive: true, force: true });
321
+ });
322
+
323
+ it("loadStore returns a fresh store when file doesn't exist", () => {
324
+ const store = loadStore(join(tmpDir, "nonexistent.json"));
325
+ assert.equal(store.version, 1);
326
+ assert.deepEqual(store.skills, {});
327
+ assert.equal(store.totalTurns, 0);
328
+ });
329
+
330
+ it("saveStore writes and loadStore reads back", () => {
331
+ const file = join(tmpDir, "test-store.json");
332
+ const original = freshStore();
333
+ original.totalTurns = 42;
334
+ original.skills["test-skill"] = {
335
+ loadedCount: 5,
336
+ invokedCount: 2,
337
+ readCount: 3,
338
+ lastUsed: "2026-06-07T12:00:00.000Z",
339
+ byProject: {
340
+ "/project/a": {
341
+ loadedCount: 3,
342
+ invokedCount: 1,
343
+ readCount: 2,
344
+ lastUsed: "2026-06-07T12:00:00.000Z",
345
+ },
346
+ },
347
+ };
348
+
349
+ saveStore(original, file);
350
+ assert.ok(existsSync(file));
351
+
352
+ const loaded = loadStore(file);
353
+ assert.equal(loaded.version, 1);
354
+ assert.equal(loaded.totalTurns, 42);
355
+ assert.ok(loaded.skills["test-skill"]);
356
+ assert.equal(loaded.skills["test-skill"].loadedCount, 5);
357
+ assert.equal(
358
+ loaded.skills["test-skill"].byProject["/project/a"].invokedCount,
359
+ 1,
360
+ );
361
+
362
+ // Ensure .tmp file was cleaned up atomically
363
+ assert.ok(!existsSync(file + ".tmp"));
364
+ });
365
+
366
+ it("saveStore updates lastUpdated timestamp", () => {
367
+ const file = join(tmpDir, "test-timestamp.json");
368
+ const store = freshStore();
369
+ store.lastUpdated = "2020-01-01T00:00:00.000Z";
370
+
371
+ saveStore(store, file);
372
+ const loaded = loadStore(file);
373
+ assert.notEqual(loaded.lastUpdated, "2020-01-01T00:00:00.000Z");
374
+ });
375
+
376
+ it("loadStore returns fresh store for corrupt data", () => {
377
+ const file = join(tmpDir, "corrupt.json");
378
+ writeFileSync(file, "this is not json{{{", "utf-8");
379
+
380
+ const store = loadStore(file);
381
+ assert.equal(store.version, 1);
382
+ assert.deepEqual(store.skills, {});
383
+ });
384
+
385
+ it("loadStore returns fresh store for wrong version", () => {
386
+ const file = join(tmpDir, "wrong-version.json");
387
+ writeFileSync(
388
+ file,
389
+ JSON.stringify({ version: 999, skills: {}, totalTurns: 0, lastUpdated: "", recentEvents: [] }),
390
+ "utf-8",
391
+ );
392
+
393
+ const store = loadStore(file);
394
+ assert.equal(store.version, 1);
395
+ assert.deepEqual(store.skills, {});
396
+ });
397
+ });
398
+
399
+ // ─── ageOutStore ─────────────────────────────────────────────────────────────
400
+
401
+ describe("ageOutStore", () => {
402
+ const recentDate = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(); // 1 day ago
403
+ const oldDate = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000).toISOString(); // 100 days ago (over 3mo)
404
+
405
+ it("removes skills with lastUsed older than 3 months", () => {
406
+ const store = freshStore();
407
+ store.skills["active"] = {
408
+ loadedCount: 5, invokedCount: 0, readCount: 0,
409
+ lastUsed: recentDate, byProject: {},
410
+ };
411
+ store.skills["stale"] = {
412
+ loadedCount: 10, invokedCount: 2, readCount: 1,
413
+ lastUsed: oldDate, byProject: {},
414
+ };
415
+
416
+ ageOutStore(store);
417
+
418
+ assert.ok(store.skills["active"], "active skill should be kept");
419
+ assert.equal(store.skills["stale"], undefined, "stale skill should be removed");
420
+ });
421
+
422
+ it("removes skills with no lastUsed (null)", () => {
423
+ const store = freshStore();
424
+ store.skills["never-used"] = {
425
+ loadedCount: 0, invokedCount: 0, readCount: 0,
426
+ lastUsed: null, byProject: {},
427
+ };
428
+
429
+ ageOutStore(store);
430
+ assert.equal(store.skills["never-used"], undefined);
431
+ });
432
+
433
+ it("removes recentEvents older than 3 months", () => {
434
+ const store = freshStore();
435
+ store.recentEvents = [
436
+ { timestamp: recentDate, skill: "active", source: "loaded", project: "/p" },
437
+ { timestamp: oldDate, skill: "stale", source: "invoked", project: "/p" },
438
+ ];
439
+
440
+ ageOutStore(store);
441
+
442
+ assert.equal(store.recentEvents.length, 1);
443
+ assert.equal(store.recentEvents[0].skill, "active");
444
+ });
445
+
446
+ it("leaves empty store unchanged", () => {
447
+ const store = freshStore();
448
+ ageOutStore(store);
449
+ assert.deepEqual(store.skills, {});
450
+ assert.deepEqual(store.recentEvents, []);
451
+ });
452
+ });