yadflow 4.3.0 → 4.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,42 @@
1
+ # [4.4.0](https://github.com/abdelrahmannasr/yadflow/compare/v4.3.0...v4.4.0) (2026-10-03)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **skill:** E51 review 1 — read the route the epic records, never a guess ([cefdb21](https://github.com/abdelrahmannasr/yadflow/commit/cefdb216139e4608961b83a5ec61f1ca6cb318c7))
7
+ * **skill:** E51 review 2 — say an epic's route is unknown; simplify the change-epic seed ([c5e6f0e](https://github.com/abdelrahmannasr/yadflow/commit/c5e6f0e12e56a768196088aff10d2eebd3f70293))
8
+ * **skill:** E52 flag a pick bound in place of yadflow's skill; list prints the catalogue's age ([a0b76f3](https://github.com/abdelrahmannasr/yadflow/commit/a0b76f30fd1d9fb3123977532ab0ef055daf89bf))
9
+ * **skill:** E52 review 1 — yadflow's skill must run last, plugin names, route view in list ([fcf675a](https://github.com/abdelrahmannasr/yadflow/commit/fcf675a6fe64788f3d0b2c12f8f7aad14eb6448c))
10
+ * **skill:** E52 review 2 — a bare bound name never matches a plugin-qualified pick ([4199fd3](https://github.com/abdelrahmannasr/yadflow/commit/4199fd332f36eb532f9aa4bd2a4f05a77bfa6cec))
11
+ * **skill:** E52 review 3 — a marketplace-spelled binding is the same pick ([04d7aaa](https://github.com/abdelrahmannasr/yadflow/commit/04d7aaafe640253dd0ecc48e922c993e0d7b3860))
12
+ * **toolbox:** E87 review 1 — CRLF, unseen tools, no Product, honest Repomix fallback ([dc3bd75](https://github.com/abdelrahmannasr/yadflow/commit/dc3bd7577271c5fb71fe5522e8a6d1f2c1fff575))
13
+ * **toolbox:** E87 review 10 — unclosed fence refused, frontmatter skipped ([577786c](https://github.com/abdelrahmannasr/yadflow/commit/577786c2ff939be1bf7565c9bc4f3b72d9569cd7))
14
+ * **toolbox:** E87 review 11 notes — frontmatter hides nothing, exact round trip ([b8bb924](https://github.com/abdelrahmannasr/yadflow/commit/b8bb924c5320f3fdf531f03fa968f77dc970f8c8))
15
+ * **toolbox:** E87 review 12 — a section can never cross the frontmatter ([cb1c111](https://github.com/abdelrahmannasr/yadflow/commit/cb1c11184fd2caea1e2f6b4c7082b65e5de462f3))
16
+ * **toolbox:** E87 review 13 notes — frontmatter closes at '--- ' or '...' ([df6c644](https://github.com/abdelrahmannasr/yadflow/commit/df6c644f639eaeef04b36eb1da2116b472452f39))
17
+ * **toolbox:** E87 review 14 — frontmatter needs a YAML key; no write leaves two copies ([20f3c75](https://github.com/abdelrahmannasr/yadflow/commit/20f3c75a231461d42566474d8be972e08edc59df))
18
+ * **toolbox:** E87 review 15 — check names the two-copy refusal too ([88757ac](https://github.com/abdelrahmannasr/yadflow/commit/88757acf9015dc8a8b2adae7137fdf1b8a151959))
19
+ * **toolbox:** E87 review 2 — connector-only sections, safer section finder ([00127ec](https://github.com/abdelrahmannasr/yadflow/commit/00127ecd4ae61c10cc5b34beaf32d42512d8ea05))
20
+ * **toolbox:** E87 review 3 — a removal works, a misread is refused by content ([a42bc78](https://github.com/abdelrahmannasr/yadflow/commit/a42bc78bc5a53ba1e9c17073615f9313bab3b66e))
21
+ * **toolbox:** E87 review 4 — one heading rule, a guard that quotes its line ([768b43a](https://github.com/abdelrahmannasr/yadflow/commit/768b43a7f8df84aeb1ca1d53818a421a1dca5ba2)), closes [#s](https://github.com/abdelrahmannasr/yadflow/issues/s)
22
+ * **toolbox:** E87 review 5 notes — fence-aware copy count, dividers, linear heading read ([d9eb679](https://github.com/abdelrahmannasr/yadflow/commit/d9eb6793ead27dfa9f76f60bc301a45df727a832)), closes [#s](https://github.com/abdelrahmannasr/yadflow/issues/s)
23
+ * **toolbox:** E87 review 6 — truly linear heading read, underline guard reads real headings ([83d0885](https://github.com/abdelrahmannasr/yadflow/commit/83d0885c9f8c6c216a35e740b365c6eebaca6b54)), closes [#s](https://github.com/abdelrahmannasr/yadflow/issues/s) [#hashtag](https://github.com/abdelrahmannasr/yadflow/issues/hashtag)
24
+ * **toolbox:** E87 review 7 — no slow pattern left in the section readers ([7201076](https://github.com/abdelrahmannasr/yadflow/commit/72010765951c569ea0c0cbc52b67c13f96c9f248))
25
+ * **toolbox:** E87 review 8 — a divider ends the block; the guard is checked against marked ([750187c](https://github.com/abdelrahmannasr/yadflow/commit/750187cecd34e8ff49c39e141424656781def287)), closes [#tag](https://github.com/abdelrahmannasr/yadflow/issues/tag)
26
+ * **toolbox:** E88 review 1 — refuse future and impossible vetting dates; pin every Repomix line ([20048b5](https://github.com/abdelrahmannasr/yadflow/commit/20048b562129a3860a521dc8a159cc8641dc3d9a))
27
+ * **toolbox:** E88 review 2 — install-line test fails closed; pin test covers npm install ([e955cbd](https://github.com/abdelrahmannasr/yadflow/commit/e955cbddc5d1d596c833dfb596b1536516b4d489))
28
+ * **toolbox:** E88 review 3 — a terms page that says license is not a licence file ([34edaf8](https://github.com/abdelrahmannasr/yadflow/commit/34edaf84f0a4a3d7338de1aae61964f975c6e2e3))
29
+ * **toolbox:** E88 review 4 — tell a licence file by where it lives, not its letter case ([4e9dbc0](https://github.com/abdelrahmannasr/yadflow/commit/4e9dbc0850ca9cba27ceba00e8b9dea777c1d1e6))
30
+ * **toolbox:** E88 review 5 — a repository's front page is not a licence file ([3f9fe15](https://github.com/abdelrahmannasr/yadflow/commit/3f9fe15a54a259c7c784a18b4e52e69ce50a7104))
31
+
32
+
33
+ ### Features
34
+
35
+ * **skill:** E51 bind a skill for one route only ([654488c](https://github.com/abdelrahmannasr/yadflow/commit/654488c7e7d876fbe29122959f2befe1122ebffe))
36
+ * **skill:** E52 recommendation catalogue and yad skill recommend ([2b4fba0](https://github.com/abdelrahmannasr/yadflow/commit/2b4fba05a6e27af978dcaeb25fe61a0dc61c36b6))
37
+ * **toolbox:** E88 vet every toolbox tool, and pin Repomix to the vetted version ([e4026f3](https://github.com/abdelrahmannasr/yadflow/commit/e4026f3140428a6b80c1842e410c8174f5ecb205))
38
+ * **toolbox:** every skill that uses a tool declares its fallback (E87) ([8d4963e](https://github.com/abdelrahmannasr/yadflow/commit/8d4963e603e92be0fcbc616e98a5ea5939befbc4))
39
+
1
40
  # [4.3.0](https://github.com/abdelrahmannasr/yadflow/compare/v4.2.0...v4.3.0) (2026-10-01)
2
41
 
3
42
 
package/README.md CHANGED
@@ -257,7 +257,8 @@ The record is worth trusting because people approve what goes into it.
257
257
  - linked changes and `yad thread`
258
258
  - `yad history`, `yad next` and `yad usage`
259
259
  - `yad detect`, which lists the skills, agents, MCP servers and plugins installed for each AI agent
260
- - `yad toolbox list`, which shows the external tools yadflow can use, which ones you have, and what happens without each; `yad toolbox add` / `remove` record the tools your team uses, and `yad toolbox check` lists the ones not ready on your machine (`yad setup`, `check`, `update` and `join` print the same list, and never install anything)
260
+ - `yad toolbox list`, which shows the external tools yadflow can use, which ones you have, and what happens without each; `yad toolbox add` / `remove` record the tools your team uses, and `yad toolbox check` lists the ones not ready on your machine (`yad setup`, `check`, `update` and `join` print the same list, and never install anything); each skill that uses a tool reads the same answer and says what it does without it
261
+ - `yad skill recommend`, which lists hand-picked skills from the recommended skill collections for each step, why each fits, and the command to bind one; it binds nothing, and every pick was checked against its source
261
262
  - draft saving, claims and owners
262
263
  - the CI checks
263
264
  - the Review Companion
package/bin/commands.mjs CHANGED
@@ -18,7 +18,7 @@ export { gateOpen, gateSync, gateComments, gateStatus, gateCi, gateReview, gateT
18
18
  export { gateApprove, gateComment, gateAdvance } from '../cli/gate-local.mjs';
19
19
  export { isValidEpicId, productDriftError, seedableProfiles } from '../cli/epic-state.mjs';
20
20
  export { runEpicNew, runFoundationNew, runFoundationStatus } from '../cli/epic.mjs';
21
- export { runSkillBind, runSkillList, runSkillUnbind } from '../cli/skill.mjs';
21
+ export { runSkillBind, runSkillList, runSkillRecommend, runSkillUnbind } from '../cli/skill.mjs';
22
22
  export { runCommit } from '../cli/commit.mjs';
23
23
  export { runOpenPr } from '../cli/openpr.mjs';
24
24
  export { reviewTrailer, reviewContext, reviewNudge, reviewReconcile, reviewWalkthrough } from '../cli/review.mjs';
package/bin/yad.mjs CHANGED
@@ -111,12 +111,22 @@ ${c.bold('Where am I / what next')}
111
111
  yad next --all Every active epic's next action at once
112
112
  yad next [<epic>] --json The same answer as a machine-readable action object (for
113
113
  agents/CI) — always every epic, so --all is implied
114
- yad skill list [--json] Which skill runs which step, and whether that is this
115
- project's choice or the engine's default
116
- yad skill bind <step> <skill> [<skill> ...]
117
- Bind a step to a skill of your own. Several skills run in
118
- the order given, one after another — each costs tokens
119
- yad skill unbind <step> Drop the binding; the step goes back to the engine's default
114
+ yad skill list [--profile <p> | --epic <id>] [--json]
115
+ Which skill runs which step, whether that is one route's
116
+ choice, the project's or the engine's default, and whether
117
+ each skill is installed here (--epic: that epic's route)
118
+ yad skill bind <step> <skill> [<skill> ...] [--profile <p>]
119
+ Bind a step to a skill of your own — for every epic, or
120
+ with --profile for epics on that route only (it wins).
121
+ Several skills run in the order given — each costs tokens.
122
+ Warns when the skill is not installed here
123
+ yad skill unbind <step> [--profile <p>]
124
+ Drop the binding; the step goes back to the project-wide
125
+ binding, then the engine's default
126
+ yad skill recommend [<step>] [--profile <p> | --epic <id>] [--json]
127
+ Hand-picked skills from the recommended pools for each step,
128
+ why each fits, whether it is installed here, and the bind
129
+ command (pool skill first, yadflow's skill last). Binds nothing
120
130
  yad toolbox list [--json] [--dir <folder>]
121
131
  The external tools yadflow can use — core (offered at setup),
122
132
  recommended pools, the design/testing/learning connectors and
@@ -425,7 +435,7 @@ let runningCmd = null;
425
435
  const ACTIONS = {
426
436
  epic: { known: ['new'] },
427
437
  foundation: { known: ['new', 'status'] },
428
- skill: { known: ['list', 'bind', 'unbind'], default: 'list' },
438
+ skill: { known: ['list', 'bind', 'unbind', 'recommend'], default: 'list' },
429
439
  gate: { known: ['open', 'sync', 'comments', 'status', 'repair', 'review', 'walkthrough', 'trailer', 'ci', 'approve', 'comment', 'advance'] },
430
440
  review: { known: ['trailer', 'context', 'chat', 'cards', 'walkthrough', 'nudge', 'reconcile'] },
431
441
  tidy: { known: ['up'] },
@@ -735,11 +745,12 @@ async function main() {
735
745
  }
736
746
  case 'skill': {
737
747
  const [, action, step, ...rest] = o._;
738
- if (action === 'list' || action === undefined) { result = commands.runSkillList(o.dir, { json: o.json }); break; }
748
+ if (action === 'list' || action === undefined) { result = commands.runSkillList(o.dir, { json: o.json, profile: o.profile, epic: o.epic }); break; }
739
749
  if ((action === 'bind' || action === 'unbind') && noProduct()) break;
740
- if (action === 'bind') { result = commands.runSkillBind(o.dir, { step, skills: rest }); break; }
741
- if (action === 'unbind') { result = commands.runSkillUnbind(o.dir, { step }); break; }
742
- refuse(`unknown skill action: ${action} (list, bind, unbind)`, 'usage: yad skill list [--json] | yad skill bind <step> <skill> [<skill> ...] | yad skill unbind <step>');
750
+ if (action === 'bind') { result = commands.runSkillBind(o.dir, { step, skills: rest, profile: o.profile }); break; }
751
+ if (action === 'unbind') { result = commands.runSkillUnbind(o.dir, { step, profile: o.profile }); break; }
752
+ if (action === 'recommend') { result = commands.runSkillRecommend(o.dir, { step: step ?? null, json: o.json, profile: o.profile, epic: o.epic }); break; }
753
+ refuse(`unknown skill action: ${action} (list, bind, unbind, recommend)`, 'usage: yad skill list [--profile <p> | --epic <id>] [--json] | yad skill bind <step> <skill> [<skill> ...] [--profile <p>] | yad skill unbind <step> [--profile <p>] | yad skill recommend [<step>] [--profile <p> | --epic <id>] [--json]');
743
754
  break;
744
755
  }
745
756
  case 'next': {
package/cli/detect.mjs CHANGED
@@ -4,8 +4,8 @@
4
4
  // yad detect [--json] [--dir <folder>]
5
5
  //
6
6
  // Worked out fresh on every run and never saved: nothing reads it yet except a person, and a saved copy
7
- // would be a new file shape that goes stale the moment someone installs a skill. E51 (bind per profile)
8
- // and E85 (the toolbox check) are the readers this is built for.
7
+ // would be a new file shape that goes stale the moment someone installs a skill. `yad skill list` /
8
+ // `yad skill bind` (E51, "is this skill installed here?") and E85 (the toolbox check) read it too.
9
9
  //
10
10
  // TWO PLACES ARE LOOKED IN: the folder the command runs on (what a teammate who clones it also gets), and
11
11
  // the user's home folder (where most people install skills). Every item says which one, and which agents
package/cli/docs.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // documentation sites generated by the yad-docs / yad-docs-overview skills. The CONTENT generation
3
3
  // (reading epic/architecture/contract/ui/stories → writing src/data/*.ts and theming index.css) is
4
4
  // the AI step inside those skills; this module only does the mechanical parts: npm build (a
5
- // subprocess, like yad-spec shelling npx repomix), the platform Pages wiring (reusing platform.mjs),
5
+ // subprocess, like yad-spec shelling out to Repomix), the platform Pages wiring (reusing platform.mjs),
6
6
  // and the manifest-hash staleness check (reusing the head-sha idea from repo.mjs). It NEVER touches
7
7
  // epic state, approvals, or the contract lock — docs are an output enrichment, not a gate.
8
8
  //
package/cli/doctor.mjs CHANGED
@@ -10,7 +10,7 @@ import { VERSION, BACKUP_SUFFIX, MIRRORED_FILES, mirrorDrift, PROJECT_FILES, MOD
10
10
  import { mergeHookSettings, hookMatcherFires, ideTargetsFor, safeIdeTargetStateFor, hookScriptReady, miswiredGuardCommand, gitHookState, legacyModuleActions, legacyProductActions, renamedNameHits, PRODUCT_CHECK_WORKFLOWS, productProfileEffect, workflowsPassingProduct, productGateBlockers, oldProfileAdvice, gateProfileFix } from './plan.mjs';
11
11
  import { hasSiblingRepo, workspaceFileState, WORKSPACE_FILE } from './find-product.mjs';
12
12
  import { planMigration } from './migrate.mjs';
13
- import { ADVANCE_VALUES, isGateStep, killSwitchOn, loadAutomation, stepDef as catalogueStep, loadLedger, owedSteps, epicIds, epicRel, epicRoot, FOUNDATION_DIR, FOUNDATION_EPIC, DISCOVERY_EPIC, staleFoundationGuards, unwrittenSections, artifactBase, artifactAgrees, epicStories, laneStarted, isValidEpicId, epicLineage, isGenesisType, readFrontmatter, resolveThread, stateInvariants, contractSurfaceHash, acceptedHashes, isStaleHash, workItemType, WORK_ITEM_TYPES, themeOf, themeKey, stepPhase, stepDef, matchLifecycleProfile, lifecycleProfile, LIFECYCLE_PROFILES, SENTINELS, normalizeBindings, optionalStepsFor, isSkippableStep, recordedRouteDisagrees, isPassed, stepStatus, claimsSkipped, STEP_STATES, isStepRecord, RECORDED_STEP_STATES, productDriftPairs } from './epic-state.mjs';
13
+ import { ADVANCE_VALUES, isGateStep, killSwitchOn, loadAutomation, stepDef as catalogueStep, loadLedger, owedSteps, epicIds, epicRel, epicRoot, FOUNDATION_DIR, FOUNDATION_EPIC, DISCOVERY_EPIC, staleFoundationGuards, unwrittenSections, artifactBase, artifactAgrees, epicStories, laneStarted, isValidEpicId, epicLineage, isGenesisType, readFrontmatter, resolveThread, stateInvariants, contractSurfaceHash, acceptedHashes, isStaleHash, workItemType, WORK_ITEM_TYPES, themeOf, themeKey, stepPhase, stepDef, matchLifecycleProfile, lifecycleProfile, LIFECYCLE_PROFILES, SENTINELS, normalizeBindings, stepsReadingBinding, optionalStepsFor, isSkippableStep, recordedRouteDisagrees, isPassed, stepStatus, claimsSkipped, STEP_STATES, isStepRecord, RECORDED_STEP_STATES, productDriftPairs } from './epic-state.mjs';
14
14
  import { loadDebt } from './thread.mjs';
15
15
  import { readShips } from './ledger.mjs';
16
16
  import { gitHead, insideWorkspace } from './setup.mjs';
@@ -2099,16 +2099,88 @@ export function skillBindingChecks(checks, root) {
2099
2099
  `${rel} binds ${gates.join(', ')}, which no skill runs — review gates are driven by \`yad gate\``,
2100
2100
  'bind the author step instead (for example `architecture`, not `architecture-review`)');
2101
2101
  }
2102
+ const routeBound = profileBindingChecks(checks, rel, raw.profiles);
2103
+ if (routeBound === null) return;
2102
2104
  // Its OWN id, not `skills` again. A file with one good binding and one broken line fires both, and
2103
2105
  // two checks sharing an id put a green tick under the complaint in prose — and, worse, let a `--json`
2104
2106
  // consumer keying by id overwrite the warning with the tick.
2105
- if (bound.length) {
2107
+ if (bound.length || routeBound) {
2106
2108
  const chained = bound.filter(([, list]) => list.length > 1).length;
2107
2109
  check(checks, 'skills:bound', 'project', 'ok',
2108
- `skills: ${bound.length} step(s) bound${chained ? `, ${chained} to more than one skill` : ''}`);
2110
+ `skills: ${bound.length} step(s) bound${chained ? `, ${chained} to more than one skill` : ''}`
2111
+ + (routeBound ? `; ${routeBound} more for one route only` : ''));
2109
2112
  }
2110
2113
  }
2111
2114
 
2115
+ // `profiles.<p>.steps` (E51): the same three "your line did nothing" warnings, each naming its route,
2116
+ // plus the two only a route can have — a route this release does not carry, and a step the route never
2117
+ // walks (`architecture` on `spike`: no epic on it ever reaches that step, which is why `yad skill bind`
2118
+ // refuses the same line). Returns how many lines bind something, or null after a fail.
2119
+ function profileBindingChecks(checks, rel, profiles) {
2120
+ if (profiles === undefined) return 0;
2121
+ if (!isPlainObject(profiles)) {
2122
+ check(checks, 'skills:profiles', 'project', 'fail', `${rel}: \`profiles\` must be a JSON object [YAD-STATE-002]`,
2123
+ 'expected `"profiles": { "<profile>": { "steps": { "<step-id>": "<skill>" } } }`');
2124
+ return null;
2125
+ }
2126
+ const broken = Object.entries(profiles)
2127
+ .filter(([, v]) => !isPlainObject(v) || (v.steps !== undefined && !isPlainObject(v.steps)))
2128
+ .map(([id]) => id);
2129
+ if (broken.length) {
2130
+ check(checks, 'skills:profiles', 'project', 'fail',
2131
+ `${rel}: ${broken.map((id) => `\`profiles.${id}\``).join(', ')} must hold a \`steps\` object [YAD-STATE-002]`,
2132
+ 'expected `"<profile>": { "steps": { "<step-id>": "<skill>" } }`');
2133
+ return null;
2134
+ }
2135
+ const bindings = normalizeBindings({ profiles });
2136
+ const unusable = [];
2137
+ const unknownStep = [];
2138
+ const gates = [];
2139
+ const offRoute = [];
2140
+ const unknownRoute = [];
2141
+ let bound = 0;
2142
+ for (const [id, entry] of Object.entries(profiles)) {
2143
+ const kept = (Object.hasOwn(bindings.profiles, id) ? bindings.profiles[id].steps : null) || {};
2144
+ const route = lifecycleProfile(id);
2145
+ if (!route && Object.keys(kept).length) unknownRoute.push(id);
2146
+ for (const step of Object.keys(entry.steps || {})) {
2147
+ if (!Object.hasOwn(kept, step)) { unusable.push(`${id}: ${step}`); continue; }
2148
+ bound += 1;
2149
+ const def = stepDef(step);
2150
+ if (!def) unknownStep.push(`${id}: ${step}`);
2151
+ else if (!def.skill) gates.push(`${id}: ${step}`);
2152
+ else if (route && (def.phase === 'build' ? route.level !== 'feature'
2153
+ : !stepsReadingBinding(step).some((s) => route.rows.some((r) => r.id === s)))) offRoute.push(`${id}: ${step}`);
2154
+ }
2155
+ }
2156
+ if (unusable.length) {
2157
+ check(checks, 'skills:profile-unusable', 'project', 'warn',
2158
+ `${rel}: ${unusable.length} route binding(s) name no skill and are ignored — ${unusable.join(', ')} [YAD-CFG-006]`,
2159
+ 'each value must be a skill name or a non-empty list of them');
2160
+ }
2161
+ if (unknownRoute.length) {
2162
+ check(checks, 'skills:unknown-profile', 'project', 'warn',
2163
+ `${rel} binds skills for ${unknownRoute.length} route(s) this yadflow does not have: ${unknownRoute.join(', ')}`,
2164
+ `check the spelling against the routes (${LIFECYCLE_PROFILES.map((p) => p.id).join(', ')}), or upgrade yadflow if the route is from a newer release`);
2165
+ }
2166
+ if (unknownStep.length) {
2167
+ check(checks, 'skills:profile-unknown-step', 'project', 'warn',
2168
+ `${rel} binds ${unknownStep.length} route step(s) this yadflow does not run: ${unknownStep.join(', ')}`,
2169
+ 'check the spelling against `yad skill list`, or upgrade yadflow if the step is from a newer release');
2170
+ }
2171
+ if (gates.length) {
2172
+ check(checks, 'skills:profile-review-step', 'project', 'warn',
2173
+ `${rel} binds ${gates.join(', ')}, which no skill runs — review gates are driven by \`yad gate\``,
2174
+ 'bind the author step instead (for example `architecture`, not `architecture-review`)');
2175
+ }
2176
+ if (offRoute.length) {
2177
+ check(checks, 'skills:off-route', 'project', 'warn',
2178
+ `${rel} binds ${offRoute.join(', ')}, a step that route never walks — no epic on it reaches the step`,
2179
+ 'bind it for a route that has the step, or project-wide under `steps`');
2180
+ }
2181
+ return bound;
2182
+ }
2183
+
2112
2184
  // `.sdlc/toolbox.json`: which toolbox tools this Product uses or skips, and its own tools (E86). Absent is
2113
2185
  // the normal case. THE FILE ONLY: whether a tool is installed is `yad toolbox check`'s answer, and a
2114
2186
  // missing tool is never a doctor line (decided with E86 — no tool is required). A broken file fails, as
@@ -2297,8 +2297,8 @@ export function markInReview(state, step, close = null) {
2297
2297
  // E108 — is `gateRuleFor` and `gateCapFor` at the top of this file (E7, E62, E72), which read
2298
2298
  // the `risk_tags` a seed copies from the row below into the epic's own `state.json`;
2299
2299
  // the fuller step-state model is E38. The `skill` column stays here as the shipped DEFAULT, and a
2300
- // project overrides it in `.sdlc/skills.json` (E6, below) — E51 later slides a per-profile default
2301
- // between the two, once E50 can detect which skills are installed. Three of the five
2300
+ // project overrides it in `.sdlc/skills.json` (E6, below), for every epic or for one route only
2301
+ // (E51: `profiles.<id>.steps`, which wins over the project-wide line). Three of the five
2302
2302
  // skills that used to hand-write a seed now run `yad epic new` instead (E17b), `yad-discovery` runs
2303
2303
  // `yad foundation new` (E75), and `yad-change` runs `yad epic new --parent` for a threaded change-epic
2304
2304
  // (E42).
@@ -2443,9 +2443,9 @@ export const STEPS = [
2443
2443
  // `yad epic new` (E17, cli/epic.mjs); shape 6 is where an epic first RECORDS which profile it is on,
2444
2444
  // and an epic seeded before that field existed still has its route derived by matching its chain
2445
2445
  // (`matchLifecycleProfile`). Which steps are optional per route already lives here and is read per
2446
- // EPIC (E35, `optionalStepsFor`); the short chore and spike lanes are E40. A PROJECT-wide skill
2447
- // binding already exists (E6, below); E51 adds a per-profile one between it and the catalogue
2448
- // default, so two routes can run different skills for the same step.
2446
+ // EPIC (E35, `optionalStepsFor`); the short chore and spike lanes are E40. Which skill runs a step
2447
+ // is bound per project (E6, below) and, more specifically, per route (E51) — both in
2448
+ // `.sdlc/skills.json`, never here, so two routes can run different skills for the same step.
2449
2449
  export const LIFECYCLE_PROFILES = [
2450
2450
  {
2451
2451
  id: 'classic',
@@ -2877,8 +2877,9 @@ const BUILD_STEP_ORDER = STEPS.filter((s) => s.phase === 'build').map((s) => s.i
2877
2877
  // THE FILE WINS, AND THE DOCTOR ONLY REPORTS. Same discipline as `workItemType` and the lifecycle
2878
2878
  // profile: a project may bind a skill this engine has never heard of — that is the whole point, since
2879
2879
  // the engine cannot know what a team installed. So nothing here validates a skill NAME. `yad doctor`
2880
- // reports a binding on a step id the catalogue does not know, and changes nothing. Detecting which
2881
- // skills are actually installed is E50; per-profile defaults are E51.
2880
+ // reports a binding on a step id the catalogue does not know, and changes nothing. `yad skill list`
2881
+ // and `yad skill bind` ask `yad detect` (E50) whether a bound skill is installed on THIS machine, and
2882
+ // only ever warn: a teammate may have it, and the engine cannot see their home folder.
2882
2883
  //
2883
2884
  // SEVERAL SKILLS PER STEP IS A CHAIN, NEVER A PANEL (closed decision 7). They run in the order given,
2884
2885
  // each one seeing what the one before it produced, and the LAST output is the artifact. A panel — run
@@ -2897,8 +2898,19 @@ const BUILD_STEP_ORDER = STEPS.filter((s) => s.phase === 'build').map((s) => s.i
2897
2898
  // }
2898
2899
  //
2899
2900
  // A value may be one skill or a list of them; both are read back as a list, so nothing downstream has
2900
- // to handle two shapes. The wrapper object exists so that E50 and E51 can add keys beside `steps`
2901
- // without the file changing shape.
2901
+ // to handle two shapes. The wrapper object exists so that later rows can add keys beside `steps`
2902
+ // without the file changing shape — and E51 did:
2903
+ //
2904
+ // "profiles": {
2905
+ // "spike": { "steps": { "epic": "spike-epic" } }
2906
+ // }
2907
+ //
2908
+ // A binding under `profiles.<id>` applies only to an epic that RECORDS that route (`recordedRoute`,
2909
+ // below — never a route guessed from its chain). THE MOST SPECIFIC LINE WINS: the route's
2910
+ // own line, then the project-wide `steps`, then the catalogue. A team that binds `epic` for `spike`
2911
+ // gets it on spike epics only, and its project-wide lines still cover every other route. No shape
2912
+ // moves: an older yadflow keeps the unknown key (`yad skill bind` writes the document it read) and
2913
+ // simply runs the project-wide answer for every route.
2902
2914
 
2903
2915
  // The catalogue's own answer for a step, Shape or Build. One lookup, because a step id belongs to
2904
2916
  // exactly one of the two tables (`catalogueSkills` splits them on the same `phase === 'build'` rule),
@@ -2908,7 +2920,8 @@ const BUILD_STEP_ORDER = STEPS.filter((s) => s.phase === 'build').map((s) => s.i
2908
2920
  // chain carrying a step called `constructor` would otherwise resolve its "skill" to a function.
2909
2921
  const CATALOGUE_SKILL = { __proto__: null, ...STEP_SKILL, ...BUILD_STEP_SKILL };
2910
2922
 
2911
- // Read a raw parsed `.sdlc/skills.json` into `{ steps: { <id>: [skill, …] } }`.
2923
+ // Read a raw parsed `.sdlc/skills.json` into
2924
+ // `{ steps: { <id>: [skill, …] }, profiles: { <profile>: { steps: { <id>: [skill, …] } } } }`.
2912
2925
  //
2913
2926
  // Junk is DROPPED here rather than rejected, and that is deliberate: this runs inside `yad next`,
2914
2927
  // which must keep working on a project whose config file someone mistyped. An empty string, an empty
@@ -2925,20 +2938,75 @@ const CATALOGUE_SKILL = { __proto__: null, ...STEP_SKILL, ...BUILD_STEP_SKILL };
2925
2938
  // and the two surfaces would disagree about it: the rendered Build chain folds them (that is what
2926
2939
  // `dedupeConsecutive` has always done for spec+tasks) while the cost note counted them, so a step
2927
2940
  // bound to `["a", "a"]` would be billed for two runs and shown as one.
2928
- export function normalizeBindings(raw) {
2941
+ //
2942
+ // `profiles` gets the same treatment one level down (E51): a profile id is a key out of the same
2943
+ // hand-edited file, so `__proto__` there is added with `defineProperty` too, and a profile whose
2944
+ // `steps` binds nothing usable is left out — it would only route a lookup to an empty layer.
2945
+ const ownKey = (obj, key, value) => Object.defineProperty(obj, key, { value, enumerable: true, writable: true, configurable: true });
2946
+
2947
+ function normalizeSteps(steps) {
2929
2948
  const out = {};
2930
- const steps = isPlainObject(raw) ? raw.steps : null;
2931
2949
  if (isPlainObject(steps)) {
2932
2950
  for (const [id, value] of Object.entries(steps)) {
2933
2951
  const list = dedupeConsecutive((Array.isArray(value) ? value : [value])
2934
2952
  .filter((s) => typeof s === 'string' && s.trim())
2935
2953
  .map((s) => s.trim()));
2936
- if (list.length) Object.defineProperty(out, id, { value: list, enumerable: true, writable: true, configurable: true });
2954
+ if (list.length) ownKey(out, id, list);
2937
2955
  }
2938
2956
  }
2939
- return { steps: out };
2957
+ return out;
2958
+ }
2959
+
2960
+ export function normalizeBindings(raw) {
2961
+ const profiles = {};
2962
+ const rawProfiles = isPlainObject(raw) ? raw.profiles : null;
2963
+ if (isPlainObject(rawProfiles)) {
2964
+ for (const [id, value] of Object.entries(rawProfiles)) {
2965
+ const steps = normalizeSteps(isPlainObject(value) ? value.steps : null);
2966
+ if (Object.keys(steps).length) ownKey(profiles, id, { steps });
2967
+ }
2968
+ }
2969
+ return { steps: normalizeSteps(isPlainObject(raw) ? raw.steps : null), profiles };
2970
+ }
2971
+
2972
+ // The bindings as ONE epic sees them: the project's, plus which route the epic is on (E51).
2973
+ //
2974
+ // A view, not a merge. `stepSkills` reads the route's layer first and the project's after it, so a
2975
+ // line in either layer keeps saying where it came from — `yad skill list` needs that for `source`,
2976
+ // and a merged object would make a route's line indistinguishable from a project-wide one. The same
2977
+ // object goes to every reader of one epic (`nextAction` and the line `yad next` prints beside it), so
2978
+ // the JSON and the prose cannot name two different skills for one step.
2979
+ //
2980
+ // `profile` is null for "no route": no epic yet (`yad next` on an empty Product, `yad setup`), or an
2981
+ // epic whose route nothing can tell. Then only the project-wide layer applies — never a guessed route.
2982
+ export function bindingsForProfile(bindings, profile) {
2983
+ const base = isPlainObject(bindings) ? bindings : { steps: {}, profiles: {} };
2984
+ const id = typeof profile === 'string' && profile ? profile : null;
2985
+ return { ...base, profile: id };
2940
2986
  }
2941
2987
 
2988
+ // The route an epic RECORDS, as written — or null when it records none.
2989
+ //
2990
+ // NOT `epicProfileId`, which falls back to matching the chain. That guess is fine for what a step may
2991
+ // skip, where a wrong answer is a refusal a person sees at once. Here it would be a silent claim that
2992
+ // is believed and acted on: `yad next` names the skill and `yad-run` runs it. Every hand-written and
2993
+ // pre-shape-6 ledger is a shortened `classic`, and the shortest route that fits `[epic, epic-review]`
2994
+ // is `chore` — so a `chore`-only line would reach every older classic epic. The E40 rule for
2995
+ // `routeLacksStep`, for the same reason.
2996
+ //
2997
+ // A route this release does not KNOW is still used as written (rule 3): an epic from a newer release
2998
+ // that records it gets the lines the team bound for it, which is what `yad skill bind` promises when it
2999
+ // records one. The lookup is `Object.hasOwn`, so an unknown id is safe.
3000
+ export const recordedRoute = (state) =>
3001
+ (isPlainObject(state) && typeof state.profile === 'string' && state.profile ? state.profile : null);
3002
+
3003
+ // The view for an epic's own `state.json` — its recorded route. A view that already names a route is
3004
+ // kept: a caller that resolved it once hands the same object on.
3005
+ export const bindingsForEpic = (bindings, state) =>
3006
+ (isPlainObject(bindings) && Object.hasOwn(bindings, 'profile')
3007
+ ? bindings
3008
+ : bindingsForProfile(bindings, recordedRoute(state)));
3009
+
2942
3010
  // The project's bindings, or the empty set when the file is absent or unreadable.
2943
3011
  export const loadSkillBindings = (root) =>
2944
3012
  normalizeBindings(readJSON(path.join(root, PROJECT_FILES.skillsConfig), null));
@@ -2955,22 +3023,48 @@ export function stepSkills(stepId, bindings = null) {
2955
3023
  // object `Object.prototype` answers for `constructor`, `toString` and friends — so a chain carrying
2956
3024
  // a step called `constructor` resolved its "skill" to a function, and `yad next` threw trying to
2957
3025
  // spread it. The catalogue map below is `__proto__: null` for the same reason.
2958
- const steps = bindings?.steps;
2959
- const boundTo = (id) => (isPlainObject(steps) && Object.hasOwn(steps, id) ? steps[id] : null);
2960
- const bound = boundTo(stepId);
2961
- if (Array.isArray(bound) && bound.length) return [...bound];
2962
- // A step that took over from an OLD id inherits the old id's binding until it is given one of its
2963
- // own (rule 3). A project that bound its own skill to `discovery` before E75 renamed the product
2964
- // step to `foundation` would otherwise be switched back to the default without anyone deciding it.
2965
- const legacy = Object.hasOwn(BINDING_SUCCEEDS, stepId) ? boundTo(BINDING_SUCCEEDS[stepId]) : null;
2966
- if (Array.isArray(legacy) && legacy.length) return [...legacy];
3026
+ const found = boundSkills(stepId, bindings);
3027
+ if (found) return [...found.skills];
2967
3028
  const fallback = CATALOGUE_SKILL[stepId];
2968
3029
  return typeof fallback === 'string' ? [fallback] : [];
2969
3030
  }
2970
3031
 
3032
+ // Which LAYER binds a step, and to what: `{ skills, source: 'profile' | 'project' }`, or null when
3033
+ // neither does (the step is on the catalogue's default). The most specific layer wins (E51): the
3034
+ // epic's route, then the project-wide `steps`.
3035
+ //
3036
+ // Inside each layer, a step that took over from an OLD id inherits the old id's binding until it is
3037
+ // given one of its own (rule 3). A project that bound its own skill to `discovery` before E75 renamed
3038
+ // the product step to `foundation` would otherwise be switched back to the default without anyone
3039
+ // deciding it. The layer order comes first: a route's `discovery` line beats a project-wide
3040
+ // `foundation` one, because it is the more specific CHOICE, whatever it is spelled.
3041
+ export function boundSkills(stepId, bindings = null) {
3042
+ const lookup = (steps) => {
3043
+ const boundTo = (id) => (isPlainObject(steps) && Object.hasOwn(steps, id) ? steps[id] : null);
3044
+ const own = boundTo(stepId);
3045
+ if (Array.isArray(own) && own.length) return own;
3046
+ const legacy = Object.hasOwn(BINDING_SUCCEEDS, stepId) ? boundTo(BINDING_SUCCEEDS[stepId]) : null;
3047
+ return Array.isArray(legacy) && legacy.length ? legacy : null;
3048
+ };
3049
+ const profiles = bindings?.profiles;
3050
+ const profile = bindings?.profile;
3051
+ const route = typeof profile === 'string' && isPlainObject(profiles) && Object.hasOwn(profiles, profile)
3052
+ ? profiles[profile]?.steps : null;
3053
+ const fromRoute = lookup(route);
3054
+ if (fromRoute) return { skills: fromRoute, source: 'profile' };
3055
+ const fromProject = lookup(bindings?.steps);
3056
+ return fromProject ? { skills: fromProject, source: 'project' } : null;
3057
+ }
3058
+
2971
3059
  // new step id -> the old step id whose binding it inherits while it has none of its own.
2972
3060
  const BINDING_SUCCEEDS = { __proto__: null, foundation: 'discovery' };
2973
3061
 
3062
+ // The step ids that read a line written under `id` — itself, and any step that took over from it. For
3063
+ // `yad doctor`: a `discovery` line on the `foundation` route is read for `foundation`, so it is not a
3064
+ // line no epic reaches.
3065
+ export const stepsReadingBinding = (id) =>
3066
+ [id, ...Object.keys(BINDING_SUCCEEDS).filter((k) => BINDING_SUCCEEDS[k] === id)];
3067
+
2974
3068
  // The two keys every action object carries for its skill, from one resolved list.
2975
3069
  //
2976
3070
  // `skill` STAYS A STRING and is the first of the chain, because ~20 call sites and `yad next --json`
@@ -3318,8 +3412,11 @@ export function repairState(state, close = null) {
3318
3412
  // reopened — a lane per step re-opened behind finished work (E41), each shaped like an action
3319
3413
  // debt — the steps still owed as debt (`owedSteps`), the reminder that repeats until they are paid
3320
3414
  export function nextAction(ledger, opts = {}) {
3321
- const a = shapeNextAction(ledger, opts);
3322
3415
  const state = ledger?.state;
3416
+ // E51: every skill this action names is the one for THIS epic's route. Resolved once, here, so the
3417
+ // Shape step, the Build lanes and the re-opened lanes all read the same view.
3418
+ opts = { ...opts, bindings: bindingsForEpic(opts.bindings ?? null, state) };
3419
+ const a = shapeNextAction(ledger, opts);
3323
3420
  if (!state || !Array.isArray(state.steps) || isProductLevel(state) || backfillAnchorKind(state)) return a;
3324
3421
  const lanes = reopenedLanes(ledger, { epicId: a.epicId, currentStep: state.currentStep, bindings: opts.bindings ?? null });
3325
3422
  const debt = owedSteps(state).map((s) => ({ step: s.id, status: stepStatus(s) ?? s.status, record: s.record || null }));
package/cli/epic.mjs CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  DISCOVERY_EPIC, epicIds, epicLineage, epicRel, epicRoot, epicStories, featureStatus, FOUNDATION_EPIC, FOUNDATION_SECTIONS,
37
37
  isGenesisType, isValidEpicId, lifecycleProfile, loadLedger, loadSkillBindings, PRODUCT_DONE, PRODUCT_EPICS,
38
38
  planThreadedSeed, readFrontmatter, roadmapFeatures, seedableProfiles, seedFoundationState,
39
- seedState, staleFoundationGuards, stepSkills, typeNoun, WORK_ITEM_TYPES, workItemType, writeJSON, writeState,
39
+ seedState, staleFoundationGuards, stepSkills, bindingsForEpic, typeNoun, WORK_ITEM_TYPES, workItemType, writeJSON, writeState,
40
40
  } from './epic-state.mjs';
41
41
  import { epicFiles, isVerifiedLedger, productConfigPath } from './manifest.mjs';
42
42
  import { refreshIndexAfterWrite } from './product-index.mjs';
@@ -223,8 +223,9 @@ export async function runEpicNew(root, { slug, type = null, profile = null, stub
223
223
  // Everything else asks the PROJECT first (E6): a team that bound its own skill to `epic` must be
224
224
  // told to run that one, or this command would hand a brand-new epic straight to a skill their
225
225
  // `yad next` will never name again. `yad-backfill` is the one exception, because waking a stub is
226
- // the engine's own `promote` verb rather than a step on any chain.
227
- const skills = stub ? ['yad-backfill'] : stepSkills(first.id, loadSkillBindings(root));
226
+ // the engine's own `promote` verb rather than a step on any chain. The route is the one just seeded
227
+ // (E51), read off the state as `yad next` will read it, so the two name the same skill.
228
+ const skills = stub ? ['yad-backfill'] : stepSkills(first.id, bindingsForEpic(loadSkillBindings(root), state));
228
229
  const skill = skills[0] || null;
229
230
  // `nextSkills` appears only for a bound chain, the same rule `yad next --json` follows — a key that
230
231
  // showed up on every seed would be one more always-null field for every reader to ignore.
@@ -314,7 +315,8 @@ function seedThreaded(root, { epic, dir, files, mdPath, fm, type, parent, profil
314
315
 
315
316
  const { state } = plan;
316
317
  const first = state.steps.find((s) => s.id === state.currentStep);
317
- const skills = stepSkills(first.id, loadSkillBindings(root));
318
+ // The route the change takes from its parent, which `planThreadedSeed` records on the new state.
319
+ const skills = stepSkills(first.id, bindingsForEpic(loadSkillBindings(root), state));
318
320
  const carried = state.steps.filter((s) => s.inherited);
319
321
  if (json) {
320
322
  return emitJSON({
@@ -389,7 +391,7 @@ export async function runFoundationNew(root, { today, json = false } = {}) {
389
391
  for (const f of [files.approvals, files.comments]) if (!fs.existsSync(f)) writeJSON(f, []);
390
392
  fs.mkdirSync(path.join(epicRoot(root, FOUNDATION_EPIC), 'reviews'), { recursive: true });
391
393
 
392
- const skills = stepSkills(state.steps[0].id, loadSkillBindings(root));
394
+ const skills = stepSkills(state.steps[0].id, bindingsForEpic(loadSkillBindings(root), state));
393
395
  const skill = skills[0] || null;
394
396
  const required = FOUNDATION_SECTIONS.filter((s) => !s.optional).map((s) => s.file);
395
397
  const optional = FOUNDATION_SECTIONS.filter((s) => s.optional).map((s) => s.file);
package/cli/next.mjs CHANGED
@@ -15,7 +15,7 @@ import { c, log, ok, info, warn, hand, fail, readJSON, exists, emitJSON } from '
15
15
  import { PROJECT_FILES, isVerifiedLedger, productConfigPath, stepAdvance } from './manifest.mjs';
16
16
  import { printTeamHint, soloTeamHint } from './people.mjs';
17
17
  import { OWNER_STEPS, liveOwner, readState as loadLedgerState } from './owners.mjs';
18
- import { dedupeConsecutive, epicIds, isGateStep, killSwitchOn, loadAutomation, epicRel, epicRoot, loadLedger, loadSkillBindings, stepSkills, nextAction, preconditionsMet, isValidEpicId, epicLineage, typeNoun, phaseOf, stepPhase, profileSteps, lifecycleProfile, PHASES, PRODUCT_DONE, PRODUCT_EPICS, STEPS } from './epic-state.mjs';
18
+ import { bindingsForEpic, bindingsForProfile, dedupeConsecutive, epicIds, isGateStep, killSwitchOn, loadAutomation, epicRel, epicRoot, loadLedger, loadSkillBindings, stepSkills, nextAction, preconditionsMet, isValidEpicId, epicLineage, typeNoun, phaseOf, stepPhase, profileSteps, lifecycleProfile, PHASES, PRODUCT_DONE, PRODUCT_EPICS, STEPS } from './epic-state.mjs';
19
19
 
20
20
  // Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
21
21
  // missing/old hub.json never breaks the driver.
@@ -46,10 +46,16 @@ function listEpics(root) {
46
46
  // `bindings` is passed in for the same reason `lin` is: every loop below renders many epics, and the
47
47
  // project's skill bindings are ONE file for all of them. The default keeps the single-epic callers a
48
48
  // one-liner; a loop reads the file once and hands the same object to every row.
49
- const actionFor = (root, id, lin = epicLineage(root, id), bindings = loadSkillBindings(root)) => {
49
+ //
50
+ // E51: the bindings are narrowed to THIS epic's route here, once, and `rowFor` hands that same view to
51
+ // the printed line. `nextAction` would narrow them itself, but `actionLine` names skills the action
52
+ // never carried (the Build fallback, the hand-off to the next epic) — a view built in two places is two
53
+ // answers for one step.
54
+ function epicAction(root, id, lin = epicLineage(root, id), bindings = loadSkillBindings(root)) {
50
55
  const ledger = loadLedger(epicRoot(root, id));
51
- return {
52
- ...nextAction(ledger, { epic: id, bindings }),
56
+ const view = bindingsForEpic(bindings, ledger?.state);
57
+ const action = {
58
+ ...nextAction(ledger, { epic: id, bindings: view }),
53
59
  lineageKind: lin.type,
54
60
  // Which ROUTE this epic RECORDED, carried so `phaseLine` can mark a phase that route never enters
55
61
  // (E40). The recorded key ONLY — never matched from the chain, which is the one place in this file
@@ -64,13 +70,19 @@ const actionFor = (root, id, lin = epicLineage(root, id), bindings = loadSkillBi
64
70
  // which route it is on simply gets the line it got before the short lanes existed.
65
71
  route: lifecycleProfile(ledger?.state?.profile) ? ledger.state.profile : null,
66
72
  };
67
- };
73
+ return { action, view };
74
+ }
75
+
76
+ const actionFor = (root, id, lin, bindings) => epicAction(root, id, lin, bindings).action;
68
77
 
69
78
  // One `epic.md` read, both things that come out of it: the action to print and the tag to print
70
79
  // beside it. Every printed path in this file goes through here.
80
+ // `bindings` on the row is the epic's own view, and every printer spreads the row AFTER its own
81
+ // `bindings`, so the row's wins.
71
82
  const rowFor = (root, id, bindings = loadSkillBindings(root)) => {
72
83
  const lin = epicLineage(root, id);
73
- return { action: actionFor(root, id, lin, bindings), theme: lin.theme, owners: liveOwners(root, id) };
84
+ const { action, view } = epicAction(root, id, lin, bindings);
85
+ return { action, theme: lin.theme, owners: liveOwners(root, id), bindings: view };
74
86
  };
75
87
 
76
88
  // The live step owners of one epic (E47), by step. Beside the action, never in it: `yad next --json`'s
@@ -388,7 +400,7 @@ function generalNext(root, { all, headCount = null } = {}) {
388
400
  if (brownfield) hand(`capture what already exists first: invoke the ${c.bold('yad-backfill')} skill`);
389
401
  // Both name a STEP's skill, so both ask the project. `yad-backfill` above does not: waking a
390
402
  // brownfield anchor is the engine's own promote verb, not a step on any chain.
391
- if (!productId) hand(`frame the whole product first (purpose, scope, MVP, roadmap, stack): run ${c.bold('yad foundation new')}, then invoke the ${c.bold(stepSkills('foundation', bindings)[0] || 'yad-discovery')} skill ${c.dim('(optional)')}`);
403
+ if (!productId) hand(`frame the whole product first (purpose, scope, MVP, roadmap, stack): run ${c.bold('yad foundation new')}, then invoke the ${c.bold(stepSkills('foundation', bindingsForProfile(bindings, 'foundation'))[0] || 'yad-discovery')} skill ${c.dim('(optional)')}`);
392
404
  hand(`start your first epic: invoke the ${c.bold(stepSkills('epic', bindings)[0] || 'yad-epic')} skill${productId ? c.dim(' (it reads the approved roadmap.md)') : ''}`);
393
405
  return;
394
406
  }
@@ -412,12 +424,12 @@ function generalNext(root, { all, headCount = null } = {}) {
412
424
  // The grouping theme rides this list too. This is the ONE `yad next` view that shows several epics
413
425
  // side by side, so it is where seeing which of them belong together is worth most — and leaving it
414
426
  // off would have meant bare `yad next`, the command people run by default, never showed the tag.
415
- for (const { action: a, theme: tag } of rows) {
427
+ for (const { action: a, theme: tag, bindings: view } of rows) {
416
428
  const theme = tag ? ` ${c.dim(`#${tag}`)}` : '';
417
429
  // Debt is reminded here too (E41): the roll-up is the view people run most, and a reminder that only
418
430
  // appears once you already look at the one epic is not a reminder.
419
431
  const owed = a.debt?.length ? ` ${c.yellow(`· ${a.debt.length} owed as debt`)}` : '';
420
- log(` ${c.cyan(`${typeNoun(a.lineageKind)} ${a.epicId}`)}${theme} ${actionLine(a, { solo, verified })}${owed}`);
432
+ log(` ${c.cyan(`${typeNoun(a.lineageKind)} ${a.epicId}`)}${theme} ${actionLine(a, { solo, bindings: view, verified })}${owed}`);
421
433
  }
422
434
  // Painted per segment, not dim-wrapping a bold: `paint` closes with a full reset, so the nested
423
435
  // form loses the dim from the first bold word to the end of the line.