karajan-code 2.6.1 → 2.7.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/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/commands/skills.js +41 -4
- package/src/config/schema.js +25 -0
- package/src/config.js +9 -0
- package/src/orchestrator/flow-runner.js +90 -0
- package/src/skills/addyosmani-catalog.js +293 -0
- package/src/skills/addyosmani-role-map.js +133 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -217,7 +217,7 @@ program
|
|
|
217
217
|
|
|
218
218
|
program
|
|
219
219
|
.command("skills [action]")
|
|
220
|
-
.description("Manage the
|
|
220
|
+
.description("Manage the skill cache (actions: list | clear-cache | sync-addyosmani | list-addyosmani)")
|
|
221
221
|
.action(async (action) => {
|
|
222
222
|
const { skillsCommand } = await import("./commands/skills.js");
|
|
223
223
|
const exitCode = await skillsCommand({ action: action || "list" });
|
package/src/commands/skills.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `kj skills` — manage the local OpenSkills cache.
|
|
2
|
+
* `kj skills` — manage the local OpenSkills cache and the addyosmani catalog.
|
|
3
3
|
*
|
|
4
4
|
* Subcommands:
|
|
5
|
-
* - list
|
|
6
|
-
* - clear-cache
|
|
5
|
+
* - list : print cached OpenSkills with their age and freshness.
|
|
6
|
+
* - clear-cache : delete all cached metadata (forces re-install on next session).
|
|
7
|
+
* - sync-addyosmani : force a git pull of addyosmani/agent-skills.
|
|
8
|
+
* - list-addyosmani : enumerate slugs available in the cached catalog.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import { listCached, clearCache, getCacheRoot, DEFAULT_TTL_MS } from "../skills/skills-cache.js";
|
|
12
|
+
import { refreshIfStale, listAvailableSlugs, getCatalogRoot, loadSkillBySlug } from "../skills/addyosmani-catalog.js";
|
|
10
13
|
|
|
11
14
|
function humanAge(cachedAt) {
|
|
12
15
|
const ms = Date.now() - Date.parse(cachedAt);
|
|
@@ -22,8 +25,12 @@ export async function skillsCommand({ action = "list" } = {}) {
|
|
|
22
25
|
return listAction();
|
|
23
26
|
case "clear-cache":
|
|
24
27
|
return clearAction();
|
|
28
|
+
case "sync-addyosmani":
|
|
29
|
+
return syncAddyosmaniAction();
|
|
30
|
+
case "list-addyosmani":
|
|
31
|
+
return listAddyosmaniAction();
|
|
25
32
|
default:
|
|
26
|
-
console.error(`Unknown action: ${action}. Use: list | clear-cache`);
|
|
33
|
+
console.error(`Unknown action: ${action}. Use: list | clear-cache | sync-addyosmani | list-addyosmani`);
|
|
27
34
|
return 1;
|
|
28
35
|
}
|
|
29
36
|
}
|
|
@@ -47,3 +54,33 @@ async function clearAction() {
|
|
|
47
54
|
console.log(`Cleared ${removed} cached skill${removed === 1 ? "" : "s"} from ${getCacheRoot()}`);
|
|
48
55
|
return 0;
|
|
49
56
|
}
|
|
57
|
+
|
|
58
|
+
async function syncAddyosmaniAction() {
|
|
59
|
+
console.log("Syncing addyosmani/agent-skills catalog...");
|
|
60
|
+
const logger = { warn: (m) => console.warn(` warn: ${m}`), info: (m) => console.log(` ${m}`) };
|
|
61
|
+
const result = await refreshIfStale({ force: true, logger });
|
|
62
|
+
if (!result.ok) {
|
|
63
|
+
console.error(`Sync failed: ${result.error || result.action}`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const slugs = await listAvailableSlugs();
|
|
67
|
+
console.log(`${result.action === "cloned" ? "Cloned" : "Pulled"} at ${getCatalogRoot()}`);
|
|
68
|
+
console.log(`Available slugs: ${slugs.length}`);
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function listAddyosmaniAction() {
|
|
73
|
+
const slugs = await listAvailableSlugs();
|
|
74
|
+
if (slugs.length === 0) {
|
|
75
|
+
console.log(`No addyosmani catalog found at ${getCatalogRoot()}`);
|
|
76
|
+
console.log("Run `kj skills sync-addyosmani` to bootstrap it.");
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
console.log(`addyosmani/agent-skills catalog at ${getCatalogRoot()}\n${slugs.length} slug(s):\n`);
|
|
80
|
+
for (const slug of slugs) {
|
|
81
|
+
const skill = await loadSkillBySlug(slug);
|
|
82
|
+
const desc = skill?.description ? ` — ${skill.description.slice(0, 100)}${skill.description.length > 100 ? "..." : ""}` : "";
|
|
83
|
+
console.log(` ${slug}${desc}`);
|
|
84
|
+
}
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
package/src/config/schema.js
CHANGED
|
@@ -151,6 +151,30 @@ const Budget = v.optional(v.looseObject({
|
|
|
151
151
|
pricing: v.optional(v.unknown()),
|
|
152
152
|
}));
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* skills.addyosmani subtree (KJC-TSK-0327). Governs the addyosmani/agent-skills
|
|
156
|
+
* catalog provider: enabled flag, refresh TTL and repo URL.
|
|
157
|
+
*/
|
|
158
|
+
const SkillsAddyosmani = v.looseObject({
|
|
159
|
+
enabled: v.optional(v.boolean()),
|
|
160
|
+
refreshDays: v.optional(v.pipe(
|
|
161
|
+
v.number(),
|
|
162
|
+
v.minValue(0, "skills.addyosmani.refreshDays must be >= 0")
|
|
163
|
+
)),
|
|
164
|
+
repoUrl: v.optional(v.string()),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* skills.* subtree. `sources` controls which providers Karajan consults and in
|
|
169
|
+
* what order (addyosmani → openskills → local by default). `mode` is the
|
|
170
|
+
* existing regex/semantic/auto/none knob from semantic-detector.js.
|
|
171
|
+
*/
|
|
172
|
+
const Skills = v.optional(v.looseObject({
|
|
173
|
+
mode: v.optional(v.picklist(["auto", "regex", "semantic", "none"], "skills.mode must be one of: auto | regex | semantic | none")),
|
|
174
|
+
sources: v.optional(v.array(v.string())),
|
|
175
|
+
addyosmani: v.optional(SkillsAddyosmani),
|
|
176
|
+
}));
|
|
177
|
+
|
|
154
178
|
/**
|
|
155
179
|
* Main config schema. `looseObject` lets every consumer that reads an
|
|
156
180
|
* undeclared key keep working while the schema catches the bugs we care
|
|
@@ -181,6 +205,7 @@ export const ConfigSchema = v.looseObject({
|
|
|
181
205
|
hu_language: v.optional(v.string()),
|
|
182
206
|
policies: v.optional(v.record(v.string(), v.unknown())),
|
|
183
207
|
telemetry: v.optional(v.boolean()),
|
|
208
|
+
skills: Skills,
|
|
184
209
|
});
|
|
185
210
|
|
|
186
211
|
/**
|
package/src/config.js
CHANGED
|
@@ -141,6 +141,15 @@ const DEFAULTS = {
|
|
|
141
141
|
policies: {},
|
|
142
142
|
serena: { enabled: false },
|
|
143
143
|
planning_game: { enabled: false, project_id: null, codeveloper: null },
|
|
144
|
+
skills: {
|
|
145
|
+
mode: "auto",
|
|
146
|
+
sources: ["addyosmani", "openskills", "local"],
|
|
147
|
+
addyosmani: {
|
|
148
|
+
enabled: true,
|
|
149
|
+
refreshDays: 7,
|
|
150
|
+
repoUrl: "https://github.com/addyosmani/agent-skills.git"
|
|
151
|
+
}
|
|
152
|
+
},
|
|
144
153
|
ci: { enabled: false, review_event: "kj-review", comment_event: "kj-comment", comment_prefix: true },
|
|
145
154
|
git: { auto_commit: false, auto_push: false, auto_pr: false, auto_rebase: true, branch_prefix: "feat/" },
|
|
146
155
|
output: { report_dir: "./.reviews", log_level: "info", quiet: true },
|
|
@@ -39,6 +39,8 @@ import { setRunner as setGitRunner } from "../utils/git.js";
|
|
|
39
39
|
import { detectNeededSkills, autoInstallSkills, cleanupAutoInstalledSkills } from "../skills/skill-detector.js";
|
|
40
40
|
import { isOpenSkillsAvailable } from "../skills/openskills-client.js";
|
|
41
41
|
import { refineSkillsSemantically, resolveSkillsMode } from "../skills/semantic-detector.js";
|
|
42
|
+
import { refreshIfStale as refreshAddyosmaniCatalog, listAvailableSlugs as listAddyosmaniSlugs } from "../skills/addyosmani-catalog.js";
|
|
43
|
+
import { resolveAddyosmaniSlugs } from "../skills/addyosmani-role-map.js";
|
|
42
44
|
|
|
43
45
|
// Extracted modules
|
|
44
46
|
import {
|
|
@@ -832,6 +834,20 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
|
|
|
832
834
|
// User explicitly opted out. Skip detection entirely.
|
|
833
835
|
return { plannedTask, updatedConfig };
|
|
834
836
|
}
|
|
837
|
+
|
|
838
|
+
// FIRST source: addyosmani/agent-skills (process skills per role).
|
|
839
|
+
// This runs BEFORE OpenSkills detection because process skills are the
|
|
840
|
+
// canonical workflows (TDD, code-review, security, performance...) that
|
|
841
|
+
// should shape every role's prompt; stack skills come second.
|
|
842
|
+
await ensureAddyosmaniSkills({
|
|
843
|
+
task: plannedTask,
|
|
844
|
+
config: updatedConfig,
|
|
845
|
+
logger,
|
|
846
|
+
session,
|
|
847
|
+
emitter,
|
|
848
|
+
eventBase,
|
|
849
|
+
});
|
|
850
|
+
|
|
835
851
|
let neededSkills = skillsMode === "semantic"
|
|
836
852
|
? []
|
|
837
853
|
: await detectNeededSkills(plannedTask, skillProjectDir);
|
|
@@ -879,6 +895,80 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
|
|
|
879
895
|
return { plannedTask, updatedConfig };
|
|
880
896
|
}
|
|
881
897
|
|
|
898
|
+
/**
|
|
899
|
+
* Ensure the addyosmani/agent-skills catalog is present and fresh, then
|
|
900
|
+
* resolve which upstream slugs are relevant for the current task. The
|
|
901
|
+
* resolved slugs are stored on `session.addyosmaniSkills` so downstream
|
|
902
|
+
* prompt builders can inject them ahead of OpenSkills-detected stack skills.
|
|
903
|
+
*
|
|
904
|
+
* Config surface (all optional, sensible defaults):
|
|
905
|
+
* skills:
|
|
906
|
+
* sources: ["addyosmani", "openskills", "local"] # default
|
|
907
|
+
* addyosmani:
|
|
908
|
+
* enabled: true
|
|
909
|
+
* refreshDays: 7
|
|
910
|
+
* repoUrl: "https://github.com/addyosmani/agent-skills.git"
|
|
911
|
+
*
|
|
912
|
+
* Setting skills.addyosmani.enabled = false, or removing "addyosmani" from
|
|
913
|
+
* skills.sources, disables this step entirely (reverts to OpenSkills-only).
|
|
914
|
+
*/
|
|
915
|
+
async function ensureAddyosmaniSkills({ task, config, logger, session, emitter, eventBase }) {
|
|
916
|
+
const skillsConfig = config?.skills || {};
|
|
917
|
+
const sources = Array.isArray(skillsConfig.sources) ? skillsConfig.sources : ["addyosmani", "openskills", "local"];
|
|
918
|
+
const addyConfig = skillsConfig.addyosmani || {};
|
|
919
|
+
// Test harness override: tests/setup.js sets __KJ_DEFAULT_ADDYOSMANI_ENABLED
|
|
920
|
+
// to false so orchestrator tests don't spawn git. Tests that need the real
|
|
921
|
+
// catalog opt in by setting config.skills.addyosmani.enabled = true.
|
|
922
|
+
const globalDefault = globalThis.__KJ_DEFAULT_ADDYOSMANI_ENABLED;
|
|
923
|
+
const enabledFromConfig = addyConfig.enabled === true
|
|
924
|
+
|| (addyConfig.enabled !== false && globalDefault !== false);
|
|
925
|
+
const enabled = enabledFromConfig && sources.includes("addyosmani");
|
|
926
|
+
if (!enabled) return;
|
|
927
|
+
|
|
928
|
+
const refreshDays = Number.isFinite(addyConfig.refreshDays) ? addyConfig.refreshDays : 7;
|
|
929
|
+
const refreshMs = Math.max(0, refreshDays) * 24 * 60 * 60 * 1000;
|
|
930
|
+
|
|
931
|
+
try {
|
|
932
|
+
const refreshResult = await refreshAddyosmaniCatalog({
|
|
933
|
+
refreshMs,
|
|
934
|
+
repoUrl: addyConfig.repoUrl,
|
|
935
|
+
logger,
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
if (!refreshResult.ok) {
|
|
939
|
+
session.addyosmaniSkills = { available: false, reason: refreshResult.error || "refresh failed" };
|
|
940
|
+
emitProgress(emitter, makeEvent("skills:addyosmani-unavailable", { ...eventBase, stage: "skills" }, {
|
|
941
|
+
message: `addyosmani/agent-skills catalog unavailable: ${refreshResult.error || refreshResult.action}`,
|
|
942
|
+
detail: { action: refreshResult.action, hint: "Install git to enable process skills from addyosmani/agent-skills" },
|
|
943
|
+
}));
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const available = new Set(await listAddyosmaniSlugs());
|
|
948
|
+
const resolved = resolveAddyosmaniSlugs({ role: null, task }); // role resolution happens per-stage later
|
|
949
|
+
const valid = resolved.filter((slug) => available.has(slug));
|
|
950
|
+
|
|
951
|
+
session.addyosmaniSkills = {
|
|
952
|
+
available: true,
|
|
953
|
+
action: refreshResult.action,
|
|
954
|
+
resolvedSlugs: valid,
|
|
955
|
+
allAvailable: Array.from(available),
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
emitProgress(emitter, makeEvent("skills:addyosmani-ready", { ...eventBase, stage: "skills" }, {
|
|
959
|
+
message: `addyosmani/agent-skills ${refreshResult.action} — ${available.size} slug(s) available, ${valid.length} relevant to task`,
|
|
960
|
+
detail: {
|
|
961
|
+
action: refreshResult.action,
|
|
962
|
+
relevantSlugs: valid,
|
|
963
|
+
availableCount: available.size,
|
|
964
|
+
},
|
|
965
|
+
}));
|
|
966
|
+
} catch (err) {
|
|
967
|
+
logger.warn(`addyosmani catalog step failed (non-blocking): ${err.message}`);
|
|
968
|
+
session.addyosmaniSkills = { available: false, reason: err.message };
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
882
972
|
/**
|
|
883
973
|
* Auto-generate HU batch from triage decomposition when no manual huFile is present.
|
|
884
974
|
* Runs after researcher/architect/planner so that context is available for better HUs.
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* addyosmani/agent-skills catalog provider (KJC-TSK-0327).
|
|
3
|
+
*
|
|
4
|
+
* Fetches and caches the addyosmani/agent-skills repository (20+ process
|
|
5
|
+
* skills: TDD, code-review, security, performance, git-workflow, debugging,
|
|
6
|
+
* CI/CD, docs, spec-driven, planning...) and exposes them as a first-class
|
|
7
|
+
* skill source for Karajan's pipeline.
|
|
8
|
+
*
|
|
9
|
+
* This catalog is ORTHOGONAL to the OpenSkills marketplace (which hosts stack
|
|
10
|
+
* skills like astro, react, prisma, python-data). Together they cover:
|
|
11
|
+
* - addyosmani → lifecycle/process skills mapped per role
|
|
12
|
+
* - OpenSkills → stack/framework skills detected from task + deps
|
|
13
|
+
* - local skills → project-specific skills under .claude/skills
|
|
14
|
+
*
|
|
15
|
+
* Lifecycle:
|
|
16
|
+
* 1. ensureCatalog() – clone (depth=1) on first use
|
|
17
|
+
* 2. refreshIfStale() – git pull when older than TTL (default 7d)
|
|
18
|
+
* 3. loadSkillBySlug() – read /skills/<slug>/SKILL.md from disk
|
|
19
|
+
* 4. listAvailableSlugs() – enumerate the /skills/ directory
|
|
20
|
+
*
|
|
21
|
+
* Degradation contract: when git is missing, the network is unreachable, or
|
|
22
|
+
* the catalog directory is malformed, every exported function returns safe
|
|
23
|
+
* empty values and logs a warning. The pipeline NEVER blocks on this module.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs/promises";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { runCommand } from "../utils/process.js";
|
|
29
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
30
|
+
|
|
31
|
+
const REPO_URL = "https://github.com/addyosmani/agent-skills.git";
|
|
32
|
+
const CATALOG_DIR_NAME = "agent-skills";
|
|
33
|
+
const SKILLS_SUBDIR = "skills";
|
|
34
|
+
const SKILL_FILE = "SKILL.md";
|
|
35
|
+
const DEFAULT_REFRESH_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
36
|
+
const META_FILENAME = ".karajan-catalog-meta.json";
|
|
37
|
+
|
|
38
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Absolute path to the cached catalog inside ~/.karajan/.
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function getCatalogRoot() {
|
|
45
|
+
return path.join(getKarajanHome(), CATALOG_DIR_NAME);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getSkillsRoot() {
|
|
49
|
+
return path.join(getCatalogRoot(), SKILLS_SUBDIR);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getMetaPath() {
|
|
53
|
+
return path.join(getCatalogRoot(), META_FILENAME);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function pathExists(p) {
|
|
57
|
+
try {
|
|
58
|
+
await fs.access(p);
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readMeta() {
|
|
66
|
+
try {
|
|
67
|
+
const raw = await fs.readFile(getMetaPath(), "utf8");
|
|
68
|
+
return JSON.parse(raw);
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function writeMeta(meta) {
|
|
75
|
+
await fs.mkdir(getCatalogRoot(), { recursive: true });
|
|
76
|
+
await fs.writeFile(getMetaPath(), JSON.stringify(meta, null, 2), "utf8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check whether `git` is on PATH. Cached per-process.
|
|
81
|
+
* @returns {Promise<boolean>}
|
|
82
|
+
*/
|
|
83
|
+
let _gitAvailable = null;
|
|
84
|
+
export async function isGitAvailable() {
|
|
85
|
+
if (_gitAvailable !== null) return _gitAvailable;
|
|
86
|
+
try {
|
|
87
|
+
const result = await runCommand("git", ["--version"], { timeout: 5_000 });
|
|
88
|
+
_gitAvailable = result.exitCode === 0;
|
|
89
|
+
} catch {
|
|
90
|
+
_gitAvailable = false;
|
|
91
|
+
}
|
|
92
|
+
return _gitAvailable;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Internal: reset the cached probe (tests use this to re-evaluate git presence).
|
|
96
|
+
export function __resetGitProbe() {
|
|
97
|
+
_gitAvailable = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Clone the addyosmani/agent-skills repo (shallow) if it does not already
|
|
102
|
+
* exist locally. No-op when it is already present.
|
|
103
|
+
*
|
|
104
|
+
* @param {{ repoUrl?: string, logger?: object }} [opts]
|
|
105
|
+
* @returns {Promise<{ok: boolean, path?: string, error?: string, skipped?: boolean}>}
|
|
106
|
+
*/
|
|
107
|
+
export async function ensureCatalog(opts = {}) {
|
|
108
|
+
const { repoUrl = REPO_URL, logger } = opts;
|
|
109
|
+
const root = getCatalogRoot();
|
|
110
|
+
|
|
111
|
+
if (await pathExists(path.join(root, ".git"))) {
|
|
112
|
+
return { ok: true, path: root, skipped: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!(await isGitAvailable())) {
|
|
116
|
+
logger?.warn?.("addyosmani-catalog: git not available — skipping catalog bootstrap");
|
|
117
|
+
return { ok: false, error: "git not available" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await fs.mkdir(path.dirname(root), { recursive: true });
|
|
121
|
+
|
|
122
|
+
const result = await runCommand(
|
|
123
|
+
"git",
|
|
124
|
+
["clone", "--depth", "1", repoUrl, root],
|
|
125
|
+
{ timeout: 120_000 }
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (result.exitCode !== 0) {
|
|
129
|
+
const stderr = (result.stderr || "").trim();
|
|
130
|
+
logger?.warn?.(`addyosmani-catalog: clone failed — ${stderr}`);
|
|
131
|
+
return { ok: false, error: stderr || `clone failed (exit ${result.exitCode})` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await writeMeta({
|
|
135
|
+
repoUrl,
|
|
136
|
+
clonedAt: new Date().toISOString(),
|
|
137
|
+
lastPullAt: new Date().toISOString(),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return { ok: true, path: root };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Refresh the catalog via `git pull` when the cached copy is older than
|
|
145
|
+
* `refreshMs`. When the catalog does not exist yet, delegates to
|
|
146
|
+
* ensureCatalog().
|
|
147
|
+
*
|
|
148
|
+
* @param {{ refreshMs?: number, repoUrl?: string, force?: boolean, logger?: object }} [opts]
|
|
149
|
+
* @returns {Promise<{ok: boolean, action: "cloned"|"pulled"|"fresh"|"skipped", error?: string}>}
|
|
150
|
+
*/
|
|
151
|
+
export async function refreshIfStale(opts = {}) {
|
|
152
|
+
const { refreshMs = DEFAULT_REFRESH_MS, repoUrl = REPO_URL, force = false, logger } = opts;
|
|
153
|
+
const root = getCatalogRoot();
|
|
154
|
+
|
|
155
|
+
if (!(await pathExists(path.join(root, ".git")))) {
|
|
156
|
+
const cloneResult = await ensureCatalog({ repoUrl, logger });
|
|
157
|
+
return cloneResult.ok
|
|
158
|
+
? { ok: true, action: "cloned" }
|
|
159
|
+
: { ok: false, action: "skipped", error: cloneResult.error };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const meta = await readMeta();
|
|
163
|
+
const lastPull = meta?.lastPullAt ? Date.parse(meta.lastPullAt) : 0;
|
|
164
|
+
const age = Date.now() - (Number.isFinite(lastPull) ? lastPull : 0);
|
|
165
|
+
if (!force && age < refreshMs) {
|
|
166
|
+
return { ok: true, action: "fresh" };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!(await isGitAvailable())) {
|
|
170
|
+
logger?.warn?.("addyosmani-catalog: git unavailable — using stale cache");
|
|
171
|
+
return { ok: true, action: "skipped" };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const result = await runCommand(
|
|
175
|
+
"git",
|
|
176
|
+
["-C", root, "pull", "--ff-only", "--depth", "1"],
|
|
177
|
+
{ timeout: 60_000 }
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
if (result.exitCode !== 0) {
|
|
181
|
+
const stderr = (result.stderr || "").trim();
|
|
182
|
+
logger?.warn?.(`addyosmani-catalog: git pull failed — ${stderr} (keeping stale cache)`);
|
|
183
|
+
return { ok: false, action: "skipped", error: stderr };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await writeMeta({
|
|
187
|
+
...(meta || {}),
|
|
188
|
+
repoUrl,
|
|
189
|
+
lastPullAt: new Date().toISOString(),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return { ok: true, action: "pulled" };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Parse YAML frontmatter for name + description (we intentionally avoid a
|
|
197
|
+
* YAML dep — a tiny regex is enough for the fields we care about and stays
|
|
198
|
+
* compatible with addyosmani's SKILL.md format).
|
|
199
|
+
*
|
|
200
|
+
* @param {string} raw
|
|
201
|
+
* @returns {{name: string|null, description: string|null, body: string}}
|
|
202
|
+
*/
|
|
203
|
+
export function parseFrontmatter(raw) {
|
|
204
|
+
if (!raw) return { name: null, description: null, body: "" };
|
|
205
|
+
const match = raw.match(FRONTMATTER_RE);
|
|
206
|
+
if (!match) {
|
|
207
|
+
return { name: null, description: null, body: raw };
|
|
208
|
+
}
|
|
209
|
+
const frontmatter = match[1];
|
|
210
|
+
const body = match[2] || "";
|
|
211
|
+
|
|
212
|
+
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|
213
|
+
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
name: nameMatch ? stripQuotes(nameMatch[1]) : null,
|
|
217
|
+
description: descMatch ? stripQuotes(descMatch[1]) : null,
|
|
218
|
+
body,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function stripQuotes(value) {
|
|
223
|
+
const trimmed = value.trim();
|
|
224
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
225
|
+
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
226
|
+
return trimmed.slice(1, -1);
|
|
227
|
+
}
|
|
228
|
+
return trimmed;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Enumerate available skill slugs under /skills/.
|
|
233
|
+
*
|
|
234
|
+
* @returns {Promise<string[]>}
|
|
235
|
+
*/
|
|
236
|
+
export async function listAvailableSlugs() {
|
|
237
|
+
const root = getSkillsRoot();
|
|
238
|
+
let entries;
|
|
239
|
+
try {
|
|
240
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
241
|
+
} catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
return entries
|
|
245
|
+
.filter((e) => e.isDirectory())
|
|
246
|
+
.map((e) => e.name)
|
|
247
|
+
.filter((name) => !name.startsWith("."))
|
|
248
|
+
.sort();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Load a skill by its slug. Returns null when the slug is not present or the
|
|
253
|
+
* SKILL.md file is missing/unreadable.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} slug
|
|
256
|
+
* @returns {Promise<{slug: string, name: string, description: string|null, content: string, path: string}|null>}
|
|
257
|
+
*/
|
|
258
|
+
export async function loadSkillBySlug(slug) {
|
|
259
|
+
if (!slug || typeof slug !== "string") return null;
|
|
260
|
+
const safeSlug = slug.trim().replace(/[^A-Za-z0-9._-]/g, "");
|
|
261
|
+
if (!safeSlug) return null;
|
|
262
|
+
|
|
263
|
+
const skillPath = path.join(getSkillsRoot(), safeSlug, SKILL_FILE);
|
|
264
|
+
let raw;
|
|
265
|
+
try {
|
|
266
|
+
raw = await fs.readFile(skillPath, "utf8");
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const parsed = parseFrontmatter(raw);
|
|
272
|
+
return {
|
|
273
|
+
slug: safeSlug,
|
|
274
|
+
name: parsed.name || safeSlug,
|
|
275
|
+
description: parsed.description,
|
|
276
|
+
content: raw,
|
|
277
|
+
path: skillPath,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Load multiple skills by slug in parallel. Silently drops missing entries.
|
|
283
|
+
*
|
|
284
|
+
* @param {string[]} slugs
|
|
285
|
+
* @returns {Promise<Array<{slug: string, name: string, description: string|null, content: string, path: string}>>}
|
|
286
|
+
*/
|
|
287
|
+
export async function loadSkillsBySlugs(slugs = []) {
|
|
288
|
+
if (!Array.isArray(slugs) || slugs.length === 0) return [];
|
|
289
|
+
const results = await Promise.all(slugs.map((s) => loadSkillBySlug(s)));
|
|
290
|
+
return results.filter(Boolean);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export { DEFAULT_REFRESH_MS, REPO_URL };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role → addyosmani/agent-skills slug mapping (KJC-TSK-0327).
|
|
3
|
+
*
|
|
4
|
+
* The addyosmani catalog is process-oriented (not stack-oriented), so the
|
|
5
|
+
* natural keying is by Karajan role: reviewer wants code-review workflows,
|
|
6
|
+
* tester wants TDD, security wants hardening, etc.
|
|
7
|
+
*
|
|
8
|
+
* Task-text triggers complement the role map: e.g. a task that mentions
|
|
9
|
+
* "performance" or "Core Web Vitals" pulls in performance-optimization,
|
|
10
|
+
* regardless of which role is running.
|
|
11
|
+
*
|
|
12
|
+
* Every slug listed here MUST exist in the upstream repo at:
|
|
13
|
+
* https://github.com/addyosmani/agent-skills/tree/main/skills/<slug>
|
|
14
|
+
* If upstream renames or removes a slug, loadSkillBySlug() degrades silently
|
|
15
|
+
* (returns null) and the pipeline continues without blocking.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Role → array of upstream slugs.
|
|
20
|
+
* Keep slugs lowercase-hyphenated, matching addyosmani's directory names.
|
|
21
|
+
*/
|
|
22
|
+
export const ROLE_TO_ADDYOSMANI_SLUGS = {
|
|
23
|
+
// The coder role benefits from broad process context: how to iterate,
|
|
24
|
+
// how to engineer context, and how to debug when things break.
|
|
25
|
+
coder: [
|
|
26
|
+
"incremental-implementation",
|
|
27
|
+
"source-driven-development",
|
|
28
|
+
"context-engineering",
|
|
29
|
+
"debugging-and-error-recovery",
|
|
30
|
+
],
|
|
31
|
+
|
|
32
|
+
// Reviewer wants the review + simplification workflows.
|
|
33
|
+
reviewer: [
|
|
34
|
+
"code-review-and-quality",
|
|
35
|
+
"code-simplification",
|
|
36
|
+
],
|
|
37
|
+
|
|
38
|
+
// Tester focuses on TDD and browser verification.
|
|
39
|
+
tester: [
|
|
40
|
+
"test-driven-development",
|
|
41
|
+
"browser-testing-with-devtools",
|
|
42
|
+
],
|
|
43
|
+
|
|
44
|
+
// Security role uses the dedicated hardening workflow.
|
|
45
|
+
security: [
|
|
46
|
+
"security-and-hardening",
|
|
47
|
+
],
|
|
48
|
+
|
|
49
|
+
// Architect covers spec-driven, API design and planning skills.
|
|
50
|
+
architect: [
|
|
51
|
+
"spec-driven-development",
|
|
52
|
+
"api-and-interface-design",
|
|
53
|
+
"planning-and-task-breakdown",
|
|
54
|
+
],
|
|
55
|
+
|
|
56
|
+
// Planner overlaps with architect but leans on task breakdown and spec.
|
|
57
|
+
planner: [
|
|
58
|
+
"planning-and-task-breakdown",
|
|
59
|
+
"spec-driven-development",
|
|
60
|
+
],
|
|
61
|
+
|
|
62
|
+
// Researcher / discover benefit from the idea-refine workflow.
|
|
63
|
+
researcher: [
|
|
64
|
+
"idea-refine",
|
|
65
|
+
],
|
|
66
|
+
discover: [
|
|
67
|
+
"idea-refine",
|
|
68
|
+
],
|
|
69
|
+
|
|
70
|
+
// Impeccable (UI/UX audit) uses the frontend engineering workflow.
|
|
71
|
+
impeccable: [
|
|
72
|
+
"frontend-ui-engineering",
|
|
73
|
+
],
|
|
74
|
+
|
|
75
|
+
// Refactorer uses simplification and debugging workflows.
|
|
76
|
+
refactorer: [
|
|
77
|
+
"code-simplification",
|
|
78
|
+
"debugging-and-error-recovery",
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Task-text patterns → addyosmani slug. These are applied in addition to the
|
|
84
|
+
* role map, so a reviewer task that mentions "performance" also pulls in the
|
|
85
|
+
* performance-optimization skill.
|
|
86
|
+
*/
|
|
87
|
+
export const TASK_PATTERN_TO_SLUG = [
|
|
88
|
+
{ pattern: /\bperformance\b|\bcore\s+web\s+vitals\b|\blcp\b|\bcls\b|\binp\b/i, slug: "performance-optimization" },
|
|
89
|
+
{ pattern: /\bsecurity\b|\bhardening\b|\bauth(entication|orization)?\b|\bxss\b|\bcsrf\b|\binjection\b/i, slug: "security-and-hardening" },
|
|
90
|
+
{ pattern: /\btdd\b|\btest[- ]driven\b/i, slug: "test-driven-development" },
|
|
91
|
+
{ pattern: /\bfrontend\b|\bui\b|\bux\b|\bcss\b|\bresponsive\b/i, slug: "frontend-ui-engineering" },
|
|
92
|
+
{ pattern: /\bapi\b|\bendpoint\b|\binterface\s+design\b|\bopenapi\b|\brest\b|\bgraphql\b/i, slug: "api-and-interface-design" },
|
|
93
|
+
{ pattern: /\bci\b|\bcd\b|\bci\/cd\b|\bpipeline\b|\bgithub\s+actions\b|\bworkflow\b/i, slug: "ci-cd-and-automation" },
|
|
94
|
+
{ pattern: /\bgit\b|\bcommit\b|\bbranch\b|\bmerge\b|\bversioning\b|\bsemver\b/i, slug: "git-workflow-and-versioning" },
|
|
95
|
+
{ pattern: /\bdebug\b|\berror\s+recovery\b|\bstack\s+trace\b/i, slug: "debugging-and-error-recovery" },
|
|
96
|
+
{ pattern: /\bdeprecat|\bmigrat/i, slug: "deprecation-and-migration" },
|
|
97
|
+
{ pattern: /\bdocs?\b|\badr\b|\breadme\b|\bdocumentation\b/i, slug: "documentation-and-adrs" },
|
|
98
|
+
{ pattern: /\brelease\b|\blaunch\b|\bship\b/i, slug: "shipping-and-launch" },
|
|
99
|
+
{ pattern: /\bspec\b|\bspecification\b|\bprd\b/i, slug: "spec-driven-development" },
|
|
100
|
+
{ pattern: /\bidea\b|\brefine\b|\bdiscover/i, slug: "idea-refine" },
|
|
101
|
+
{ pattern: /\bbrowser\s+test|\bdevtools\b|\bchrome\s+devtools\b/i, slug: "browser-testing-with-devtools" },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the set of addyosmani slugs that apply to a given (role, task)
|
|
106
|
+
* tuple. Returns a deduped array preserving role-first ordering (so role
|
|
107
|
+
* skills appear before task-inferred ones in the prompt).
|
|
108
|
+
*
|
|
109
|
+
* @param {{role?: string|null, task?: string|null}} args
|
|
110
|
+
* @returns {string[]}
|
|
111
|
+
*/
|
|
112
|
+
export function resolveAddyosmaniSlugs({ role = null, task = null } = {}) {
|
|
113
|
+
const out = [];
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
|
|
116
|
+
const push = (slug) => {
|
|
117
|
+
if (!slug || seen.has(slug)) return;
|
|
118
|
+
seen.add(slug);
|
|
119
|
+
out.push(slug);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (role && ROLE_TO_ADDYOSMANI_SLUGS[role]) {
|
|
123
|
+
for (const slug of ROLE_TO_ADDYOSMANI_SLUGS[role]) push(slug);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (task && typeof task === "string") {
|
|
127
|
+
for (const { pattern, slug } of TASK_PATTERN_TO_SLUG) {
|
|
128
|
+
if (pattern.test(task)) push(slug);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return out;
|
|
133
|
+
}
|