@forwardimpact/pathway 0.25.21 → 0.25.22

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/pathway",
3
- "version": "0.25.21",
3
+ "version": "0.25.22",
4
4
  "description": "Career progression web app and CLI for exploring roles and generating agent teams",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -32,11 +32,11 @@ import {
32
32
  deriveReferenceLevel,
33
33
  deriveAgentSkills,
34
34
  generateSkillMarkdown,
35
- deriveToolkit,
36
35
  getDisciplineAbbreviation,
37
36
  toKebabCase,
38
37
  interpolateTeamInstructions,
39
- } from "@forwardimpact/libskill";
38
+ } from "@forwardimpact/libskill/agent";
39
+ import { deriveToolkit } from "@forwardimpact/libskill/toolkit";
40
40
  import { formatAgentProfile } from "../formatters/agent/profile.js";
41
41
  import { formatError, formatSuccess } from "../lib/cli-output.js";
42
42
  import { toolkitToPlainList } from "../formatters/toolkit/markdown.js";
@@ -94,7 +94,7 @@ function showAgentSummary(data, agentData, skillsWithAgent) {
94
94
  * @param {Object} agentData - Agent-specific data
95
95
  * @returns {Array<{discipline: Object, track: Object, humanDiscipline: Object, humanTrack: Object}>}
96
96
  */
97
- function findValidCombinations(data, agentData) {
97
+ export function findValidCombinations(data, agentData) {
98
98
  const pairs = [];
99
99
  for (const discipline of agentData.disciplines) {
100
100
  for (const track of agentData.tracks) {
@@ -237,10 +237,10 @@ function resolveAgentEntities(data, agentData, disciplineId, trackId) {
237
237
  * @param {Object} humanDiscipline
238
238
  */
239
239
  function printTeamInstructions(agentTrack, humanDiscipline) {
240
- const teamInstructions = interpolateTeamInstructions(
240
+ const teamInstructions = interpolateTeamInstructions({
241
241
  agentTrack,
242
242
  humanDiscipline,
243
- );
243
+ });
244
244
  if (teamInstructions) {
245
245
  console.log("# Team Instructions (CLAUDE.md)\n");
246
246
  console.log(teamInstructions.trim());
@@ -287,10 +287,10 @@ async function handleSingleStage({
287
287
  return;
288
288
  }
289
289
 
290
- const teamInstructions = interpolateTeamInstructions(
290
+ const teamInstructions = interpolateTeamInstructions({
291
291
  agentTrack,
292
292
  humanDiscipline,
293
- );
293
+ });
294
294
  await writeTeamInstructions(teamInstructions, baseDir);
295
295
  await writeProfile(profile, baseDir, agentTemplate);
296
296
  await generateClaudeCodeSettings(baseDir, agentData.claudeCodeSettings);
@@ -331,7 +331,9 @@ async function handleAllStages({
331
331
  const skillFiles = derivedSkills
332
332
  .map((derived) => skillsWithAgent.find((s) => s.id === derived.skillId))
333
333
  .filter((skill) => skill?.agent)
334
- .map((skill) => generateSkillMarkdown(skill, data.stages));
334
+ .map((skill) =>
335
+ generateSkillMarkdown({ skillData: skill, stages: data.stages }),
336
+ );
335
337
 
336
338
  for (const profile of profiles) {
337
339
  const errors = validateAgentProfile(profile);
@@ -373,10 +375,10 @@ async function handleAllStages({
373
375
  return;
374
376
  }
375
377
 
376
- const teamInstructions = interpolateTeamInstructions(
378
+ const teamInstructions = interpolateTeamInstructions({
377
379
  agentTrack,
378
380
  humanDiscipline,
379
- );
381
+ });
380
382
  await writeTeamInstructions(teamInstructions, baseDir);
381
383
  for (const profile of profiles) {
382
384
  await writeProfile(profile, baseDir, agentTemplate);
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Bundle generation for Pathway distribution.
3
+ *
4
+ * Emits `bundle.tar.gz` and `install.sh` alongside the static site so
5
+ * engineers can install `@forwardimpact/pathway` globally via
6
+ * `curl -fsSL <site>/install.sh | bash`. Invoked from build.js when
7
+ * `framework.distribution.siteUrl` is configured.
8
+ */
9
+
10
+ import { cp, mkdir, rm, readFile, writeFile } from "fs/promises";
11
+ import { join } from "path";
12
+ import { execFileSync } from "child_process";
13
+ import Mustache from "mustache";
14
+
15
+ /**
16
+ * Generate distribution bundle (bundle.tar.gz + install.sh)
17
+ * @param {Object} params
18
+ * @param {string} params.outputDir - Build output directory
19
+ * @param {string} params.dataDir - Source data directory
20
+ * @param {string} params.siteUrl - Base URL for the published site
21
+ * @param {Object} params.framework - Framework configuration
22
+ * @param {string} params.version - Pathway package version
23
+ * @param {string} params.templatesDir - Absolute path to pathway/templates
24
+ */
25
+ export async function generateBundle({
26
+ outputDir,
27
+ dataDir,
28
+ siteUrl,
29
+ framework,
30
+ version,
31
+ templatesDir,
32
+ }) {
33
+ console.log("📦 Generating distribution bundle...");
34
+
35
+ const frameworkTitle = framework.title || "Engineering Pathway";
36
+
37
+ // 1. Create temporary bundle directory
38
+ const bundleDir = join(outputDir, "_bundle");
39
+ await mkdir(bundleDir, { recursive: true });
40
+
41
+ // 2. Generate minimal package.json for the bundle
42
+ const bundlePkg = {
43
+ name: "fit-pathway-local",
44
+ version: version,
45
+ private: true,
46
+ dependencies: {
47
+ "@forwardimpact/pathway": `^${version}`,
48
+ },
49
+ };
50
+ await writeFile(
51
+ join(bundleDir, "package.json"),
52
+ JSON.stringify(bundlePkg, null, 2) + "\n",
53
+ );
54
+ console.log(` ✓ package.json (pathway ^${version})`);
55
+
56
+ // 3. Copy data files into bundle
57
+ await cp(dataDir, join(bundleDir, "data"), {
58
+ recursive: true,
59
+ dereference: true,
60
+ });
61
+ console.log(" ✓ data/");
62
+
63
+ // 4. Create tar.gz from the bundle directory
64
+ execFileSync("tar", [
65
+ "-czf",
66
+ join(outputDir, "bundle.tar.gz"),
67
+ "-C",
68
+ outputDir,
69
+ "_bundle",
70
+ ]);
71
+ console.log(" ✓ bundle.tar.gz");
72
+
73
+ // 5. Clean up temporary bundle directory
74
+ await rm(bundleDir, { recursive: true });
75
+
76
+ // 6. Render install.sh from template
77
+ const templatePath = join(templatesDir, "install.template.sh");
78
+ const template = await readFile(templatePath, "utf8");
79
+ const installScript = Mustache.render(template, {
80
+ siteUrl: siteUrl.replace(/\/$/, ""),
81
+ version,
82
+ frameworkTitle,
83
+ });
84
+ await writeFile(join(outputDir, "install.sh"), installScript, {
85
+ mode: 0o755,
86
+ });
87
+ console.log(" ✓ install.sh");
88
+ }
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Pack generation for Pathway distribution.
3
+ *
4
+ * Emits one pre-built agent/skill pack per valid discipline/track combination
5
+ * and two discovery manifests (`.well-known/agent-skills/index.json` for
6
+ * `npx skills` and `apm.yml` for Microsoft APM). See
7
+ * specs/320-pathway-ecosystem-distribution for context.
8
+ *
9
+ * Invoked from build.js after the distribution bundle has been generated.
10
+ */
11
+
12
+ import { mkdir, rm, readFile, writeFile } from "fs/promises";
13
+ import { join } from "path";
14
+ import { execFileSync } from "child_process";
15
+ import { createHash } from "crypto";
16
+
17
+ import { createDataLoader } from "@forwardimpact/map/loader";
18
+ import { createTemplateLoader } from "@forwardimpact/libtemplate";
19
+ import {
20
+ generateStageAgentProfile,
21
+ deriveReferenceLevel,
22
+ deriveAgentSkills,
23
+ generateSkillMarkdown,
24
+ interpolateTeamInstructions,
25
+ getDisciplineAbbreviation,
26
+ toKebabCase,
27
+ } from "@forwardimpact/libskill/agent";
28
+
29
+ import { formatAgentProfile } from "../formatters/agent/profile.js";
30
+ import {
31
+ formatAgentSkill,
32
+ formatInstallScript,
33
+ formatReference,
34
+ } from "../formatters/agent/skill.js";
35
+ import { findValidCombinations } from "./agent.js";
36
+
37
+ /**
38
+ * Slugify a string for use as a package name.
39
+ * @param {string} text
40
+ * @returns {string}
41
+ */
42
+ function slugify(text) {
43
+ return text
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]+/g, "-")
46
+ .replace(/^-+|-+$/g, "");
47
+ }
48
+
49
+ /**
50
+ * Stringify a JSON value with object keys sorted recursively.
51
+ * Produces deterministic output for digest stability.
52
+ * @param {unknown} value
53
+ * @returns {string}
54
+ */
55
+ function stringifySorted(value) {
56
+ const seen = new WeakSet();
57
+ const sort = (v) => {
58
+ if (v === null || typeof v !== "object") return v;
59
+ if (seen.has(v)) throw new Error("Cannot stringify circular structure");
60
+ seen.add(v);
61
+ if (Array.isArray(v)) return v.map(sort);
62
+ const out = {};
63
+ for (const key of Object.keys(v).sort()) out[key] = sort(v[key]);
64
+ return out;
65
+ };
66
+ return JSON.stringify(sort(value), null, 2) + "\n";
67
+ }
68
+
69
+ /**
70
+ * Escape a YAML scalar double-quoted value.
71
+ * @param {string} text
72
+ * @returns {string}
73
+ */
74
+ function yamlQuote(text) {
75
+ return `"${String(text).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
76
+ }
77
+
78
+ /**
79
+ * Write a single pack's files to disk under the staging directory.
80
+ * Calls the formatters directly (silent, no console output).
81
+ * @param {Object} params
82
+ * @returns {Promise<void>}
83
+ */
84
+ async function writePackFiles({
85
+ packDir,
86
+ profiles,
87
+ skillFiles,
88
+ teamInstructions,
89
+ agentTemplate,
90
+ skillTemplates,
91
+ claudeCodeSettings,
92
+ }) {
93
+ const claudeDir = join(packDir, ".claude");
94
+ const agentsDir = join(claudeDir, "agents");
95
+ const skillsDir = join(claudeDir, "skills");
96
+
97
+ await mkdir(agentsDir, { recursive: true });
98
+ await mkdir(skillsDir, { recursive: true });
99
+
100
+ for (const profile of profiles) {
101
+ const profilePath = join(agentsDir, profile.filename);
102
+ await writeFile(
103
+ profilePath,
104
+ formatAgentProfile(profile, agentTemplate),
105
+ "utf-8",
106
+ );
107
+ }
108
+
109
+ for (const skill of skillFiles) {
110
+ const skillDir = join(skillsDir, skill.dirname);
111
+ await mkdir(skillDir, { recursive: true });
112
+
113
+ await writeFile(
114
+ join(skillDir, "SKILL.md"),
115
+ formatAgentSkill(skill, skillTemplates.skill),
116
+ "utf-8",
117
+ );
118
+
119
+ if (skill.installScript) {
120
+ const scriptsDir = join(skillDir, "scripts");
121
+ await mkdir(scriptsDir, { recursive: true });
122
+ await writeFile(
123
+ join(scriptsDir, "install.sh"),
124
+ formatInstallScript(skill, skillTemplates.install),
125
+ { mode: 0o755 },
126
+ );
127
+ }
128
+
129
+ if (skill.implementationReference) {
130
+ const refsDir = join(skillDir, "references");
131
+ await mkdir(refsDir, { recursive: true });
132
+ await writeFile(
133
+ join(refsDir, "REFERENCE.md"),
134
+ formatReference(skill, skillTemplates.reference),
135
+ "utf-8",
136
+ );
137
+ }
138
+ }
139
+
140
+ if (teamInstructions) {
141
+ await writeFile(
142
+ join(claudeDir, "CLAUDE.md"),
143
+ teamInstructions.trim() + "\n",
144
+ "utf-8",
145
+ );
146
+ }
147
+
148
+ // Claude Code settings — matches the CLI path's generateClaudeCodeSettings
149
+ // output format (no merge with existing files since the staging dir starts
150
+ // empty).
151
+ const settings = { ...(claudeCodeSettings || {}) };
152
+ await writeFile(
153
+ join(claudeDir, "settings.json"),
154
+ JSON.stringify(settings, null, 2) + "\n",
155
+ "utf-8",
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Derive profiles, skills, and team instructions for a single combination.
161
+ * @param {Object} params
162
+ * @returns {{profiles: Array, skillFiles: Array, teamInstructions: string|null}}
163
+ */
164
+ function derivePackContent({
165
+ discipline,
166
+ track,
167
+ humanDiscipline,
168
+ humanTrack,
169
+ data,
170
+ agentData,
171
+ skillsWithAgent,
172
+ level,
173
+ }) {
174
+ const stageParams = {
175
+ discipline: humanDiscipline,
176
+ track: humanTrack,
177
+ level,
178
+ skills: skillsWithAgent,
179
+ behaviours: data.behaviours,
180
+ agentBehaviours: agentData.behaviours,
181
+ agentDiscipline: discipline,
182
+ agentTrack: track,
183
+ stages: data.stages,
184
+ };
185
+
186
+ const profiles = data.stages.map((stage) =>
187
+ generateStageAgentProfile({ ...stageParams, stage }),
188
+ );
189
+
190
+ const derivedSkills = deriveAgentSkills({
191
+ discipline: humanDiscipline,
192
+ track: humanTrack,
193
+ level,
194
+ skills: skillsWithAgent,
195
+ });
196
+
197
+ const skillFiles = derivedSkills
198
+ .map((derived) => skillsWithAgent.find((s) => s.id === derived.skillId))
199
+ .filter((skill) => skill?.agent)
200
+ .map((skill) =>
201
+ generateSkillMarkdown({ skillData: skill, stages: data.stages }),
202
+ );
203
+
204
+ const teamInstructions = interpolateTeamInstructions({
205
+ agentTrack: track,
206
+ humanDiscipline,
207
+ });
208
+
209
+ return { profiles, skillFiles, teamInstructions };
210
+ }
211
+
212
+ /**
213
+ * Archive a staged pack directory as a deterministic tar.gz and return its
214
+ * sha256 digest.
215
+ * @param {string} packDir - Staging directory containing the pack files
216
+ * @param {string} archivePath - Destination path for the tar.gz
217
+ * @returns {Promise<string>} sha256 digest string (e.g. "sha256:abc...")
218
+ */
219
+ async function archivePack(packDir, archivePath) {
220
+ execFileSync("tar", [
221
+ "-czf",
222
+ archivePath,
223
+ "--sort=name",
224
+ "--mtime=1970-01-01",
225
+ "--owner=0",
226
+ "--group=0",
227
+ "--numeric-owner",
228
+ "-C",
229
+ packDir,
230
+ ".",
231
+ ]);
232
+
233
+ const bytes = await readFile(archivePath);
234
+ return "sha256:" + createHash("sha256").update(bytes).digest("hex");
235
+ }
236
+
237
+ /**
238
+ * Write the `npx skills` discovery manifest.
239
+ * @param {string} outputDir
240
+ * @param {Array<{name: string, description: string, url: string, digest: string}>} packs
241
+ * @param {string} version
242
+ */
243
+ async function writeSkillsManifest(outputDir, packs, version) {
244
+ const wellKnownDir = join(outputDir, ".well-known", "agent-skills");
245
+ await mkdir(wellKnownDir, { recursive: true });
246
+ const manifest = {
247
+ $schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
248
+ skills: packs.map((pack) => ({
249
+ description: pack.description,
250
+ digest: pack.digest,
251
+ name: pack.name,
252
+ type: "archive",
253
+ url: pack.url,
254
+ version,
255
+ })),
256
+ };
257
+ await writeFile(
258
+ join(wellKnownDir, "index.json"),
259
+ stringifySorted(manifest),
260
+ "utf-8",
261
+ );
262
+ }
263
+
264
+ /**
265
+ * Write the Microsoft APM manifest at the site root.
266
+ * @param {string} outputDir
267
+ * @param {Array<{name: string, description: string, url: string, digest: string}>} packs
268
+ * @param {string} version
269
+ * @param {string} frameworkTitle
270
+ */
271
+ async function writeApmManifest(outputDir, packs, version, frameworkTitle) {
272
+ const lines = [
273
+ `name: ${slugify(frameworkTitle)}`,
274
+ `version: ${version}`,
275
+ `description: ${yamlQuote(`${frameworkTitle} agent teams for Claude Code`)}`,
276
+ "",
277
+ "skills:",
278
+ ];
279
+ for (const pack of packs) {
280
+ lines.push(` - name: ${pack.name}`);
281
+ lines.push(` description: ${yamlQuote(pack.description)}`);
282
+ lines.push(` version: ${version}`);
283
+ lines.push(` url: ${yamlQuote(pack.url)}`);
284
+ lines.push(` digest: ${yamlQuote(pack.digest)}`);
285
+ }
286
+ lines.push("");
287
+ await writeFile(join(outputDir, "apm.yml"), lines.join("\n"), "utf-8");
288
+ }
289
+
290
+ /**
291
+ * Generate pre-built agent/skill packs for installation through ecosystem
292
+ * tools like `npx skills` and Microsoft APM. One pack per valid
293
+ * discipline/track combination.
294
+ *
295
+ * @param {Object} params
296
+ * @param {string} params.outputDir - Build output directory
297
+ * @param {string} params.dataDir - Source data directory
298
+ * @param {string} params.siteUrl - Base URL for the published site
299
+ * @param {Object} params.framework - Framework configuration
300
+ * @param {string} params.version - Pathway package version
301
+ * @param {string} params.templatesDir - Absolute path to pathway/templates
302
+ */
303
+ export async function generatePacks({
304
+ outputDir,
305
+ dataDir,
306
+ siteUrl,
307
+ framework,
308
+ version,
309
+ templatesDir,
310
+ }) {
311
+ console.log("📦 Generating agent/skill packs...");
312
+
313
+ const normalizedSiteUrl = siteUrl.replace(/\/$/, "");
314
+ const frameworkTitle = framework.title || "Engineering Pathway";
315
+
316
+ const loader = createDataLoader();
317
+ const templateLoader = createTemplateLoader(templatesDir);
318
+
319
+ const data = await loader.loadAllData(dataDir);
320
+ const agentData = await loader.loadAgentData(dataDir);
321
+ const skillsWithAgent = await loader.loadSkillsWithAgentData(dataDir);
322
+
323
+ const level = deriveReferenceLevel(data.levels);
324
+
325
+ const agentTemplate = templateLoader.load("agent.template.md", dataDir);
326
+ const skillTemplates = {
327
+ skill: templateLoader.load("skill.template.md", dataDir),
328
+ install: templateLoader.load("skill-install.template.sh", dataDir),
329
+ reference: templateLoader.load("skill-reference.template.md", dataDir),
330
+ };
331
+
332
+ const stagingDir = join(outputDir, "_packs");
333
+ const packsDir = join(outputDir, "packs");
334
+ await mkdir(stagingDir, { recursive: true });
335
+ await mkdir(packsDir, { recursive: true });
336
+
337
+ const combinations = findValidCombinations(data, agentData);
338
+ if (combinations.length === 0) {
339
+ console.log(" (no valid discipline/track combinations — skipping)");
340
+ await rm(stagingDir, { recursive: true, force: true });
341
+ return;
342
+ }
343
+
344
+ const packs = [];
345
+
346
+ for (const combination of combinations) {
347
+ const { discipline, track, humanDiscipline, humanTrack } = combination;
348
+ const abbrev = getDisciplineAbbreviation(discipline.id);
349
+ const agentName = `${abbrev}-${toKebabCase(track.id)}`;
350
+ const specName = humanDiscipline.specialization || humanDiscipline.name;
351
+ const description = `${specName} (${humanTrack.name}) — agent team`;
352
+
353
+ const { profiles, skillFiles, teamInstructions } = derivePackContent({
354
+ ...combination,
355
+ data,
356
+ agentData,
357
+ skillsWithAgent,
358
+ level,
359
+ });
360
+
361
+ const packDir = join(stagingDir, agentName);
362
+ await writePackFiles({
363
+ packDir,
364
+ profiles,
365
+ skillFiles,
366
+ teamInstructions,
367
+ agentTemplate,
368
+ skillTemplates,
369
+ claudeCodeSettings: agentData.claudeCodeSettings,
370
+ });
371
+
372
+ const archivePath = join(packsDir, `${agentName}.tar.gz`);
373
+ const digest = await archivePack(packDir, archivePath);
374
+
375
+ packs.push({
376
+ name: agentName,
377
+ description,
378
+ url: `${normalizedSiteUrl}/packs/${agentName}.tar.gz`,
379
+ digest,
380
+ });
381
+
382
+ console.log(` ✓ packs/${agentName}.tar.gz`);
383
+ }
384
+
385
+ await rm(stagingDir, { recursive: true, force: true });
386
+
387
+ await writeSkillsManifest(outputDir, packs, version);
388
+ console.log(" ✓ .well-known/agent-skills/index.json");
389
+
390
+ await writeApmManifest(outputDir, packs, version, frameworkTitle);
391
+ console.log(" ✓ apm.yml");
392
+ }
@@ -3,26 +3,20 @@
3
3
  *
4
4
  * Generates a static site from the Engineering Pathway data.
5
5
  * Copies all necessary files (HTML, JS, CSS) and data to an output directory.
6
- * Optionally generates a distribution bundle (bundle.tar.gz + install.sh)
7
- * for local installs by individual engineers.
6
+ * Optionally delegates to build-bundle and build-packs to produce the
7
+ * distribution surfaces (bundle.tar.gz + install.sh for the curl|bash flow,
8
+ * and agent/skill packs for ecosystem tools like `npx skills` and APM) when
9
+ * `framework.distribution.siteUrl` is configured.
8
10
  */
9
11
 
10
- import {
11
- cp,
12
- mkdir,
13
- rm,
14
- access,
15
- realpath,
16
- readFile,
17
- writeFile,
18
- } from "fs/promises";
12
+ import { cp, mkdir, rm, access, realpath, writeFile } from "fs/promises";
19
13
  import { readFileSync } from "fs";
20
14
  import { join, dirname, relative, resolve } from "path";
21
15
  import { fileURLToPath } from "url";
22
- import { execFileSync } from "child_process";
23
- import Mustache from "mustache";
24
16
  import { createIndexGenerator } from "@forwardimpact/map/index-generator";
25
17
  import { createDataLoader } from "@forwardimpact/map/loader";
18
+ import { generateBundle } from "./build-bundle.js";
19
+ import { generatePacks } from "./build-packs.js";
26
20
 
27
21
  const __filename = fileURLToPath(import.meta.url);
28
22
  const __dirname = dirname(__filename);
@@ -201,10 +195,26 @@ ${framework.emojiIcon} Generating ${framework.title} static site...
201
195
  );
202
196
  console.log(` ✓ version.json (${version})`);
203
197
 
204
- // Generate distribution bundle if siteUrl is configured
198
+ // Generate distribution surfaces if siteUrl is configured
205
199
  const siteUrl = options.url || framework.distribution?.siteUrl;
206
200
  if (siteUrl) {
207
- await generateBundle({ outputDir, dataDir, siteUrl, framework });
201
+ const templatesDir = join(appDir, "..", "templates");
202
+ await generateBundle({
203
+ outputDir,
204
+ dataDir,
205
+ siteUrl,
206
+ framework,
207
+ version,
208
+ templatesDir,
209
+ });
210
+ await generatePacks({
211
+ outputDir,
212
+ dataDir,
213
+ siteUrl,
214
+ framework,
215
+ version,
216
+ templatesDir,
217
+ });
208
218
  }
209
219
 
210
220
  // Show summary
@@ -212,7 +222,7 @@ ${framework.emojiIcon} Generating ${framework.title} static site...
212
222
  ✅ Site generated successfully!
213
223
 
214
224
  Output: ${outputDir}
215
- ${siteUrl ? `\nDistribution:\n ${outputDir}/bundle.tar.gz\n ${outputDir}/install.sh\n` : ""}
225
+ ${siteUrl ? `\nDistribution:\n ${outputDir}/bundle.tar.gz\n ${outputDir}/install.sh\n ${outputDir}/packs/ (agent/skill packs)\n ${outputDir}/.well-known/agent-skills/index.json\n ${outputDir}/apm.yml\n` : ""}
216
226
  To serve locally:
217
227
  cd ${relative(process.cwd(), outputDir) || "."}
218
228
  bunx serve .
@@ -228,70 +238,3 @@ function getPathwayVersion() {
228
238
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
229
239
  return pkg.version;
230
240
  }
231
-
232
- /**
233
- * Generate distribution bundle (bundle.tar.gz + install.sh)
234
- * @param {Object} params
235
- * @param {string} params.outputDir - Build output directory
236
- * @param {string} params.dataDir - Source data directory
237
- * @param {string} params.siteUrl - Base URL for the published site
238
- * @param {Object} params.framework - Framework configuration
239
- */
240
- async function generateBundle({ outputDir, dataDir, siteUrl, framework }) {
241
- console.log("📦 Generating distribution bundle...");
242
-
243
- const version = getPathwayVersion();
244
- const frameworkTitle = framework.title || "Engineering Pathway";
245
-
246
- // 1. Create temporary bundle directory
247
- const bundleDir = join(outputDir, "_bundle");
248
- await mkdir(bundleDir, { recursive: true });
249
-
250
- // 2. Generate minimal package.json for the bundle
251
- const bundlePkg = {
252
- name: "fit-pathway-local",
253
- version: version,
254
- private: true,
255
- dependencies: {
256
- "@forwardimpact/pathway": `^${version}`,
257
- },
258
- };
259
- await writeFile(
260
- join(bundleDir, "package.json"),
261
- JSON.stringify(bundlePkg, null, 2) + "\n",
262
- );
263
- console.log(` ✓ package.json (pathway ^${version})`);
264
-
265
- // 3. Copy data files into bundle
266
- await cp(dataDir, join(bundleDir, "data"), {
267
- recursive: true,
268
- dereference: true,
269
- });
270
- console.log(" ✓ data/");
271
-
272
- // 4. Create tar.gz from the bundle directory
273
- execFileSync("tar", [
274
- "-czf",
275
- join(outputDir, "bundle.tar.gz"),
276
- "-C",
277
- outputDir,
278
- "_bundle",
279
- ]);
280
- console.log(" ✓ bundle.tar.gz");
281
-
282
- // 5. Clean up temporary bundle directory
283
- await rm(bundleDir, { recursive: true });
284
-
285
- // 6. Render install.sh from template
286
- const templatePath = join(appDir, "..", "templates", "install.template.sh");
287
- const template = await readFile(templatePath, "utf8");
288
- const installScript = Mustache.render(template, {
289
- siteUrl: siteUrl.replace(/\/$/, ""),
290
- version,
291
- frameworkTitle,
292
- });
293
- await writeFile(join(outputDir, "install.sh"), installScript, {
294
- mode: 0o755,
295
- });
296
- console.log(" ✓ install.sh");
297
- }
@@ -44,7 +44,11 @@ function formatJob(view, _options, entities, jobTemplate) {
44
44
  */
45
45
  function printJobList(filteredJobs) {
46
46
  for (const job of filteredJobs) {
47
- const title = generateJobTitle(job.discipline, job.level, job.track);
47
+ const title = generateJobTitle({
48
+ discipline: job.discipline,
49
+ level: job.level,
50
+ track: job.track,
51
+ });
48
52
  if (job.track) {
49
53
  console.log(
50
54
  `${job.discipline.id} ${job.level.id} ${job.track.id}, ${title}`,
@@ -79,7 +79,7 @@ async function formatAgentDetail(skill, stages, templateLoader, dataDir) {
79
79
  }
80
80
 
81
81
  const template = templateLoader.load("skill.template.md", dataDir);
82
- const skillMd = generateSkillMarkdown(skill, stages);
82
+ const skillMd = generateSkillMarkdown({ skillData: skill, stages });
83
83
  const output = formatAgentSkill(skillMd, template);
84
84
  console.log(output);
85
85
  }
@@ -33,6 +33,54 @@
33
33
  gap: var(--space-xl);
34
34
  }
35
35
 
36
+ /* Install section — ecosystem-tool install commands shown between the
37
+ dropdowns and the preview cards. Uses the same surface treatment as
38
+ the form so the two blocks read as a matched pair. */
39
+ .agent-install-section {
40
+ background: var(--color-surface);
41
+ border-radius: var(--radius-lg);
42
+ padding: var(--space-xl);
43
+ box-shadow: var(--shadow-md);
44
+ margin-bottom: var(--space-xl);
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: var(--space-lg);
48
+ }
49
+
50
+ .agent-install-header h2 {
51
+ margin: 0 0 var(--space-sm) 0;
52
+ }
53
+
54
+ .agent-install-description {
55
+ margin: 0;
56
+ }
57
+
58
+ .agent-install-commands {
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: var(--space-md);
62
+ }
63
+
64
+ .agent-install-command {
65
+ display: flex;
66
+ flex-direction: column;
67
+ gap: var(--space-xs);
68
+ }
69
+
70
+ .agent-install-command-label {
71
+ margin: 0;
72
+ font-size: var(--font-size-sm);
73
+ font-weight: 600;
74
+ color: var(--color-text-secondary);
75
+ }
76
+
77
+ /* Allow the command prompts inside the install section to grow beyond the
78
+ 600px center-aligned default used on the landing hero. */
79
+ .agent-install-command .command-prompt {
80
+ max-width: none;
81
+ margin: 0;
82
+ }
83
+
36
84
  /* Agent section — spacing container, no surface */
37
85
  .agent-section {
38
86
  margin-bottom: var(--space-lg);
@@ -14,7 +14,9 @@ import {
14
14
  deriveDecompositionInterview,
15
15
  deriveStakeholderInterview,
16
16
  } from "@forwardimpact/libskill/interview";
17
- import { getOrCreateJob } from "@forwardimpact/libskill/job-cache";
17
+ import { createJobCache } from "@forwardimpact/libskill/job-cache";
18
+
19
+ const jobCache = createJobCache();
18
20
 
19
21
  /**
20
22
  * Interview type configurations
@@ -140,7 +142,7 @@ export function prepareInterviewDetail({
140
142
  }) {
141
143
  if (!discipline || !level) return null;
142
144
 
143
- const job = getOrCreateJob({
145
+ const job = jobCache.getOrCreate({
144
146
  discipline,
145
147
  level,
146
148
  track,
@@ -275,7 +277,7 @@ export function prepareInterviewBuilderPreview({
275
277
  };
276
278
  }
277
279
 
278
- const title = generateJobTitle(discipline, level, track);
280
+ const title = generateJobTitle({ discipline, level, track });
279
281
  const totalSkills = getDisciplineSkillIds(discipline).length;
280
282
 
281
283
  return {
@@ -320,7 +322,7 @@ export function prepareAllInterviews({
320
322
  // Track is optional (null = generalist)
321
323
  if (!discipline || !level) return null;
322
324
 
323
- const job = getOrCreateJob({
325
+ const job = jobCache.getOrCreate({
324
326
  discipline,
325
327
  level,
326
328
  track,
@@ -13,7 +13,9 @@ import {
13
13
  analyzeCustomProgression,
14
14
  getNextLevel,
15
15
  } from "@forwardimpact/libskill/progression";
16
- import { getOrCreateJob } from "@forwardimpact/libskill/job-cache";
16
+ import { createJobCache } from "@forwardimpact/libskill/job-cache";
17
+
18
+ const jobCache = createJobCache();
17
19
 
18
20
  /**
19
21
  * Get the next level for progression
@@ -22,20 +24,7 @@ import { getOrCreateJob } from "@forwardimpact/libskill/job-cache";
22
24
  * @returns {Object|null}
23
25
  */
24
26
  export function getDefaultTargetLevel(currentLevel, levels) {
25
- return getNextLevel(currentLevel, levels);
26
- }
27
-
28
- /**
29
- * Check if a job combination is valid
30
- * @param {Object} params
31
- * @param {Object} params.discipline
32
- * @param {Object} params.level
33
- * @param {Object} params.track
34
- * @param {Array} [params.levels] - All levels for validation
35
- * @returns {boolean}
36
- */
37
- export function isValidCombination({ discipline, level, track, levels }) {
38
- return isValidJobCombination({ discipline, level, track, levels });
27
+ return getNextLevel({ level: currentLevel, levels });
39
28
  }
40
29
 
41
30
  /**
@@ -69,7 +58,7 @@ export function prepareCurrentJob({
69
58
  }) {
70
59
  if (!discipline || !level) return null;
71
60
 
72
- const job = getOrCreateJob({
61
+ const job = jobCache.getOrCreate({
73
62
  discipline,
74
63
  level,
75
64
  track,
@@ -148,8 +137,8 @@ export function prepareCareerProgressPreview({
148
137
  };
149
138
  }
150
139
 
151
- const title = generateJobTitle(discipline, level, track);
152
- const nextLevel = getNextLevel(level, levels);
140
+ const title = generateJobTitle({ discipline, level, track });
141
+ const nextLevel = getNextLevel({ level, levels });
153
142
 
154
143
  // Find other valid tracks for comparison (exclude current track if any)
155
144
  const validTracks = tracks.filter(
@@ -209,7 +198,7 @@ export function prepareProgressDetail({
209
198
  if (!fromDiscipline || !fromLevel) return null;
210
199
  if (!toDiscipline || !toLevel) return null;
211
200
 
212
- const fromJob = getOrCreateJob({
201
+ const fromJob = jobCache.getOrCreate({
213
202
  discipline: fromDiscipline,
214
203
  level: fromLevel,
215
204
  track: fromTrack,
@@ -218,7 +207,7 @@ export function prepareProgressDetail({
218
207
  capabilities,
219
208
  });
220
209
 
221
- const toJob = getOrCreateJob({
210
+ const toJob = jobCache.getOrCreate({
222
211
  discipline: toDiscipline,
223
212
  level: toLevel,
224
213
  track: toTrack,
@@ -95,11 +95,18 @@ export function prepareSkillDetail(
95
95
  if (!skill) return null;
96
96
 
97
97
  const relatedDisciplines = disciplines
98
- .filter((d) => getSkillTypeForDiscipline(d, skill.id) !== null)
98
+ .filter(
99
+ (d) =>
100
+ getSkillTypeForDiscipline({ discipline: d, skillId: skill.id }) !==
101
+ null,
102
+ )
99
103
  .map((d) => ({
100
104
  id: d.id,
101
105
  name: d.specialization || d.name,
102
- skillType: getSkillTypeForDiscipline(d, skill.id),
106
+ skillType: getSkillTypeForDiscipline({
107
+ discipline: d,
108
+ skillId: skill.id,
109
+ }),
103
110
  }));
104
111
 
105
112
  const relatedTracks = tracks
@@ -88,7 +88,10 @@ export function prepareTrackDetail(track, { skills, behaviours }) {
88
88
  const skillModifiers = track.skillModifiers
89
89
  ? Object.entries(track.skillModifiers).map(([key, modifier]) => {
90
90
  if (isCapability(key)) {
91
- const capabilitySkills = getSkillsByCapability(skills, key);
91
+ const capabilitySkills = getSkillsByCapability({
92
+ skills,
93
+ capability: key,
94
+ });
92
95
  return {
93
96
  id: key,
94
97
  name: key.charAt(0).toUpperCase() + key.slice(1),
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Agent builder install section
3
+ *
4
+ * Surfaces the ecosystem-tool install commands (Microsoft APM and
5
+ * `npx skills`) for the currently selected discipline/track pack. The packs
6
+ * themselves are emitted by `fit-pathway build` when
7
+ * `framework.distribution.siteUrl` is configured — see spec 320 and
8
+ * `products/pathway/src/commands/build-packs.js`. The pack name derivation
9
+ * here must stay in sync with that generator so the command points at an
10
+ * archive that actually exists on the deployed site.
11
+ */
12
+
13
+ import { code, div, h2, p, section } from "../lib/render.js";
14
+ import {
15
+ getDisciplineAbbreviation,
16
+ toKebabCase,
17
+ } from "@forwardimpact/libskill/agent";
18
+ import { createCommandPrompt } from "../components/command-prompt.js";
19
+
20
+ /** Stable id for the install section heading (for aria-labelledby). */
21
+ const INSTALL_HEADING_ID = "agent-builder-install-heading";
22
+
23
+ /**
24
+ * Derive the pack archive name for a discipline/track combination.
25
+ * Must match `build-packs.js` → `${abbrev}-${toKebabCase(track.id)}`.
26
+ * @param {{id: string}} discipline
27
+ * @param {{id: string}} track
28
+ * @returns {string}
29
+ */
30
+ export function getPackName(discipline, track) {
31
+ return `${getDisciplineAbbreviation(discipline.id)}-${toKebabCase(track.id)}`;
32
+ }
33
+
34
+ /**
35
+ * Normalize a site URL by stripping a trailing slash. Matches the
36
+ * normalization applied by `generatePacks` so the displayed URL lines up
37
+ * with the manifest entries.
38
+ * @param {string} siteUrl
39
+ * @returns {string}
40
+ */
41
+ function normalizeSiteUrl(siteUrl) {
42
+ return siteUrl.replace(/\/$/, "");
43
+ }
44
+
45
+ /**
46
+ * Build the `apm install` command for a specific pack archive. Targets the
47
+ * direct archive URL (rather than a registry-style shorthand) because it is
48
+ * the most durable path through APM's evolving resolution logic and matches
49
+ * the URL listed in the generated `apm.yml` manifest.
50
+ * @param {string} siteUrl
51
+ * @param {string} packName
52
+ * @returns {string}
53
+ */
54
+ export function getApmInstallCommand(siteUrl, packName) {
55
+ return `apm install ${normalizeSiteUrl(siteUrl)}/packs/${packName}.tar.gz`;
56
+ }
57
+
58
+ /**
59
+ * Build the `npx skills add` command that discovers the published pack
60
+ * registry at `<siteUrl>/.well-known/agent-skills/index.json`.
61
+ * @param {string} siteUrl
62
+ * @returns {string}
63
+ */
64
+ export function getSkillsAddCommand(siteUrl) {
65
+ return `npx skills add ${normalizeSiteUrl(siteUrl)}`;
66
+ }
67
+
68
+ /**
69
+ * Render the install section for the selected agent combination. Returns
70
+ * `null` when no site URL is configured (no packs have been published, so
71
+ * there is nothing meaningful to install) so the caller can skip rendering.
72
+ * @param {Object} params
73
+ * @param {{id: string}} params.discipline - Selected human discipline
74
+ * @param {{id: string}} params.track - Selected human track
75
+ * @param {string|undefined} params.siteUrl - Framework distribution site URL
76
+ * @returns {HTMLElement|null}
77
+ */
78
+ export function createInstallSection({ discipline, track, siteUrl }) {
79
+ if (!siteUrl) return null;
80
+
81
+ const packName = getPackName(discipline, track);
82
+ const apmCommand = getApmInstallCommand(siteUrl, packName);
83
+ const skillsCommand = getSkillsAddCommand(siteUrl);
84
+
85
+ return section(
86
+ {
87
+ className: "agent-install-section",
88
+ "aria-labelledby": INSTALL_HEADING_ID,
89
+ },
90
+ div(
91
+ { className: "agent-install-header" },
92
+ h2({ id: INSTALL_HEADING_ID }, "📦 Install This Agent Team"),
93
+ p(
94
+ { className: "text-muted agent-install-description" },
95
+ "Install the pre-built pack for this discipline × track combination " +
96
+ "directly through an ecosystem package manager. The pack contains " +
97
+ "the same stage agents, skills, team instructions, and Claude Code " +
98
+ "settings shown below — installed into your project's ",
99
+ code({}, ".claude/"),
100
+ " directory.",
101
+ ),
102
+ ),
103
+ div(
104
+ { className: "agent-install-commands" },
105
+ div(
106
+ { className: "agent-install-command" },
107
+ p({ className: "agent-install-command-label" }, "Microsoft APM"),
108
+ createCommandPrompt(apmCommand),
109
+ ),
110
+ div(
111
+ { className: "agent-install-command" },
112
+ p({ className: "agent-install-command-label" }, "npx skills"),
113
+ createCommandPrompt(skillsCommand),
114
+ ),
115
+ ),
116
+ );
117
+ }
@@ -10,8 +10,8 @@ import {
10
10
  deriveStageAgent,
11
11
  generateSkillMarkdown,
12
12
  deriveAgentSkills,
13
- deriveToolkit,
14
- } from "@forwardimpact/libskill";
13
+ } from "@forwardimpact/libskill/agent";
14
+ import { deriveToolkit } from "@forwardimpact/libskill/toolkit";
15
15
  import { getStageEmoji } from "../formatters/stage/shared.js";
16
16
  import { formatAgentProfile } from "../formatters/agent/profile.js";
17
17
  import {
@@ -115,7 +115,7 @@ function deriveSkillData(context) {
115
115
  const skillFiles = derivedSkills
116
116
  .map((d) => skills.find((s) => s.id === d.skillId))
117
117
  .filter((skill) => skill?.agent)
118
- .map((skill) => generateSkillMarkdown(skill, stages));
118
+ .map((skill) => generateSkillMarkdown({ skillData: skill, stages }));
119
119
 
120
120
  const toolkit = deriveToolkit({
121
121
  skillMatrix: derivedSkills,
@@ -18,7 +18,7 @@ import {
18
18
  } from "../lib/render.js";
19
19
  import { getState } from "../lib/state.js";
20
20
  import { loadAgentDataBrowser } from "../lib/yaml-loader.js";
21
- import { deriveReferenceLevel } from "@forwardimpact/libskill";
21
+ import { deriveReferenceLevel } from "@forwardimpact/libskill/agent";
22
22
  import {
23
23
  createSelectWithValue,
24
24
  createDisciplineSelect,
@@ -30,6 +30,7 @@ import {
30
30
  createSingleStagePreview,
31
31
  createHelpSection,
32
32
  } from "./agent-builder-preview.js";
33
+ import { createInstallSection } from "./agent-builder-install.js";
33
34
 
34
35
  /** All stages option value */
35
36
  const ALL_STAGES_VALUE = "all";
@@ -79,6 +80,7 @@ async function getTemplates() {
79
80
  */
80
81
  export async function renderAgentBuilder() {
81
82
  const { data } = getState();
83
+ const siteUrl = data.framework?.distribution?.siteUrl;
82
84
 
83
85
  // Show loading state
84
86
  render(
@@ -260,8 +262,27 @@ export async function renderAgentBuilder() {
260
262
  templates,
261
263
  };
262
264
 
265
+ // Install section (ecosystem-tool install commands) — appears above the
266
+ // preview cards so the install action is visible before users scroll
267
+ // through the generated files. Only rendered when the framework has a
268
+ // published distribution site URL, since the packs only exist at that
269
+ // URL after a `fit-pathway build`. Must come after the stage-validity
270
+ // guard below so an invalid stage id (e.g. from a stale bookmark) does
271
+ // not pair the install card with a "Stage not found" error.
272
+ function appendInstallSection() {
273
+ const installSection = createInstallSection({
274
+ discipline: humanDiscipline,
275
+ track: humanTrack,
276
+ siteUrl,
277
+ });
278
+ if (installSection) {
279
+ previewContainer.appendChild(installSection);
280
+ }
281
+ }
282
+
263
283
  // Generate preview based on stage selection
264
284
  if (stage === ALL_STAGES_VALUE) {
285
+ appendInstallSection();
265
286
  previewContainer.appendChild(createAllStagesPreview(context));
266
287
  } else {
267
288
  const stageObj = stages.find((s) => s.id === stage);
@@ -274,6 +295,7 @@ export async function renderAgentBuilder() {
274
295
  );
275
296
  return;
276
297
  }
298
+ appendInstallSection();
277
299
  previewContainer.appendChild(createSingleStagePreview(context, stageObj));
278
300
  }
279
301
  }
@@ -16,8 +16,8 @@ import {
16
16
  prepareCurrentJob,
17
17
  prepareCustomProgression,
18
18
  getDefaultTargetLevel,
19
- isValidCombination,
20
19
  } from "../formatters/progress/shared.js";
20
+ import { isValidJobCombination } from "@forwardimpact/libskill/derivation";
21
21
  import { buildComparisonResult } from "./progress-comparison.js";
22
22
 
23
23
  /**
@@ -188,7 +188,7 @@ function createComparisonSelectorsSection({
188
188
  for (const level of data.levels) {
189
189
  // Check trackless combination
190
190
  if (
191
- isValidCombination({ discipline: selectedDisc, level, track: null })
191
+ isValidJobCombination({ discipline: selectedDisc, level, track: null })
192
192
  ) {
193
193
  if (!validLevels.find((g) => g.id === level.id)) {
194
194
  validLevels.push(level);
@@ -197,7 +197,7 @@ function createComparisonSelectorsSection({
197
197
  }
198
198
  // Check each track combination
199
199
  for (const track of data.tracks) {
200
- if (isValidCombination({ discipline: selectedDisc, level, track })) {
200
+ if (isValidJobCombination({ discipline: selectedDisc, level, track })) {
201
201
  if (!validLevels.find((g) => g.id === level.id)) {
202
202
  validLevels.push(level);
203
203
  }
@@ -11,7 +11,7 @@ import { prepareSkillsList } from "../formatters/skill/shared.js";
11
11
  import { skillToDOM } from "../formatters/skill/dom.js";
12
12
  import { skillToCardConfig } from "../lib/card-mappers.js";
13
13
  import { getCapabilityEmoji, getConceptEmoji } from "@forwardimpact/map/levels";
14
- import { generateSkillMarkdown } from "@forwardimpact/libskill";
14
+ import { generateSkillMarkdown } from "@forwardimpact/libskill/agent";
15
15
  import { formatAgentSkill } from "../formatters/agent/skill.js";
16
16
 
17
17
  /** @type {string|null} Cached skill template */
@@ -101,7 +101,10 @@ export async function renderSkillDetail(params) {
101
101
  let agentSkillContent;
102
102
  if (skill.agent) {
103
103
  const template = await getSkillTemplate();
104
- const skillData = generateSkillMarkdown(skill, data.stages);
104
+ const skillData = generateSkillMarkdown({
105
+ skillData: skill,
106
+ stages: data.stages,
107
+ });
105
108
  agentSkillContent = formatAgentSkill(skillData, template);
106
109
  }
107
110
 
@@ -1,89 +0,0 @@
1
- /**
2
- * Job Cache
3
- *
4
- * Centralized caching for generated job definitions.
5
- * Provides consistent key generation and get-or-create pattern.
6
- */
7
-
8
- import { deriveJob } from "@forwardimpact/libskill/derivation";
9
-
10
- /** @type {Map<string, Object>} */
11
- const cache = new Map();
12
-
13
- /**
14
- * Build a consistent cache key from job parameters
15
- * @param {string} disciplineId
16
- * @param {string} levelId
17
- * @param {string} [trackId] - Optional track ID
18
- * @returns {string}
19
- */
20
- export function buildJobKey(disciplineId, levelId, trackId = null) {
21
- if (trackId) {
22
- return `${disciplineId}_${levelId}_${trackId}`;
23
- }
24
- return `${disciplineId}_${levelId}`;
25
- }
26
-
27
- /**
28
- * Get or create a cached job definition
29
- * @param {Object} params
30
- * @param {Object} params.discipline
31
- * @param {Object} params.level
32
- * @param {Object} [params.track] - Optional track
33
- * @param {Array} params.skills
34
- * @param {Array} params.behaviours
35
- * @param {Array} [params.capabilities]
36
- * @returns {Object|null}
37
- */
38
- export function getOrCreateJob({
39
- discipline,
40
- level,
41
- track = null,
42
- skills,
43
- behaviours,
44
- capabilities,
45
- }) {
46
- const key = buildJobKey(discipline.id, level.id, track?.id);
47
-
48
- if (!cache.has(key)) {
49
- const job = deriveJob({
50
- discipline,
51
- level,
52
- track,
53
- skills,
54
- behaviours,
55
- capabilities,
56
- });
57
- if (job) {
58
- cache.set(key, job);
59
- }
60
- return job;
61
- }
62
-
63
- return cache.get(key);
64
- }
65
-
66
- /**
67
- * Clear all cached jobs
68
- */
69
- export function clearCache() {
70
- cache.clear();
71
- }
72
-
73
- /**
74
- * Invalidate a specific job from the cache
75
- * @param {string} disciplineId
76
- * @param {string} levelId
77
- * @param {string} [trackId] - Optional track ID
78
- */
79
- export function invalidateCachedJob(disciplineId, levelId, trackId = null) {
80
- cache.delete(buildJobKey(disciplineId, levelId, trackId));
81
- }
82
-
83
- /**
84
- * Get the number of cached jobs (for testing/debugging)
85
- * @returns {number}
86
- */
87
- export function getCachedJobCount() {
88
- return cache.size;
89
- }