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.
Files changed (48) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +4 -2
  3. package/bin/commands.mjs +3 -1
  4. package/bin/yad.mjs +80 -14
  5. package/cli/claims.mjs +16 -13
  6. package/cli/detect.mjs +2 -2
  7. package/cli/docs.mjs +1 -1
  8. package/cli/doctor.mjs +108 -3
  9. package/cli/epic-state.mjs +121 -24
  10. package/cli/epic.mjs +7 -5
  11. package/cli/manifest.mjs +3 -0
  12. package/cli/members.mjs +822 -0
  13. package/cli/next.mjs +21 -9
  14. package/cli/people.mjs +13 -3
  15. package/cli/platform.mjs +1 -1
  16. package/cli/pr-list.mjs +125 -0
  17. package/cli/protection.mjs +4 -4
  18. package/cli/recommend.mjs +301 -0
  19. package/cli/setup.mjs +3 -2
  20. package/cli/skill.mjs +263 -63
  21. package/cli/standup.mjs +709 -0
  22. package/cli/toolbox.mjs +433 -11
  23. package/cli/usage.mjs +18 -11
  24. package/cli/workspace.mjs +14 -5
  25. package/package.json +5 -3
  26. package/skills/sdlc/config.yaml +1 -1
  27. package/skills/sdlc/module-help.csv +1 -1
  28. package/skills/yad-backfill/SKILL.md +40 -7
  29. package/skills/yad-backfill/references/backfill.md +5 -4
  30. package/skills/yad-change/SKILL.md +1 -1
  31. package/skills/yad-checks/templates/checks/member-check.mjs +197 -0
  32. package/skills/yad-checks/templates/github/yad-product-checks.yml +15 -0
  33. package/skills/yad-checks/templates/gitlab/yad-product-checks.gitlab-ci.yml +9 -0
  34. package/skills/yad-connect-design/SKILL.md +16 -0
  35. package/skills/yad-connect-learning/SKILL.md +14 -0
  36. package/skills/yad-connect-repos/SKILL.md +34 -5
  37. package/skills/yad-connect-repos/references/code-context.md +5 -5
  38. package/skills/yad-connect-testing/SKILL.md +20 -0
  39. package/skills/yad-docs/SKILL.md +1 -1
  40. package/skills/yad-epic/references/state-schema.md +10 -2
  41. package/skills/yad-learn/SKILL.md +14 -0
  42. package/skills/yad-run/SKILL.md +7 -4
  43. package/skills/yad-run/references/run-loop.md +2 -1
  44. package/skills/yad-spec/SKILL.md +37 -6
  45. package/skills/yad-spec/references/spec-handoff.md +2 -2
  46. package/skills/yad-status/SKILL.md +3 -0
  47. package/skills/yad-test-cases/SKILL.md +20 -0
  48. package/skills/yad-ui/SKILL.md +36 -3
@@ -2297,8 +2297,8 @@ export function markInReview(state, step, close = null) {
2297
2297
  // E108 — is `gateRuleFor` and `gateCapFor` at the top of this file (E7, E62, E72), which read
2298
2298
  // the `risk_tags` a seed copies from the row below into the epic's own `state.json`;
2299
2299
  // the fuller step-state model is E38. The `skill` column stays here as the shipped DEFAULT, and a
2300
- // project overrides it in `.sdlc/skills.json` (E6, below) — E51 later slides a per-profile default
2301
- // between the two, once E50 can detect which skills are installed. Three of the five
2300
+ // project overrides it in `.sdlc/skills.json` (E6, below), for every epic or for one route only
2301
+ // (E51: `profiles.<id>.steps`, which wins over the project-wide line). Three of the five
2302
2302
  // skills that used to hand-write a seed now run `yad epic new` instead (E17b), `yad-discovery` runs
2303
2303
  // `yad foundation new` (E75), and `yad-change` runs `yad epic new --parent` for a threaded change-epic
2304
2304
  // (E42).
@@ -2443,9 +2443,9 @@ export const STEPS = [
2443
2443
  // `yad epic new` (E17, cli/epic.mjs); shape 6 is where an epic first RECORDS which profile it is on,
2444
2444
  // and an epic seeded before that field existed still has its route derived by matching its chain
2445
2445
  // (`matchLifecycleProfile`). Which steps are optional per route already lives here and is read per
2446
- // EPIC (E35, `optionalStepsFor`); the short chore and spike lanes are E40. A PROJECT-wide skill
2447
- // binding already exists (E6, below); E51 adds a per-profile one between it and the catalogue
2448
- // default, so two routes can run different skills for the same step.
2446
+ // EPIC (E35, `optionalStepsFor`); the short chore and spike lanes are E40. Which skill runs a step
2447
+ // is bound per project (E6, below) and, more specifically, per route (E51) — both in
2448
+ // `.sdlc/skills.json`, never here, so two routes can run different skills for the same step.
2449
2449
  export const LIFECYCLE_PROFILES = [
2450
2450
  {
2451
2451
  id: 'classic',
@@ -2877,8 +2877,9 @@ const BUILD_STEP_ORDER = STEPS.filter((s) => s.phase === 'build').map((s) => s.i
2877
2877
  // THE FILE WINS, AND THE DOCTOR ONLY REPORTS. Same discipline as `workItemType` and the lifecycle
2878
2878
  // profile: a project may bind a skill this engine has never heard of — that is the whole point, since
2879
2879
  // the engine cannot know what a team installed. So nothing here validates a skill NAME. `yad doctor`
2880
- // reports a binding on a step id the catalogue does not know, and changes nothing. Detecting which
2881
- // skills are actually installed is E50; per-profile defaults are E51.
2880
+ // reports a binding on a step id the catalogue does not know, and changes nothing. `yad skill list`
2881
+ // and `yad skill bind` ask `yad detect` (E50) whether a bound skill is installed on THIS machine, and
2882
+ // only ever warn: a teammate may have it, and the engine cannot see their home folder.
2882
2883
  //
2883
2884
  // SEVERAL SKILLS PER STEP IS A CHAIN, NEVER A PANEL (closed decision 7). They run in the order given,
2884
2885
  // each one seeing what the one before it produced, and the LAST output is the artifact. A panel — run
@@ -2897,8 +2898,19 @@ const BUILD_STEP_ORDER = STEPS.filter((s) => s.phase === 'build').map((s) => s.i
2897
2898
  // }
2898
2899
  //
2899
2900
  // A value may be one skill or a list of them; both are read back as a list, so nothing downstream has
2900
- // to handle two shapes. The wrapper object exists so that E50 and E51 can add keys beside `steps`
2901
- // without the file changing shape.
2901
+ // to handle two shapes. The wrapper object exists so that later rows can add keys beside `steps`
2902
+ // without the file changing shape — and E51 did:
2903
+ //
2904
+ // "profiles": {
2905
+ // "spike": { "steps": { "epic": "spike-epic" } }
2906
+ // }
2907
+ //
2908
+ // A binding under `profiles.<id>` applies only to an epic that RECORDS that route (`recordedRoute`,
2909
+ // below — never a route guessed from its chain). THE MOST SPECIFIC LINE WINS: the route's
2910
+ // own line, then the project-wide `steps`, then the catalogue. A team that binds `epic` for `spike`
2911
+ // gets it on spike epics only, and its project-wide lines still cover every other route. No shape
2912
+ // moves: an older yadflow keeps the unknown key (`yad skill bind` writes the document it read) and
2913
+ // simply runs the project-wide answer for every route.
2902
2914
 
2903
2915
  // The catalogue's own answer for a step, Shape or Build. One lookup, because a step id belongs to
2904
2916
  // exactly one of the two tables (`catalogueSkills` splits them on the same `phase === 'build'` rule),
@@ -2908,7 +2920,8 @@ const BUILD_STEP_ORDER = STEPS.filter((s) => s.phase === 'build').map((s) => s.i
2908
2920
  // chain carrying a step called `constructor` would otherwise resolve its "skill" to a function.
2909
2921
  const CATALOGUE_SKILL = { __proto__: null, ...STEP_SKILL, ...BUILD_STEP_SKILL };
2910
2922
 
2911
- // Read a raw parsed `.sdlc/skills.json` into `{ steps: { <id>: [skill, …] } }`.
2923
+ // Read a raw parsed `.sdlc/skills.json` into
2924
+ // `{ steps: { <id>: [skill, …] }, profiles: { <profile>: { steps: { <id>: [skill, …] } } } }`.
2912
2925
  //
2913
2926
  // Junk is DROPPED here rather than rejected, and that is deliberate: this runs inside `yad next`,
2914
2927
  // which must keep working on a project whose config file someone mistyped. An empty string, an empty
@@ -2925,20 +2938,75 @@ const CATALOGUE_SKILL = { __proto__: null, ...STEP_SKILL, ...BUILD_STEP_SKILL };
2925
2938
  // and the two surfaces would disagree about it: the rendered Build chain folds them (that is what
2926
2939
  // `dedupeConsecutive` has always done for spec+tasks) while the cost note counted them, so a step
2927
2940
  // bound to `["a", "a"]` would be billed for two runs and shown as one.
2928
- export function normalizeBindings(raw) {
2941
+ //
2942
+ // `profiles` gets the same treatment one level down (E51): a profile id is a key out of the same
2943
+ // hand-edited file, so `__proto__` there is added with `defineProperty` too, and a profile whose
2944
+ // `steps` binds nothing usable is left out — it would only route a lookup to an empty layer.
2945
+ const ownKey = (obj, key, value) => Object.defineProperty(obj, key, { value, enumerable: true, writable: true, configurable: true });
2946
+
2947
+ function normalizeSteps(steps) {
2929
2948
  const out = {};
2930
- const steps = isPlainObject(raw) ? raw.steps : null;
2931
2949
  if (isPlainObject(steps)) {
2932
2950
  for (const [id, value] of Object.entries(steps)) {
2933
2951
  const list = dedupeConsecutive((Array.isArray(value) ? value : [value])
2934
2952
  .filter((s) => typeof s === 'string' && s.trim())
2935
2953
  .map((s) => s.trim()));
2936
- if (list.length) Object.defineProperty(out, id, { value: list, enumerable: true, writable: true, configurable: true });
2954
+ if (list.length) ownKey(out, id, list);
2937
2955
  }
2938
2956
  }
2939
- return { steps: out };
2957
+ return out;
2958
+ }
2959
+
2960
+ export function normalizeBindings(raw) {
2961
+ const profiles = {};
2962
+ const rawProfiles = isPlainObject(raw) ? raw.profiles : null;
2963
+ if (isPlainObject(rawProfiles)) {
2964
+ for (const [id, value] of Object.entries(rawProfiles)) {
2965
+ const steps = normalizeSteps(isPlainObject(value) ? value.steps : null);
2966
+ if (Object.keys(steps).length) ownKey(profiles, id, { steps });
2967
+ }
2968
+ }
2969
+ return { steps: normalizeSteps(isPlainObject(raw) ? raw.steps : null), profiles };
2970
+ }
2971
+
2972
+ // The bindings as ONE epic sees them: the project's, plus which route the epic is on (E51).
2973
+ //
2974
+ // A view, not a merge. `stepSkills` reads the route's layer first and the project's after it, so a
2975
+ // line in either layer keeps saying where it came from — `yad skill list` needs that for `source`,
2976
+ // and a merged object would make a route's line indistinguishable from a project-wide one. The same
2977
+ // object goes to every reader of one epic (`nextAction` and the line `yad next` prints beside it), so
2978
+ // the JSON and the prose cannot name two different skills for one step.
2979
+ //
2980
+ // `profile` is null for "no route": no epic yet (`yad next` on an empty Product, `yad setup`), or an
2981
+ // epic whose route nothing can tell. Then only the project-wide layer applies — never a guessed route.
2982
+ export function bindingsForProfile(bindings, profile) {
2983
+ const base = isPlainObject(bindings) ? bindings : { steps: {}, profiles: {} };
2984
+ const id = typeof profile === 'string' && profile ? profile : null;
2985
+ return { ...base, profile: id };
2940
2986
  }
2941
2987
 
2988
+ // The route an epic RECORDS, as written — or null when it records none.
2989
+ //
2990
+ // NOT `epicProfileId`, which falls back to matching the chain. That guess is fine for what a step may
2991
+ // skip, where a wrong answer is a refusal a person sees at once. Here it would be a silent claim that
2992
+ // is believed and acted on: `yad next` names the skill and `yad-run` runs it. Every hand-written and
2993
+ // pre-shape-6 ledger is a shortened `classic`, and the shortest route that fits `[epic, epic-review]`
2994
+ // is `chore` — so a `chore`-only line would reach every older classic epic. The E40 rule for
2995
+ // `routeLacksStep`, for the same reason.
2996
+ //
2997
+ // A route this release does not KNOW is still used as written (rule 3): an epic from a newer release
2998
+ // that records it gets the lines the team bound for it, which is what `yad skill bind` promises when it
2999
+ // records one. The lookup is `Object.hasOwn`, so an unknown id is safe.
3000
+ export const recordedRoute = (state) =>
3001
+ (isPlainObject(state) && typeof state.profile === 'string' && state.profile ? state.profile : null);
3002
+
3003
+ // The view for an epic's own `state.json` — its recorded route. A view that already names a route is
3004
+ // kept: a caller that resolved it once hands the same object on.
3005
+ export const bindingsForEpic = (bindings, state) =>
3006
+ (isPlainObject(bindings) && Object.hasOwn(bindings, 'profile')
3007
+ ? bindings
3008
+ : bindingsForProfile(bindings, recordedRoute(state)));
3009
+
2942
3010
  // The project's bindings, or the empty set when the file is absent or unreadable.
2943
3011
  export const loadSkillBindings = (root) =>
2944
3012
  normalizeBindings(readJSON(path.join(root, PROJECT_FILES.skillsConfig), null));
@@ -2955,22 +3023,48 @@ export function stepSkills(stepId, bindings = null) {
2955
3023
  // object `Object.prototype` answers for `constructor`, `toString` and friends — so a chain carrying
2956
3024
  // a step called `constructor` resolved its "skill" to a function, and `yad next` threw trying to
2957
3025
  // spread it. The catalogue map below is `__proto__: null` for the same reason.
2958
- const steps = bindings?.steps;
2959
- const boundTo = (id) => (isPlainObject(steps) && Object.hasOwn(steps, id) ? steps[id] : null);
2960
- const bound = boundTo(stepId);
2961
- if (Array.isArray(bound) && bound.length) return [...bound];
2962
- // A step that took over from an OLD id inherits the old id's binding until it is given one of its
2963
- // own (rule 3). A project that bound its own skill to `discovery` before E75 renamed the product
2964
- // step to `foundation` would otherwise be switched back to the default without anyone deciding it.
2965
- const legacy = Object.hasOwn(BINDING_SUCCEEDS, stepId) ? boundTo(BINDING_SUCCEEDS[stepId]) : null;
2966
- if (Array.isArray(legacy) && legacy.length) return [...legacy];
3026
+ const found = boundSkills(stepId, bindings);
3027
+ if (found) return [...found.skills];
2967
3028
  const fallback = CATALOGUE_SKILL[stepId];
2968
3029
  return typeof fallback === 'string' ? [fallback] : [];
2969
3030
  }
2970
3031
 
3032
+ // Which LAYER binds a step, and to what: `{ skills, source: 'profile' | 'project' }`, or null when
3033
+ // neither does (the step is on the catalogue's default). The most specific layer wins (E51): the
3034
+ // epic's route, then the project-wide `steps`.
3035
+ //
3036
+ // Inside each layer, a step that took over from an OLD id inherits the old id's binding until it is
3037
+ // given one of its own (rule 3). A project that bound its own skill to `discovery` before E75 renamed
3038
+ // the product step to `foundation` would otherwise be switched back to the default without anyone
3039
+ // deciding it. The layer order comes first: a route's `discovery` line beats a project-wide
3040
+ // `foundation` one, because it is the more specific CHOICE, whatever it is spelled.
3041
+ export function boundSkills(stepId, bindings = null) {
3042
+ const lookup = (steps) => {
3043
+ const boundTo = (id) => (isPlainObject(steps) && Object.hasOwn(steps, id) ? steps[id] : null);
3044
+ const own = boundTo(stepId);
3045
+ if (Array.isArray(own) && own.length) return own;
3046
+ const legacy = Object.hasOwn(BINDING_SUCCEEDS, stepId) ? boundTo(BINDING_SUCCEEDS[stepId]) : null;
3047
+ return Array.isArray(legacy) && legacy.length ? legacy : null;
3048
+ };
3049
+ const profiles = bindings?.profiles;
3050
+ const profile = bindings?.profile;
3051
+ const route = typeof profile === 'string' && isPlainObject(profiles) && Object.hasOwn(profiles, profile)
3052
+ ? profiles[profile]?.steps : null;
3053
+ const fromRoute = lookup(route);
3054
+ if (fromRoute) return { skills: fromRoute, source: 'profile' };
3055
+ const fromProject = lookup(bindings?.steps);
3056
+ return fromProject ? { skills: fromProject, source: 'project' } : null;
3057
+ }
3058
+
2971
3059
  // new step id -> the old step id whose binding it inherits while it has none of its own.
2972
3060
  const BINDING_SUCCEEDS = { __proto__: null, foundation: 'discovery' };
2973
3061
 
3062
+ // The step ids that read a line written under `id` — itself, and any step that took over from it. For
3063
+ // `yad doctor`: a `discovery` line on the `foundation` route is read for `foundation`, so it is not a
3064
+ // line no epic reaches.
3065
+ export const stepsReadingBinding = (id) =>
3066
+ [id, ...Object.keys(BINDING_SUCCEEDS).filter((k) => BINDING_SUCCEEDS[k] === id)];
3067
+
2974
3068
  // The two keys every action object carries for its skill, from one resolved list.
2975
3069
  //
2976
3070
  // `skill` STAYS A STRING and is the first of the chain, because ~20 call sites and `yad next --json`
@@ -3318,8 +3412,11 @@ export function repairState(state, close = null) {
3318
3412
  // reopened — a lane per step re-opened behind finished work (E41), each shaped like an action
3319
3413
  // debt — the steps still owed as debt (`owedSteps`), the reminder that repeats until they are paid
3320
3414
  export function nextAction(ledger, opts = {}) {
3321
- const a = shapeNextAction(ledger, opts);
3322
3415
  const state = ledger?.state;
3416
+ // E51: every skill this action names is the one for THIS epic's route. Resolved once, here, so the
3417
+ // Shape step, the Build lanes and the re-opened lanes all read the same view.
3418
+ opts = { ...opts, bindings: bindingsForEpic(opts.bindings ?? null, state) };
3419
+ const a = shapeNextAction(ledger, opts);
3323
3420
  if (!state || !Array.isArray(state.steps) || isProductLevel(state) || backfillAnchorKind(state)) return a;
3324
3421
  const lanes = reopenedLanes(ledger, { epicId: a.epicId, currentStep: state.currentStep, bindings: opts.bindings ?? null });
3325
3422
  const debt = owedSteps(state).map((s) => ({ step: s.id, status: stepStatus(s) ?? s.status, record: s.record || null }));
package/cli/epic.mjs CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  DISCOVERY_EPIC, epicIds, epicLineage, epicRel, epicRoot, epicStories, featureStatus, FOUNDATION_EPIC, FOUNDATION_SECTIONS,
37
37
  isGenesisType, isValidEpicId, lifecycleProfile, loadLedger, loadSkillBindings, PRODUCT_DONE, PRODUCT_EPICS,
38
38
  planThreadedSeed, readFrontmatter, roadmapFeatures, seedableProfiles, seedFoundationState,
39
- seedState, staleFoundationGuards, stepSkills, typeNoun, WORK_ITEM_TYPES, workItemType, writeJSON, writeState,
39
+ seedState, staleFoundationGuards, stepSkills, bindingsForEpic, typeNoun, WORK_ITEM_TYPES, workItemType, writeJSON, writeState,
40
40
  } from './epic-state.mjs';
41
41
  import { epicFiles, isVerifiedLedger, productConfigPath } from './manifest.mjs';
42
42
  import { refreshIndexAfterWrite } from './product-index.mjs';
@@ -223,8 +223,9 @@ export async function runEpicNew(root, { slug, type = null, profile = null, stub
223
223
  // Everything else asks the PROJECT first (E6): a team that bound its own skill to `epic` must be
224
224
  // told to run that one, or this command would hand a brand-new epic straight to a skill their
225
225
  // `yad next` will never name again. `yad-backfill` is the one exception, because waking a stub is
226
- // the engine's own `promote` verb rather than a step on any chain.
227
- const skills = stub ? ['yad-backfill'] : stepSkills(first.id, loadSkillBindings(root));
226
+ // the engine's own `promote` verb rather than a step on any chain. The route is the one just seeded
227
+ // (E51), read off the state as `yad next` will read it, so the two name the same skill.
228
+ const skills = stub ? ['yad-backfill'] : stepSkills(first.id, bindingsForEpic(loadSkillBindings(root), state));
228
229
  const skill = skills[0] || null;
229
230
  // `nextSkills` appears only for a bound chain, the same rule `yad next --json` follows — a key that
230
231
  // showed up on every seed would be one more always-null field for every reader to ignore.
@@ -314,7 +315,8 @@ function seedThreaded(root, { epic, dir, files, mdPath, fm, type, parent, profil
314
315
 
315
316
  const { state } = plan;
316
317
  const first = state.steps.find((s) => s.id === state.currentStep);
317
- const skills = stepSkills(first.id, loadSkillBindings(root));
318
+ // The route the change takes from its parent, which `planThreadedSeed` records on the new state.
319
+ const skills = stepSkills(first.id, bindingsForEpic(loadSkillBindings(root), state));
318
320
  const carried = state.steps.filter((s) => s.inherited);
319
321
  if (json) {
320
322
  return emitJSON({
@@ -389,7 +391,7 @@ export async function runFoundationNew(root, { today, json = false } = {}) {
389
391
  for (const f of [files.approvals, files.comments]) if (!fs.existsSync(f)) writeJSON(f, []);
390
392
  fs.mkdirSync(path.join(epicRoot(root, FOUNDATION_EPIC), 'reviews'), { recursive: true });
391
393
 
392
- const skills = stepSkills(state.steps[0].id, loadSkillBindings(root));
394
+ const skills = stepSkills(state.steps[0].id, bindingsForEpic(loadSkillBindings(root), state));
393
395
  const skill = skills[0] || null;
394
396
  const required = FOUNDATION_SECTIONS.filter((s) => !s.optional).map((s) => s.file);
395
397
  const optional = FOUNDATION_SECTIONS.filter((s) => s.optional).map((s) => s.file);
package/cli/manifest.mjs CHANGED
@@ -539,6 +539,9 @@ export const PRODUCT_WIRING = {
539
539
  { src: 'skills/yad-checks/templates/checks/commit-message.sh', dest: 'checks/commit-message.sh', exec: true },
540
540
  { src: 'skills/yad-pr-template/templates/checks/pr-title.sh', dest: 'checks/pr-title.sh', exec: true },
541
541
  { src: 'skills/yad-pr-template/templates/checks/pr-template.sh', dest: 'checks/pr-template.sh', exec: true },
542
+ // E131: a member file is changed only by its own person, and each email it adds is proven on the PR.
543
+ // A Node script (`node checks/member-check.mjs`), so it needs no execute bit.
544
+ { src: 'skills/yad-checks/templates/checks/member-check.mjs', dest: 'checks/member-check.mjs' },
542
545
  ],
543
546
  github: [
544
547
  { src: 'skills/yad-product-bridge/templates/github/yad-gate-sync.yml', dest: '.github/workflows/yad-gate-sync.yml' },