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