yadflow 4.2.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,57 @@
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
+
40
+ # [4.3.0](https://github.com/abdelrahmannasr/yadflow/compare/v4.2.0...v4.3.0) (2026-10-01)
41
+
42
+
43
+ ### Bug Fixes
44
+
45
+ * **toolbox:** E85 review 1 — hint order, the folder check looks in, team-wide remove advice ([c7f6592](https://github.com/abdelrahmannasr/yadflow/commit/c7f65922cdad49e9816c4cbe81190283392c1df6))
46
+ * **toolbox:** E85 review 2 — look for tools at the top of the repo, read --dir's Product ([82fb5ed](https://github.com/abdelrahmannasr/yadflow/commit/82fb5ed6a1afcf364a9844d3faf2676bd4a84274))
47
+ * **toolbox:** E85 review 3 — a worktree is its own checkout; look at the deepest top ([4926799](https://github.com/abdelrahmannasr/yadflow/commit/4926799f6db758c7abb59029bd8e71923550c227))
48
+ * **toolbox:** E85 review 4 — find the checkout top under both spellings of a linked folder ([9728384](https://github.com/abdelrahmannasr/yadflow/commit/9728384c140178d55608d2ab2b67037a2f80140d))
49
+
50
+
51
+ ### Features
52
+
53
+ * **toolbox:** offer the missing tools in setup, check, update and join (E85) ([53e06c6](https://github.com/abdelrahmannasr/yadflow/commit/53e06c6da8bf585ced68435b052d01ba4ca5e6fa))
54
+
1
55
  # [4.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v4.1.0...v4.2.0) (2026-10-01)
2
56
 
3
57
 
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
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
@@ -12,13 +12,13 @@
12
12
 
13
13
  export { runSetup } from '../cli/setup.mjs';
14
14
  export { runNew, runInit, runJoin } from '../cli/workspace.mjs';
15
- export { findProduct, hasProduct, workspaceProduct, registryEntryFor, WORKSPACE_FILE } from '../cli/find-product.mjs';
15
+ export { findProduct, hasProduct, workspaceProduct, registryEntryFor, toolsFolder, WORKSPACE_FILE } from '../cli/find-product.mjs';
16
16
  export { reconcile } from '../cli/reconcile.mjs';
17
17
  export { gateOpen, gateSync, gateComments, gateStatus, gateCi, gateReview, gateTrailer, gateWalkthrough, gateRepair } from '../cli/gate.mjs';
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
@@ -24,7 +24,8 @@ ${c.bold('Start a workspace')} ${c.dim('(the folder holding product/ and the cod
24
24
  Never commits, pushes, or changes a shared file
25
25
 
26
26
  ${c.bold('Setup & maintenance')}
27
- yad setup Guided first-run setup (profile interview, install, connect & wire repos)
27
+ yad setup Guided first-run setup (profile interview, install, connect & wire repos,
28
+ then the tools this project uses that are missing — offered, never installed)
28
29
  profile flags: --solo | --team <n>, --greenfield | --brownfield,
29
30
  --monorepo | --separate, --tools (configure design/testing/learning now)
30
31
  yad check Report what is missing / drifted / modified / stale / legacy (read-only)
@@ -110,12 +111,22 @@ ${c.bold('Where am I / what next')}
110
111
  yad next --all Every active epic's next action at once
111
112
  yad next [<epic>] --json The same answer as a machine-readable action object (for
112
113
  agents/CI) — always every epic, so --all is implied
113
- yad skill list [--json] Which skill runs which step, and whether that is this
114
- project's choice or the engine's default
115
- yad skill bind <step> <skill> [<skill> ...]
116
- Bind a step to a skill of your own. Several skills run in
117
- the order given, one after another — each costs tokens
118
- 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
119
130
  yad toolbox list [--json] [--dir <folder>]
120
131
  The external tools yadflow can use — core (offered at setup),
121
132
  recommended pools, the design/testing/learning connectors and
@@ -424,7 +435,7 @@ let runningCmd = null;
424
435
  const ACTIONS = {
425
436
  epic: { known: ['new'] },
426
437
  foundation: { known: ['new', 'status'] },
427
- skill: { known: ['list', 'bind', 'unbind'], default: 'list' },
438
+ skill: { known: ['list', 'bind', 'unbind', 'recommend'], default: 'list' },
428
439
  gate: { known: ['open', 'sync', 'comments', 'status', 'repair', 'review', 'walkthrough', 'trailer', 'ci', 'approve', 'comment', 'advance'] },
429
440
  review: { known: ['trailer', 'context', 'chat', 'cards', 'walkthrough', 'nudge', 'reconcile'] },
430
441
  tidy: { known: ['up'] },
@@ -539,8 +550,12 @@ async function main() {
539
550
  // registers (through `.yad-workspace.json`) — and says where, on stderr. A code-repo command keeps acting
540
551
  // on the repo it runs in, and is handed the Product to read (its registry: name, platform, branch).
541
552
  const dirGiven = process.argv.slice(2).some((a) => a === '--dir' || a.startsWith('--dir='));
553
+ // E85: where `check` and `update` look for tools, and whose choices they read — worked out from the
554
+ // folder the person ran yad in (or named), before a Product command moves `o.dir` to the Product.
555
+ let toolboxAt = null;
542
556
  if (PRODUCT_CMDS.has(cmd) || REPO_CMDS.has(cmd)) {
543
557
  const found = commands.findProduct(dirGiven ? o.dir : shellCwd());
558
+ toolboxAt = { from: commands.toolsFolder(found, dirGiven ? o.dir : shellCwd()), product: found?.root ?? null };
544
559
  const shown = (p) => path.relative(process.cwd(), p) || '.';
545
560
  if (found?.problem) warn(`${found.problem} — using ${o.dir}`);
546
561
  else if (found?.elsewhere) {
@@ -625,11 +640,11 @@ async function main() {
625
640
  }
626
641
  case 'check':
627
642
  if (noProduct()) break;
628
- result = await commands.reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
643
+ result = await commands.reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today, toolbox: toolboxAt });
629
644
  break;
630
645
  case 'update':
631
646
  if (noProduct()) break;
632
- result = await commands.reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
647
+ result = await commands.reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today, toolbox: toolboxAt });
633
648
  break;
634
649
  case 'doctor':
635
650
  result = await commands.runDoctor(o.dir, { json: o.json });
@@ -655,7 +670,7 @@ async function main() {
655
670
  else if (found?.elsewhere) warn(`this folder is not in a repo the Product registers — the Product is ${rel(found.elsewhere)}: cd there, or pass --dir ${rel(found.elsewhere)}`);
656
671
  const productRoot = found?.root ?? null;
657
672
  const run = action === 'list' ? commands.runToolboxList : commands.runToolboxCheck;
658
- result = run(o.dir || process.cwd(), { json: o.json, productRoot });
673
+ result = run(commands.toolsFolder(found, o.dir || process.cwd()), { json: o.json, productRoot });
659
674
  break;
660
675
  }
661
676
  if (extra) { refuse(`yad toolbox ${action} takes one tool id (got also: ${extra})`, usage); break; }
@@ -730,11 +745,12 @@ async function main() {
730
745
  }
731
746
  case 'skill': {
732
747
  const [, action, step, ...rest] = o._;
733
- 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; }
734
749
  if ((action === 'bind' || action === 'unbind') && noProduct()) break;
735
- if (action === 'bind') { result = commands.runSkillBind(o.dir, { step, skills: rest }); break; }
736
- if (action === 'unbind') { result = commands.runSkillUnbind(o.dir, { step }); break; }
737
- 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]');
738
754
  break;
739
755
  }
740
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);
@@ -181,6 +181,30 @@ export function gitTopOf(dir) {
181
181
  }
182
182
  }
183
183
 
184
+ // Where `yad toolbox` and the toolbox section of `check` / `update` look for tools (E85), given
185
+ // `findProduct(from)`'s answer. Finding reads `<folder>/.claude/…` and never walks up, so a subfolder
186
+ // must be lifted to the top of what the person is working in. That is the DEEPEST of these that holds
187
+ // `from`: the git checkout it is in (a worktree inside `api/` is its own checkout, on its own branch — never
188
+ // swapped for api's, as in E80 review 3), the registered repo holding it (`apps/web` in a monorepo is not
189
+ // a checkout of its own), and the Product found from it. A checkout ABOVE the Product (a Product that is
190
+ // not a git repo, inside one that is not ours) is never deeper than the Product, so it never wins. Reads
191
+ // only whether `.git` exists; runs nothing.
192
+ export function toolsFolder(found, from) {
193
+ const at = real(from);
194
+ if (!at) return from;
195
+ // Both spellings: the shell's (through a link) and the one on disk, so `check` (given the shell's
196
+ // folder) and `yad toolbox check` (given the process's) look in the same place (E85 review 4).
197
+ const candidates = [gitTopOf(from), gitTopOf(at)];
198
+ if (found?.root && found.repo && typeof found.repo.path === 'string') candidates.push(path.resolve(found.root, found.repo.path));
199
+ if (found?.root && (found.via === 'here' || found.via === 'above')) candidates.push(found.root);
200
+ let best = null;
201
+ for (const c of candidates) {
202
+ const cAt = c && real(c);
203
+ if (cAt && within(at, cAt) && (!best || cAt.length > best.length)) best = cAt;
204
+ }
205
+ return best ?? from;
206
+ }
207
+
184
208
  // The registry entry for the code repo a code-repo command runs in (E80), with the folder to work in.
185
209
  // It is the CHECKOUT that must be the registered repo — not merely a folder inside it: a worktree or a
186
210
  // repo nested in `backend/` is another checkout, on another branch, and must never be swapped for