@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
@@ -89,46 +89,4 @@
89
89
  font-weight: 600;
90
90
  color: var(--color-primary);
91
91
  }
92
-
93
- /* Job description section */
94
- .job-description-container {
95
- display: flex;
96
- flex-direction: column;
97
- gap: var(--space-md);
98
- }
99
-
100
- .job-description-header {
101
- display: flex;
102
- justify-content: space-between;
103
- align-items: center;
104
- flex-wrap: wrap;
105
- gap: var(--space-md);
106
- }
107
-
108
- .job-description-header .text-muted {
109
- margin: 0;
110
- flex: 1;
111
- min-width: 200px;
112
- }
113
-
114
- .job-description-textarea {
115
- width: 100%;
116
- min-height: 400px;
117
- padding: var(--space-md);
118
- font-family:
119
- ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
120
- font-size: var(--font-size-sm);
121
- line-height: 1.6;
122
- background-color: var(--color-bg);
123
- border: 1px solid var(--color-border);
124
- border-radius: var(--radius-md);
125
- resize: vertical;
126
- color: var(--color-text);
127
- }
128
-
129
- .job-description-textarea:focus {
130
- outline: none;
131
- border-color: var(--color-primary);
132
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
133
- }
134
92
  }
@@ -10,6 +10,66 @@
10
10
 
11
11
  import Mustache from "mustache";
12
12
 
13
+ import { trimValue, trimRequired, trimFields } from "../shared.js";
14
+
15
+ /**
16
+ * Prepare agent profile data for template rendering
17
+ * Normalizes string values by trimming trailing newlines for consistent template output.
18
+ * @param {Object} params
19
+ * @param {Object} params.frontmatter - YAML frontmatter data
20
+ * @param {string} params.frontmatter.name - Agent name
21
+ * @param {string} params.frontmatter.description - Agent description
22
+ * @param {boolean} params.frontmatter.infer - Whether to auto-select
23
+ * @param {Array} [params.frontmatter.handoffs] - Handoff definitions
24
+ * @param {Object} params.bodyData - Structured body data
25
+ * @param {string} params.bodyData.title - Agent title
26
+ * @param {string} params.bodyData.stageDescription - Stage description text
27
+ * @param {string} params.bodyData.identity - Core identity text
28
+ * @param {string} [params.bodyData.priority] - Priority/philosophy statement
29
+ * @param {string[]} params.bodyData.capabilities - List of capability names
30
+ * @param {Array<{index: number, text: string}>} params.bodyData.beforeMakingChanges - Numbered steps
31
+ * @param {string} [params.bodyData.delegation] - Delegation guidance
32
+ * @param {string} params.bodyData.operationalContext - Operational context text
33
+ * @param {string} params.bodyData.workingStyle - Working style markdown section
34
+ * @param {string} [params.bodyData.beforeHandoff] - Before handoff checklist markdown
35
+ * @param {string[]} params.bodyData.constraints - List of constraints
36
+ * @returns {Object} Data object ready for Mustache template
37
+ */
38
+ function prepareAgentProfileData({ frontmatter, bodyData }) {
39
+ // Trim array fields using helpers
40
+ const handoffs = trimFields(frontmatter.handoffs, { prompt: "required" });
41
+ const beforeMakingChanges = trimFields(bodyData.beforeMakingChanges, {
42
+ text: "required",
43
+ });
44
+
45
+ // Trim simple string arrays
46
+ const constraints = (bodyData.constraints || []).map((c) => trimRequired(c));
47
+ const capabilities = (bodyData.capabilities || []).map((c) =>
48
+ trimRequired(c),
49
+ );
50
+
51
+ return {
52
+ // Frontmatter
53
+ name: frontmatter.name,
54
+ description: trimRequired(frontmatter.description),
55
+ infer: frontmatter.infer,
56
+ handoffs,
57
+
58
+ // Body data - trim all string fields
59
+ title: bodyData.title,
60
+ stageDescription: trimValue(bodyData.stageDescription),
61
+ identity: trimValue(bodyData.identity),
62
+ priority: trimValue(bodyData.priority),
63
+ capabilities,
64
+ beforeMakingChanges,
65
+ delegation: trimValue(bodyData.delegation),
66
+ operationalContext: trimValue(bodyData.operationalContext),
67
+ workingStyle: trimValue(bodyData.workingStyle),
68
+ beforeHandoff: trimValue(bodyData.beforeHandoff),
69
+ constraints,
70
+ };
71
+ }
72
+
13
73
  /**
14
74
  * Format agent profile as .agent.md file content using Mustache template
15
75
  * @param {Object} profile - Profile with frontmatter and bodyData
@@ -35,125 +95,6 @@ import Mustache from "mustache";
35
95
  * @returns {string} Complete .agent.md file content
36
96
  */
37
97
  export function formatAgentProfile({ frontmatter, bodyData }, template) {
38
- const data = {
39
- // Frontmatter
40
- name: frontmatter.name,
41
- description: frontmatter.description,
42
- infer: frontmatter.infer,
43
- handoffs: frontmatter.handoffs || [],
44
- // Body data
45
- ...bodyData,
46
- };
98
+ const data = prepareAgentProfileData({ frontmatter, bodyData });
47
99
  return Mustache.render(template, data);
48
100
  }
49
-
50
- /**
51
- * Format agent profile for CLI output (markdown)
52
- * @param {Object} profile - Profile with frontmatter and bodyData
53
- * @returns {string} Markdown formatted for CLI display
54
- */
55
- export function formatAgentProfileForCli({ frontmatter, bodyData }) {
56
- const lines = [];
57
-
58
- lines.push(`# Agent Profile: ${frontmatter.name}`);
59
- lines.push("");
60
- lines.push(`**Description:** ${frontmatter.description}`);
61
- lines.push("");
62
- lines.push(`**Infer:** ${frontmatter.infer}`);
63
-
64
- if (frontmatter.handoffs && frontmatter.handoffs.length > 0) {
65
- lines.push("");
66
- lines.push("**Handoffs:**");
67
- for (const handoff of frontmatter.handoffs) {
68
- const target = handoff.agent ? ` → ${handoff.agent}` : " (self)";
69
- lines.push(` - ${handoff.label}${target}`);
70
- }
71
- }
72
-
73
- lines.push("");
74
- lines.push("---");
75
- lines.push("");
76
-
77
- // Render structured body data
78
- lines.push(`# ${bodyData.title}`);
79
- lines.push("");
80
- lines.push(bodyData.stageDescription);
81
- lines.push("");
82
-
83
- lines.push("## Core Identity");
84
- lines.push("");
85
- lines.push(bodyData.identity);
86
- lines.push("");
87
-
88
- if (bodyData.priority) {
89
- lines.push(bodyData.priority);
90
- lines.push("");
91
- }
92
-
93
- if (bodyData.capabilities && bodyData.capabilities.length > 0) {
94
- lines.push("Your primary capabilities:");
95
- for (const cap of bodyData.capabilities) {
96
- lines.push(`- ${cap}`);
97
- }
98
- lines.push("");
99
- }
100
-
101
- if (bodyData.beforeMakingChanges && bodyData.beforeMakingChanges.length > 0) {
102
- lines.push("Before making changes:");
103
- for (const step of bodyData.beforeMakingChanges) {
104
- lines.push(`${step.index}. ${step.text}`);
105
- }
106
- lines.push("");
107
- }
108
-
109
- if (bodyData.delegation) {
110
- lines.push("## Delegation");
111
- lines.push("");
112
- lines.push(bodyData.delegation);
113
- lines.push("");
114
- }
115
-
116
- lines.push("## Operational Context");
117
- lines.push("");
118
- lines.push(bodyData.operationalContext);
119
- lines.push("");
120
-
121
- lines.push(bodyData.workingStyle);
122
-
123
- if (bodyData.beforeHandoff) {
124
- lines.push("## Before Handoff");
125
- lines.push("");
126
- lines.push(
127
- "Before offering a handoff, verify and summarize completion of these items:",
128
- );
129
- lines.push("");
130
- lines.push(bodyData.beforeHandoff);
131
- lines.push("");
132
- lines.push(
133
- "When verified, summarize what was accomplished then offer the handoff.",
134
- );
135
- lines.push("If items are incomplete, explain what remains.");
136
- lines.push("");
137
- }
138
-
139
- lines.push("## Return Format");
140
- lines.push("");
141
- lines.push("When completing work (for handoff or as a subagent), provide:");
142
- lines.push("");
143
- lines.push("1. **Work completed**: What was accomplished");
144
- lines.push(
145
- "2. **Checklist status**: Items verified from Before Handoff section",
146
- );
147
- lines.push("3. **Recommendation**: Ready for next stage, or needs more work");
148
- lines.push("");
149
-
150
- if (bodyData.constraints && bodyData.constraints.length > 0) {
151
- lines.push("## Constraints");
152
- lines.push("");
153
- for (const constraint of bodyData.constraints) {
154
- lines.push(`- ${constraint}`);
155
- }
156
- }
157
-
158
- return lines.join("\n");
159
- }
@@ -10,6 +10,47 @@
10
10
 
11
11
  import Mustache from "mustache";
12
12
 
13
+ import { trimValue, splitLines, trimFields } from "../shared.js";
14
+
15
+ /**
16
+ * Prepare agent skill data for template rendering
17
+ * Normalizes string values by trimming trailing newlines for consistent template output.
18
+ * @param {Object} params
19
+ * @param {Object} params.frontmatter - YAML frontmatter data
20
+ * @param {string} params.frontmatter.name - Skill name (required)
21
+ * @param {string} params.frontmatter.description - Skill description (required)
22
+ * @param {string} [params.frontmatter.useWhen] - When to use this skill
23
+ * @param {string} params.title - Human-readable skill title for heading
24
+ * @param {Array} params.stages - Array of stage objects with stageName, focus, activities, ready
25
+ * @param {string} params.reference - Reference content (markdown)
26
+ * @param {Array} [params.toolReferences] - Array of tool reference objects
27
+ * @returns {Object} Data object ready for Mustache template
28
+ */
29
+ function prepareAgentSkillData({
30
+ frontmatter,
31
+ title,
32
+ stages,
33
+ reference,
34
+ toolReferences,
35
+ }) {
36
+ // Process stages - trim focus and array values
37
+ const processedStages = trimFields(stages, {
38
+ focus: "required",
39
+ activities: "array",
40
+ ready: "array",
41
+ });
42
+
43
+ return {
44
+ name: frontmatter.name,
45
+ descriptionLines: splitLines(frontmatter.description),
46
+ useWhenLines: splitLines(frontmatter.useWhen),
47
+ title,
48
+ stages: processedStages,
49
+ reference: trimValue(reference) || "",
50
+ toolReferences: toolReferences || [],
51
+ };
52
+ }
53
+
13
54
  /**
14
55
  * Format agent skill as SKILL.md file content using Mustache template
15
56
  * @param {Object} skill - Skill with frontmatter, title, stages, reference
@@ -19,73 +60,20 @@ import Mustache from "mustache";
19
60
  * @param {string} skill.title - Human-readable skill title for heading
20
61
  * @param {Array} skill.stages - Array of stage objects with stageName, focus, activities, ready
21
62
  * @param {string} skill.reference - Reference content (markdown)
63
+ * @param {Array} [skill.toolReferences] - Array of tool reference objects
22
64
  * @param {string} template - Mustache template string
23
65
  * @returns {string} Complete SKILL.md file content
24
66
  */
25
67
  export function formatAgentSkill(
26
- { frontmatter, title, stages, reference },
68
+ { frontmatter, title, stages, reference, toolReferences },
27
69
  template,
28
70
  ) {
29
- const data = {
30
- name: frontmatter.name,
31
- descriptionLines: frontmatter.description.trim().split("\n"),
71
+ const data = prepareAgentSkillData({
72
+ frontmatter,
32
73
  title,
33
74
  stages,
34
- reference: reference ? reference.trim() : "",
35
- };
75
+ reference,
76
+ toolReferences,
77
+ });
36
78
  return Mustache.render(template, data);
37
79
  }
38
-
39
- /**
40
- * Format agent skill for CLI output (markdown)
41
- * @param {Object} skill - Skill with frontmatter, title, stages, reference
42
- * @returns {string} Markdown formatted for CLI display
43
- */
44
- export function formatAgentSkillForCli({
45
- frontmatter,
46
- title,
47
- stages,
48
- reference,
49
- }) {
50
- const lines = [];
51
-
52
- lines.push(`# ${title}`);
53
- lines.push("");
54
- lines.push(`**Name:** ${frontmatter.name}`);
55
- lines.push("");
56
- lines.push(`**Description:** ${frontmatter.description.trim()}`);
57
- lines.push("");
58
-
59
- if (stages && stages.length > 0) {
60
- lines.push("## Stage Guidance");
61
- lines.push("");
62
- for (const stage of stages) {
63
- lines.push(`### ${stage.stageName} Stage`);
64
- lines.push("");
65
- lines.push(`**Focus:** ${stage.focus.trim()}`);
66
- lines.push("");
67
- if (stage.activities && stage.activities.length > 0) {
68
- lines.push("**Activities:**");
69
- for (const item of stage.activities) {
70
- lines.push(`- ${item}`);
71
- }
72
- lines.push("");
73
- }
74
- if (stage.ready && stage.ready.length > 0) {
75
- lines.push(`**Ready for ${stage.nextStageName} when:**`);
76
- for (const item of stage.ready) {
77
- lines.push(`- [ ] ${item}`);
78
- }
79
- lines.push("");
80
- }
81
- }
82
- }
83
-
84
- if (reference) {
85
- lines.push("## Reference");
86
- lines.push("");
87
- lines.push(reference.trim());
88
- }
89
-
90
- return lines.join("\n");
91
- }
@@ -14,6 +14,7 @@ import {
14
14
  tr,
15
15
  th,
16
16
  td,
17
+ formatLevel,
17
18
  } from "../../lib/render.js";
18
19
  import { createBackLink } from "../../components/nav.js";
19
20
  import { createLevelDots } from "../../components/detail.js";
@@ -102,10 +103,7 @@ export function gradeToDOM(grade, { framework, showBackLink = true } = {}) {
102
103
  ...Object.entries(view.expectations).map(([key, value]) =>
103
104
  div(
104
105
  { className: "list-item" },
105
- p(
106
- { className: "label" },
107
- key.charAt(0).toUpperCase() + key.slice(1),
108
- ),
106
+ p({ className: "label" }, formatLevel(key)),
109
107
  p({}, value),
110
108
  ),
111
109
  ),
@@ -3,41 +3,29 @@
3
3
  *
4
4
  * Formats job data into markdown job description content.
5
5
  * Parallels formatters/agent/profile.js in structure.
6
+ *
7
+ * Uses Mustache templates for flexible output formatting.
8
+ * Templates are loaded from data/ directory with fallback to templates/ directory.
6
9
  */
7
10
 
11
+ import Mustache from "mustache";
12
+
8
13
  import {
9
14
  SKILL_LEVEL_ORDER,
10
15
  BEHAVIOUR_MATURITY_ORDER,
11
16
  } from "../../model/levels.js";
17
+ import { trimValue, trimFields } from "../shared.js";
12
18
 
13
19
  /**
14
- * Format job as a markdown job description
20
+ * Prepare job data for template rendering
15
21
  * @param {Object} params
16
22
  * @param {Object} params.job - The job definition
17
23
  * @param {Object} params.discipline - The discipline
18
24
  * @param {Object} params.grade - The grade
19
- * @param {Object} params.track - The track
20
- * @returns {string} Markdown formatted job description
25
+ * @param {Object} [params.track] - The track (optional)
26
+ * @returns {Object} Data object ready for Mustache template
21
27
  */
22
- export function formatJobDescription({ job, discipline, grade, track }) {
23
- const lines = [];
24
-
25
- // Title
26
- lines.push(`# ${job.title}`);
27
- lines.push("");
28
-
29
- // Meta information
30
- lines.push(`- **Level:** ${grade.id}`);
31
- lines.push(`- **Experience:** ${grade.typicalExperienceRange}`);
32
- if (track) {
33
- lines.push(`- **Track:** ${track.name}`);
34
- }
35
- lines.push("");
36
-
37
- // Role Summary
38
- lines.push("## ROLE SUMMARY");
39
- lines.push("");
40
-
28
+ function prepareJobDescriptionData({ job, discipline, grade, track }) {
41
29
  // Build role summary from discipline - use manager version if applicable
42
30
  const isManagement = discipline.isManagement === true;
43
31
  let roleSummary =
@@ -48,16 +36,9 @@ export function formatJobDescription({ job, discipline, grade, track }) {
48
36
  const { roleTitle, specialization } = discipline;
49
37
  roleSummary = roleSummary.replace(/\{roleTitle\}/g, roleTitle);
50
38
  roleSummary = roleSummary.replace(/\{specialization\}/g, specialization);
51
- lines.push(roleSummary);
52
- lines.push("");
53
-
54
- // Add track context
55
- if (track?.roleContext) {
56
- lines.push(track.roleContext);
57
- lines.push("");
58
- }
59
39
 
60
- // Add grade expectations as natural paragraphs
40
+ // Build expectations paragraph
41
+ let expectationsParagraph = "";
61
42
  if (job.expectations) {
62
43
  const exp = job.expectations;
63
44
  const expectationSentences = [];
@@ -89,45 +70,20 @@ export function formatJobDescription({ job, discipline, grade, track }) {
89
70
  }
90
71
 
91
72
  if (expectationSentences.length > 0) {
92
- lines.push(expectationSentences.join(" "));
93
- lines.push("");
73
+ expectationsParagraph = expectationSentences.join(" ");
94
74
  }
95
75
  }
96
76
 
97
- // Key Responsibilities
98
- lines.push("## ROLE RESPONSIBILITIES");
99
- lines.push("");
100
-
101
- // Use derived responsibilities (already sorted by level descending)
102
- const derivedResponsibilities = job.derivedResponsibilities || [];
103
-
104
- for (const r of derivedResponsibilities) {
105
- lines.push(`- **${r.capabilityName}:** ${r.responsibility}`);
106
- }
107
- lines.push("");
108
-
109
- // Key Behaviours
110
- lines.push("## ROLE BEHAVIOURS");
111
- lines.push("");
112
-
113
77
  // Sort behaviours by maturity level (highest first)
114
78
  const sortedBehaviours = [...job.behaviourProfile].sort((a, b) => {
115
79
  const indexA = BEHAVIOUR_MATURITY_ORDER.indexOf(a.maturity);
116
80
  const indexB = BEHAVIOUR_MATURITY_ORDER.indexOf(b.maturity);
117
- // Sort in reverse order (exemplifying first, emerging last)
118
81
  if (indexA === -1 && indexB === -1) return 0;
119
82
  if (indexA === -1) return 1;
120
83
  if (indexB === -1) return -1;
121
84
  return indexB - indexA;
122
85
  });
123
86
 
124
- for (const behaviour of sortedBehaviours) {
125
- lines.push(
126
- `- **${behaviour.behaviourName}:** ${behaviour.maturityDescription || ""}`,
127
- );
128
- }
129
- lines.push("");
130
-
131
87
  // Group skills by level
132
88
  const skillsByLevel = {};
133
89
  for (const skill of job.skillMatrix) {
@@ -138,41 +94,77 @@ export function formatJobDescription({ job, discipline, grade, track }) {
138
94
  skillsByLevel[level].push(skill);
139
95
  }
140
96
 
141
- // Sort levels in a logical order using SKILL_LEVEL_ORDER from types.js
97
+ // Sort levels in reverse order (expert first, awareness last)
142
98
  const sortedLevels = Object.keys(skillsByLevel).sort((a, b) => {
143
99
  const indexA = SKILL_LEVEL_ORDER.indexOf(a.toLowerCase());
144
100
  const indexB = SKILL_LEVEL_ORDER.indexOf(b.toLowerCase());
145
- // Sort in reverse order (expert first, awareness last)
146
101
  if (indexA === -1 && indexB === -1) return a.localeCompare(b);
147
102
  if (indexA === -1) return 1;
148
103
  if (indexB === -1) return -1;
149
104
  return indexB - indexA;
150
105
  });
151
106
 
152
- for (const level of sortedLevels) {
153
- const skills = skillsByLevel[level];
154
- if (skills.length > 0) {
155
- lines.push(`## ${level.toUpperCase()}-LEVEL SKILLS`);
156
- lines.push("");
157
- // Sort skills alphabetically by name
158
- const sortedSkills = [...skills].sort((a, b) =>
159
- (a.skillName || "").localeCompare(b.skillName || ""),
160
- );
161
- for (const skill of sortedSkills) {
162
- lines.push(`- **${skill.skillName}:** ${skill.levelDescription || ""}`);
163
- }
164
- lines.push("");
165
- }
166
- }
107
+ // Keep only the top 2 skill levels for job descriptions
108
+ const topLevels = sortedLevels.slice(0, 2);
167
109
 
168
- // Qualifications
169
- lines.push("## QUALIFICATIONS");
170
- lines.push("");
110
+ // Build skill levels array for template
111
+ const skillLevels = topLevels.map((level) => {
112
+ const skills = skillsByLevel[level];
113
+ const sortedSkills = [...skills].sort((a, b) =>
114
+ (a.skillName || "").localeCompare(b.skillName || ""),
115
+ );
116
+ return {
117
+ levelHeading: `${level.toUpperCase()}-LEVEL SKILLS`,
118
+ skills: sortedSkills.map((s) => ({
119
+ skillName: s.skillName,
120
+ levelDescription: s.levelDescription || "",
121
+ })),
122
+ };
123
+ });
171
124
 
172
- if (grade.qualificationSummary) {
173
- lines.push(grade.qualificationSummary);
174
- lines.push("");
175
- }
125
+ // Build qualification summary with placeholder replacement
126
+ const qualificationSummary =
127
+ (grade.qualificationSummary || "").replace(
128
+ /\{typicalExperienceRange\}/g,
129
+ grade.typicalExperienceRange || "",
130
+ ) || null;
131
+
132
+ return {
133
+ title: job.title,
134
+ gradeId: grade.id,
135
+ typicalExperienceRange: grade.typicalExperienceRange,
136
+ trackName: track?.name || null,
137
+ roleSummary: trimValue(roleSummary),
138
+ trackRoleContext: trimValue(track?.roleContext),
139
+ expectationsParagraph: trimValue(expectationsParagraph),
140
+ responsibilities: trimFields(job.derivedResponsibilities, {
141
+ responsibility: "required",
142
+ }),
143
+ behaviours: trimFields(sortedBehaviours, {
144
+ maturityDescription: "optional",
145
+ }),
146
+ skillLevels: skillLevels.map((level) => ({
147
+ ...level,
148
+ skills: trimFields(level.skills, { levelDescription: "optional" }),
149
+ })),
150
+ qualificationSummary: trimValue(qualificationSummary),
151
+ };
152
+ }
176
153
 
177
- return lines.join("\n");
154
+ /**
155
+ * Format job as a markdown job description using Mustache template
156
+ * @param {Object} params
157
+ * @param {Object} params.job - The job definition
158
+ * @param {Object} params.discipline - The discipline
159
+ * @param {Object} params.grade - The grade
160
+ * @param {Object} [params.track] - The track (optional)
161
+ * @param {string} template - Mustache template string
162
+ * @returns {string} Markdown formatted job description
163
+ */
164
+ export function formatJobDescription(
165
+ { job, discipline, grade, track },
166
+ template,
167
+ ) {
168
+ const data = prepareJobDescriptionData({ job, discipline, grade, track });
169
+ return Mustache.render(template, data);
178
170
  }