yadflow 4.1.0 → 4.3.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,37 @@
1
+ # [4.3.0](https://github.com/abdelrahmannasr/yadflow/compare/v4.2.0...v4.3.0) (2026-10-01)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **toolbox:** E85 review 1 — hint order, the folder check looks in, team-wide remove advice ([c7f6592](https://github.com/abdelrahmannasr/yadflow/commit/c7f65922cdad49e9816c4cbe81190283392c1df6))
7
+ * **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))
8
+ * **toolbox:** E85 review 3 — a worktree is its own checkout; look at the deepest top ([4926799](https://github.com/abdelrahmannasr/yadflow/commit/4926799f6db758c7abb59029bd8e71923550c227))
9
+ * **toolbox:** E85 review 4 — find the checkout top under both spellings of a linked folder ([9728384](https://github.com/abdelrahmannasr/yadflow/commit/9728384c140178d55608d2ab2b67037a2f80140d))
10
+
11
+
12
+ ### Features
13
+
14
+ * **toolbox:** offer the missing tools in setup, check, update and join (E85) ([53e06c6](https://github.com/abdelrahmannasr/yadflow/commit/53e06c6da8bf585ced68435b052d01ba4ca5e6fa))
15
+
16
+ # [4.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v4.1.0...v4.2.0) (2026-10-01)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **toolbox:** a turned-off plugin is disabled, and ranges read only full versions (E84 review 1) ([56a9864](https://github.com/abdelrahmannasr/yadflow/commit/56a9864bf91b764b736c6da12b2d30cc1d1fd861))
22
+ * **toolbox:** add and remove report a shipped-tool edit through one function (E86 review 4) ([161ece4](https://github.com/abdelrahmannasr/yadflow/commit/161ece48cffd879fe0106747ae10698f7b5e67cf))
23
+ * **toolbox:** clearer remove and dead-copy wording (E86 review 5 notes) ([33ba0b7](https://github.com/abdelrahmannasr/yadflow/commit/33ba0b7bae11462d9b81a739df302fe20bbd2a7c))
24
+ * **toolbox:** npx before a turned-off plugin, Claude Code switches only, Windows PATH rules (E84 review 2) ([f052979](https://github.com/abdelrahmannasr/yadflow/commit/f052979a3018790c1d7c94fe5b761ce32bf15e55))
25
+ * **toolbox:** offer an undo only when the tool's use changed (E86 review 3) ([32e5105](https://github.com/abdelrahmannasr/yadflow/commit/32e5105bb83f856b9b37b8bc0e96e7bf15e396e1))
26
+ * **toolbox:** one remove clears everything under an id; add agrees with the list (E86 review 2) ([da75777](https://github.com/abdelrahmannasr/yadflow/commit/da75777496e4c705e52423c77c553f35e5c99002))
27
+ * **toolbox:** truthful edit messages, remove clears dead lines, --dir is the Product (E86 review 1) ([2159543](https://github.com/abdelrahmannasr/yadflow/commit/21595432d611e190c93d97fc707f27c432ba936f))
28
+
29
+
30
+ ### Features
31
+
32
+ * **toolbox:** yad toolbox add, remove and check (E86) ([715cd4c](https://github.com/abdelrahmannasr/yadflow/commit/715cd4c6cc63ffdcfdc33c97a9d2df8078965b64))
33
+ * **toolbox:** yad toolbox list and the shipped toolbox of external tools (E84) ([3f5374b](https://github.com/abdelrahmannasr/yadflow/commit/3f5374b830c83782b884f48a7e760943bc2349d4))
34
+
1
35
  # [4.1.0](https://github.com/abdelrahmannasr/yadflow/compare/v4.0.1...v4.1.0) (2026-10-01)
2
36
 
3
37
 
package/README.md CHANGED
@@ -155,7 +155,8 @@ unless CI runs it), or `yad gate advance` with no platform, is the human decisio
155
155
  - `yad skip` and `yad defer` pass over optional steps, such as the UI design.
156
156
 
157
157
  **Already using Yadflow?** Follow [staying up to date](docs/CLI.md#staying-up-to-date). Coming from
158
- 3.x, read [upgrading to 4.0](docs/migrations/upgrading-to-4.md) first.
158
+ 3.x, read [upgrading to 4.0](docs/migrations/upgrading-to-4.md) first, then follow the
159
+ [step-by-step upgrade guide](docs/migrations/upgrade-guide-3x-to-4x.md).
159
160
 
160
161
  ## How the project record evolves
161
162
 
@@ -256,6 +257,7 @@ The record is worth trusting because people approve what goes into it.
256
257
  - linked changes and `yad thread`
257
258
  - `yad history`, `yad next` and `yad usage`
258
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)
259
261
  - draft saving, claims and owners
260
262
  - the CI checks
261
263
  - the Review Companion
@@ -280,7 +282,8 @@ The record is worth trusting because people approve what goes into it.
280
282
  - [all 38 skills](docs/SKILLS.md)
281
283
  - [the by-hand walkthrough](docs/WALKTHROUGH.md)
282
284
  - **Upgrade:** [staying up to date](docs/CLI.md#staying-up-to-date) ·
283
- [upgrading to 4.0](docs/migrations/upgrading-to-4.md)
285
+ [upgrading to 4.0](docs/migrations/upgrading-to-4.md) ·
286
+ [step-by-step upgrade guide](docs/migrations/upgrade-guide-3x-to-4x.md)
284
287
  - **Project:**
285
288
  - [CONTRIBUTING.md](CONTRIBUTING.md)
286
289
  - [SECURITY.md](SECURITY.md)
package/bin/commands.mjs CHANGED
@@ -12,7 +12,7 @@
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';
@@ -36,6 +36,7 @@ export { runRiskMap } from '../cli/riskmap-command.mjs';
36
36
  export { runCodeowners } from '../cli/codeowners-command.mjs';
37
37
  export { runDocs } from '../cli/docs.mjs';
38
38
  export { runDetect } from '../cli/detect.mjs';
39
+ export { runToolboxAdd, runToolboxCheck, runToolboxList, runToolboxRemove } from '../cli/toolbox.mjs';
39
40
  export { runDoctor } from '../cli/doctor.mjs';
40
41
  export { runMigrate, warnIfProjectAhead } from '../cli/migrate.mjs';
41
42
  export { runNext } from '../cli/next.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)
@@ -116,6 +117,18 @@ ${c.bold('Where am I / what next')}
116
117
  Bind a step to a skill of your own. Several skills run in
117
118
  the order given, one after another — each costs tokens
118
119
  yad skill unbind <step> Drop the binding; the step goes back to the engine's default
120
+ yad toolbox list [--json] [--dir <folder>]
121
+ The external tools yadflow can use — core (offered at setup),
122
+ recommended pools, the design/testing/learning connectors and
123
+ this project's own — whether each is found here, whether this
124
+ project uses it, and what yadflow does without it. Installs nothing
125
+ yad toolbox check [--json] The tools this project uses that are not ready here, each with
126
+ its install command and fallback. Never fails: none is required
127
+ yad toolbox add <id> Use a tool in this Product (.sdlc/toolbox.json); prints how to
128
+ install it, installs nothing. Your own tool: add <id> --custom
129
+ --role "<text>" --fallback "<text>" --detect <kind>:<name>[,…]
130
+ [--install "<type>: <command>"] [--source <https URL>]
131
+ yad toolbox remove <id> Stop using a tool in this Product, or drop one of your own
119
132
  yad detect [--json] [--dir <folder>] Which skills, agents, MCP servers and plugins are installed
120
133
  for the agents that work here — in this folder and in your
121
134
  home folder, and which agents read each place. Read-only;
@@ -326,7 +339,7 @@ ${c.bold('Environment')}
326
339
  YAD_NO_REPORT=1 Never offer to file a bug report after a failure
327
340
  YAD_PLATFORM_LOGIN=0 Name a record's author by git user.name; never ask gh/glab who is logged in`;
328
341
 
329
- const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--profile', '--parent', '--inherits', '--to', '--retro-ship', '--merge-commit', '--path', '--ide-targets', '--theme', '--thread', '--by', '--count', '--engagement', '--keep']);
342
+ const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--profile', '--parent', '--inherits', '--to', '--retro-ship', '--merge-commit', '--path', '--ide-targets', '--theme', '--thread', '--by', '--count', '--engagement', '--keep', '--role', '--fallback', '--detect', '--install', '--source']);
330
343
 
331
344
  function parseArgs(argv) {
332
345
  const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
@@ -373,6 +386,7 @@ function parseArgs(argv) {
373
386
  // The roadmap and the release check spell the default `--preview`. Accepting it means a script can
374
387
  // say what it means rather than relying on the absence of a flag.
375
388
  else if (a === '--preview') o.apply = false;
389
+ else if (a === '--custom') o.custom = true;
376
390
  else if (a === '--json') o.json = true;
377
391
  else if (a === '-h' || a === '--help') o.help = true;
378
392
  else if (a === '-v' || a === '--version') o.version = true;
@@ -422,6 +436,7 @@ const ACTIONS = {
422
436
  docs: { known: ['list', 'build', 'deploy', 'sync'], default: 'list' },
423
437
  reconcile: { known: ['check', 'refresh', 'wire'] },
424
438
  hook: { known: ['ledger-guard'] },
439
+ toolbox: { known: ['list', 'check', 'add', 'remove'], default: 'list' },
425
440
  };
426
441
  // E80 — how each command finds its folder. A PRODUCT command works on the Product; a REPO command works
427
442
  // on the code repo it runs in and reads the Product beside it. The rest take their folder as given:
@@ -525,8 +540,12 @@ async function main() {
525
540
  // registers (through `.yad-workspace.json`) — and says where, on stderr. A code-repo command keeps acting
526
541
  // on the repo it runs in, and is handed the Product to read (its registry: name, platform, branch).
527
542
  const dirGiven = process.argv.slice(2).some((a) => a === '--dir' || a.startsWith('--dir='));
543
+ // E85: where `check` and `update` look for tools, and whose choices they read — worked out from the
544
+ // folder the person ran yad in (or named), before a Product command moves `o.dir` to the Product.
545
+ let toolboxAt = null;
528
546
  if (PRODUCT_CMDS.has(cmd) || REPO_CMDS.has(cmd)) {
529
547
  const found = commands.findProduct(dirGiven ? o.dir : shellCwd());
548
+ toolboxAt = { from: commands.toolsFolder(found, dirGiven ? o.dir : shellCwd()), product: found?.root ?? null };
530
549
  const shown = (p) => path.relative(process.cwd(), p) || '.';
531
550
  if (found?.problem) warn(`${found.problem} — using ${o.dir}`);
532
551
  else if (found?.elsewhere) {
@@ -557,12 +576,12 @@ async function main() {
557
576
  // A project written by a newer yadflow is warned about before any command reads it (docs/migrations/
558
577
  // shape-8.md). Not on `hook` — its stderr is the channel a block reason reaches a model on — and not
559
578
  // where the command reports the same thing itself (doctor, migrate) or runs before a project exists.
560
- if (!['hook', 'doctor', 'migrate', 'setup', 'report', 'detect', 'new', 'init', 'join'].includes(cmd)) commands.warnIfProjectAhead(o.dir || process.cwd());
579
+ if (!['hook', 'doctor', 'migrate', 'setup', 'report', 'detect', 'toolbox', 'new', 'init', 'join'].includes(cmd)) commands.warnIfProjectAhead(o.dir || process.cwd());
561
580
  // E122: the Product's settings (or an epic's PR ledger) under two names that say different things.
562
581
  // Every command refuses rather than pick one — whichever it picked, somebody's edit would be silently
563
582
  // ignored. Not `doctor` (it reports the drift), `migrate` (it ends it) or `report` (it files a bug
564
583
  // about a broken flow, and reads nothing it could get wrong). `hook` returned above: it guards.
565
- if (!['doctor', 'migrate', 'report', 'detect'].includes(cmd)) {
584
+ if (!['doctor', 'migrate', 'report', 'detect', 'toolbox'].includes(cmd)) {
566
585
  // Both the folder the command runs on and, for a code-repo command, the Product it reads.
567
586
  for (const root of new Set([o.dir || process.cwd(), o.product].filter(Boolean))) {
568
587
  const drift = commands.productDriftError(root);
@@ -611,11 +630,11 @@ async function main() {
611
630
  }
612
631
  case 'check':
613
632
  if (noProduct()) break;
614
- 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 });
633
+ 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 });
615
634
  break;
616
635
  case 'update':
617
636
  if (noProduct()) break;
618
- result = await commands.reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
637
+ 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 });
619
638
  break;
620
639
  case 'doctor':
621
640
  result = await commands.runDoctor(o.dir, { json: o.json });
@@ -623,6 +642,49 @@ async function main() {
623
642
  case 'migrate':
624
643
  result = await commands.runMigrate(o.dir, { apply: o.apply, json: o.json, keep: o.keep ?? null });
625
644
  break;
645
+ case 'toolbox': {
646
+ // E84 + E86. Finding a tool reads the folder yad runs in (a code repo's own skills count); the
647
+ // team's choices are the Product's `.sdlc/toolbox.json`, found the way every Product command finds
648
+ // it. So `toolbox` is not in PRODUCT_CMDS: that would move the finding to the Product's folder.
649
+ const [, action = 'list', id, extra] = o._;
650
+ const usage = 'usage: yad toolbox list | check | add <id> [--custom …] | remove <id> [--json] [--dir <folder>]';
651
+ if (!['list', 'check', 'add', 'remove'].includes(action)) { refuse(`unknown toolbox action: ${action} (list, check, add, remove)`, usage); break; }
652
+ const found = commands.findProduct(dirGiven ? o.dir : shellCwd());
653
+ const rel = (p) => path.relative(process.cwd(), p) || '.';
654
+ if (action === 'list' || action === 'check') {
655
+ if (id) { refuse(`yad toolbox ${action} takes no more words (got: ${id})`, usage); break; }
656
+ // A reader may be pointed at a code repo with --dir: it finds tools there and reads the Product
657
+ // that repo belongs to. Why no Product was found is said, as every Product command says it —
658
+ // otherwise the team's choices would be missing from the answer without a word.
659
+ if (found?.problem) warn(`${found.problem} — showing the defaults`);
660
+ 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)}`);
661
+ const productRoot = found?.root ?? null;
662
+ const run = action === 'list' ? commands.runToolboxList : commands.runToolboxCheck;
663
+ result = run(commands.toolsFolder(found, o.dir || process.cwd()), { json: o.json, productRoot });
664
+ break;
665
+ }
666
+ if (extra) { refuse(`yad toolbox ${action} takes one tool id (got also: ${extra})`, usage); break; }
667
+ // A writer: the Product's file, never a code repo's, and the same refusals every writer has. With
668
+ // --dir it is the Product itself, as for every other Product command (`yad skill bind --dir`) —
669
+ // never a folder a walk up from it happens to reach.
670
+ const productRoot = dirGiven ? (found?.via === 'here' ? found.root : null) : (found?.root ?? null);
671
+ if (!productRoot) {
672
+ const near = found?.root ?? found?.elsewhere;
673
+ const where = dirGiven ? `with --dir, name the Product folder itself (the one holding .sdlc/product.json)${near ? `: --dir ${rel(near)}` : ''}`
674
+ : found?.elsewhere ? `the Product is ${rel(found.elsewhere)}: cd there, or pass --dir ${rel(found.elsewhere)}`
675
+ : `${found?.problem ? `${found.problem}. ` : ''}run it from the Product, from inside a repo it registers, or pass --dir <the Product>; \`yad new\` / \`yad init\` start one`;
676
+ refuse(`no Product here (${o.dir}) — yad toolbox ${action} writes the Product's .sdlc/toolbox.json`, where);
677
+ break;
678
+ }
679
+ if (found.via !== 'here') process.stderr.write(c.dim(`Product: ${rel(productRoot)}\n`));
680
+ commands.warnIfProjectAhead(productRoot);
681
+ const drift = commands.productDriftError(productRoot);
682
+ if (drift) throw drift;
683
+ result = action === 'add'
684
+ ? commands.runToolboxAdd(productRoot, { id, custom: o.custom, role: o.role, fallback: o.fallback, detect: o.detect, install: o.install, source: o.source, json: o.json })
685
+ : commands.runToolboxRemove(productRoot, { id, custom: o.custom, role: o.role, fallback: o.fallback, detect: o.detect, install: o.install, source: o.source, json: o.json });
686
+ break;
687
+ }
626
688
  case 'detect': {
627
689
  // E50. Reads the folder and the home folder; writes nothing.
628
690
  const [, extra] = o._;
@@ -1001,8 +1063,9 @@ main()
1001
1063
  // block reason travels on — an update banner there would land in front of a model.
1002
1064
  // Resolved the way main() resolves it, NOT from argv[2]: that is the first raw argument, so
1003
1065
  // `yad --dir <path> hook ledger-guard` puts `--dir` there and the banner slips through.
1004
- // Nor `detect` (E50): it promises no network and no file written, and the check does both.
1005
- if (!['hook', 'detect'].includes(parseArgs(process.argv.slice(2))._[0])) {
1066
+ // Nor `detect` (E50) or `toolbox` (E84): they promise no network and no file written, and the
1067
+ // check does both.
1068
+ if (!['hook', 'detect', 'toolbox'].includes(parseArgs(process.argv.slice(2))._[0])) {
1006
1069
  const { maybeNotifyUpdate } = await import('./commands.mjs');
1007
1070
  await maybeNotifyUpdate();
1008
1071
  }
package/cli/detect.mjs CHANGED
@@ -163,7 +163,7 @@ const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
163
163
  // `--json` escapes only U+0000–001F and hands the rest to a program unchanged.
164
164
  // eslint-disable-next-line no-control-regex -- matching control characters is the point
165
165
  const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g;
166
- const clean = (v) => String(v).replace(UNSAFE, '?');
166
+ export const clean = (v) => String(v).replace(UNSAFE, '?');
167
167
 
168
168
  // ---- the three file formats -------------------------------------------------------------------------
169
169
 
package/cli/doctor.mjs CHANGED
@@ -25,6 +25,7 @@ import { soloTeamHint, TEAM_CMD } from './people.mjs';
25
25
  import { indexFreshness, INDEX_FILE } from './product-index.mjs';
26
26
  import { productGit, resolveDefaultBranch } from './productcommit.mjs';
27
27
  import { readOwners } from './owners.mjs';
28
+ import { loadChoices, readToolboxFile } from './toolbox.mjs';
28
29
 
29
30
  // A registered path doctor may run git in (E81): a checkout the judgement accepts — never a refused
30
31
  // entry (git run in a folder the Product shaped as a repo reads its `config`, which can run a command),
@@ -446,6 +447,7 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
446
447
  // inside the `project` block. The renderer prints a header every time the section CHANGES, so a
447
448
  // `project` check added after the `shape` section prints the word "project" a second time.
448
449
  skillBindingChecks(checks, root);
450
+ toolboxFileChecks(checks, root);
449
451
 
450
452
  // repos.json: parse + every entry is a live git repo; staleness vs syncedHead
451
453
  let registry = { repos: [] };
@@ -2107,6 +2109,28 @@ export function skillBindingChecks(checks, root) {
2107
2109
  }
2108
2110
  }
2109
2111
 
2112
+ // `.sdlc/toolbox.json`: which toolbox tools this Product uses or skips, and its own tools (E86). Absent is
2113
+ // the normal case. THE FILE ONLY: whether a tool is installed is `yad toolbox check`'s answer, and a
2114
+ // missing tool is never a doctor line (decided with E86 — no tool is required). A broken file fails, as
2115
+ // skills.json does; a line that does nothing warns, because `yad toolbox list` quietly shows the default.
2116
+ export function toolboxFileChecks(checks, root) {
2117
+ const rel = PROJECT_FILES.toolboxConfig;
2118
+ if (!exists(path.join(root, rel))) return;
2119
+ const { error } = readToolboxFile(root);
2120
+ if (error) {
2121
+ check(checks, 'toolbox', 'project', 'fail', `${rel} ${error}`, 'fix the JSON or restore it from git — `yad toolbox list` shows the defaults until then');
2122
+ return;
2123
+ }
2124
+ const { problems, unreachable, shipped, custom } = loadChoices(root);
2125
+ if (problems.length) {
2126
+ // An entry with no id (or an empty one) is out of `remove`'s reach; only hand-editing fixes it.
2127
+ check(checks, 'toolbox', 'project', 'warn', `${rel}: ${problems.length} line(s) do nothing — ${problems.map((p) => p.replace(`${rel}: `, '')).join('; ')} [YAD-CFG-007]`,
2128
+ `fix them by hand, or \`yad toolbox remove <id>\` — it clears whatever the file holds under that id — and add the tool again${unreachable ? ' (an entry with no id, or an empty one: fix it by hand)' : ''}`);
2129
+ return;
2130
+ }
2131
+ check(checks, 'toolbox', 'project', 'ok', `toolbox: ${shipped.size} shipped tool choice(s), ${custom.length} tool(s) of this project's own`);
2132
+ }
2133
+
2110
2134
  // A step this release does not recognise. Every step the engine can run has a row in the step
2111
2135
  // catalogue (E4), and that row is what gives it both a phase and a skill. So an id no phase claims is
2112
2136
  // an id with no row at all: one `yad next` cannot guide, `yad gate` has no artifact rule for, and no
package/cli/errors.mjs CHANGED
@@ -30,6 +30,7 @@ export const CODES = {
30
30
  'YAD-CFG-004': 'learning.json names an unknown learning tool (expected one of config.yaml learning.tools, or none)',
31
31
  'YAD-CFG-005': 'the Product settings (product.json, or hub.json on a Product that has only that name) set a platform but are missing git_url (required to scope auth + open PRs)',
32
32
  'YAD-CFG-006': 'skills.json binds a step to something that is not a skill name (expected a string, or a non-empty list of strings)',
33
+ 'YAD-CFG-007': 'toolbox.json has a line that does nothing — a tool this yadflow does not ship, a choice other than use or skip, or a custom tool that is incomplete, malformed, listed twice or uses a shipped tool\'s id',
33
34
  'YAD-CLI-001': 'a --json run needed an answer that only a prompt could give',
34
35
  };
35
36
 
@@ -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
package/cli/manifest.mjs CHANGED
@@ -275,6 +275,9 @@ export const PROJECT_FILES = {
275
275
  // the normal case and means "every step uses the skill the catalogue names" — like design.json,
276
276
  // which is absent on a markdown-only project.
277
277
  skillsConfig: '.sdlc/skills.json',
278
+ // Which toolbox tools this project uses or skips, and its own tools (E86). Absent is the normal case:
279
+ // the core tools and the connected design, testing and learning tools are the ones in use.
280
+ toolboxConfig: '.sdlc/toolbox.json',
278
281
  // The kill switch and the Shape steps a team has set to `advance: auto` (E34). Absent is the normal
279
282
  // case: the switch is off and every Shape step is `human`. A project file, not the module config the
280
283
  // switch lived in before (`_bmad/sdlc/config.yaml` then, `.sdlc/config.yaml` since E3) — that one is
package/cli/reconcile.mjs CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import { gitHead, packRepo } from './setup.mjs';
20
20
  import { groupByRoot, commitUpdates, repoLabel } from './update-commit.mjs';
21
21
  import { hasSiblingRepo, workspaceFileState, writeWorkspaceFile, WORKSPACE_FILE } from './find-product.mjs';
22
+ import { offerToolbox } from './toolbox.mjs';
22
23
 
23
24
  const MARK = { missing: c.red('missing'), new: c.cyan('new'), outdated: c.yellow('outdated'), modified: c.cyan('modified'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
24
25
 
@@ -30,7 +31,7 @@ const repoShown = (repo) => `product (the code repo at ${forTerminal(repo.path)}
30
31
  const shown = (a) => a.shownScope ?? a.scope;
31
32
  const itemsOf = (actions) => actions.map((a) => ({ scope: a.scope, item: a.item, status: a.status, product: !a.fromRepo }));
32
33
 
33
- export async function reconcile(root, { fix = false, scope = 'all', force = false, push = false, allowBranch = false, overwriteLocal = false } = {}) {
34
+ export async function reconcile(root, { fix = false, scope = 'all', force = false, push = false, allowBranch = false, overwriteLocal = false, toolbox: toolboxAt = null } = {}) {
34
35
  log(c.bold(`\nSDLC reconcile ${c.dim('v' + VERSION)}`));
35
36
  log(c.dim(`target: ${root}\n`));
36
37
 
@@ -239,7 +240,9 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
239
240
  if (workspaceFile === 'missing') info(`no ${WORKSPACE_FILE} beside the Product — \`yad check --fix\` writes it, so yad finds the Product from its code repos`);
240
241
  if (push) warn('--push has no effect without --fix (there is nothing applied to commit).');
241
242
  if (fixable.length || gaps.length) hand('run `yad check --fix` to reconcile (or `yad setup` for missing one-time setup).');
242
- return { fix: false, counts, gaps, items: itemsOf(actions), applied: 0, modified: modified.length, commits: [], workspaceFile };
243
+ // After the --fix hint, never above it: --fix installs no tool, and must not read as if it did.
244
+ const toolbox = toolboxSection(root, toolboxAt);
245
+ return { fix: false, counts, gaps, items: itemsOf(actions), applied: 0, modified: modified.length, commits: [], workspaceFile, toolbox };
243
246
  }
244
247
 
245
248
  // --- apply --- (collect the applied actions so --push can stage each repo's exact allowlist) ---
@@ -314,5 +317,18 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
314
317
  },
315
318
  });
316
319
  }
317
- return { fix: true, counts, gaps, items: itemsOf(actions), applied, modified: modified.length, commits, workspaceFile };
320
+ const toolbox = toolboxSection(root, toolboxAt);
321
+ return { fix: true, counts, gaps, items: itemsOf(actions), applied, modified: modified.length, commits, workspaceFile, toolbox };
322
+ }
323
+
324
+ // E85: `yad check` and `yad update` end with the toolbox check — the tools this Product uses that are not
325
+ // here. Report only: it never adds to what `--fix` applies, never changes the exit code, and installs
326
+ // nothing. Both verbs, because both run here and a release can add a core tool. `at` comes from the
327
+ // command line: `from`, the top of the repo the person ran yad in, where tools are looked for (as
328
+ // `yad toolbox check` does — a code repo's own skills count); `product`, the Product whose choices are
329
+ // read, which is not `root` under `--dir <a code repo>`.
330
+ function toolboxSection(root, at) {
331
+ log('');
332
+ log(c.bold('Toolbox'));
333
+ return offerToolbox(at?.from || root, at?.product || root);
318
334
  }
package/cli/setup.mjs CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  import { modeFields, modeOf } from './mode.mjs';
17
17
  import { recordActor } from './skip.mjs';
18
18
  import { loadSkillBindings, stepSkills } from './epic-state.mjs';
19
+ import { offerToolbox } from './toolbox.mjs';
19
20
 
20
21
  // Parse a comma/space separated list into a clean, deduped array of trimmed tokens.
21
22
  export function parseList(s) {
@@ -322,8 +323,9 @@ export async function runSetup(root, opts = {}) {
322
323
 
323
324
  // 0. Profile interview — branch the wizard to the user's situation (solo/team, code, repo layout).
324
325
  const { solo, team_size, codebase, repo_layout, configureTools } = await resolveProfile(root, opts);
325
- // Steps: interview, preflight, install, Product, tools (1 if deferred else 3), repos, wire, coderabbit, done.
326
- const total = 8 + (configureTools ? 3 : 1);
326
+ // Steps: interview, preflight, install, Product, tools (1 if deferred else 3), repos, wire, coderabbit,
327
+ // toolbox, done.
328
+ const total = 9 + (configureTools ? 3 : 1);
327
329
  let _n = 0;
328
330
  const S = (title) => step(++_n, total, title);
329
331
 
@@ -672,6 +674,13 @@ export async function runSetup(root, opts = {}) {
672
674
  }
673
675
  }
674
676
 
677
+ // E85: the external tools this project uses that are not here, and how to get each. AFTER the tools
678
+ // step, so a connector chosen there counts as in use. It offers and never installs: nothing is run and
679
+ // no file is written, and a missing tool never fails setup (each has a fallback).
680
+ S('Toolbox (external tools this project uses)');
681
+ guide(['Each tool is optional and has a fallback. yad installs nothing: it prints how to get each one, and you choose.']);
682
+ const toolbox = offerToolbox(root, root);
683
+
675
684
  // Summary + version stamp
676
685
  S('Done');
677
686
  writeJSON(path.join(root, PROJECT_FILES.version), { version: VERSION, ideTargets, updatedAt: opts.today ?? null });
@@ -713,7 +722,7 @@ export async function runSetup(root, opts = {}) {
713
722
  // step has written, so a scripted setup passes every flag (or SDLC_NONINTERACTIVE for the defaults).
714
723
  return {
715
724
  profile: { solo, team_size, codebase, repo_layout }, ideTargets,
716
- repos: registry.repos.map((r) => r.name ?? null), next: epicSkill,
725
+ repos: registry.repos.map((r) => r.name ?? null), next: epicSkill, toolbox,
717
726
  };
718
727
  }
719
728