yadflow 3.18.1 → 4.0.0-next.1
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 +355 -0
- package/README.md +79 -26
- package/bin/commands.mjs +41 -0
- package/bin/yad.mjs +437 -124
- package/cli/artifact-status.mjs +34 -15
- package/cli/checkpoint.mjs +69 -49
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/companion.mjs +2 -2
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1472 -97
- package/cli/epic-state.mjs +3478 -232
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +4 -1
- package/cli/gate.mjs +1002 -209
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +266 -55
- package/cli/hubcommit.mjs +6 -17
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +57 -7
- package/cli/lib.mjs +184 -18
- package/cli/manifest.mjs +367 -56
- package/cli/migrate.mjs +726 -53
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +349 -90
- package/cli/openpr.mjs +191 -39
- package/cli/people.mjs +654 -0
- package/cli/plan.mjs +417 -132
- package/cli/platform.mjs +110 -129
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +38 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +21 -15
- package/cli/review.mjs +24 -27
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +139 -287
- package/cli/ship.mjs +7 -6
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +42 -17
- package/cli/tidy.mjs +20 -20
- package/cli/update-commit.mjs +22 -22
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +166 -87
- package/skills/sdlc/module-help.csv +35 -35
- package/skills/yad-analysis/SKILL.md +125 -65
- package/skills/yad-architecture/SKILL.md +34 -23
- package/skills/yad-architecture/references/contract-format.md +10 -8
- package/skills/yad-backfill/SKILL.md +14 -8
- package/skills/yad-backfill/references/backfill.md +1 -1
- package/skills/yad-change/SKILL.md +127 -52
- package/skills/yad-change/references/triage.md +42 -28
- package/skills/yad-checks/SKILL.md +89 -45
- package/skills/yad-checks/references/check-gates.md +315 -92
- package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
- package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
- package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
- package/skills/yad-commit/SKILL.md +6 -6
- package/skills/yad-connect-design/SKILL.md +6 -6
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +12 -12
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +92 -54
- package/skills/yad-connect-repos/references/code-context.md +6 -6
- package/skills/yad-connect-repos/references/hub-config.md +68 -58
- package/skills/yad-connect-repos/references/repos-registry.md +10 -9
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +6 -6
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +8 -8
- package/skills/yad-discovery/SKILL.md +130 -94
- package/skills/yad-discovery/references/discovery-schema.md +23 -7
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +16 -11
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +32 -17
- package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +88 -24
- package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
- package/skills/yad-epic/SKILL.md +178 -100
- package/skills/yad-epic/references/state-schema.md +626 -117
- package/skills/yad-hub-bridge/SKILL.md +66 -48
- package/skills/yad-hub-bridge/references/bridge.md +110 -83
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
- package/skills/yad-implement/SKILL.md +29 -15
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +64 -29
- package/skills/yad-pair-review/SKILL.md +18 -16
- package/skills/yad-pair-review/references/session-state.md +4 -4
- package/skills/yad-pr-template/SKILL.md +48 -27
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
- package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +12 -9
- package/skills/yad-review-gate/SKILL.md +198 -79
- package/skills/yad-review-gate/references/gating.md +230 -54
- package/skills/yad-run/SKILL.md +86 -56
- package/skills/yad-run/references/run-loop.md +67 -45
- package/skills/yad-ship/SKILL.md +18 -14
- package/skills/yad-spec/SKILL.md +31 -17
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +114 -56
- package/skills/yad-stories/SKILL.md +42 -27
- package/skills/yad-stories/references/story-schema.md +10 -9
- package/skills/yad-stub/SKILL.md +59 -48
- package/skills/yad-sync-repos/SKILL.md +3 -3
- package/skills/yad-test-cases/SKILL.md +37 -30
- package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +46 -25
- package/cli/roster.mjs +0 -164
- package/skills/sdlc/install.sh +0 -68
package/cli/next.mjs
CHANGED
|
@@ -1,50 +1,76 @@
|
|
|
1
1
|
// `yad next` — the unified next-step driver. Read-only: it never writes state or acts. It reads the
|
|
2
2
|
// file ledger and prints the ONE concrete, copy-pasteable next action (and a one-line why), so a user
|
|
3
3
|
// never has to remember which of the 38 skills / gate commands comes next. "Guide, don't act" — the
|
|
4
|
-
//
|
|
4
|
+
// Shape still never auto-advances. Once an epic is `ready-for-build`, it reads each story's
|
|
5
5
|
// build-state and prints the next BUILD sub-step per repo (spec → tasks → implement → checks → engineer-review)
|
|
6
|
-
// plus the remaining chain — so
|
|
6
|
+
// plus the remaining chain — so Build is guided too, not just hinted at.
|
|
7
7
|
//
|
|
8
8
|
// yad next general orientation across the whole project
|
|
9
9
|
// yad next <epic> the single next action for one epic
|
|
10
10
|
// yad next <epic> --check <step> exit 0 if <step> is runnable now, else 1 (the precondition guard)
|
|
11
11
|
// yad next --all every active epic's next action at once
|
|
12
12
|
// yad next [<epic>] --json the same answer as an action object, for an agent or CI
|
|
13
|
-
import fs from 'node:fs';
|
|
14
13
|
import path from 'node:path';
|
|
15
|
-
import { c, log, ok, info, warn, hand, fail, readJSON, exists } from './lib.mjs';
|
|
16
|
-
import { PROJECT_FILES,
|
|
17
|
-
import {
|
|
14
|
+
import { c, log, ok, info, warn, hand, fail, readJSON, exists, emitJSON } from './lib.mjs';
|
|
15
|
+
import { PROJECT_FILES, isVerifiedLedger, productConfigPath, stepAdvance } from './manifest.mjs';
|
|
16
|
+
import { printTeamHint, soloTeamHint } from './people.mjs';
|
|
17
|
+
import { dedupeConsecutive, epicIds, isGateStep, killSwitchOn, loadAutomation, epicRel, epicRoot, loadLedger, loadSkillBindings, stepSkills, nextAction, preconditionsMet, isValidEpicId, epicLineage, typeNoun, phaseOf, stepPhase, profileSteps, lifecycleProfile, PHASES, PRODUCT_DONE, PRODUCT_EPICS } from './epic-state.mjs';
|
|
18
18
|
|
|
19
19
|
// Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
|
|
20
20
|
// missing/old hub.json never breaks the driver.
|
|
21
|
+
const soloOf = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
|
|
21
22
|
function isSolo(root) {
|
|
22
|
-
|
|
23
|
-
return !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
|
|
23
|
+
return soloOf(readJSON(productConfigPath(root), null));
|
|
24
24
|
}
|
|
25
|
-
//
|
|
26
|
-
const
|
|
27
|
-
// Has `yad setup` run here? True once the version stamp or hub config exists.
|
|
28
|
-
const isSetUp = (root) => exists(path.join(root, PROJECT_FILES.version)) || exists(path.join(root, PROJECT_FILES.hubConfig));
|
|
25
|
+
// Has `yad setup` run here? True once the version stamp or Product config exists.
|
|
26
|
+
const isSetUp = (root) => exists(path.join(root, PROJECT_FILES.version)) || exists(productConfigPath(root));
|
|
29
27
|
|
|
30
|
-
// Every epic that has a state ledger, in
|
|
28
|
+
// Every epic that has a state ledger, the Foundation included, in id order.
|
|
31
29
|
function listEpics(root) {
|
|
32
|
-
|
|
33
|
-
if (!exists(dir)) return [];
|
|
34
|
-
return fs.readdirSync(dir, { withFileTypes: true })
|
|
35
|
-
.filter((e) => e.isDirectory() && isValidEpicId(e.name))
|
|
36
|
-
.map((e) => e.name)
|
|
37
|
-
.filter((id) => exists(path.join(dir, id, '.sdlc', 'state.json')))
|
|
38
|
-
.sort();
|
|
30
|
+
return epicIds(root).filter((id) => exists(path.join(epicRoot(root, id), '.sdlc', 'state.json')));
|
|
39
31
|
}
|
|
40
32
|
|
|
41
|
-
// The action object for ONE epic, with its
|
|
33
|
+
// The action object for ONE epic, with its work-item type attached. The single shape both surfaces
|
|
42
34
|
// consume — `printAction` renders it, `--json` emits it verbatim — so the prose and the machine
|
|
43
35
|
// answer can never drift apart.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
36
|
+
//
|
|
37
|
+
// The key stays `lineageKind` even though the word is now `type`, and there is no `lineageType`
|
|
38
|
+
// beside it. `yad next --json` is deep-equalled by the golden test (cli/test-golden.mjs), which
|
|
39
|
+
// rule 6 says never changes — and a deep-equal breaks on an ADDED key just as hard as a renamed
|
|
40
|
+
// one. The value is the same either way; only the label is old.
|
|
41
|
+
//
|
|
42
|
+
// The lineage may be passed in. `epicLineage` opens and parses `epic.md`, and every printed surface
|
|
43
|
+
// needs the grouping theme off the SAME read — without this the file would be parsed twice per epic
|
|
44
|
+
// on every `yad next`. The default keeps the `--json` path a one-liner, where the theme is not wanted.
|
|
45
|
+
// `bindings` is passed in for the same reason `lin` is: every loop below renders many epics, and the
|
|
46
|
+
// project's skill bindings are ONE file for all of them. The default keeps the single-epic callers a
|
|
47
|
+
// one-liner; a loop reads the file once and hands the same object to every row.
|
|
48
|
+
const actionFor = (root, id, lin = epicLineage(root, id), bindings = loadSkillBindings(root)) => {
|
|
49
|
+
const ledger = loadLedger(epicRoot(root, id));
|
|
50
|
+
return {
|
|
51
|
+
...nextAction(ledger, { epic: id, bindings }),
|
|
52
|
+
lineageKind: lin.type,
|
|
53
|
+
// Which ROUTE this epic RECORDED, carried so `phaseLine` can mark a phase that route never enters
|
|
54
|
+
// (E40). The recorded key ONLY — never matched from the chain, which is the one place in this file
|
|
55
|
+
// that deliberately asks less than `epicProfileId` does.
|
|
56
|
+
//
|
|
57
|
+
// A chain is allowed to be short without its route being short: every hand-written and pre-shape-6
|
|
58
|
+
// ledger is a truncated `classic`, and matching would read `[epic, epic-review]` as the chore lane
|
|
59
|
+
// and grey out two phases the epic is going to walk. Matching is the right fallback where the
|
|
60
|
+
// answer only has to be the best available one — what a step may skip, what `yad doctor` reports.
|
|
61
|
+
// Here it would be a visible claim about an epic's future, made from a guess, and it would flip
|
|
62
|
+
// the moment the next gate write stamped `classic` on the same file. An epic that never says
|
|
63
|
+
// which route it is on simply gets the line it got before the short lanes existed.
|
|
64
|
+
route: lifecycleProfile(ledger?.state?.profile) ? ledger.state.profile : null,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// One `epic.md` read, both things that come out of it: the action to print and the tag to print
|
|
69
|
+
// beside it. Every printed path in this file goes through here.
|
|
70
|
+
const rowFor = (root, id, bindings = loadSkillBindings(root)) => {
|
|
71
|
+
const lin = epicLineage(root, id);
|
|
72
|
+
return { action: actionFor(root, id, lin, bindings), theme: lin.theme };
|
|
73
|
+
};
|
|
48
74
|
|
|
49
75
|
// EP-checkout-S03 → S03 (the compact lane label for the roll-up). Falls back to the full id.
|
|
50
76
|
const shortStory = (s) => (s && s.match(/S\d+$/i)?.[0]) || s || '(story)';
|
|
@@ -52,114 +78,330 @@ const shortStory = (s) => (s && s.match(/S\d+$/i)?.[0]) || s || '(story)';
|
|
|
52
78
|
// Every per-repo lane across the build, flattened with its story id attached.
|
|
53
79
|
const buildLanes = (builds = []) => builds.flatMap((b) => b.repos.map((r) => ({ ...r, story: b.story })));
|
|
54
80
|
|
|
55
|
-
// The dial note for a build lane:
|
|
81
|
+
// The dial note for a build lane: an `advance: auto` lane is driven by yad-run; everything else stops
|
|
56
82
|
// for a human (the locked engineer-review always does).
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
83
|
+
//
|
|
84
|
+
// This is the sentence a person LEARNS the vocabulary from, so it says the new words even though the
|
|
85
|
+
// `--json` key beside it still says `automation` (see buildNextForRepo). Different audiences: the key
|
|
86
|
+
// is read by scripts and is frozen by the golden test; this line is read by people.
|
|
87
|
+
//
|
|
88
|
+
// The kill switch (E34) holds an `auto` lane at human, and the line says so: a lane that reads `auto` while
|
|
89
|
+
// nothing drives it would send a person to wait for a run that is never coming.
|
|
90
|
+
//
|
|
91
|
+
// The gate test is the engine's (`isGateStep`), not `locked` alone: since E34 a `locked` Build author step is
|
|
92
|
+
// not held at human, so calling it "a human merge gate" would say the opposite of what runs.
|
|
93
|
+
function dialNote(r, automation = null) {
|
|
94
|
+
if (isGateStep({ id: r.step, locked: r.locked })) return c.dim('human merge gate');
|
|
95
|
+
if (stepAdvance(r) !== 'auto') return c.dim('advance: human');
|
|
96
|
+
// A broken file holds every step at human too, and `yad unkill` cannot fix a file it cannot read.
|
|
97
|
+
if (automation?.error) return c.dim(`advance: auto — held at human: .sdlc/automation.json ${automation.error} (yad doctor)`);
|
|
98
|
+
return killSwitchOn(automation)
|
|
99
|
+
? c.dim('advance: auto — held at human by the kill switch (yad unkill)')
|
|
100
|
+
: c.dim('advance: auto — yad-run auto-drives');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- how a step's skills read on a line (E6) -----------------------------------------------------
|
|
104
|
+
//
|
|
105
|
+
// A step's skill is no longer one string the engine chose: `.sdlc/skills.json` can bind a different
|
|
106
|
+
// one, or several. The resolver puts the first in `skill` and, ONLY when there is more than one, the
|
|
107
|
+
// whole chain in `skills` — so everything here reads `skills` when it is there and falls back to the
|
|
108
|
+
// single field, and a project that binds nothing prints exactly what it printed before.
|
|
109
|
+
const skillList = (a) => a?.skills || (a?.skill ? [a.skill] : []);
|
|
110
|
+
|
|
111
|
+
// "the yad-architecture skill", or for a bound chain "the shape-it skill, then yad-stories". Closed
|
|
112
|
+
// decision 7: several skills on one step are a CHAIN, not a panel — they run in the order given, each
|
|
113
|
+
// seeing what the one before it produced, and the last output is the artifact.
|
|
114
|
+
function skillPhrase(a, fallback = null) {
|
|
115
|
+
const ids = skillList(a);
|
|
116
|
+
if (!ids.length) return `the ${c.bold(fallback || '(no skill)')} skill`;
|
|
117
|
+
const [first, ...rest] = ids;
|
|
118
|
+
const then = rest.length ? `, then ${rest.map((s) => c.bold(s)).join(', then ')}` : '';
|
|
119
|
+
return `the ${c.bold(first)} skill${then}`;
|
|
62
120
|
}
|
|
63
121
|
|
|
122
|
+
// The cost warning decision 7 asks for. Extra skills are opt-in, and each one is another model run —
|
|
123
|
+
// said here, where the chain is actually about to be invoked, rather than in a document nobody reopens
|
|
124
|
+
// after binding. Null for the normal one-skill step, so the common case gains no noise.
|
|
125
|
+
const costNote = (a) => {
|
|
126
|
+
const n = skillList(a).length;
|
|
127
|
+
return n > 1 ? `${n} skills run for this step, one after another — each one costs tokens` : null;
|
|
128
|
+
};
|
|
129
|
+
|
|
64
130
|
// The detailed per-story/per-repo build lanes for `printAction`. Each open lane is a 2-line block:
|
|
65
131
|
// a header naming the active step + dial, then the actionable `▸` skill line with the remaining chain.
|
|
66
|
-
function printBuildLanes(builds) {
|
|
132
|
+
function printBuildLanes(builds, automation = null) {
|
|
67
133
|
for (const lane of buildLanes(builds)) {
|
|
68
134
|
const where = `${c.cyan(lane.story || '(story)')} / ${c.bold(lane.repo)}`;
|
|
69
135
|
if (lane.shipped) { log(` ${where} ${c.green('— shipped ✓')}`); continue; }
|
|
136
|
+
// A lane skipped whole (E39): nothing to drive, and the reason is the point of the record.
|
|
137
|
+
if (lane.status === 'skipped') { log(` ${where} ${c.dim(`— skipped (N/A)${lane.record?.reason ? `: ${lane.record.reason}` : ''}`)}`); continue; }
|
|
70
138
|
// No resolvable next skill (an empty/half-seeded build-state file): show it as not-started, no ▸.
|
|
71
139
|
if (!lane.skill) { log(` ${where} ${c.dim('— not started yet (no build steps recorded)')}`); continue; }
|
|
72
|
-
log(` ${where} ${c.dim('—')} ${c.bold(lane.step)} (${dialNote(lane)})`);
|
|
73
|
-
|
|
140
|
+
log(` ${where} ${c.dim('—')} ${c.bold(lane.step)} (${dialNote(lane, automation)})`);
|
|
141
|
+
// `chain` is every skill still to run in this lane, the active step's own included — so drop this
|
|
142
|
+
// step's own off the front, or a bound chain would print its second skill again as if it came
|
|
143
|
+
// later. Folded through the SAME helper the chain was built with: `chain` collapses consecutive
|
|
144
|
+
// duplicates, so a step bound to one skill twice has two entries here and one there, and slicing
|
|
145
|
+
// by the raw count would swallow the next step's skill.
|
|
146
|
+
const rest = (lane.chain || []).slice(dedupeConsecutive(skillList(lane)).length);
|
|
74
147
|
const then = rest.length ? ` ${c.dim(`then → ${rest.join(' → ')}`)}` : '';
|
|
75
|
-
hand(`invoke
|
|
148
|
+
hand(`invoke ${skillPhrase(lane)}${then}`);
|
|
149
|
+
const note = costNote(lane);
|
|
150
|
+
if (note) info(c.dim(note));
|
|
76
151
|
}
|
|
77
152
|
}
|
|
78
153
|
|
|
79
154
|
// A short, copy-pasteable line for one action — the `▸` line a user can act on directly.
|
|
80
|
-
|
|
155
|
+
//
|
|
156
|
+
// `bindings` arrives here as well as on the action object, because two of these lines name a skill
|
|
157
|
+
// the RESOLVER never put on the action: the `discovery-done` hand-off to the epic step, and the Build
|
|
158
|
+
// fallback printed when no story has a build-state yet. Both are the engine naming a step's skill in
|
|
159
|
+
// prose, so both must name the project's choice — otherwise `yad next` and `yad skill list` disagree
|
|
160
|
+
// about the same project.
|
|
161
|
+
function actionLine(a, { solo, bindings = null, verified = false } = {}) {
|
|
162
|
+
const runs = (stepId, fallback) => stepSkills(stepId, bindings)[0] || fallback;
|
|
81
163
|
switch (a.kind) {
|
|
82
164
|
case 'new':
|
|
83
|
-
return `invoke
|
|
165
|
+
return `invoke ${skillPhrase(a)} ${c.dim('(author the epic)')}`;
|
|
84
166
|
case 'author':
|
|
85
|
-
return `invoke
|
|
167
|
+
return `invoke ${skillPhrase(a, `yad-${a.step}`)} ${c.dim(`(author ${a.artifact})`)}`;
|
|
86
168
|
case 'review-open':
|
|
87
169
|
case 'review-sync':
|
|
88
170
|
return `${c.bold(a.command)}${solo ? c.dim(' (solo: no approval needed — just merge your own PR)') : ''}`;
|
|
89
171
|
case 'build': {
|
|
90
|
-
// In
|
|
172
|
+
// In Build: compact the lanes to "N lane(s) in build — next: <skill> @ <story>/<repo>".
|
|
91
173
|
if (a.builds?.length) {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
174
|
+
const all = buildLanes(a.builds);
|
|
175
|
+
const open = all.filter((r) => !r.shipped && r.status !== 'skipped');
|
|
176
|
+
if (open.length) {
|
|
177
|
+
// Headline the first lane with a resolvable next skill; if none, the half is started but unspecced.
|
|
178
|
+
const first = open.find((r) => r.skill);
|
|
179
|
+
if (!first) return c.dim(`${open.length} lane(s) in build — not specced yet`);
|
|
180
|
+
return `${c.dim(`${open.length} lane(s) in build — next:`)} ${c.bold(first.skill)} ${c.dim(`@ ${shortStory(first.story)}/${first.repo}`)}`;
|
|
181
|
+
}
|
|
182
|
+
if (all.some((r) => r.shipped)) return c.dim(all.some((r) => r.status === 'skipped') ? 'Build — every lane shipped or skipped' : 'Build — every lane shipped');
|
|
183
|
+
// Every recorded lane is SKIPPED and none shipped (E39 review): a skip written before Build began
|
|
184
|
+
// creates the build-state file on its own. Build has not started, so fall through to the yad-run hint.
|
|
98
185
|
}
|
|
99
|
-
return `${c.bold('yad-run')} ${c.dim(
|
|
186
|
+
return `${c.bold('yad-run')} ${c.dim(`(or per story: ${runs('spec', 'yad-spec')} → ${runs('implement', 'yad-implement')} → yad ship → ${runs('engineer-review', 'yad-engineer-review')})`)}`;
|
|
100
187
|
}
|
|
188
|
+
case 'foundation-done':
|
|
101
189
|
case 'discovery-done':
|
|
102
|
-
return `invoke the ${c.bold('yad-epic')} skill ${c.dim('(seed
|
|
190
|
+
return `invoke the ${c.bold(runs('epic', 'yad-epic'))} skill ${c.dim('(seed the next feature from roadmap.md — `yad foundation status` shows which are started)')}`;
|
|
103
191
|
case 'backfill-pending':
|
|
104
192
|
return `invoke the ${c.bold('yad-backfill')} skill ${c.dim('(document the code, then `yad-backfill promote` — or thread bugs now with yad-change)')}`;
|
|
105
193
|
case 'backfill-done':
|
|
106
194
|
return `invoke the ${c.bold('yad-change')} skill ${c.dim('(documented anchor — evolve it by threading a change/defect off it)')}`;
|
|
195
|
+
// A blocked step (E38) has no command to run, and that is the point: the thing in the way is not in
|
|
196
|
+
// this tool. The line says who recorded the block and when, and how to clear it once the wait is
|
|
197
|
+
// over (E37); the reason itself is on the line above. It used to print `by` as "waiting on", but
|
|
198
|
+
// `by` is who WROTE the record, on every record — the `yad-run` skill writes the login the run ran as —
|
|
199
|
+
// so it named the wrong party.
|
|
200
|
+
case 'blocked': {
|
|
201
|
+
const who = a.record?.by ? ` — recorded by ${a.record.by}${a.record.date ? ` on ${a.record.date}` : ''}` : '';
|
|
202
|
+
// On a verified Product the verb refuses once the ledger is on the default branch, and CI has no step
|
|
203
|
+
// for it yet: naming it there would send the reader to a refusal (E34 review).
|
|
204
|
+
const clear = verified
|
|
205
|
+
? 'on a verified Product only CI can clear it once the epic\'s ledger is on the default branch, and CI has no step for that yet (yad unblock refuses)'
|
|
206
|
+
: `clear it with yad unblock ${a.epicId} ${a.step} once resolved`;
|
|
207
|
+
return c.dim(`blocked${who} — ${clear}`);
|
|
208
|
+
}
|
|
107
209
|
default:
|
|
108
210
|
return c.dim('nothing to do');
|
|
109
211
|
}
|
|
110
212
|
}
|
|
111
213
|
|
|
112
214
|
// Full, friendly printout for a single epic.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
215
|
+
//
|
|
216
|
+
// The grouping theme arrives as an argument rather than as another field on the action object:
|
|
217
|
+
// `printAction` renders `a` and `--json` emits the SAME `a` verbatim, and that JSON is deep-equalled
|
|
218
|
+
// by the golden test, which an added key breaks (see actionFor). It comes from `rowFor`, off the same
|
|
219
|
+
// `epic.md` read the action's own `lineageKind` came from.
|
|
220
|
+
function printAction(a, { solo, theme: tag = null, bindings = null, automation = null, verified = false } = {}) {
|
|
221
|
+
// Prefix the id with the type noun (Defect / Change request / Hotfix / Chore / Epic) so a glance
|
|
222
|
+
// says what kind of work this is. The product level is not a feature — leave it un-prefixed.
|
|
223
|
+
const noun = a.lineageKind && !PRODUCT_EPICS.includes(a.epicId) ? `${typeNoun(a.lineageKind)} ` : '';
|
|
224
|
+
// The free grouping tag, printed only when the epic has one — most do not, and an empty marker on
|
|
225
|
+
// every line would cost more attention than it pays back.
|
|
226
|
+
const theme = tag ? ` ${c.dim(`#${tag}`)}` : '';
|
|
227
|
+
log(`\n ${c.bold(`${noun}${a.epicId || '(epic)'}`)}${theme} ${c.dim(`— ${a.why}`)}`);
|
|
228
|
+
// In Build with live lanes, print each story/repo's next sub-step + remaining chain instead
|
|
119
229
|
// of the single static hint; otherwise the one actionable line.
|
|
120
|
-
if (a.kind === 'build' && a.builds?.length)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
230
|
+
if (a.kind === 'build' && a.builds?.length) {
|
|
231
|
+
printBuildLanes(a.builds, automation);
|
|
232
|
+
// Every lane printed is a skip and nothing has started (E39 review): the lanes alone name no command,
|
|
233
|
+
// so the one actionable line is printed too.
|
|
234
|
+
const lanes = buildLanes(a.builds);
|
|
235
|
+
if (lanes.length && lanes.every((r) => r.status === 'skipped')) hand(actionLine(a, { solo, bindings, verified }));
|
|
236
|
+
} else {
|
|
237
|
+
hand(actionLine(a, { solo, bindings, verified }));
|
|
238
|
+
const note = costNote(a);
|
|
239
|
+
if (note) info(c.dim(note));
|
|
240
|
+
// A Shape author step the team set to `auto` (E34). Recorded, not acted on: say so, or the line reads
|
|
241
|
+
// as a promise that something will run.
|
|
242
|
+
if (a.kind === 'author' && automation?.steps?.[a.step] === 'auto') {
|
|
243
|
+
info(c.dim(killSwitchOn(automation)
|
|
244
|
+
? 'advance: auto — held at human by the kill switch (yad unkill)'
|
|
245
|
+
: 'advance: auto — recorded; nothing drives a Shape step on its own yet'));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Not `c.dim(... c.bold(...) ...)`: `paint` closes with a full reset, so a bold word inside a dim
|
|
249
|
+
// string ends the dim and everything after it reads bright. Painted per segment instead.
|
|
250
|
+
if (a.kind === 'review-sync') info(`${c.dim('unresolved comments?')} ${c.bold(`yad gate comments ${a.epicId} ${a.artifact}`)}`);
|
|
251
|
+
if (a.parallel) {
|
|
252
|
+
hand(`parallel track: invoke ${skillPhrase(a.parallel)} ${c.dim(`(author ${a.parallel.artifact})`)}`);
|
|
253
|
+
const note = costNote(a.parallel);
|
|
254
|
+
if (note) info(c.dim(note));
|
|
255
|
+
}
|
|
256
|
+
// A step re-opened behind finished work (E41) is a lane of its own beside the chain, like the
|
|
257
|
+
// test-cases track above: it gets its own line, with the same words a chain step would get.
|
|
258
|
+
for (const lane of a.reopened || []) hand(`re-opened lane: ${actionLine({ ...lane, epicId: a.epicId }, { solo, bindings, verified })}`);
|
|
259
|
+
// DEBT is reminded on every run until it is paid (E41). The line says what is owed and the one command
|
|
260
|
+
// that starts paying it — or, once that has started, what finishes it.
|
|
261
|
+
for (const d of a.debt || []) warn(debtLine(a.epicId, d, verified));
|
|
262
|
+
phaseLine(a);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// One reminder line for a step owed as debt (E41).
|
|
266
|
+
function debtLine(epicId, d, verified = false) {
|
|
267
|
+
const author = d.step.replace(/-review$/, '');
|
|
268
|
+
if (d.status === 'deferred') {
|
|
269
|
+
const r = d.record || {};
|
|
270
|
+
const when = [r.by ? `by ${r.by}` : '', r.date ? `on ${r.date}` : ''].filter(Boolean).join(' ');
|
|
271
|
+
return `owed (debt): ${author} — deferred${when ? ` ${when}` : ''}${r.reason ? `: ${r.reason}` : ''} — pay it back with yad undefer ${epicId} ${author}${verified ? ' (on a verified Product it is refused once the epic\'s ledger is on the default branch, until CI has a step for it)' : ''}`;
|
|
272
|
+
}
|
|
273
|
+
// Put back, but waiting behind an earlier step that has not passed: say so, rather than "being paid
|
|
274
|
+
// back" about a step nobody can work on yet.
|
|
275
|
+
if (d.status === 'todo') return `owed (debt): ${author} — back in the chain, waiting for an earlier step; the debt clears when ${author}-review passes`;
|
|
276
|
+
return `owed (debt): ${author} — being paid back (${d.step} is ${d.status}); the debt clears when ${author}-review passes`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Where this epic sits in the lifecycle: the six phases, with the current one marked.
|
|
280
|
+
//
|
|
281
|
+
// `phaseOf` is given the same two facts every renderer needs — the epic's current step and whether it
|
|
282
|
+
// is the product level — so `yad next` and `yad thread` cannot answer this differently. It returns
|
|
283
|
+
// null for a stub and for any step id this release does not recognise, and this line then stays away
|
|
284
|
+
// entirely: a wrong phase is worse than no phase.
|
|
285
|
+
//
|
|
286
|
+
// The two unbuilt phases are shown, greyed, rather than hidden. Someone reading this needs to see
|
|
287
|
+
// that the lifecycle does not stop at merge; a list that ended at Build would say it does.
|
|
288
|
+
//
|
|
289
|
+
// THE PRODUCT LEVEL GETS ITS OWN LINE (E75), not the six-phase strip. The Foundation runs once per
|
|
290
|
+
// product and never walks Design, Plan or Build, so printing those six with nothing marked would claim
|
|
291
|
+
// a journey it does not take — which is why this line used to be absent for `EP-discovery` altogether.
|
|
292
|
+
// It now has a phase of its own, on a ladder of its own, and says so in words.
|
|
293
|
+
function phaseLine(a) {
|
|
294
|
+
const here = phaseOf(a.step, { product: PRODUCT_EPICS.includes(a.epicId) });
|
|
295
|
+
if (!here) return;
|
|
296
|
+
if (here.level === 'product') {
|
|
297
|
+
info(`${c.dim('phase:')} ${c.bold(here.name)} ${c.dim('— the Product level, once per product, before any feature epic')}`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// A phase this epic's ROUTE never enters is marked, not hidden (E40). A `chore` epic has no
|
|
301
|
+
// architecture or UI-design step, so it never sees Design — and printing Design exactly as a
|
|
302
|
+
// `classic` epic prints it makes a lane that will never go there look like one that has already
|
|
303
|
+
// been. Marked rather than dropped for the same reason the two unbuilt phases are: a person reading
|
|
304
|
+
// this needs the whole shape, with their own position in it, not an edited version of the lifecycle.
|
|
305
|
+
// Build is never marked: it is where every feature route ends up, and the Build steps live per story
|
|
306
|
+
// in `build-state/` rather than in this chain, so their absence here means nothing.
|
|
307
|
+
const onRoute = a.route ? profileSteps(a.route) : null;
|
|
308
|
+
const covered = onRoute?.length
|
|
309
|
+
? new Set(PHASES.filter((p) => p.id === 'build'
|
|
310
|
+
|| onRoute.some((id) => stepPhase(id) === p.id)).map((p) => p.id))
|
|
311
|
+
: null;
|
|
312
|
+
const rendered = PHASES.map((p) => {
|
|
313
|
+
if (p.id === here.id) return c.bold(p.name);
|
|
314
|
+
if (!p.built) return c.dim(`${p.name} (planned)`);
|
|
315
|
+
if (covered && !covered.has(p.id)) return c.dim(`${p.name} (not on this route)`);
|
|
316
|
+
return c.dim(p.name);
|
|
317
|
+
});
|
|
318
|
+
// Coloured PER SEGMENT, never nested: `paint` closes with a full reset (\x1b[0m), so a bold word
|
|
319
|
+
// inside a dim string ends the dim for everything after it and the rest of the line reads bright.
|
|
320
|
+
// The current phase is named in WORDS as well as marked in bold, because bold is not always there:
|
|
321
|
+
// `useColor` is off whenever output is not a terminal or NO_COLOR is set, which is every pipe, every
|
|
322
|
+
// log file and every CI job. Without this the line would list six phases and give no way to tell
|
|
323
|
+
// which one you are in — the one thing it exists to say. The part is added only when it is a
|
|
324
|
+
// different word; "now: Build (Build part)" tells nobody anything.
|
|
325
|
+
const part = here.name === here.part ? '' : ` (${here.part} part)`;
|
|
326
|
+
info(`${c.dim('phase:')} ${rendered.join(c.dim(' · '))} ${c.dim(`— now: ${here.name}${part}`)}`);
|
|
124
327
|
}
|
|
125
328
|
|
|
126
329
|
// `yad next` with no epic: orient across the whole project, always ending on ONE thing to do.
|
|
127
|
-
function generalNext(root, { all } = {}) {
|
|
330
|
+
function generalNext(root, { all, headCount = null } = {}) {
|
|
128
331
|
if (!isSetUp(root)) {
|
|
129
332
|
log(`\n ${c.bold('Project not set up yet.')}`);
|
|
130
333
|
hand(`run ${c.bold('yad setup')} ${c.dim('(then come back to `yad next`)')}`);
|
|
131
334
|
return;
|
|
132
335
|
}
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
//
|
|
336
|
+
// The Product config, read ONCE for this view.
|
|
337
|
+
const hub = readJSON(productConfigPath(root), null);
|
|
338
|
+
const solo = soloOf(hub);
|
|
339
|
+
// E74: in solo mode, suggest `yad mode team` when the count shows more than one person may work here.
|
|
340
|
+
// Printed first, so it is not lost under a long list of epics. An unknown count is not said here —
|
|
341
|
+
// `yad doctor` and `yad mode` say it (the user's choice, 2026-09-22). `headCount` is for tests.
|
|
342
|
+
printTeamHint(soloTeamHint(root, hub, { solo, headCount }));
|
|
343
|
+
// `profile` here is the SETUP profile `yad setup` records (codebase / repo_layout / team_size), not the
|
|
344
|
+
// lifecycle profile an epic walks (`LIFECYCLE_PROFILES`, E5): two things share the word.
|
|
345
|
+
const brownfield = hub?.profile?.codebase === 'brownfield';
|
|
346
|
+
// The PRODUCT level (the Foundation, or a ledger still in its old `discovery` spelling) is not a
|
|
347
|
+
// feature epic — split it out so it is surfaced on its own line and never mixed into the roll-up.
|
|
137
348
|
const allEpics = listEpics(root);
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
const
|
|
349
|
+
// One read of `.sdlc/skills.json` for the whole roll-up, not one per epic.
|
|
350
|
+
const bindings = loadSkillBindings(root);
|
|
351
|
+
const automation = loadAutomation(root);
|
|
352
|
+
const verified = isVerifiedLedger(hub);
|
|
353
|
+
// The Foundation wins when both exist. Two product levels is a fault `yad doctor` fails on; until it
|
|
354
|
+
// is fixed the new spelling is the one worth acting on, and the old one is named so it is not lost.
|
|
355
|
+
const productIds = PRODUCT_EPICS.filter((id) => allEpics.includes(id));
|
|
356
|
+
const productId = productIds[0] || null;
|
|
357
|
+
const featureEpics = allEpics.filter((id) => !PRODUCT_EPICS.includes(id));
|
|
358
|
+
const productRow = productId ? rowFor(root, productId, bindings) : null;
|
|
359
|
+
const productOpen = !!productRow && !PRODUCT_DONE.includes(productRow.action.kind);
|
|
360
|
+
if (productIds.length > 1) {
|
|
361
|
+
warn(`two product levels: ${productIds.map((id) => epicRel(id)).join(' and ')} — ${productId} is the one used`);
|
|
362
|
+
hand(`run ${c.bold('yad doctor')} ${c.dim('(a product has one Foundation; `yad migrate` converts the old one when it can)')}`);
|
|
363
|
+
}
|
|
142
364
|
|
|
143
365
|
if (!featureEpics.length) {
|
|
144
|
-
if (
|
|
366
|
+
if (productOpen) { printAction(productRow.action, { solo, bindings, automation, verified, ...productRow }); return; }
|
|
145
367
|
log(`\n ${c.bold('Set up — no feature epics yet.')}`);
|
|
146
368
|
if (brownfield) hand(`capture what already exists first: invoke the ${c.bold('yad-backfill')} skill`);
|
|
147
|
-
|
|
148
|
-
|
|
369
|
+
// Both name a STEP's skill, so both ask the project. `yad-backfill` above does not: waking a
|
|
370
|
+
// brownfield anchor is the engine's own promote verb, not a step on any chain.
|
|
371
|
+
if (!productId) hand(`frame the whole product first (purpose, scope, MVP, roadmap, stack): run ${c.bold('yad foundation new')}, then invoke the ${c.bold(stepSkills('foundation', bindings)[0] || 'yad-discovery')} skill ${c.dim('(optional)')}`);
|
|
372
|
+
hand(`start your first epic: invoke the ${c.bold(stepSkills('epic', bindings)[0] || 'yad-epic')} skill${productId ? c.dim(' (it reads the approved roadmap.md)') : ''}`);
|
|
149
373
|
return;
|
|
150
374
|
}
|
|
151
375
|
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
376
|
+
const rows = featureEpics.map((id) => rowFor(root, id, bindings));
|
|
377
|
+
if (productOpen) {
|
|
378
|
+
printAction(productRow.action, { solo, bindings, automation, verified, ...productRow }); // an unfinished Foundation comes first
|
|
379
|
+
// REPORTED, NOT ENFORCED (E75). The roadmap puts one approval on the Foundation "before feature
|
|
380
|
+
// work begins", and the decision for this release is to say so rather than to block on it: a
|
|
381
|
+
// project that began its epics first — every project older than this release — must keep working
|
|
382
|
+
// (rule 5), and a hard stop here would be the first thing that ever refused to show an epic.
|
|
383
|
+
info(c.dim(`${productId} has not passed its gate — ${featureEpics.length} feature epic(s) are going ahead of it (reported, not enforced)`));
|
|
384
|
+
}
|
|
154
385
|
|
|
155
386
|
if (featureEpics.length === 1 || all) {
|
|
156
|
-
for (const
|
|
387
|
+
for (const r of rows) printAction(r.action, { solo, bindings, automation, verified, ...r });
|
|
157
388
|
return;
|
|
158
389
|
}
|
|
159
390
|
// Several epics — list each with a one-liner, then point at the per-epic / --all views.
|
|
160
391
|
log(`\n ${c.bold(`${featureEpics.length} epics`)} ${c.dim('— next action each:')}`);
|
|
161
|
-
|
|
162
|
-
|
|
392
|
+
// The grouping theme rides this list too. This is the ONE `yad next` view that shows several epics
|
|
393
|
+
// side by side, so it is where seeing which of them belong together is worth most — and leaving it
|
|
394
|
+
// off would have meant bare `yad next`, the command people run by default, never showed the tag.
|
|
395
|
+
for (const { action: a, theme: tag } of rows) {
|
|
396
|
+
const theme = tag ? ` ${c.dim(`#${tag}`)}` : '';
|
|
397
|
+
// Debt is reminded here too (E41): the roll-up is the view people run most, and a reminder that only
|
|
398
|
+
// appears once you already look at the one epic is not a reminder.
|
|
399
|
+
const owed = a.debt?.length ? ` ${c.yellow(`· ${a.debt.length} owed as debt`)}` : '';
|
|
400
|
+
log(` ${c.cyan(`${typeNoun(a.lineageKind)} ${a.epicId}`)}${theme} ${actionLine(a, { solo, verified })}${owed}`);
|
|
401
|
+
}
|
|
402
|
+
// Painted per segment, not dim-wrapping a bold: `paint` closes with a full reset, so the nested
|
|
403
|
+
// form loses the dim from the first bold word to the end of the line.
|
|
404
|
+
info(`${c.dim('detail:')} ${c.bold('yad next <epic>')} ${c.dim('• all at once:')} ${c.bold('yad next --all')}`);
|
|
163
405
|
}
|
|
164
406
|
|
|
165
407
|
// `yad next <epic> --check <step>`: the precondition guard. Exit 0 if runnable now, 1 otherwise.
|
|
@@ -178,20 +420,30 @@ function checkPrecondition(root, epic, stepId) {
|
|
|
178
420
|
// ---- machine-readable output (`--json`) --------------------------------------------------------
|
|
179
421
|
// `nextAction` already computes exactly what a caller needs; until now the ANSI prose renderer was
|
|
180
422
|
// its only consumer, so anything driving yadflow had to regex coloured English. This emits the SAME
|
|
181
|
-
// objects, unrendered
|
|
423
|
+
// objects, unrendered, inside E1's envelope (`jsonVersion`, `version`, `command`, `ok`, `warnings`).
|
|
424
|
+
// One shape for every route, so a caller never has to branch on it:
|
|
182
425
|
//
|
|
183
|
-
// {
|
|
184
|
-
// {
|
|
185
|
-
// {
|
|
186
|
-
// {
|
|
426
|
+
// { …, ok: true, actions: [ <action>, … ] } next / next <epic>
|
|
427
|
+
// { …, ok, check: { epic, step, ok, reason } } next <epic> --check <step>
|
|
428
|
+
// { …, ok: true, setUp: false, actions: [] } the project has no `yad setup` yet
|
|
429
|
+
// { …, ok: false, error, code, hint } bad epic id / no state.json
|
|
187
430
|
//
|
|
188
431
|
// Exit codes are unchanged from the prose path — only the rendering differs.
|
|
189
|
-
|
|
432
|
+
// `route` is a RENDERING input, not part of the answer: `phaseLine` uses it to mark a phase this
|
|
433
|
+
// epic's route never enters, and a script reading `yad next --json` gets the route from `state.json`'s
|
|
434
|
+
// own `profile` key, which is the record. Stripped here rather than
|
|
435
|
+
// threaded past the JSON path, because one strip is easier to keep right than three call sites — and
|
|
436
|
+
// `--json` is a machine contract, so a field added by accident is a field somebody starts depending on.
|
|
437
|
+
const forJSON = (a) => Object.fromEntries(Object.entries(a).filter(([k]) => k !== 'route'));
|
|
438
|
+
const answer = (payload) => emitJSON({
|
|
439
|
+
...payload,
|
|
440
|
+
...(payload.actions ? { actions: payload.actions.map(forJSON) } : {}),
|
|
441
|
+
});
|
|
190
442
|
|
|
191
443
|
// A JSON error still leaves stdout parseable: a caller that pipes us into a parser gets an object
|
|
192
444
|
// explaining the failure, never half a document or a bare ANSI line.
|
|
193
445
|
function jsonError(message) {
|
|
194
|
-
|
|
446
|
+
answer({ ok: false, error: message });
|
|
195
447
|
process.exitCode = 1;
|
|
196
448
|
}
|
|
197
449
|
|
|
@@ -200,26 +452,27 @@ function jsonError(message) {
|
|
|
200
452
|
function jsonNext(root, { epic, check }) {
|
|
201
453
|
if (epic && check) {
|
|
202
454
|
const res = preconditionsMet(loadLedger(epicRoot(root, epic)).state, check);
|
|
203
|
-
|
|
455
|
+
answer({ ok: !!res.ok, check: { epic, step: check, ok: !!res.ok, ...(res.reason ? { reason: res.reason } : {}) } });
|
|
204
456
|
if (!res.ok) process.exitCode = 1;
|
|
205
457
|
return;
|
|
206
458
|
}
|
|
207
459
|
if (epic) {
|
|
208
460
|
if (!exists(path.join(epicRoot(root, epic), '.sdlc', 'state.json'))) {
|
|
209
|
-
return jsonError(`no epic state at
|
|
461
|
+
return jsonError(`no epic state at ${epicRel(epic)}/.sdlc/state.json`);
|
|
210
462
|
}
|
|
211
|
-
return
|
|
463
|
+
return answer({ ok: true, actions: [actionFor(root, epic)] });
|
|
212
464
|
}
|
|
213
|
-
if (!isSetUp(root)) return
|
|
214
|
-
// Every epic that HAS a ledger, discovery included — its
|
|
465
|
+
if (!isSetUp(root)) return answer({ ok: true, setUp: false, actions: [] });
|
|
466
|
+
// Every epic that HAS a ledger, discovery included — its ACTION kind already says whether it is open
|
|
215
467
|
// (`discovery-*`) or finished, so filtering it out would hide a fact rather than clarify one.
|
|
216
468
|
// `--all` is implied: an array always carries everything, so there is nothing left to expand.
|
|
217
|
-
|
|
469
|
+
const bindings = loadSkillBindings(root);
|
|
470
|
+
return answer({ ok: true, actions: listEpics(root).map((id) => actionFor(root, id, undefined, bindings)) });
|
|
218
471
|
}
|
|
219
472
|
|
|
220
473
|
// Entry point for the `next` command: route to the precondition check, a single epic's action, or the
|
|
221
474
|
// project-wide general view. Validates the epic id first.
|
|
222
|
-
export async function runNext(root, { epic, check, all, json } = {}) {
|
|
475
|
+
export async function runNext(root, { epic, check, all, json, headCount = null } = {}) {
|
|
223
476
|
if (epic && !isValidEpicId(epic)) {
|
|
224
477
|
const message = `invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`;
|
|
225
478
|
if (json) return jsonError(message);
|
|
@@ -229,7 +482,7 @@ export async function runNext(root, { epic, check, all, json } = {}) {
|
|
|
229
482
|
}
|
|
230
483
|
if (json) return jsonNext(root, { epic, check });
|
|
231
484
|
if (epic && check) return checkPrecondition(root, epic, check);
|
|
232
|
-
if (!epic) return generalNext(root, { all });
|
|
485
|
+
if (!epic) return generalNext(root, { all, headCount });
|
|
233
486
|
|
|
234
487
|
const epicDir = epicRoot(root, epic);
|
|
235
488
|
if (!exists(path.join(epicDir, '.sdlc', 'state.json'))) {
|
|
@@ -238,5 +491,11 @@ export async function runNext(root, { epic, check, all, json } = {}) {
|
|
|
238
491
|
process.exitCode = 1;
|
|
239
492
|
return;
|
|
240
493
|
}
|
|
241
|
-
|
|
494
|
+
// One read, two readers: the resolver puts the bound skill on the action, and the renderer names it
|
|
495
|
+
// in the two prose lines no action carries.
|
|
496
|
+
const bindings = loadSkillBindings(root);
|
|
497
|
+
const automation = loadAutomation(root);
|
|
498
|
+
const verified = isVerifiedLedger(readJSON(productConfigPath(root), null));
|
|
499
|
+
const row = rowFor(root, epic, bindings);
|
|
500
|
+
printAction(row.action, { solo: isSolo(root), bindings, automation, verified, ...row });
|
|
242
501
|
}
|