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 +54 -0
- package/README.md +2 -1
- package/bin/commands.mjs +2 -2
- package/bin/yad.mjs +31 -15
- package/cli/detect.mjs +2 -2
- package/cli/docs.mjs +1 -1
- package/cli/doctor.mjs +75 -3
- package/cli/epic-state.mjs +121 -24
- package/cli/epic.mjs +7 -5
- package/cli/find-product.mjs +24 -0
- package/cli/next.mjs +21 -9
- package/cli/recommend.mjs +301 -0
- package/cli/reconcile.mjs +19 -3
- package/cli/setup.mjs +14 -4
- package/cli/skill.mjs +263 -63
- package/cli/toolbox.mjs +479 -26
- package/cli/workspace.mjs +9 -1
- package/package.json +1 -1
- package/skills/sdlc/config.yaml +1 -1
- package/skills/sdlc/module-help.csv +1 -1
- package/skills/yad-backfill/SKILL.md +40 -7
- package/skills/yad-backfill/references/backfill.md +5 -4
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-connect-design/SKILL.md +16 -0
- package/skills/yad-connect-learning/SKILL.md +14 -0
- package/skills/yad-connect-repos/SKILL.md +34 -5
- package/skills/yad-connect-repos/references/code-context.md +5 -5
- package/skills/yad-connect-testing/SKILL.md +20 -0
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-epic/references/state-schema.md +10 -2
- package/skills/yad-learn/SKILL.md +14 -0
- package/skills/yad-run/SKILL.md +7 -4
- package/skills/yad-run/references/run-loop.md +2 -1
- package/skills/yad-spec/SKILL.md +37 -6
- package/skills/yad-spec/references/spec-handoff.md +2 -2
- package/skills/yad-test-cases/SKILL.md +20 -0
- package/skills/yad-ui/SKILL.md +36 -3
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 [--
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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.
|
|
8
|
-
// and E85 (the toolbox check)
|
|
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
|
|
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
|
package/cli/epic-state.mjs
CHANGED
|
@@ -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)
|
|
2301
|
-
//
|
|
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.
|
|
2447
|
-
//
|
|
2448
|
-
//
|
|
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.
|
|
2881
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
2954
|
+
if (list.length) ownKey(out, id, list);
|
|
2937
2955
|
}
|
|
2938
2956
|
}
|
|
2939
|
-
return
|
|
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
|
|
2959
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/find-product.mjs
CHANGED
|
@@ -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
|