@forwardimpact/pathway 0.1.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 (227) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/app/commands/agent.js +430 -0
  4. package/app/commands/behaviour.js +61 -0
  5. package/app/commands/command-factory.js +211 -0
  6. package/app/commands/discipline.js +58 -0
  7. package/app/commands/driver.js +94 -0
  8. package/app/commands/grade.js +60 -0
  9. package/app/commands/index.js +20 -0
  10. package/app/commands/init.js +67 -0
  11. package/app/commands/interview.js +68 -0
  12. package/app/commands/job.js +157 -0
  13. package/app/commands/progress.js +77 -0
  14. package/app/commands/questions.js +179 -0
  15. package/app/commands/serve.js +143 -0
  16. package/app/commands/site.js +121 -0
  17. package/app/commands/skill.js +76 -0
  18. package/app/commands/stage.js +129 -0
  19. package/app/commands/track.js +70 -0
  20. package/app/components/action-buttons.js +66 -0
  21. package/app/components/behaviour-profile.js +53 -0
  22. package/app/components/builder.js +341 -0
  23. package/app/components/card.js +98 -0
  24. package/app/components/checklist.js +145 -0
  25. package/app/components/comparison-radar.js +237 -0
  26. package/app/components/detail.js +230 -0
  27. package/app/components/error-page.js +72 -0
  28. package/app/components/grid.js +109 -0
  29. package/app/components/list.js +120 -0
  30. package/app/components/modifier-table.js +142 -0
  31. package/app/components/nav.js +64 -0
  32. package/app/components/progression-table.js +320 -0
  33. package/app/components/radar-chart.js +102 -0
  34. package/app/components/skill-matrix.js +97 -0
  35. package/app/css/base.css +56 -0
  36. package/app/css/bundles/app.css +40 -0
  37. package/app/css/bundles/handout.css +43 -0
  38. package/app/css/bundles/slides.css +40 -0
  39. package/app/css/components/badges.css +215 -0
  40. package/app/css/components/buttons.css +101 -0
  41. package/app/css/components/forms.css +105 -0
  42. package/app/css/components/layout.css +209 -0
  43. package/app/css/components/nav.css +166 -0
  44. package/app/css/components/progress.css +166 -0
  45. package/app/css/components/states.css +82 -0
  46. package/app/css/components/surfaces.css +243 -0
  47. package/app/css/components/tables.css +362 -0
  48. package/app/css/components/typography.css +122 -0
  49. package/app/css/components/utilities.css +41 -0
  50. package/app/css/pages/agent-builder.css +391 -0
  51. package/app/css/pages/assessment-results.css +453 -0
  52. package/app/css/pages/detail.css +59 -0
  53. package/app/css/pages/interview-builder.css +148 -0
  54. package/app/css/pages/job-builder.css +134 -0
  55. package/app/css/pages/landing.css +92 -0
  56. package/app/css/pages/lifecycle.css +118 -0
  57. package/app/css/pages/progress-builder.css +274 -0
  58. package/app/css/pages/self-assessment.css +502 -0
  59. package/app/css/reset.css +50 -0
  60. package/app/css/tokens.css +153 -0
  61. package/app/css/views/handout.css +30 -0
  62. package/app/css/views/print.css +608 -0
  63. package/app/css/views/slide-animations.css +113 -0
  64. package/app/css/views/slide-base.css +330 -0
  65. package/app/css/views/slide-sections.css +597 -0
  66. package/app/css/views/slide-tables.css +275 -0
  67. package/app/formatters/agent/dom.js +540 -0
  68. package/app/formatters/agent/profile.js +133 -0
  69. package/app/formatters/agent/skill.js +58 -0
  70. package/app/formatters/behaviour/dom.js +91 -0
  71. package/app/formatters/behaviour/markdown.js +54 -0
  72. package/app/formatters/behaviour/shared.js +64 -0
  73. package/app/formatters/discipline/dom.js +187 -0
  74. package/app/formatters/discipline/markdown.js +87 -0
  75. package/app/formatters/discipline/shared.js +131 -0
  76. package/app/formatters/driver/dom.js +103 -0
  77. package/app/formatters/driver/shared.js +92 -0
  78. package/app/formatters/grade/dom.js +208 -0
  79. package/app/formatters/grade/markdown.js +94 -0
  80. package/app/formatters/grade/shared.js +86 -0
  81. package/app/formatters/index.js +50 -0
  82. package/app/formatters/interview/dom.js +97 -0
  83. package/app/formatters/interview/markdown.js +66 -0
  84. package/app/formatters/interview/shared.js +332 -0
  85. package/app/formatters/job/description.js +176 -0
  86. package/app/formatters/job/dom.js +411 -0
  87. package/app/formatters/job/markdown.js +102 -0
  88. package/app/formatters/progress/dom.js +135 -0
  89. package/app/formatters/progress/markdown.js +86 -0
  90. package/app/formatters/progress/shared.js +339 -0
  91. package/app/formatters/questions/json.js +43 -0
  92. package/app/formatters/questions/markdown.js +303 -0
  93. package/app/formatters/questions/shared.js +274 -0
  94. package/app/formatters/questions/yaml.js +76 -0
  95. package/app/formatters/shared.js +71 -0
  96. package/app/formatters/skill/dom.js +168 -0
  97. package/app/formatters/skill/markdown.js +109 -0
  98. package/app/formatters/skill/shared.js +125 -0
  99. package/app/formatters/stage/dom.js +135 -0
  100. package/app/formatters/stage/index.js +12 -0
  101. package/app/formatters/stage/shared.js +111 -0
  102. package/app/formatters/track/dom.js +128 -0
  103. package/app/formatters/track/markdown.js +105 -0
  104. package/app/formatters/track/shared.js +181 -0
  105. package/app/handout-main.js +421 -0
  106. package/app/handout.html +21 -0
  107. package/app/index.html +59 -0
  108. package/app/lib/card-mappers.js +173 -0
  109. package/app/lib/cli-output.js +270 -0
  110. package/app/lib/error-boundary.js +70 -0
  111. package/app/lib/errors.js +49 -0
  112. package/app/lib/form-controls.js +47 -0
  113. package/app/lib/job-cache.js +86 -0
  114. package/app/lib/markdown.js +114 -0
  115. package/app/lib/radar.js +866 -0
  116. package/app/lib/reactive.js +77 -0
  117. package/app/lib/render.js +212 -0
  118. package/app/lib/router-core.js +160 -0
  119. package/app/lib/router-pages.js +16 -0
  120. package/app/lib/router-slides.js +202 -0
  121. package/app/lib/state.js +148 -0
  122. package/app/lib/utils.js +14 -0
  123. package/app/lib/yaml-loader.js +327 -0
  124. package/app/main.js +213 -0
  125. package/app/model/agent.js +702 -0
  126. package/app/model/checklist.js +137 -0
  127. package/app/model/derivation.js +699 -0
  128. package/app/model/index-generator.js +71 -0
  129. package/app/model/interview.js +539 -0
  130. package/app/model/job.js +222 -0
  131. package/app/model/levels.js +591 -0
  132. package/app/model/loader.js +564 -0
  133. package/app/model/matching.js +858 -0
  134. package/app/model/modifiers.js +158 -0
  135. package/app/model/profile.js +266 -0
  136. package/app/model/progression.js +507 -0
  137. package/app/model/validation.js +1385 -0
  138. package/app/pages/agent-builder.js +823 -0
  139. package/app/pages/assessment-results.js +507 -0
  140. package/app/pages/behaviour.js +70 -0
  141. package/app/pages/discipline.js +71 -0
  142. package/app/pages/driver.js +106 -0
  143. package/app/pages/grade.js +117 -0
  144. package/app/pages/interview-builder.js +50 -0
  145. package/app/pages/interview.js +304 -0
  146. package/app/pages/job-builder.js +50 -0
  147. package/app/pages/job.js +58 -0
  148. package/app/pages/landing.js +305 -0
  149. package/app/pages/progress-builder.js +58 -0
  150. package/app/pages/progress.js +495 -0
  151. package/app/pages/self-assessment.js +729 -0
  152. package/app/pages/skill.js +113 -0
  153. package/app/pages/stage.js +231 -0
  154. package/app/pages/track.js +69 -0
  155. package/app/slide-main.js +360 -0
  156. package/app/slides/behaviour.js +38 -0
  157. package/app/slides/chapter.js +82 -0
  158. package/app/slides/discipline.js +40 -0
  159. package/app/slides/driver.js +39 -0
  160. package/app/slides/grade.js +32 -0
  161. package/app/slides/index.js +198 -0
  162. package/app/slides/interview.js +58 -0
  163. package/app/slides/job.js +55 -0
  164. package/app/slides/overview.js +126 -0
  165. package/app/slides/progress.js +83 -0
  166. package/app/slides/skill.js +40 -0
  167. package/app/slides/track.js +39 -0
  168. package/app/slides.html +56 -0
  169. package/app/types.js +147 -0
  170. package/bin/pathway.js +489 -0
  171. package/examples/agents/.claude/skills/architecture-design/SKILL.md +88 -0
  172. package/examples/agents/.claude/skills/cloud-platforms/SKILL.md +90 -0
  173. package/examples/agents/.claude/skills/code-quality-review/SKILL.md +67 -0
  174. package/examples/agents/.claude/skills/data-modeling/SKILL.md +99 -0
  175. package/examples/agents/.claude/skills/developer-experience/SKILL.md +99 -0
  176. package/examples/agents/.claude/skills/devops-cicd/SKILL.md +96 -0
  177. package/examples/agents/.claude/skills/full-stack-development/SKILL.md +90 -0
  178. package/examples/agents/.claude/skills/knowledge-management/SKILL.md +100 -0
  179. package/examples/agents/.claude/skills/pattern-generalization/SKILL.md +102 -0
  180. package/examples/agents/.claude/skills/sre-practices/SKILL.md +117 -0
  181. package/examples/agents/.claude/skills/technical-debt-management/SKILL.md +123 -0
  182. package/examples/agents/.claude/skills/technical-writing/SKILL.md +129 -0
  183. package/examples/agents/.github/agents/se-platform-code.agent.md +181 -0
  184. package/examples/agents/.github/agents/se-platform-plan.agent.md +178 -0
  185. package/examples/agents/.github/agents/se-platform-review.agent.md +113 -0
  186. package/examples/agents/.vscode/settings.json +8 -0
  187. package/examples/behaviours/_index.yaml +8 -0
  188. package/examples/behaviours/outcome_ownership.yaml +44 -0
  189. package/examples/behaviours/polymathic_knowledge.yaml +42 -0
  190. package/examples/behaviours/precise_communication.yaml +40 -0
  191. package/examples/behaviours/relentless_curiosity.yaml +38 -0
  192. package/examples/behaviours/systems_thinking.yaml +41 -0
  193. package/examples/capabilities/_index.yaml +8 -0
  194. package/examples/capabilities/business.yaml +251 -0
  195. package/examples/capabilities/delivery.yaml +352 -0
  196. package/examples/capabilities/people.yaml +100 -0
  197. package/examples/capabilities/reliability.yaml +318 -0
  198. package/examples/capabilities/scale.yaml +394 -0
  199. package/examples/disciplines/_index.yaml +5 -0
  200. package/examples/disciplines/data_engineering.yaml +76 -0
  201. package/examples/disciplines/software_engineering.yaml +76 -0
  202. package/examples/drivers.yaml +205 -0
  203. package/examples/framework.yaml +58 -0
  204. package/examples/grades.yaml +118 -0
  205. package/examples/questions/behaviours/outcome_ownership.yaml +52 -0
  206. package/examples/questions/behaviours/polymathic_knowledge.yaml +48 -0
  207. package/examples/questions/behaviours/precise_communication.yaml +55 -0
  208. package/examples/questions/behaviours/relentless_curiosity.yaml +51 -0
  209. package/examples/questions/behaviours/systems_thinking.yaml +53 -0
  210. package/examples/questions/skills/architecture_design.yaml +54 -0
  211. package/examples/questions/skills/cloud_platforms.yaml +48 -0
  212. package/examples/questions/skills/code_quality.yaml +49 -0
  213. package/examples/questions/skills/data_modeling.yaml +46 -0
  214. package/examples/questions/skills/devops.yaml +47 -0
  215. package/examples/questions/skills/full_stack_development.yaml +48 -0
  216. package/examples/questions/skills/sre_practices.yaml +44 -0
  217. package/examples/questions/skills/stakeholder_management.yaml +49 -0
  218. package/examples/questions/skills/team_collaboration.yaml +43 -0
  219. package/examples/questions/skills/technical_writing.yaml +43 -0
  220. package/examples/self-assessments.yaml +66 -0
  221. package/examples/stages.yaml +76 -0
  222. package/examples/tracks/_index.yaml +6 -0
  223. package/examples/tracks/manager.yaml +53 -0
  224. package/examples/tracks/platform.yaml +54 -0
  225. package/examples/tracks/sre.yaml +58 -0
  226. package/examples/vscode-settings.yaml +22 -0
  227. package/package.json +68 -0
@@ -0,0 +1,699 @@
1
+ /**
2
+ * Engineering Pathway Job Derivation Functions
3
+ *
4
+ * This module provides pure functions for deriving job definitions from
5
+ * discipline, track, and grade combinations.
6
+ */
7
+
8
+ import {
9
+ SkillType,
10
+ SkillLevel,
11
+ BehaviourMaturity,
12
+ SKILL_LEVEL_ORDER,
13
+ getSkillLevelIndex,
14
+ getBehaviourMaturityIndex,
15
+ clampSkillLevel,
16
+ clampBehaviourMaturity,
17
+ skillLevelMeetsRequirement,
18
+ } from "./levels.js";
19
+
20
+ import { resolveSkillModifier } from "./modifiers.js";
21
+
22
+ /**
23
+ * Build a Map of skillId → skillType for a discipline
24
+ * Enables O(1) lookup instead of repeated array scans
25
+ * @param {import('./levels.js').Discipline} discipline - The discipline
26
+ * @returns {Map<string, string>} Map of skill ID to skill type
27
+ */
28
+ export function buildSkillTypeMap(discipline) {
29
+ const map = new Map();
30
+ for (const id of discipline.coreSkills || []) {
31
+ map.set(id, SkillType.PRIMARY);
32
+ }
33
+ for (const id of discipline.supportingSkills || []) {
34
+ map.set(id, SkillType.SECONDARY);
35
+ }
36
+ for (const id of discipline.broadSkills || []) {
37
+ map.set(id, SkillType.BROAD);
38
+ }
39
+ return map;
40
+ }
41
+
42
+ /**
43
+ * Determine the skill type (primary/secondary/broad) for a skill within a discipline
44
+ * @param {import('./levels.js').Discipline} discipline - The discipline
45
+ * @param {string} skillId - The skill ID
46
+ * @returns {string|null} The skill type or null if skill not in discipline
47
+ */
48
+ export function getSkillTypeForDiscipline(discipline, skillId) {
49
+ if (discipline.coreSkills?.includes(skillId)) {
50
+ return SkillType.PRIMARY;
51
+ }
52
+ if (discipline.supportingSkills?.includes(skillId)) {
53
+ return SkillType.SECONDARY;
54
+ }
55
+ if (discipline.broadSkills?.includes(skillId)) {
56
+ return SkillType.BROAD;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * Find the highest base skill level index for a grade
63
+ *
64
+ * This returns the maximum skill level index across primary, secondary, and broad
65
+ * skill types for the given grade. Used to cap positive skill modifiers.
66
+ *
67
+ * @param {import('./levels.js').Grade} grade - The grade
68
+ * @returns {number} The highest base skill level index
69
+ */
70
+ export function findMaxBaseSkillLevel(grade) {
71
+ const primaryIndex = getSkillLevelIndex(grade.baseSkillLevels.primary);
72
+ const secondaryIndex = getSkillLevelIndex(grade.baseSkillLevels.secondary);
73
+ const broadIndex = getSkillLevelIndex(grade.baseSkillLevels.broad);
74
+ return Math.max(primaryIndex, secondaryIndex, broadIndex);
75
+ }
76
+
77
+ /**
78
+ * Derive the skill level for a specific skill given discipline, track, and grade
79
+ *
80
+ * Resolves capability-based modifiers (e.g., { scale: 1 }) by looking up the skill's capability.
81
+ *
82
+ * Positive modifiers are capped at the highest base skill level for the grade,
83
+ * ensuring skills cannot exceed what's appropriate for that career level.
84
+ * Negative modifiers can still bring skills below their base to create emphasis.
85
+ *
86
+ * @param {Object} params
87
+ * @param {import('./levels.js').Discipline} params.discipline - The discipline
88
+ * @param {import('./levels.js').Track} params.track - The track
89
+ * @param {import('./levels.js').Grade} params.grade - The grade
90
+ * @param {string} params.skillId - The skill ID
91
+ * @param {import('./levels.js').Skill[]} params.skills - All available skills (for capability lookup)
92
+ * @returns {string|null} The derived skill level or null if skill not in discipline
93
+ */
94
+ export function deriveSkillLevel({
95
+ discipline,
96
+ grade,
97
+ track,
98
+ skillId,
99
+ skills,
100
+ }) {
101
+ // 1. Determine skill type for discipline
102
+ const skillType = getSkillTypeForDiscipline(discipline, skillId);
103
+
104
+ // 2. Get base level from grade for that skill type
105
+ // Track-added skills (null skillType) use broad as base
106
+ const effectiveType = skillType || SkillType.BROAD;
107
+ const baseLevel = grade.baseSkillLevels[effectiveType];
108
+ const baseIndex = getSkillLevelIndex(baseLevel);
109
+
110
+ // 3. Apply track modifier via capability lookup
111
+ const modifier = resolveSkillModifier(skillId, track.skillModifiers, skills);
112
+
113
+ // Track-added skills require a positive modifier to be included
114
+ if (!skillType && modifier <= 0) {
115
+ return null;
116
+ }
117
+
118
+ let modifiedIndex = baseIndex + modifier;
119
+
120
+ // 4. Cap positive modifications at the grade's highest base skill level
121
+ // Negative modifiers can bring skills below base to create emphasis,
122
+ // but positive modifiers should not push skills beyond the grade ceiling
123
+ if (modifier > 0) {
124
+ const maxIndex = findMaxBaseSkillLevel(grade);
125
+ modifiedIndex = Math.min(modifiedIndex, maxIndex);
126
+ }
127
+
128
+ // 5. Clamp to valid range
129
+ return clampSkillLevel(modifiedIndex);
130
+ }
131
+
132
+ /**
133
+ * Derive the behaviour maturity for a specific behaviour given discipline, track, and grade
134
+ * @param {Object} params
135
+ * @param {import('./levels.js').Discipline} params.discipline - The discipline
136
+ * @param {import('./levels.js').Track} params.track - The track
137
+ * @param {import('./levels.js').Grade} params.grade - The grade
138
+ * @param {string} params.behaviourId - The behaviour ID
139
+ * @returns {string} The derived maturity level
140
+ */
141
+ export function deriveBehaviourMaturity({
142
+ discipline,
143
+ grade,
144
+ track,
145
+ behaviourId,
146
+ }) {
147
+ // 1. Get base maturity from grade
148
+ const baseMaturity = grade.baseBehaviourMaturity;
149
+ const baseIndex = getBehaviourMaturityIndex(baseMaturity);
150
+
151
+ // 2. Calculate behaviour modifiers (additive from discipline and track)
152
+ const disciplineModifier = discipline.behaviourModifiers?.[behaviourId] ?? 0;
153
+ const trackModifier = track.behaviourModifiers?.[behaviourId] ?? 0;
154
+ const totalModifier = disciplineModifier + trackModifier;
155
+
156
+ // 3. Apply modifier and clamp
157
+ const modifiedIndex = baseIndex + totalModifier;
158
+ return clampBehaviourMaturity(modifiedIndex);
159
+ }
160
+
161
+ /**
162
+ * Derive the complete skill matrix for a job
163
+ * @param {Object} params
164
+ * @param {import('./levels.js').Discipline} params.discipline - The discipline
165
+ * @param {import('./levels.js').Grade} params.grade - The grade
166
+ * @param {import('./levels.js').Track} params.track - The track
167
+ * @param {import('./levels.js').Skill[]} params.skills - All available skills
168
+ * @returns {import('./levels.js').SkillMatrixEntry[]} Complete skill matrix
169
+ */
170
+ export function deriveSkillMatrix({ discipline, grade, track, skills }) {
171
+ const matrix = [];
172
+
173
+ // Collect all skills for this discipline
174
+ const allDisciplineSkills = new Set([
175
+ ...(discipline.coreSkills || []),
176
+ ...(discipline.supportingSkills || []),
177
+ ...(discipline.broadSkills || []),
178
+ ]);
179
+
180
+ // Collect capabilities with positive track modifiers
181
+ const trackCapabilities = new Set(
182
+ Object.entries(track.skillModifiers || {})
183
+ .filter(([_, modifier]) => modifier > 0)
184
+ .map(([capability]) => capability),
185
+ );
186
+
187
+ for (const skill of skills) {
188
+ // Include skill if it's in the discipline OR in a track-modified capability
189
+ const inDiscipline = allDisciplineSkills.has(skill.id);
190
+ const inTrackCapability = trackCapabilities.has(skill.capability);
191
+
192
+ if (!inDiscipline && !inTrackCapability) {
193
+ continue;
194
+ }
195
+
196
+ const skillType = getSkillTypeForDiscipline(discipline, skill.id);
197
+ const level = deriveSkillLevel({
198
+ discipline,
199
+ grade,
200
+ track,
201
+ skillId: skill.id,
202
+ skills, // Pass skills array to enable capability-based modifiers
203
+ });
204
+
205
+ // Skip if deriveSkillLevel returns null (track-added skill with no positive modifier)
206
+ if (level === null) {
207
+ continue;
208
+ }
209
+
210
+ matrix.push({
211
+ skillId: skill.id,
212
+ skillName: skill.name,
213
+ capability: skill.capability,
214
+ isHumanOnly: skill.isHumanOnly || false,
215
+ type: skillType || SkillType.TRACK,
216
+ level,
217
+ levelDescription: skill.levelDescriptions?.[level] || "",
218
+ });
219
+ }
220
+
221
+ // Sort by type (primary first, then secondary, then broad, then track) and then by name
222
+ const typeOrder = {
223
+ [SkillType.PRIMARY]: 0,
224
+ [SkillType.SECONDARY]: 1,
225
+ [SkillType.BROAD]: 2,
226
+ [SkillType.TRACK]: 3,
227
+ };
228
+ matrix.sort((a, b) => {
229
+ const typeCompare = typeOrder[a.type] - typeOrder[b.type];
230
+ if (typeCompare !== 0) return typeCompare;
231
+ return a.skillName.localeCompare(b.skillName);
232
+ });
233
+
234
+ return matrix;
235
+ }
236
+
237
+ /**
238
+ * Derive the complete behaviour profile for a job
239
+ * @param {Object} params
240
+ * @param {import('./levels.js').Discipline} params.discipline - The discipline
241
+ * @param {import('./levels.js').Grade} params.grade - The grade
242
+ * @param {import('./levels.js').Track} params.track - The track
243
+ * @param {import('./levels.js').Behaviour[]} params.behaviours - All available behaviours
244
+ * @returns {import('./levels.js').BehaviourProfileEntry[]} Complete behaviour profile
245
+ */
246
+ export function deriveBehaviourProfile({
247
+ discipline,
248
+ grade,
249
+ track,
250
+ behaviours,
251
+ }) {
252
+ const profile = [];
253
+
254
+ for (const behaviour of behaviours) {
255
+ const maturity = deriveBehaviourMaturity({
256
+ discipline,
257
+ grade,
258
+ track,
259
+ behaviourId: behaviour.id,
260
+ });
261
+
262
+ profile.push({
263
+ behaviourId: behaviour.id,
264
+ behaviourName: behaviour.name,
265
+ maturity,
266
+ maturityDescription: behaviour.maturityDescriptions?.[maturity] || "",
267
+ });
268
+ }
269
+
270
+ // Sort by name
271
+ profile.sort((a, b) => a.behaviourName.localeCompare(b.behaviourName));
272
+
273
+ return profile;
274
+ }
275
+
276
+ /**
277
+ * Check if a job combination is valid
278
+ * @param {Object} params
279
+ * @param {import('./levels.js').Discipline} params.discipline - The discipline
280
+ * @param {import('./levels.js').Grade} params.grade - The grade
281
+ * @param {import('./levels.js').Track} params.track - The track
282
+ * @param {import('./levels.js').JobValidationRules} [params.validationRules] - Optional validation rules
283
+ * @param {Array<import('./levels.js').Grade>} [params.grades] - Optional array of all grades for minGrade validation
284
+ * @returns {boolean} True if the combination is valid
285
+ */
286
+ export function isValidJobCombination({
287
+ discipline,
288
+ grade,
289
+ track,
290
+ validationRules,
291
+ grades,
292
+ }) {
293
+ // Check track's validDisciplines constraint (e.g., SRE only valid for Software Engineering and Data Engineering)
294
+ if (track.validDisciplines && track.validDisciplines.length > 0) {
295
+ if (!track.validDisciplines.includes(discipline.id)) {
296
+ return false;
297
+ }
298
+ }
299
+
300
+ // Check track's minGrade constraint (e.g., manager track only valid for senior grades)
301
+ if (track.minGrade && grades) {
302
+ const minGradeObj = grades.find((g) => g.id === track.minGrade);
303
+ if (minGradeObj && grade.ordinalRank < minGradeObj.ordinalRank) {
304
+ return false;
305
+ }
306
+ }
307
+
308
+ if (!validationRules) {
309
+ return true;
310
+ }
311
+
312
+ // Check invalid combinations
313
+ if (validationRules.invalidCombinations) {
314
+ for (const combo of validationRules.invalidCombinations) {
315
+ const disciplineMatch =
316
+ !combo.discipline || combo.discipline === discipline.id;
317
+ const trackMatch = !combo.track || combo.track === track.id;
318
+ const gradeMatch = !combo.grade || combo.grade === grade.id;
319
+
320
+ if (disciplineMatch && trackMatch && gradeMatch) {
321
+ return false;
322
+ }
323
+ }
324
+ }
325
+
326
+ // Check valid tracks by discipline
327
+ if (validationRules.validTracksByDiscipline) {
328
+ const validTracks = validationRules.validTracksByDiscipline[discipline.id];
329
+ if (validTracks && !validTracks.includes(track.id)) {
330
+ return false;
331
+ }
332
+ }
333
+
334
+ return true;
335
+ }
336
+
337
+ /**
338
+ * Generate a job title from discipline, track, and grade
339
+ *
340
+ * Rules:
341
+ * - If professional track and grade starts with "Level": ${discipline.roleTitle} ${grade.professionalTitle} - ${track.name}
342
+ * - Else if professional track: ${grade.professionalTitle} ${discipline.roleTitle} - ${track.name}
343
+ * - Else if management track: ${grade.managementTitle}, ${discipline.specialization} - ${track.name}
344
+ *
345
+ * @param {import('./levels.js').Discipline} discipline - The discipline
346
+ * @param {import('./levels.js').Track} track - The track
347
+ * @param {import('./levels.js').Grade} grade - The grade
348
+ * @returns {string} Generated job title
349
+ */
350
+ export function generateJobTitle(discipline, grade, track) {
351
+ const { roleTitle, specialization } = discipline;
352
+ const { professionalTitle, managementTitle } = grade;
353
+
354
+ // Determine if track is professional or management (default to professional)
355
+ const isProfessional = track.isProfessional !== false && !track.isManagement;
356
+ const isManagement = track.isManagement === true;
357
+
358
+ if (isManagement) {
359
+ // Management family track: "Director, Software Engineering"
360
+ if (track.id == "manager") {
361
+ return `${managementTitle}, ${specialization}`;
362
+ }
363
+ // Other management tracks: "Director, Developer Experience"
364
+ return `${managementTitle}, ${track.name}`;
365
+ } else if (isProfessional && professionalTitle.startsWith("Level")) {
366
+ // Professional track with Level grade: "Software Engineer Level II - Platform"
367
+ return `${roleTitle} ${professionalTitle} - ${track.name}`;
368
+ } else {
369
+ // Professional track with non-Level grade: "Staff Software Engineer - Platform"
370
+ return `${professionalTitle} ${roleTitle} - ${track.name}`;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Generate a job ID from discipline, track, and grade
376
+ * @param {import('./levels.js').Discipline} discipline - The discipline
377
+ * @param {import('./levels.js').Track} track - The track
378
+ * @param {import('./levels.js').Grade} grade - The grade
379
+ * @returns {string} Generated job ID
380
+ */
381
+ function generateJobId(discipline, track, grade) {
382
+ return `${discipline.id}_${track.id}_${grade.id}`;
383
+ }
384
+
385
+ /**
386
+ * Derive role responsibilities from skill matrix and capabilities
387
+ *
388
+ * Responsibilities are determined by finding the maximum skill level
389
+ * achieved in each capability, then looking up the corresponding
390
+ * responsibility statement from the capability definition.
391
+ *
392
+ * Capabilities are sorted by their maximum skill level (descending),
393
+ * so Expert-level capabilities appear before Practitioner-level, etc.
394
+ *
395
+ * Uses professionalResponsibilities for professional tracks (professional: true)
396
+ * and managementResponsibilities for management tracks (management: true).
397
+ *
398
+ * @param {Object} params
399
+ * @param {import('./levels.js').SkillMatrixEntry[]} params.skillMatrix - Derived skill matrix for the job
400
+ * @param {Object[]} params.capabilities - Capability definitions with responsibilities
401
+ * @param {import('./levels.js').Track} params.track - The track (determines which responsibilities to use)
402
+ * @returns {Array<{capability: string, capabilityName: string, emoji: string, responsibility: string, level: string}>}
403
+ */
404
+ export function deriveResponsibilities({ skillMatrix, capabilities, track }) {
405
+ if (!capabilities || capabilities.length === 0) {
406
+ return [];
407
+ }
408
+
409
+ // Determine which responsibility set to use based on track type
410
+ // Management tracks use managementResponsibilities, professional tracks use professionalResponsibilities
411
+ const responsibilityKey = track?.isManagement
412
+ ? "managementResponsibilities"
413
+ : "professionalResponsibilities";
414
+
415
+ // Group skills by capability and find max level per capability
416
+ const capabilityLevels = new Map();
417
+
418
+ for (const skill of skillMatrix) {
419
+ const currentLevel = capabilityLevels.get(skill.capability);
420
+ const skillLevelIndex = SKILL_LEVEL_ORDER.indexOf(skill.level);
421
+ const currentIndex = currentLevel
422
+ ? SKILL_LEVEL_ORDER.indexOf(currentLevel)
423
+ : -1;
424
+
425
+ if (skillLevelIndex > currentIndex) {
426
+ capabilityLevels.set(skill.capability, skill.level);
427
+ }
428
+ }
429
+
430
+ // Build capability lookup map
431
+ const capabilityMap = new Map(capabilities.map((c) => [c.id, c]));
432
+
433
+ // Build responsibilities from all capabilities with meaningful levels
434
+ const responsibilities = [];
435
+
436
+ for (const [capabilityId, level] of capabilityLevels) {
437
+ if (level === "awareness") continue; // Skip awareness-only capabilities
438
+
439
+ const capability = capabilityMap.get(capabilityId);
440
+ const responsibilityText = capability?.[responsibilityKey]?.[level];
441
+ if (responsibilityText) {
442
+ responsibilities.push({
443
+ capability: capabilityId,
444
+ capabilityName: capability.name,
445
+ emoji: capability.emoji || "💡",
446
+ displayOrder: capability.displayOrder ?? 999,
447
+ responsibility: responsibilityText,
448
+ level,
449
+ levelIndex: SKILL_LEVEL_ORDER.indexOf(level),
450
+ });
451
+ }
452
+ }
453
+
454
+ // Sort by level descending (expert first), then by capability order
455
+ responsibilities.sort((a, b) => {
456
+ if (b.levelIndex !== a.levelIndex) {
457
+ return b.levelIndex - a.levelIndex;
458
+ }
459
+ return a.displayOrder - b.displayOrder;
460
+ });
461
+
462
+ // Remove levelIndex from output (internal use only)
463
+ return responsibilities.map(({ levelIndex: _levelIndex, ...rest }) => rest);
464
+ }
465
+
466
+ /**
467
+ * Create a complete job definition from discipline, track, and grade
468
+ * @param {Object} params
469
+ * @param {import('./levels.js').Discipline} params.discipline - The discipline
470
+ * @param {import('./levels.js').Grade} params.grade - The grade
471
+ * @param {import('./levels.js').Track} params.track - The track
472
+ * @param {import('./levels.js').Skill[]} params.skills - All available skills
473
+ * @param {import('./levels.js').Behaviour[]} params.behaviours - All available behaviours
474
+ * @param {Object[]} [params.capabilities] - Optional capabilities for responsibility derivation
475
+ * @param {import('./levels.js').JobValidationRules} [params.validationRules] - Optional validation rules
476
+ * @returns {import('./levels.js').JobDefinition|null} The job definition or null if invalid
477
+ */
478
+ export function deriveJob({
479
+ discipline,
480
+ grade,
481
+ track,
482
+ skills,
483
+ behaviours,
484
+ capabilities,
485
+ validationRules,
486
+ }) {
487
+ // Check if combination is valid
488
+ if (
489
+ !isValidJobCombination({
490
+ discipline,
491
+ grade,
492
+ track,
493
+ validationRules,
494
+ grades: validationRules?.grades,
495
+ })
496
+ ) {
497
+ return null;
498
+ }
499
+
500
+ const skillMatrix = deriveSkillMatrix({ discipline, grade, track, skills });
501
+ const behaviourProfile = deriveBehaviourProfile({
502
+ discipline,
503
+ grade,
504
+ track,
505
+ behaviours,
506
+ });
507
+
508
+ // Derive responsibilities if capabilities are provided
509
+ let derivedResponsibilities = [];
510
+ if (capabilities && capabilities.length > 0) {
511
+ derivedResponsibilities = deriveResponsibilities({
512
+ skillMatrix,
513
+ capabilities,
514
+ track,
515
+ });
516
+ }
517
+
518
+ return {
519
+ id: generateJobId(discipline, grade, track),
520
+ title: generateJobTitle(discipline, grade, track),
521
+ discipline,
522
+ grade,
523
+ track,
524
+ skillMatrix,
525
+ behaviourProfile,
526
+ derivedResponsibilities,
527
+ expectations: grade.expectations || {},
528
+ };
529
+ }
530
+
531
+ /**
532
+ * Calculate driver coverage for a job
533
+ * @param {Object} params
534
+ * @param {import('./levels.js').JobDefinition} params.job - The job definition
535
+ * @param {import('./levels.js').Driver[]} params.drivers - All drivers
536
+ * @returns {import('./levels.js').DriverCoverage[]} Coverage analysis for each driver
537
+ */
538
+ export function calculateDriverCoverage({ job, drivers }) {
539
+ const coverageResults = [];
540
+
541
+ // Create lookup maps for the job's skills and behaviours
542
+ const jobSkillLevels = new Map(
543
+ job.skillMatrix.map((s) => [s.skillId, s.level]),
544
+ );
545
+ const jobBehaviourMaturities = new Map(
546
+ job.behaviourProfile.map((b) => [b.behaviourId, b.maturity]),
547
+ );
548
+
549
+ for (const driver of drivers) {
550
+ const contributingSkills = driver.contributingSkills || [];
551
+ const contributingBehaviours = driver.contributingBehaviours || [];
552
+
553
+ // Calculate skill coverage (Working+ level threshold)
554
+ const coveredSkills = [];
555
+ const missingSkills = [];
556
+
557
+ for (const skillId of contributingSkills) {
558
+ const level = jobSkillLevels.get(skillId);
559
+ if (level && skillLevelMeetsRequirement(level, SkillLevel.WORKING)) {
560
+ coveredSkills.push(skillId);
561
+ } else {
562
+ missingSkills.push(skillId);
563
+ }
564
+ }
565
+
566
+ const skillCoverage =
567
+ contributingSkills.length > 0
568
+ ? coveredSkills.length / contributingSkills.length
569
+ : 1;
570
+
571
+ // Calculate behaviour coverage (Practicing+ maturity threshold)
572
+ const coveredBehaviours = [];
573
+ const missingBehaviours = [];
574
+ const practicingIndex = getBehaviourMaturityIndex(
575
+ BehaviourMaturity.PRACTICING,
576
+ );
577
+
578
+ for (const behaviourId of contributingBehaviours) {
579
+ const maturity = jobBehaviourMaturities.get(behaviourId);
580
+ if (maturity && getBehaviourMaturityIndex(maturity) >= practicingIndex) {
581
+ coveredBehaviours.push(behaviourId);
582
+ } else {
583
+ missingBehaviours.push(behaviourId);
584
+ }
585
+ }
586
+
587
+ const behaviourCoverage =
588
+ contributingBehaviours.length > 0
589
+ ? coveredBehaviours.length / contributingBehaviours.length
590
+ : 1;
591
+
592
+ // Overall score is weighted average (50/50)
593
+ const overallScore = (skillCoverage + behaviourCoverage) / 2;
594
+
595
+ coverageResults.push({
596
+ driverId: driver.id,
597
+ driverName: driver.name,
598
+ skillCoverage,
599
+ behaviourCoverage,
600
+ overallScore,
601
+ coveredSkills,
602
+ coveredBehaviours,
603
+ missingSkills,
604
+ missingBehaviours,
605
+ });
606
+ }
607
+
608
+ // Sort by overall score descending
609
+ coverageResults.sort((a, b) => b.overallScore - a.overallScore);
610
+
611
+ return coverageResults;
612
+ }
613
+
614
+ /**
615
+ * Get all skills in a discipline
616
+ * @param {import('./levels.js').Discipline} discipline - The discipline
617
+ * @returns {string[]} All skill IDs in the discipline
618
+ */
619
+ export function getDisciplineSkillIds(discipline) {
620
+ return [
621
+ ...(discipline.coreSkills || []),
622
+ ...(discipline.supportingSkills || []),
623
+ ...(discipline.broadSkills || []),
624
+ ];
625
+ }
626
+
627
+ /**
628
+ * Get the grade level number (for comparison/sorting)
629
+ * @param {import('./levels.js').Grade} grade - The grade
630
+ * @returns {number} The grade level
631
+ */
632
+ export function getGradeLevel(grade) {
633
+ return grade.ordinalRank;
634
+ }
635
+
636
+ /**
637
+ * Check if a grade is senior level (Staff+)
638
+ * @param {import('./levels.js').Grade} grade - The grade
639
+ * @returns {boolean} True if the grade is senior level
640
+ */
641
+ export function isSeniorGrade(grade) {
642
+ // Typically Staff+ is level 5 or higher
643
+ return grade.ordinalRank >= 5;
644
+ }
645
+
646
+ /**
647
+ * Generate all valid job definitions from the data
648
+ * @param {Object} params
649
+ * @param {import('./levels.js').Discipline[]} params.disciplines - All disciplines
650
+ * @param {import('./levels.js').Grade[]} params.grades - All grades
651
+ * @param {import('./levels.js').Track[]} params.tracks - All tracks
652
+ * @param {import('./levels.js').Skill[]} params.skills - All skills
653
+ * @param {import('./levels.js').Behaviour[]} params.behaviours - All behaviours
654
+ * @param {import('./levels.js').JobValidationRules} [params.validationRules] - Optional validation rules
655
+ * @returns {import('./levels.js').JobDefinition[]} All valid job definitions
656
+ */
657
+ export function generateAllJobs({
658
+ disciplines,
659
+ grades,
660
+ tracks,
661
+ skills,
662
+ behaviours,
663
+ validationRules,
664
+ }) {
665
+ const jobs = [];
666
+
667
+ for (const discipline of disciplines) {
668
+ for (const track of tracks) {
669
+ for (const grade of grades) {
670
+ if (
671
+ !isValidJobCombination({
672
+ discipline,
673
+ grade,
674
+ track,
675
+ validationRules,
676
+ grades,
677
+ })
678
+ ) {
679
+ continue;
680
+ }
681
+
682
+ const job = deriveJob({
683
+ discipline,
684
+ grade,
685
+ track,
686
+ skills,
687
+ behaviours,
688
+ validationRules,
689
+ });
690
+
691
+ if (job) {
692
+ jobs.push(job);
693
+ }
694
+ }
695
+ }
696
+ }
697
+
698
+ return jobs;
699
+ }