@forwardimpact/pathway 0.2.0 → 0.4.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 (62) hide show
  1. package/app/commands/agent.js +20 -20
  2. package/app/commands/index.js +4 -3
  3. package/app/commands/job.js +9 -4
  4. package/app/commands/skill.js +56 -2
  5. package/app/commands/tool.js +112 -0
  6. package/app/components/builder.js +6 -3
  7. package/app/components/checklist.js +6 -4
  8. package/app/components/markdown-textarea.js +132 -0
  9. package/app/css/components/forms.css +45 -0
  10. package/app/css/components/layout.css +12 -0
  11. package/app/css/components/surfaces.css +22 -0
  12. package/app/css/pages/detail.css +50 -0
  13. package/app/css/pages/job-builder.css +0 -42
  14. package/app/formatters/agent/profile.js +61 -120
  15. package/app/formatters/agent/skill.js +48 -60
  16. package/app/formatters/grade/dom.js +2 -4
  17. package/app/formatters/job/description.js +74 -82
  18. package/app/formatters/job/dom.js +45 -179
  19. package/app/formatters/job/markdown.js +17 -13
  20. package/app/formatters/shared.js +65 -2
  21. package/app/formatters/skill/dom.js +57 -2
  22. package/app/formatters/skill/markdown.js +18 -0
  23. package/app/formatters/skill/shared.js +12 -4
  24. package/app/formatters/stage/microdata.js +1 -1
  25. package/app/formatters/stage/shared.js +1 -1
  26. package/app/formatters/tool/shared.js +72 -0
  27. package/app/handout-main.js +7 -7
  28. package/app/handout.html +7 -0
  29. package/app/index.html +10 -3
  30. package/app/lib/card-mappers.js +64 -17
  31. package/app/lib/form-controls.js +64 -1
  32. package/app/lib/render.js +12 -1
  33. package/app/lib/template-loader.js +9 -0
  34. package/app/lib/yaml-loader.js +12 -1
  35. package/app/main.js +4 -0
  36. package/app/model/agent.js +26 -18
  37. package/app/model/derivation.js +3 -3
  38. package/app/model/levels.js +2 -0
  39. package/app/model/loader.js +12 -1
  40. package/app/model/validation.js +74 -8
  41. package/app/pages/agent-builder.js +8 -5
  42. package/app/pages/job.js +28 -4
  43. package/app/pages/landing.js +34 -14
  44. package/app/pages/progress.js +6 -5
  45. package/app/pages/self-assessment.js +10 -8
  46. package/app/pages/skill.js +5 -17
  47. package/app/pages/stage.js +10 -6
  48. package/app/pages/tool.js +50 -0
  49. package/app/slides/index.js +25 -25
  50. package/app/slides.html +7 -0
  51. package/bin/pathway.js +41 -27
  52. package/examples/capabilities/business.yaml +17 -17
  53. package/examples/capabilities/delivery.yaml +51 -36
  54. package/examples/capabilities/reliability.yaml +127 -114
  55. package/examples/capabilities/scale.yaml +38 -36
  56. package/examples/disciplines/engineering_management.yaml +1 -1
  57. package/examples/framework.yaml +12 -0
  58. package/examples/grades.yaml +18 -19
  59. package/examples/self-assessments.yaml +1 -1
  60. package/package.json +1 -1
  61. package/templates/job.template.md +47 -0
  62. package/templates/skill.template.md +31 -12
@@ -2,18 +2,7 @@
2
2
  * Job formatting for DOM/web output
3
3
  */
4
4
 
5
- import {
6
- div,
7
- h1,
8
- h2,
9
- p,
10
- a,
11
- span,
12
- button,
13
- section,
14
- details,
15
- summary,
16
- } from "../../lib/render.js";
5
+ import { div, h1, h2, p, a, span, section } from "../../lib/render.js";
17
6
  import { createBackLink } from "../../components/nav.js";
18
7
  import {
19
8
  createDetailSection,
@@ -25,6 +14,7 @@ import {
25
14
  } from "../../components/radar-chart.js";
26
15
  import { createSkillMatrix } from "../../components/skill-matrix.js";
27
16
  import { createBehaviourProfile } from "../../components/behaviour-profile.js";
17
+ import { createMarkdownTextarea } from "../../components/markdown-textarea.js";
28
18
  import { markdownToHtml } from "../../lib/markdown.js";
29
19
  import { formatJobDescription } from "./description.js";
30
20
 
@@ -39,6 +29,7 @@ import { formatJobDescription } from "./description.js";
39
29
  * @param {Object} [options.discipline] - Discipline entity for job description
40
30
  * @param {Object} [options.grade] - Grade entity for job description
41
31
  * @param {Object} [options.track] - Track entity for job description
32
+ * @param {string} [options.jobTemplate] - Mustache template for job description
42
33
  * @returns {HTMLElement}
43
34
  */
44
35
  export function jobToDOM(view, options = {}) {
@@ -50,9 +41,10 @@ export function jobToDOM(view, options = {}) {
50
41
  discipline,
51
42
  grade,
52
43
  track,
44
+ jobTemplate,
53
45
  } = options;
54
46
 
55
- const hasEntities = discipline && grade && track;
47
+ const hasEntities = discipline && grade && jobTemplate;
56
48
 
57
49
  return div(
58
50
  { className: "job-detail-page" },
@@ -108,6 +100,7 @@ export function jobToDOM(view, options = {}) {
108
100
  discipline,
109
101
  grade,
110
102
  track,
103
+ template: jobTemplate,
111
104
  })
112
105
  : null,
113
106
 
@@ -140,14 +133,6 @@ export function jobToDOM(view, options = {}) {
140
133
  ),
141
134
  })
142
135
  : null,
143
-
144
- // Handoff Checklists
145
- view.checklists && hasChecklistItems(view.checklists)
146
- ? createDetailSection({
147
- title: "📋 Handoff Checklists",
148
- content: createChecklistSections(view.checklists),
149
- })
150
- : null,
151
136
  )
152
137
  : null,
153
138
 
@@ -164,6 +149,7 @@ export function jobToDOM(view, options = {}) {
164
149
  discipline,
165
150
  grade,
166
151
  track,
152
+ template: jobTemplate,
167
153
  })
168
154
  : null,
169
155
  );
@@ -211,84 +197,6 @@ function getScoreColor(score) {
211
197
  return "#ef4444"; // Red
212
198
  }
213
199
 
214
- /**
215
- * Check if any checklist has items
216
- * @param {Object} checklists - Checklists object keyed by handoff type
217
- * @returns {boolean}
218
- */
219
- function hasChecklistItems(checklists) {
220
- for (const items of Object.values(checklists)) {
221
- if (items && items.length > 0) {
222
- return true;
223
- }
224
- }
225
- return false;
226
- }
227
-
228
- /**
229
- * Create collapsible checklist sections for all handoffs
230
- * @param {Object} checklists - Checklists object keyed by handoff type
231
- * @returns {HTMLElement}
232
- */
233
- function createChecklistSections(checklists) {
234
- const handoffLabels = {
235
- plan_to_code: "📋 → 💻 Plan → Code",
236
- code_to_review: "💻 → ✅ Code → Review",
237
- };
238
-
239
- const sections = Object.entries(checklists)
240
- .filter(([_, items]) => items && items.length > 0)
241
- .map(([handoff, groups]) => {
242
- const label = handoffLabels[handoff] || handoff;
243
- const totalItems = groups.reduce((sum, g) => sum + g.items.length, 0);
244
-
245
- return details(
246
- { className: "checklist-section" },
247
- summary(
248
- { className: "checklist-section-header" },
249
- span({ className: "checklist-section-label" }, label),
250
- span({ className: "badge badge-default" }, `${totalItems} items`),
251
- ),
252
- div(
253
- { className: "checklist-section-content" },
254
- ...groups.map((group) => createChecklistGroup(group)),
255
- ),
256
- );
257
- });
258
-
259
- return div({ className: "checklist-sections" }, ...sections);
260
- }
261
-
262
- /**
263
- * Create a checklist group for a capability
264
- * @param {Object} group - Group with capability, level, and items
265
- * @returns {HTMLElement}
266
- */
267
- function createChecklistGroup(group) {
268
- const emoji = group.capability.emoji || "📌";
269
- const capabilityName = group.capability.name || group.capability.id;
270
-
271
- return div(
272
- { className: "checklist-group" },
273
- div(
274
- { className: "checklist-group-header" },
275
- span({ className: "checklist-emoji" }, emoji),
276
- span({ className: "checklist-capability" }, capabilityName),
277
- span({ className: "badge badge-secondary" }, group.level),
278
- ),
279
- div(
280
- { className: "checklist-items" },
281
- ...group.items.map((item) =>
282
- div(
283
- { className: "checklist-item" },
284
- span({ className: "checklist-checkbox" }, "☐"),
285
- span({}, item),
286
- ),
287
- ),
288
- ),
289
- );
290
- }
291
-
292
200
  /**
293
201
  * Create the job description section with copy button
294
202
  * @param {Object} params
@@ -296,87 +204,35 @@ function createChecklistGroup(group) {
296
204
  * @param {Object} params.discipline - The discipline
297
205
  * @param {Object} params.grade - The grade
298
206
  * @param {Object} params.track - The track
207
+ * @param {string} params.template - Mustache template for job description
299
208
  * @returns {HTMLElement} The job description section element
300
209
  */
301
- export function createJobDescriptionSection({ job, discipline, grade, track }) {
302
- const markdown = formatJobDescription({
303
- job,
304
- discipline,
305
- grade,
306
- track,
307
- });
308
-
309
- const copyButton = button(
210
+ export function createJobDescriptionSection({
211
+ job,
212
+ discipline,
213
+ grade,
214
+ track,
215
+ template,
216
+ }) {
217
+ const markdown = formatJobDescription(
310
218
  {
311
- className: "btn btn-primary copy-btn",
312
- onClick: async () => {
313
- try {
314
- await navigator.clipboard.writeText(markdown);
315
- copyButton.textContent = "✓ Copied!";
316
- copyButton.classList.add("copied");
317
- setTimeout(() => {
318
- copyButton.textContent = "Copy Markdown";
319
- copyButton.classList.remove("copied");
320
- }, 2000);
321
- } catch (err) {
322
- console.error("Failed to copy:", err);
323
- copyButton.textContent = "Copy failed";
324
- setTimeout(() => {
325
- copyButton.textContent = "Copy Markdown";
326
- }, 2000);
327
- }
328
- },
219
+ job,
220
+ discipline,
221
+ grade,
222
+ track,
329
223
  },
330
- "Copy Markdown",
224
+ template,
331
225
  );
332
226
 
333
- const copyHtmlButton = button(
334
- {
335
- className: "btn btn-secondary copy-btn",
336
- onClick: async () => {
337
- try {
338
- const html = markdownToHtml(markdown);
339
- // Use ClipboardItem with text/html MIME type for rich text pasting in Word
340
- const blob = new Blob([html], { type: "text/html" });
341
- const clipboardItem = new ClipboardItem({ "text/html": blob });
342
- await navigator.clipboard.write([clipboardItem]);
343
- copyHtmlButton.textContent = "✓ Copied!";
344
- copyHtmlButton.classList.add("copied");
345
- setTimeout(() => {
346
- copyHtmlButton.textContent = "Copy as HTML";
347
- copyHtmlButton.classList.remove("copied");
348
- }, 2000);
349
- } catch (err) {
350
- console.error("Failed to copy:", err);
351
- copyHtmlButton.textContent = "Copy failed";
352
- setTimeout(() => {
353
- copyHtmlButton.textContent = "Copy as HTML";
354
- }, 2000);
355
- }
356
- },
357
- },
358
- "Copy as HTML",
359
- );
360
-
361
- const textarea = document.createElement("textarea");
362
- textarea.className = "job-description-textarea";
363
- textarea.readOnly = true;
364
- textarea.value = markdown;
365
-
366
227
  return createDetailSection({
367
228
  title: "Job Description",
368
- content: div(
369
- { className: "job-description-container" },
370
- div(
371
- { className: "job-description-header" },
372
- p(
373
- { className: "text-muted" },
374
- "Copy this markdown-formatted job description for use in job postings, documentation, or sharing.",
375
- ),
376
- div({ className: "button-group" }, copyButton, copyHtmlButton),
377
- ),
378
- textarea,
379
- ),
229
+ content: createMarkdownTextarea({
230
+ markdown,
231
+ description:
232
+ "Copy this markdown-formatted job description for use in job postings, documentation, or sharing.",
233
+ toHtml: markdownToHtml,
234
+ minHeight: 450,
235
+ }),
380
236
  });
381
237
  }
382
238
 
@@ -388,15 +244,25 @@ export function createJobDescriptionSection({ job, discipline, grade, track }) {
388
244
  * @param {Object} params.discipline - The discipline
389
245
  * @param {Object} params.grade - The grade
390
246
  * @param {Object} params.track - The track
247
+ * @param {string} params.template - Mustache template for job description
391
248
  * @returns {HTMLElement} The job description HTML element (print-only)
392
249
  */
393
- export function createJobDescriptionHtml({ job, discipline, grade, track }) {
394
- const markdown = formatJobDescription({
395
- job,
396
- discipline,
397
- grade,
398
- track,
399
- });
250
+ export function createJobDescriptionHtml({
251
+ job,
252
+ discipline,
253
+ grade,
254
+ track,
255
+ template,
256
+ }) {
257
+ const markdown = formatJobDescription(
258
+ {
259
+ job,
260
+ discipline,
261
+ grade,
262
+ track,
263
+ },
264
+ template,
265
+ );
400
266
 
401
267
  const html = markdownToHtml(markdown);
402
268
 
@@ -15,9 +15,10 @@ import { SKILL_LEVEL_ORDER } from "../../model/levels.js";
15
15
  * Format job detail as markdown
16
16
  * @param {Object} view - Job detail view from presenter
17
17
  * @param {Object} [entities] - Original entities (for job description)
18
+ * @param {string} [jobTemplate] - Mustache template for job description
18
19
  * @returns {string}
19
20
  */
20
- export function jobToMarkdown(view, entities = {}) {
21
+ export function jobToMarkdown(view, entities = {}, jobTemplate) {
21
22
  const lines = [
22
23
  `# ${view.title}`,
23
24
  "",
@@ -77,23 +78,26 @@ export function jobToMarkdown(view, entities = {}) {
77
78
  }
78
79
 
79
80
  // Job Description (copyable markdown)
80
- if (entities.discipline && entities.grade && entities.track) {
81
+ if (entities.discipline && entities.grade && jobTemplate) {
81
82
  lines.push("---", "");
82
83
  lines.push("## Job Description", "");
83
84
  lines.push("```markdown");
84
85
  lines.push(
85
- formatJobDescription({
86
- job: {
87
- title: view.title,
88
- skillMatrix: view.skillMatrix,
89
- behaviourProfile: view.behaviourProfile,
90
- expectations: view.expectations,
91
- derivedResponsibilities: view.derivedResponsibilities,
86
+ formatJobDescription(
87
+ {
88
+ job: {
89
+ title: view.title,
90
+ skillMatrix: view.skillMatrix,
91
+ behaviourProfile: view.behaviourProfile,
92
+ expectations: view.expectations,
93
+ derivedResponsibilities: view.derivedResponsibilities,
94
+ },
95
+ discipline: entities.discipline,
96
+ grade: entities.grade,
97
+ track: entities.track,
92
98
  },
93
- discipline: entities.discipline,
94
- grade: entities.grade,
95
- track: entities.track,
96
- }),
99
+ jobTemplate,
100
+ ),
97
101
  );
98
102
  lines.push("```");
99
103
  }
@@ -4,6 +4,64 @@
4
4
  * Common formatting functions used across different output formats (CLI, DOM, markdown)
5
5
  */
6
6
 
7
+ /**
8
+ * Trim trailing newlines from a string value
9
+ * Used by template prepare functions for consistent output formatting.
10
+ * @param {string|null|undefined} value - Value to trim
11
+ * @returns {string|null} Trimmed value or null if empty
12
+ */
13
+ export function trimValue(value) {
14
+ if (value == null) return null;
15
+ const trimmed = value.replace(/\n+$/, "");
16
+ return trimmed || null;
17
+ }
18
+
19
+ /**
20
+ * Trim a required field, preserving original if trim would result in empty
21
+ * Use for fields that must have a value.
22
+ * @param {string|null|undefined} value - Value to trim
23
+ * @returns {string} Trimmed value or original
24
+ */
25
+ export function trimRequired(value) {
26
+ return trimValue(value) || value || "";
27
+ }
28
+
29
+ /**
30
+ * Trim and split a string into lines
31
+ * @param {string|null|undefined} value - Value to process
32
+ * @returns {string[]} Array of lines (empty array if no value)
33
+ */
34
+ export function splitLines(value) {
35
+ const trimmed = trimValue(value);
36
+ return trimmed ? trimmed.split("\n") : [];
37
+ }
38
+
39
+ /**
40
+ * Transform an array of objects by applying trimValue to specified fields
41
+ * @param {Array<Object>} array - Array of objects to transform
42
+ * @param {Object<string, 'optional'|'required'|'array'>} fieldSpec - Fields to trim and their type
43
+ * - 'optional': use trimValue (returns null if empty)
44
+ * - 'required': use trimRequired (preserves original if empty)
45
+ * - 'array': trim each element in array field
46
+ * @returns {Array<Object>} Transformed array
47
+ */
48
+ export function trimFields(array, fieldSpec) {
49
+ if (!array) return [];
50
+ return array.map((item) => {
51
+ const result = { ...item };
52
+ for (const [field, type] of Object.entries(fieldSpec)) {
53
+ if (type === "optional") {
54
+ result[field] = trimValue(item[field]);
55
+ } else if (type === "required") {
56
+ result[field] = trimRequired(item[field]);
57
+ } else if (type === "array") {
58
+ result[field] = (item[field] || []).map((v) => trimRequired(v));
59
+ }
60
+ }
61
+ return result;
62
+ });
63
+ }
64
+
7
65
  /**
8
66
  * Format level as text with dots (for CLI/markdown)
9
67
  * @param {number} level - 1-5
@@ -36,7 +94,7 @@ export function tableToMarkdown(headers, rows) {
36
94
  export function objectToMarkdownList(obj, indent = 0) {
37
95
  const prefix = " ".repeat(indent);
38
96
  return Object.entries(obj)
39
- .map(([key, value]) => `${prefix}- **${key}**: ${value}`)
97
+ .map(([key, value]) => `${prefix}- **${capitalize(key)}**: ${value}`)
40
98
  .join("\n");
41
99
  }
42
100
 
@@ -51,12 +109,17 @@ export function formatPercent(value) {
51
109
 
52
110
  /**
53
111
  * Capitalize first letter of each word
112
+ * Handles both snake_case and camelCase
54
113
  * @param {string} str
55
114
  * @returns {string}
56
115
  */
57
116
  export function capitalize(str) {
58
117
  if (!str) return "";
59
- return str.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
118
+ // Insert space before uppercase letters (for camelCase), then handle snake_case
119
+ return str
120
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
121
+ .replace(/_/g, " ")
122
+ .replace(/\b\w/g, (c) => c.toUpperCase());
60
123
  }
61
124
 
62
125
  /**
@@ -18,8 +18,9 @@ import {
18
18
  } from "../../lib/render.js";
19
19
  import { createBackLink } from "../../components/nav.js";
20
20
  import { createLevelCell } from "../../components/detail.js";
21
+ import { createMarkdownTextarea } from "../../components/markdown-textarea.js";
21
22
  import { SKILL_LEVEL_ORDER } from "../../model/levels.js";
22
- import { prepareSkillDetail, formatCapability } from "./shared.js";
23
+ import { prepareSkillDetail } from "./shared.js";
23
24
  import { createJsonLdScript, skillToJsonLd } from "../json-ld.js";
24
25
 
25
26
  /**
@@ -61,7 +62,7 @@ export function skillToDOM(
61
62
  { className: "page-meta" },
62
63
  span(
63
64
  { className: "badge badge-default" },
64
- formatCapability(view.capability),
65
+ `${view.capabilityEmoji} ${view.capability.toUpperCase()}`,
65
66
  ),
66
67
  view.isHumanOnly
67
68
  ? span(
@@ -97,6 +98,60 @@ export function skillToDOM(
97
98
  ),
98
99
  ),
99
100
 
101
+ // Recommended Tools
102
+ view.toolReferences.length > 0
103
+ ? div(
104
+ { className: "detail-section" },
105
+ heading2({ className: "section-title" }, "Recommended Tools"),
106
+ table(
107
+ { className: "tools-table" },
108
+ thead({}, tr({}, th({}, "Tool"), th({}, "Use When"))),
109
+ tbody(
110
+ {},
111
+ ...view.toolReferences.map((tool) =>
112
+ tr(
113
+ {},
114
+ td(
115
+ {},
116
+ tool.url
117
+ ? a(
118
+ {
119
+ href: tool.url,
120
+ target: "_blank",
121
+ rel: "noopener noreferrer",
122
+ },
123
+ tool.name,
124
+ )
125
+ : tool.name,
126
+ ),
127
+ td({}, tool.useWhen),
128
+ ),
129
+ ),
130
+ ),
131
+ ),
132
+ showBackLink
133
+ ? p(
134
+ { className: "see-all-link" },
135
+ a({ href: "#/tool" }, "See all tools →"),
136
+ )
137
+ : null,
138
+ )
139
+ : null,
140
+
141
+ // Implementation Reference
142
+ view.implementationReference
143
+ ? div(
144
+ { className: "detail-section" },
145
+ heading2({ className: "section-title" }, "Implementation Patterns"),
146
+ createMarkdownTextarea({
147
+ markdown: view.implementationReference,
148
+ description:
149
+ "Project-specific implementation guidance for this skill.",
150
+ minHeight: 450,
151
+ }),
152
+ )
153
+ : null,
154
+
100
155
  // Used in Disciplines and Linked to Drivers in two columns
101
156
  view.relatedDisciplines.length > 0 || view.relatedDrivers.length > 0
102
157
  ? div(
@@ -105,5 +105,23 @@ export function skillToMarkdown(
105
105
  lines.push("");
106
106
  }
107
107
 
108
+ // Recommended tools
109
+ if (view.toolReferences.length > 0) {
110
+ lines.push("## Recommended Tools", "");
111
+ const toolRows = view.toolReferences.map((tool) => [
112
+ tool.url ? `[${tool.name}](${tool.url})` : tool.name,
113
+ tool.useWhen,
114
+ ]);
115
+ lines.push(tableToMarkdown(["Tool", "Use When"], toolRows));
116
+ lines.push("");
117
+ }
118
+
119
+ // Implementation reference
120
+ if (view.implementationReference) {
121
+ lines.push("## Implementation Patterns", "");
122
+ lines.push(view.implementationReference);
123
+ lines.push("");
124
+ }
125
+
108
126
  return lines.join("\n");
109
127
  }
@@ -13,12 +13,12 @@ import { truncate } from "../shared.js";
13
13
 
14
14
  /**
15
15
  * Format capability name for display
16
- * @param {string} capability
16
+ * @param {string} capabilityName - The capability name to display
17
17
  * @returns {string}
18
18
  */
19
- export function formatCapability(capability) {
20
- if (!capability) return "";
21
- return capability.charAt(0).toUpperCase() + capability.slice(1);
19
+ export function formatCapability(capabilityName) {
20
+ if (!capabilityName) return "";
21
+ return capabilityName;
22
22
  }
23
23
 
24
24
  /**
@@ -66,12 +66,15 @@ export function prepareSkillsList(
66
66
  * @property {string} name
67
67
  * @property {string} description
68
68
  * @property {string} capability
69
+ * @property {string} capabilityName
69
70
  * @property {boolean} isHumanOnly
70
71
  * @property {string} capabilityEmoji
71
72
  * @property {Object<string, string>} levelDescriptions
72
73
  * @property {Array<{id: string, name: string, skillType: string}>} relatedDisciplines
73
74
  * @property {Array<{id: string, name: string, modifier: number}>} relatedTracks
74
75
  * @property {Array<{id: string, name: string}>} relatedDrivers
76
+ * @property {Array<{name: string, url?: string, description: string, useWhen: string}>} toolReferences
77
+ * @property {string|null} implementationReference
75
78
  */
76
79
 
77
80
  /**
@@ -110,16 +113,21 @@ export function prepareSkillDetail(
110
113
  .filter((d) => d.contributingSkills?.includes(skill.id))
111
114
  .map((d) => ({ id: d.id, name: d.name }));
112
115
 
116
+ const capabilityEntity = capabilities.find((c) => c.id === skill.capability);
117
+
113
118
  return {
114
119
  id: skill.id,
115
120
  name: skill.name,
116
121
  description: skill.description,
117
122
  capability: skill.capability,
123
+ capabilityName: capabilityEntity?.name || skill.capability,
118
124
  isHumanOnly: skill.isHumanOnly || false,
119
125
  capabilityEmoji: getCapabilityEmoji(capabilities, skill.capability),
120
126
  levelDescriptions: skill.levelDescriptions,
121
127
  relatedDisciplines,
122
128
  relatedTracks,
123
129
  relatedDrivers,
130
+ toolReferences: skill.toolReferences || [],
131
+ implementationReference: skill.implementationReference || null,
124
132
  };
125
133
  }
@@ -32,7 +32,7 @@ export function stageListToMicrodata(stages) {
32
32
  ? `→ ${stage.handoffs.map((h) => h.target).join(", ")}`
33
33
  : "";
34
34
  return `${openTag("article", { itemtype: "Stage", itemid: `#${stage.id}` })}
35
- ${prop("h2", "name", `${stage.emoji || ""} ${stage.name}`)}
35
+ ${prop("h2", "name", `${stage.emoji} ${stage.name}`)}
36
36
  ${prop("p", "description", stage.truncatedDescription)}
37
37
  ${handoffText ? `<p>Handoffs: ${handoffText}</p>` : ""}
38
38
  </article>`;
@@ -80,5 +80,5 @@ export function prepareStageDetail(stage) {
80
80
  */
81
81
  export function getStageEmoji(stages, stageId) {
82
82
  const stage = stages.find((s) => s.id === stageId);
83
- return stage?.emoji || "🔄";
83
+ return stage?.emoji;
84
84
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Tool presentation helpers
3
+ *
4
+ * Shared utilities for formatting tool data across DOM and CLI outputs.
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} ToolUsage
9
+ * @property {string} skillId
10
+ * @property {string} skillName
11
+ * @property {string} capabilityId
12
+ * @property {string} useWhen
13
+ */
14
+
15
+ /**
16
+ * @typedef {Object} AggregatedTool
17
+ * @property {string} name
18
+ * @property {string} [url]
19
+ * @property {string} description
20
+ * @property {ToolUsage[]} usages
21
+ */
22
+
23
+ /**
24
+ * Aggregate tools from all skills, deduplicating by name
25
+ * @param {Array} skills - All skills with toolReferences
26
+ * @returns {AggregatedTool[]}
27
+ */
28
+ export function aggregateTools(skills) {
29
+ const toolMap = new Map();
30
+
31
+ for (const skill of skills) {
32
+ if (!skill.toolReferences) continue;
33
+
34
+ for (const tool of skill.toolReferences) {
35
+ const usage = {
36
+ skillId: skill.id,
37
+ skillName: skill.name,
38
+ capabilityId: skill.capability,
39
+ useWhen: tool.useWhen,
40
+ };
41
+
42
+ const existing = toolMap.get(tool.name);
43
+ if (existing) {
44
+ existing.usages.push(usage);
45
+ } else {
46
+ toolMap.set(tool.name, {
47
+ name: tool.name,
48
+ url: tool.url,
49
+ description: tool.description,
50
+ usages: [usage],
51
+ });
52
+ }
53
+ }
54
+ }
55
+
56
+ return Array.from(toolMap.values()).sort((a, b) =>
57
+ a.name.localeCompare(b.name),
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Prepare tools list view data
63
+ * @param {Array} skills - All skills
64
+ * @returns {{ tools: AggregatedTool[], totalCount: number }}
65
+ */
66
+ export function prepareToolsList(skills) {
67
+ const tools = aggregateTools(skills);
68
+ return {
69
+ tools,
70
+ totalCount: tools.length,
71
+ };
72
+ }