yadflow 4.3.0 → 4.5.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 (48) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +4 -2
  3. package/bin/commands.mjs +3 -1
  4. package/bin/yad.mjs +80 -14
  5. package/cli/claims.mjs +16 -13
  6. package/cli/detect.mjs +2 -2
  7. package/cli/docs.mjs +1 -1
  8. package/cli/doctor.mjs +108 -3
  9. package/cli/epic-state.mjs +121 -24
  10. package/cli/epic.mjs +7 -5
  11. package/cli/manifest.mjs +3 -0
  12. package/cli/members.mjs +822 -0
  13. package/cli/next.mjs +21 -9
  14. package/cli/people.mjs +13 -3
  15. package/cli/platform.mjs +1 -1
  16. package/cli/pr-list.mjs +125 -0
  17. package/cli/protection.mjs +4 -4
  18. package/cli/recommend.mjs +301 -0
  19. package/cli/setup.mjs +3 -2
  20. package/cli/skill.mjs +263 -63
  21. package/cli/standup.mjs +709 -0
  22. package/cli/toolbox.mjs +433 -11
  23. package/cli/usage.mjs +18 -11
  24. package/cli/workspace.mjs +14 -5
  25. package/package.json +5 -3
  26. package/skills/sdlc/config.yaml +1 -1
  27. package/skills/sdlc/module-help.csv +1 -1
  28. package/skills/yad-backfill/SKILL.md +40 -7
  29. package/skills/yad-backfill/references/backfill.md +5 -4
  30. package/skills/yad-change/SKILL.md +1 -1
  31. package/skills/yad-checks/templates/checks/member-check.mjs +197 -0
  32. package/skills/yad-checks/templates/github/yad-product-checks.yml +15 -0
  33. package/skills/yad-checks/templates/gitlab/yad-product-checks.gitlab-ci.yml +9 -0
  34. package/skills/yad-connect-design/SKILL.md +16 -0
  35. package/skills/yad-connect-learning/SKILL.md +14 -0
  36. package/skills/yad-connect-repos/SKILL.md +34 -5
  37. package/skills/yad-connect-repos/references/code-context.md +5 -5
  38. package/skills/yad-connect-testing/SKILL.md +20 -0
  39. package/skills/yad-docs/SKILL.md +1 -1
  40. package/skills/yad-epic/references/state-schema.md +10 -2
  41. package/skills/yad-learn/SKILL.md +14 -0
  42. package/skills/yad-run/SKILL.md +7 -4
  43. package/skills/yad-run/references/run-loop.md +2 -1
  44. package/skills/yad-spec/SKILL.md +37 -6
  45. package/skills/yad-spec/references/spec-handoff.md +2 -2
  46. package/skills/yad-status/SKILL.md +3 -0
  47. package/skills/yad-test-cases/SKILL.md +20 -0
  48. package/skills/yad-ui/SKILL.md +36 -3
package/cli/skill.mjs CHANGED
@@ -1,12 +1,22 @@
1
- // `yad skill` — which skill runs which step (E6).
1
+ // `yad skill` — which skill runs which step (E6), for every epic or for one route (E51).
2
2
  //
3
3
  // The engine ships a default for every step (the `skill` column of the step catalogue in
4
4
  // epic-state.mjs). A project that wants a different one records it in `.sdlc/skills.json`, and every
5
5
  // surface that names a skill — `yad next`, `yad epic new` — reads the project's answer first.
6
6
  //
7
- // yad skill list [--json] every step, its bound skill(s), and where that came from
8
- // yad skill bind <step> <skill> [<skill>…] bind one step; several skills run in the order given
9
- // yad skill unbind <step> drop the binding and fall back to the engine's default
7
+ // yad skill list [--profile <p> | --epic <id>] [--json]
8
+ // every step, its bound skill(s), where that came from, and
9
+ // whether each skill is installed on this machine
10
+ // yad skill bind <step> <skill> [<skill>…] [--profile <p>]
11
+ // bind one step; several skills run in the order given
12
+ // yad skill unbind <step> [--profile <p>] drop the binding and fall back to the next layer
13
+ // yad skill recommend [<step>] [--profile <p> | --epic <id>] [--json]
14
+ // the catalogue's hand-picked skills for each step (E52), and
15
+ // the bind command for each — it binds nothing itself
16
+ //
17
+ // TWO LAYERS, THE MOST SPECIFIC WINS (E51). `steps` binds a step for every epic; `profiles.<p>.steps`
18
+ // binds it for epics on route `<p>` only, and wins over the project-wide line on those epics. Unbinding
19
+ // a route's line falls back to the project-wide one, then to the engine's default.
10
20
  //
11
21
  // WHY A COMMAND AND NOT JUST A FILE. The file stays hand-editable — it is read by
12
22
  // `loadSkillBindings`, and `yad doctor` reports a line that binds nothing rather than correcting it.
@@ -14,10 +24,22 @@
14
24
  // then tell the user to migrate the config file it had just asked them to write. Writing it through
15
25
  // `writeJSON` stamps the shape like every other engine-written file, and gives the cost warning a
16
26
  // place to appear at the moment somebody opts into a chain.
27
+ //
28
+ // INSTALLED IS ASKED, NEVER ENFORCED (E51). `list` and `bind` ask `yad detect` (E50) whether each
29
+ // skill is installed here, in this folder or the home folder. A skill it cannot find is a WARNING: the
30
+ // binding is the team's, committed, and a teammate may well have a skill this machine lacks. A skill
31
+ // it finds is "found", not "this agent will run it" — which copy an agent loads is each agent's own
32
+ // rule, and E50 leaves it unresolved on purpose.
33
+ import os from 'node:os';
17
34
  import path from 'node:path';
18
35
  import { c, exists, fail, hand, info, log, ok, readJSONStrict, warn, writeJSON, emitJSON } from './lib.mjs';
19
- import { loadSkillBindings, stepDef, stepSkills, STEPS } from './epic-state.mjs';
36
+ import {
37
+ boundSkills, bindingsForProfile, recordedRoute, epicRoot, isValidEpicId, lifecycleProfile, loadLedger,
38
+ loadSkillBindings, normalizeBindings, profileSteps, stepDef, stepSkills, STEPS,
39
+ } from './epic-state.mjs';
40
+ import { detectInstalled } from './detect.mjs';
20
41
  import { PROJECT_FILES, SCHEMA_VERSION } from './manifest.mjs';
42
+ import { CATALOGUE, catalogueAge, catalogueLabel, recommendedSkills, showRecommendations } from './recommend.mjs';
21
43
 
22
44
  const bail = (message, hint) => { fail(message); if (hint) hand(hint); process.exitCode = 1; };
23
45
 
@@ -28,8 +50,10 @@ const bindableSteps = () => STEPS.filter((s) => s.skill).map((s) => s.id);
28
50
 
29
51
  const skillsFile = (root) => path.join(root, PROJECT_FILES.skillsConfig);
30
52
 
31
- // Read the file as it is on disk, so a write preserves keys this release does not know about (E50 and
32
- // E51 add some). `normalizeBindings` is for READING a binding; this is for editing the document.
53
+ const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
54
+
55
+ // Read the file as it is on disk, so a write preserves keys this release does not know about.
56
+ // `normalizeBindings` is for READING a binding; this is for editing the document.
33
57
  //
34
58
  // STRICT, unlike the resolver. `loadSkillBindings` treats a broken file as an empty one because
35
59
  // `yad next` must still answer; this is a read-modify-write, and doing the same here would rebuild
@@ -48,6 +72,23 @@ const readRaw = (root) => {
48
72
  return { doc: raw };
49
73
  };
50
74
 
75
+ // The step lines one layer of the document holds: the project-wide `steps`, or `profiles.<p>.steps`.
76
+ //
77
+ // A layer that is THERE but is not an object is an error, not an empty layer, for a route: writing one
78
+ // line into it would replace whatever the person put there. The project-wide `steps` keeps E6's
79
+ // behaviour (a non-object is rebuilt), which `yad doctor` already reports as a fail.
80
+ function layerOf(doc, profile) {
81
+ if (!profile) return { steps: isObject(doc.steps) ? { ...doc.steps } : {} };
82
+ if (doc.profiles === undefined) return { steps: {} };
83
+ if (!isObject(doc.profiles)) return { error: '`profiles` is not a JSON object [YAD-STATE-002]' };
84
+ if (!Object.hasOwn(doc.profiles, profile)) return { steps: {} };
85
+ const entry = doc.profiles[profile];
86
+ if (!isObject(entry)) return { error: `\`profiles.${profile}\` is not a JSON object [YAD-STATE-002]` };
87
+ if (entry.steps === undefined) return { steps: {} };
88
+ if (!isObject(entry.steps)) return { error: `\`profiles.${profile}.steps\` is not a JSON object [YAD-STATE-002]` };
89
+ return { steps: { ...entry.steps } };
90
+ }
91
+
51
92
  // An existing document plus this edit, ready to write.
52
93
  //
53
94
  // `schemaVersion` is set OUTRIGHT rather than carried through. `readJSON` back-stamps an unstamped
@@ -55,7 +96,14 @@ const readRaw = (root) => {
55
96
  // was handed — so writing the document back unchanged would stamp a hand-authored file as shape 1 and
56
97
  // `yad doctor` would immediately report it as behind. This command is the file's engine writer; like
57
98
  // `writeState`, its write IS the file's migration.
58
- const withSteps = (raw, steps) => ({ ...raw, schemaVersion: SCHEMA_VERSION, steps });
99
+ //
100
+ // A route's other keys — beside its `steps`, and the other routes beside it — are carried as they are.
101
+ const withSteps = (raw, steps, profile = null) => {
102
+ if (!profile) return { ...raw, schemaVersion: SCHEMA_VERSION, steps };
103
+ const profiles = isObject(raw.profiles) ? raw.profiles : {};
104
+ const entry = isObject(profiles[profile]) ? profiles[profile] : {};
105
+ return { ...raw, schemaVersion: SCHEMA_VERSION, profiles: { ...profiles, [profile]: { ...entry, steps } } };
106
+ };
59
107
 
60
108
  const brokenFile = (error) => bail(`${PROJECT_FILES.skillsConfig} ${error}`,
61
109
  'fix the file (or restore it from git) first — writing over it would delete every binding it holds');
@@ -64,74 +112,195 @@ const brokenFile = (error) => bail(`${PROJECT_FILES.skillsConfig} ${error}`,
64
112
  // with a warning (the file wins, rule 3), so this is not an allowlist — it is a shape guard, and the
65
113
  // one thing it has to stop is `__proto__`: assigning that key to the document sets the object's
66
114
  // prototype instead of adding a line, `JSON.stringify` then drops it, and the command would report a
67
- // binding it did not write. `yad epic new` guards its slug the same way.
115
+ // binding it did not write. `yad epic new` guards its slug the same way. A profile id is a key in the
116
+ // same document and every shipped one has the same shape, so the same guard covers `--profile`.
68
117
  const STEP_ID = /^[a-z][a-z0-9-]*$/;
69
118
 
70
- // One row per step the engine can run a skill for: what runs it now, and whether that is the project's
71
- // choice or the engine's. `source` is the field worth having — "yad-stories" alone never says whether
72
- // someone chose it.
73
- // `written` is the set of step ids the FILE mentions, whether or not the value was usable. It is what
74
- // separates "this project left the step alone" from "this project wrote a line here and the line does
75
- // nothing" — the second is invisible otherwise, which is the failure `yad skill list` exists to end.
119
+ const badProfile = (profile) => bail(`\`${profile}\` is not a profile id`,
120
+ 'a profile id is lower-case letters, digits and dashes — for example `classic` or `spike`');
121
+
122
+ // ---- installed? (E50) --------------------------------------------------------------------------------
123
+
124
+ // The skill names `yad detect` finds here, or null when it could not look (an unreadable home folder).
125
+ // A plugin's skill is called `<plugin>:<skill>`, where `<plugin>` is the name before the `@` of the id
126
+ // the install record keeps (`codex@openai-codex` → `codex:rescue`), so all three spellings count.
127
+ export function installedSkillNames(root, { home = os.homedir() } = {}) {
128
+ let items;
129
+ try { ({ items } = detectInstalled(root, { home })); } catch { return null; }
130
+ const names = new Set();
131
+ for (const it of items) {
132
+ if (it.kind !== 'skill' || typeof it.name !== 'string') continue;
133
+ names.add(it.name);
134
+ if (typeof it.plugin === 'string' && it.plugin) {
135
+ names.add(`${it.plugin}:${it.name}`);
136
+ names.add(`${it.plugin.split('@')[0]}:${it.name}`);
137
+ }
138
+ }
139
+ return names;
140
+ }
141
+
142
+ // `{ <skill>: true | false }` for a row's skills, or null when nothing could be checked.
143
+ const installedMap = (skills, names) => (names ? Object.fromEntries(skills.map((s) => [s, names.has(s)])) : null);
144
+
145
+ // ---- list ----------------------------------------------------------------------------------------------
146
+
147
+ // A row still running the engine's default: nothing bound, or a line that binds nothing (`ignored`). The
148
+ // two places that offer the catalogue's picks (E52) read this, so they agree on which rows get one.
149
+ const onDefault = (r) => r.source === 'engine' || r.source === 'ignored';
150
+
151
+ // One row per step the engine can run a skill for: what runs it now, and whether that is this route's
152
+ // choice, the project's, or the engine's. `source` is the field worth having — "yad-stories" alone
153
+ // never says whether someone chose it.
154
+ // `written` is the set of step ids the FILE mentions in the layers that apply, whether or not the value
155
+ // was usable. It is what separates "this project left the step alone" from "this project wrote a line
156
+ // here and the line does nothing" — the second is invisible otherwise, which is the failure
157
+ // `yad skill list` exists to end.
76
158
  export function skillRows(root, bindings = loadSkillBindings(root), written = null) {
77
- return bindableSteps().map((id) => {
78
- const bound = Object.hasOwn(bindings.steps, id) ? bindings.steps[id] : null;
79
- return {
80
- step: id,
81
- phase: stepDef(id).phase,
82
- skills: stepSkills(id, bindings),
83
- source: bound?.length ? 'project' : (written?.has(id) ? 'ignored' : 'engine'),
84
- default: stepDef(id).skill,
85
- };
86
- });
159
+ return bindableSteps().map((id) => rowOf(id, bindings, written));
160
+ }
161
+
162
+ function rowOf(id, bindings, written) {
163
+ const found = boundSkills(id, bindings);
164
+ return {
165
+ step: id,
166
+ phase: stepDef(id)?.phase ?? null,
167
+ skills: stepSkills(id, bindings),
168
+ source: found ? found.source : (written?.has(id) ? 'ignored' : 'engine'),
169
+ default: stepDef(id)?.skill ?? null,
170
+ };
171
+ }
172
+
173
+ // Which route `list` shows: `--profile` as given, or the route the epic `--epic` names RECORDS — what
174
+ // `yad next` uses for it; none recorded means the project-wide view. `{ profile }` or `{ error, hint }`.
175
+ function routeFor(root, { profile, epic }) {
176
+ if (profile && epic) return { error: 'give --profile or --epic, not both', hint: '--epic reads the route off that epic' };
177
+ if (profile !== undefined && profile !== null) {
178
+ if (!STEP_ID.test(String(profile))) return { error: `\`${profile}\` is not a profile id`, hint: 'for example `classic` or `spike`' };
179
+ return { profile: String(profile) };
180
+ }
181
+ if (epic === undefined || epic === null) return { profile: null };
182
+ if (!isValidEpicId(epic)) return { error: `\`${epic}\` is not an epic id`, hint: 'an epic id looks like EP-<slug>' };
183
+ let state;
184
+ try { state = loadLedger(epicRoot(root, epic))?.state; } catch (e) {
185
+ return { error: `${epic}'s ledger could not be read${e.code ? ` [${e.code}]` : ''}`, hint: 'see `yad doctor`' };
186
+ }
187
+ if (!state) return { error: `${epic} has no state.json`, hint: 'see `yad next` for the epics in this Product' };
188
+ return { profile: recordedRoute(state), epic };
87
189
  }
88
190
 
89
- export function runSkillList(root, { json = false } = {}) {
90
- const bindings = loadSkillBindings(root);
191
+ export function runSkillList(root, { json = false, profile, epic, home = os.homedir(), today = new Date() } = {}) {
192
+ const route = routeFor(root, { profile, epic });
193
+ if (route.error) return bail(route.error, route.hint);
194
+ const view = bindingsForProfile(loadSkillBindings(root), route.profile);
91
195
  const { doc, error } = readRaw(root);
92
- const written = new Set(error ? [] : Object.keys(doc.steps && typeof doc.steps === 'object' ? doc.steps : {}));
93
- const rows = skillRows(root, bindings, written);
196
+ const written = new Set(error ? [] : Object.keys(isObject(doc.steps) ? doc.steps : {}));
197
+ if (route.profile && !error) {
198
+ const layer = layerOf(doc, route.profile);
199
+ for (const id of Object.keys(layer.steps || {})) written.add(id);
200
+ }
201
+ const names = installedSkillNames(root, { home });
202
+ const rows = skillRows(root, view, written);
94
203
  // Bindings on ids the catalogue does not know are listed too, and marked. They are the ones a person
95
204
  // most needs to see: `yad next` never looks them up, so without this line they are invisible.
96
- const extra = [...written]
97
- .filter((id) => !stepDef(id))
98
- .map((step) => ({
99
- step, phase: null,
100
- skills: stepSkills(step, bindings),
101
- source: Object.hasOwn(bindings.steps, step) ? 'project' : 'ignored',
102
- default: null,
103
- }));
205
+ const extra = [...written].filter((id) => !stepDef(id)).map((id) => rowOf(id, view, written));
206
+ // E52: what the recommendation catalogue offers for each step, by name. Shown in prose only where the
207
+ // step still runs the engine's default — a step the team already chose needs no nudge.
208
+ const onRoute = (s) => !route.profile || !routeLacks(route.profile, s);
209
+ const all = [...rows, ...extra].map((r) => ({ ...r, installed: installedMap(r.skills, names), recommended: recommendedSkills(r.step, onRoute) }));
210
+ // Every route's own lines, whichever route is shown — so a line bound for `spike` is never invisible
211
+ // from the plain `yad skill list`.
212
+ const routes = Object.entries(view.profiles || {}).map(([id, { steps }]) => ({
213
+ profile: id, known: !!lifecycleProfile(id), steps: Object.entries(steps).map(([step, skills]) => ({ step, skills })),
214
+ }));
104
215
 
105
- if (json) return emitJSON({ ok: true, file: PROJECT_FILES.skillsConfig, steps: [...rows, ...extra] });
216
+ if (json) {
217
+ return emitJSON({
218
+ ok: true, file: PROJECT_FILES.skillsConfig, profile: route.profile, ...(route.epic ? { epic: route.epic } : {}),
219
+ installedChecked: !!names, steps: all, profiles: routes,
220
+ catalogue: { version: CATALOGUE.version, checked: CATALOGUE.checked, age: catalogueAge(today) },
221
+ });
222
+ }
106
223
 
107
224
  if (error) warn(`${PROJECT_FILES.skillsConfig} ${error} — showing the engine's defaults`);
225
+ if (route.epic) {
226
+ info(route.profile
227
+ ? `${route.epic} is on route ${c.bold(route.profile)}${lifecycleProfile(route.profile) ? '' : c.yellow(' — not a route this yadflow has')}`
228
+ : `${route.epic} records no route — it runs the project-wide lines`);
229
+ }
230
+ else if (route.profile) info(`route ${c.bold(route.profile)}${lifecycleProfile(route.profile) ? '' : c.yellow(' — not a route this yadflow has')}`);
108
231
  log(`\n ${c.bold('step')} ${c.bold('skill(s)')}`);
109
- for (const r of [...rows, ...extra]) {
110
- const mark = r.source === 'project' ? c.cyan('•') : (r.source === 'ignored' ? c.red('!') : ' ');
232
+ for (const r of all) {
233
+ const mark = r.source === 'profile' ? c.green('◆') : r.source === 'project' ? c.cyan('•') : (r.source === 'ignored' ? c.red('!') : ' ');
111
234
  const notes = [];
112
235
  if (r.phase === null) notes.push('not a step this yadflow runs');
113
236
  if (r.source === 'ignored') notes.push('the file has a line for this step that names no skill');
237
+ const missing = r.installed ? r.skills.filter((s) => !r.installed[s]) : [];
238
+ if (missing.length) notes.push(`not found here: ${missing.join(', ')}`);
239
+ if (onDefault(r) && r.recommended.length) notes.push(`recommended: ${r.recommended.join(', ')}`);
114
240
  const note = notes.length ? c.dim(` (${notes.join('; ')})`) : '';
115
- log(` ${mark} ${r.step.padEnd(18)} ${r.skills.join(c.dim(' → ')) || c.dim('(none)')}${note}`);
241
+ const shown = r.skills.map((s) => (r.installed && !r.installed[s] ? `${s}${c.yellow('?')}` : s));
242
+ log(` ${mark} ${r.step.padEnd(18)} ${shown.join(c.dim(' → ')) || c.dim('(none)')}${note}`);
116
243
  }
117
- const all = [...rows, ...extra];
118
- const chosen = all.filter((r) => r.source === 'project').length;
244
+ const chosen = all.filter((r) => r.source === 'project' || r.source === 'profile').length;
119
245
  const ignored = all.filter((r) => r.source === 'ignored').length;
246
+ if (all.some((r) => r.source === 'profile')) info(`${c.green('◆')} = bound for route ${route.profile} only`);
120
247
  info(chosen
121
248
  ? `${c.cyan('•')} = bound by this project in ${PROJECT_FILES.skillsConfig}; the rest are the engine's defaults`
122
249
  : `every step is on the engine's default — bind one with \`yad skill bind <step> <skill>\``);
123
250
  if (ignored) info(`${c.red('!')} = a line in that file that binds nothing — \`yad doctor\` says which`);
251
+ if (!names) warn('could not check which skills are installed here — `yad detect` could not read this folder or the home folder');
252
+ else if (all.some((r) => r.installed && r.skills.some((s) => !r.installed[s]))) {
253
+ info(`${c.yellow('?')} = \`yad detect\` found no skill by that name in this folder or the home folder — a teammate may still have it`);
254
+ }
255
+ if (all.some((r) => onDefault(r) && r.recommended.length)) {
256
+ info(`\`yad skill recommend <step>${route.profile ? ` --profile ${route.profile}` : ''}\` says why each recommended skill fits, and how to bind it — ${catalogueLabel(today)}`);
257
+ }
258
+ const others = routes.filter((r) => r.profile !== route.profile);
259
+ if (others.length) {
260
+ log(`\n ${c.bold('bound for one route only')} ${c.dim('— see each with `yad skill list --profile <p>`')}`);
261
+ for (const r of others) {
262
+ for (const s of r.steps) log(` ${c.green('◆')} ${`${r.profile}: ${s.step}`.padEnd(18)} ${s.skills.join(c.dim(' → '))}${r.known ? '' : c.dim(' (not a route this yadflow has)')}`);
263
+ }
264
+ }
124
265
  }
125
266
 
126
- export function runSkillBind(root, { step, skills = [] } = {}) {
267
+ // ---- recommend (E52) -----------------------------------------------------------------------------------
268
+
269
+ // Read-only. The same route rules as `list` (`--profile`, or the route `--epic` records), so "already
270
+ // bound" and the printed bind commands are about the route the person asked for.
271
+ export function runSkillRecommend(root, { step = null, json = false, profile, epic, home = os.homedir(), today = new Date() } = {}) {
272
+ const route = routeFor(root, { profile, epic });
273
+ if (route.error) return bail(route.error, route.hint);
274
+ const onRoute = (s) => !route.profile || !routeLacks(route.profile, s);
275
+ return showRecommendations(root, { step, json, route, names: installedSkillNames(root, { home }), today, onRoute });
276
+ }
277
+
278
+ // ---- bind / unbind -------------------------------------------------------------------------------------
279
+
280
+ // Would anything ever run this step on this route? A Shape step the route does not walk — `architecture`
281
+ // on `spike` — never comes up on an epic on it, so a line there records a decision that never happens:
282
+ // refused, for the reason a review gate is (E6). A Build step is on no route's row but runs on every
283
+ // FEATURE route, so it is allowed there; a Product route has no Build half. An unknown route is let
284
+ // through by the caller (rule 3), so this only judges a route this release carries.
285
+ function routeLacks(profile, step) {
286
+ const route = lifecycleProfile(profile);
287
+ const def = stepDef(step);
288
+ if (!route || !def) return false;
289
+ if (def.phase === 'build') return route.level !== 'feature';
290
+ return !profileSteps(profile).includes(step);
291
+ }
292
+
293
+ export function runSkillBind(root, { step, skills = [], profile = null, home = os.homedir() } = {}) {
127
294
  const names = skills.map((s) => String(s || '').trim()).filter(Boolean);
128
295
  if (!step || !names.length) {
129
- return bail('usage: yad skill bind <step> <skill> [<skill> …]',
296
+ return bail('usage: yad skill bind <step> <skill> [<skill> …] [--profile <p>]',
130
297
  `bindable steps: ${bindableSteps().join(' · ')}`);
131
298
  }
132
299
  if (!STEP_ID.test(step)) {
133
300
  return bail(`\`${step}\` is not a step id`, 'a step id is lower-case letters, digits and dashes — for example `architecture` or `ui-design`');
134
301
  }
302
+ if (profile !== null && profile !== undefined && !STEP_ID.test(String(profile))) return badProfile(profile);
303
+ const route = profile ? String(profile) : null;
135
304
  const def = stepDef(step);
136
305
  // A review gate is refused rather than warned about: nothing would ever invoke the binding, so
137
306
  // writing it would record a decision that silently never happens.
@@ -139,42 +308,73 @@ export function runSkillBind(root, { step, skills = [] } = {}) {
139
308
  return bail(`\`${step}\` is a review gate — no skill runs it`,
140
309
  `it is driven by \`yad gate open\` / \`yad gate sync\`. Bind the step it reviews instead${def.reviews ? `: \`${def.reviews}\`` : ''}`);
141
310
  }
311
+ if (route && routeLacks(route, step)) {
312
+ return bail(`route \`${route}\` has no \`${step}\` step — nothing would ever run this binding`,
313
+ `the steps on ${route}: ${profileSteps(route).filter((id) => stepDef(id)?.skill).join(' · ')}${lifecycleProfile(route).level === 'feature' ? ' · and the Build steps' : ''}`);
314
+ }
142
315
  // An UNKNOWN id is allowed through with a warning, not refused. The file wins for this whole major
143
316
  // (rule 3), and a project may legitimately hold a step from a newer release than the CLI in hand.
317
+ // The same holds for an unknown route.
144
318
  const { doc, error } = readRaw(root);
145
319
  if (error) return brokenFile(error);
146
- const steps = doc.steps && typeof doc.steps === 'object' && !Array.isArray(doc.steps) ? { ...doc.steps } : {};
320
+ const layer = layerOf(doc, route);
321
+ if (layer.error) return brokenFile(layer.error);
322
+ const steps = layer.steps;
147
323
  steps[step] = names.length === 1 ? names[0] : names;
148
- writeJSON(skillsFile(root), withSteps(doc, steps));
324
+ writeJSON(skillsFile(root), withSteps(doc, steps, route));
149
325
 
150
- ok(`${step} → ${names.join(' → ')}`);
326
+ ok(`${route ? `${route}: ` : ''}${step} → ${names.join(' → ')}`);
151
327
  if (!def) {
152
328
  info(`\`${step}\` is not a step this yadflow runs — the binding is recorded but nothing will invoke it`);
153
329
  }
330
+ if (route && !lifecycleProfile(route)) {
331
+ info(`\`${route}\` is not a route this yadflow has — the binding is recorded, and applies to an epic that records it`);
332
+ }
154
333
  // Closed decision 7: extra skills are a chain, never a panel, and every extra one costs another run.
155
334
  if (names.length > 1) {
156
335
  info(`${names.length} skills run for this step, one after another — each one costs tokens`);
157
336
  info('they chain: each sees what the one before it produced, and the last output is the artifact');
158
337
  }
159
- hand(`written to ${PROJECT_FILES.skillsConfig} — \`yad next\` names it from now on (undo with \`yad skill unbind ${step}\`)`);
160
- return { step, skills: names, known: !!def, file: PROJECT_FILES.skillsConfig };
338
+ // Asked AFTER the write, and only warned: the binding is the team's, and this machine is one person's.
339
+ const found = installedSkillNames(root, { home });
340
+ const notFound = found ? names.filter((n) => !found.has(n)) : [];
341
+ if (!found) warn('could not check whether the skill is installed — `yad detect` could not read this folder or the home folder');
342
+ for (const n of notFound) {
343
+ warn(`\`${n}\` is not installed here — \`yad detect\` found no skill by that name in this folder or the home folder`);
344
+ }
345
+ if (notFound.length) info('the binding is written anyway: a teammate may have it — install it here before running the step');
346
+ hand(`written to ${PROJECT_FILES.skillsConfig} — \`yad next\` names it ${route ? `for epics on ${route} ` : ''}from now on (undo with \`yad skill unbind ${step}${route ? ` --profile ${route}` : ''}\`)`);
347
+ return { step, skills: names, profile: route, known: !!def, file: PROJECT_FILES.skillsConfig, notInstalled: found ? notFound : null };
161
348
  }
162
349
 
163
- export function runSkillUnbind(root, { step } = {}) {
164
- if (!step) return bail('usage: yad skill unbind <step>');
350
+ export function runSkillUnbind(root, { step, profile = null } = {}) {
351
+ if (!step) return bail('usage: yad skill unbind <step> [--profile <p>]');
352
+ if (profile !== null && profile !== undefined && !STEP_ID.test(String(profile))) return badProfile(profile);
353
+ const route = profile ? String(profile) : null;
165
354
  const { doc, error } = readRaw(root);
166
355
  if (error) return brokenFile(error);
167
- const steps = doc.steps && typeof doc.steps === 'object' && !Array.isArray(doc.steps) ? { ...doc.steps } : {};
168
- if (!(step in steps)) {
169
- return bail(`${step} is not bound in ${PROJECT_FILES.skillsConfig}`, 'see `yad skill list` for what is bound');
356
+ const layer = layerOf(doc, route);
357
+ if (layer.error) return brokenFile(layer.error);
358
+ const steps = layer.steps;
359
+ if (!Object.hasOwn(steps, step)) {
360
+ return bail(`${step} is not bound${route ? ` for route ${route}` : ''} in ${PROJECT_FILES.skillsConfig}`,
361
+ `see \`yad skill list${route ? ` --profile ${route}` : ''}\` for what is bound`);
170
362
  }
171
363
  delete steps[step];
172
- // The file is left behind, holding an empty `steps`, rather than deleted. Deleting a file the user
173
- // may have hand-authored — with comments-by-convention, or keys a later release reads — to undo one
174
- // line would throw away more than was asked for.
175
- writeJSON(skillsFile(root), withSteps(doc, steps));
176
- // What runs it now: the engine's default, or nothing at all if this engine does not know the step.
177
- const fallback = stepSkills(step, null);
178
- ok(`${step} unbound${fallback.length ? ` — back to the engine's default (${fallback.join(' → ')})` : ' — this yadflow runs no skill for it'}`);
179
- return { step, skills: fallback, file: PROJECT_FILES.skillsConfig };
364
+ // The file is left behind, holding an empty `steps`, rather than deleted — and a route's entry is left
365
+ // holding an empty `steps` the same way. Deleting a file (or a route's entry) the user may have
366
+ // hand-authored, with keys a later release reads, to undo one line would throw away more than was
367
+ // asked for.
368
+ const next = withSteps(doc, steps, route);
369
+ writeJSON(skillsFile(root), next);
370
+ // What runs it now: the next layer down — the project-wide line for a route, else the engine's default,
371
+ // or nothing at all if this engine does not know the step.
372
+ const found = boundSkills(step, bindingsForProfile(normalizeBindings(next), route));
373
+ const fallback = stepSkills(step, bindingsForProfile(normalizeBindings(next), route));
374
+ // A route's line can still bind the step under its OLD id (`discovery` for `foundation`), so the
375
+ // layer is read off the answer, never assumed.
376
+ const where = !found ? "the engine's default"
377
+ : found.source === 'profile' ? `this route's line under the step's old name` : 'the project-wide binding';
378
+ ok(`${route ? `${route}: ` : ''}${step} unbound${fallback.length ? ` — back to ${where} (${fallback.join(' → ')})` : ' — this yadflow runs no skill for it'}`);
379
+ return { step, skills: fallback, profile: route, file: PROJECT_FILES.skillsConfig };
180
380
  }