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/epic.mjs
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// `yad epic new <slug> --type <type> --profile <profile>` — the ENGINE seeds an epic's lifecycle.
|
|
2
|
+
//
|
|
3
|
+
// Roadmap E17. The chain a new epic walks used to be hand-written — five skills seeded one, four from
|
|
4
|
+
// a literal JSON block and `yad-change` in prose. Five copies of one table is five chances to
|
|
5
|
+
// disagree, and they already did: two of them were on different routes, a third was on the same route
|
|
6
|
+
// with different statuses, and nothing in the engine could tell. The catalogue (E4) says what each step IS and the profiles (E5) say which route an
|
|
7
|
+
// epic takes; this command is what finally writes one from the other.
|
|
8
|
+
//
|
|
9
|
+
// WHAT IT WRITES, AND WHAT IT DOES NOT. It writes the LEDGER — `state.json`, an empty `approvals.json`
|
|
10
|
+
// and `comments.json`, and the `reviews/` directory. It does not write `epic.md`, does not create a
|
|
11
|
+
// branch, and does not commit. `epic.md` is prose authored WITH the user, which is `yad-epic`'s job and
|
|
12
|
+
// stays there; a command that guessed a goal and a scope would produce a document nobody wrote. So the
|
|
13
|
+
// order is: seed the chain here, then run the authoring skill the chain names.
|
|
14
|
+
//
|
|
15
|
+
// THREE OF THE FIVE NOW CALL THIS (E17b): `yad-epic`, `yad-analysis` and `yad-stub` run it instead of
|
|
16
|
+
// carrying a chain, and their templates are gone — a test asserts no step chain comes back into those
|
|
17
|
+
// files, because nothing else would notice a copy quietly drifting from the catalogue again.
|
|
18
|
+
//
|
|
19
|
+
// `yad-discovery` now runs `yad foundation new` (E75, below) for the Product level, which is not an epic
|
|
20
|
+
// and has a command of its own.
|
|
21
|
+
//
|
|
22
|
+
// A THREADED CHAIN IS SEEDED HERE TOO, since E42. `yad-change` seeds a change, defect or hotfix with
|
|
23
|
+
// `--parent` and `--inherits`: its inherited steps are `satisfied` and bound to the owning epic's artifact
|
|
24
|
+
// hashes, its approvals ledger carries a provenance record per inherited gate, and an inherited
|
|
25
|
+
// architecture writes a pointer contract-lock. What stays in the skill is the depth triage that DECIDES
|
|
26
|
+
// which bases are inherited — a judgement made with a person, not a flag's default. A threaded type with
|
|
27
|
+
// no parent is still refused, and so is a genesis type with one.
|
|
28
|
+
//
|
|
29
|
+
// The seed is still built key-by-key in the order those templates used, because the chains on disk in
|
|
30
|
+
// every existing project were written that way and a re-seeded epic must not churn their bytes.
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
|
|
34
|
+
import { c, fail, hand, info, log, ok, readJSON, warn, emitJSON } from './lib.mjs';
|
|
35
|
+
import {
|
|
36
|
+
DISCOVERY_EPIC, epicIds, epicLineage, epicRel, epicRoot, epicStories, featureStatus, FOUNDATION_EPIC, FOUNDATION_SECTIONS,
|
|
37
|
+
isGenesisType, isValidEpicId, lifecycleProfile, loadLedger, loadSkillBindings, PRODUCT_DONE, PRODUCT_EPICS,
|
|
38
|
+
planThreadedSeed, readFrontmatter, roadmapFeatures, seedableProfiles, seedFoundationState,
|
|
39
|
+
seedState, staleFoundationGuards, stepSkills, typeNoun, WORK_ITEM_TYPES, workItemType, writeJSON, writeState,
|
|
40
|
+
} from './epic-state.mjs';
|
|
41
|
+
import { epicFiles, isVerifiedLedger, productConfigPath } from './manifest.mjs';
|
|
42
|
+
import { refreshIndexAfterWrite } from './product-index.mjs';
|
|
43
|
+
|
|
44
|
+
// `foo`, `EP-foo` and `epics/EP-foo` all name the same epic. Accepting only one spelling would make the
|
|
45
|
+
// command reject the id the user just read out of `yad next`.
|
|
46
|
+
export function epicIdFrom(slug) {
|
|
47
|
+
const raw = String(slug || '').trim().replace(/^epics\//, '').replace(/\/+$/, '');
|
|
48
|
+
if (!raw) return null;
|
|
49
|
+
return raw.startsWith('EP-') ? raw : `EP-${raw}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `type` defaults to null rather than `feature` so an explicit `--type` is distinguishable from no
|
|
53
|
+
// flag at all — which is what lets an existing `epic.md` supply the answer without overriding the user.
|
|
54
|
+
export async function runEpicNew(root, { slug, type = null, profile = null, stub = false, parent = null, inherits = null, today, json = false } = {}) {
|
|
55
|
+
const bail = (message, hint) => {
|
|
56
|
+
if (json) emitJSON({ ok: false, error: message, hint });
|
|
57
|
+
else { fail(message); if (hint) hand(hint); }
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const epic = epicIdFrom(slug);
|
|
62
|
+
if (!epic) return bail(`usage: yad epic new <slug> [--type ${WORK_ITEM_TYPES.join('|')}] [--profile ${seedableProfiles().join('|')}] [--parent EP-<slug> --inherits <bases>]`);
|
|
63
|
+
// The id becomes a path segment under epics/ — reject anything but EP-<slug> outright, the same
|
|
64
|
+
// guard every other epic-taking command applies.
|
|
65
|
+
if (!isValidEpicId(epic)) return bail(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`);
|
|
66
|
+
// The Product level's ids are RESERVED, and refusing the profile is not enough to protect them: the
|
|
67
|
+
// route defaults to `classic`, so `yad epic new discovery` would have written a 10-step feature chain
|
|
68
|
+
// onto the one id a product may only ever have one of — with no `kind` marker, which is what every
|
|
69
|
+
// reader of the product level keys off. Worse, it would then be permanent: the next run refuses the
|
|
70
|
+
// id as already seeded. `EP-foundation` is sharper still (E75): its directory is not under `epics/` —
|
|
71
|
+
// `epicRoot` sends it to `foundation/` — so a feature seed would land in the Foundation's own ledger.
|
|
72
|
+
if (PRODUCT_EPICS.includes(epic)) {
|
|
73
|
+
return bail(`${epic} is the Product level, not an epic on the ladder`,
|
|
74
|
+
'it is one per product, has no epic.md and no work-item type, and its ledger carries a `kind` marker this command does not write. Run `yad foundation new`, then the yad-discovery skill');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const dir = epicRoot(root, epic);
|
|
78
|
+
const files = epicFiles(dir);
|
|
79
|
+
// Refuse rather than overwrite. A `state.json` is the epic's audit trail; replacing one is how a
|
|
80
|
+
// project loses every approval it holds, and there is no flag for it on purpose. On a verified
|
|
81
|
+
// Product it is also the line `ledger-guard` draws — creating a new epic's ledger is exempt
|
|
82
|
+
// (creation is not mutation, #162), rewriting an existing one is not.
|
|
83
|
+
if (fs.existsSync(files.state)) {
|
|
84
|
+
return bail(`${epic} already has a lifecycle — ${path.relative(root, files.state)} exists`,
|
|
85
|
+
`run \`yad next ${epic}\` to see where it is. Nothing here overwrites a ledger`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// THE TYPE IS AUTHORED IN `epic.md`, and copied into the ledger — that is the shape-5 rule, and it
|
|
89
|
+
// holds here too. Normally no `epic.md` exists yet, so the flag (or `feature`) is the answer. When
|
|
90
|
+
// one is already there, it is the author's word and it wins over a default: seeding `feature` beside
|
|
91
|
+
// a header that says `chore` would mint an epic whose two records disagree from its first second,
|
|
92
|
+
// which `yad doctor` would then report as `type:ledger` on an epic nobody had touched.
|
|
93
|
+
//
|
|
94
|
+
// An explicit `--type` that CONTRADICTS the header is refused rather than silently resolved. Only
|
|
95
|
+
// the person typing it knows which of the two they meant, and picking one would overwrite an answer.
|
|
96
|
+
// The OLD frontmatter name wins inside `workItemType`, the same tie-break the stamper uses.
|
|
97
|
+
//
|
|
98
|
+
// DECLARES is the word, and it is not the same as what `workItemType` RESOLVES. That function
|
|
99
|
+
// defaults to `feature`, so a header carrying no type at all resolves identically to one that says
|
|
100
|
+
// `feature` — and clashing on the resolved value would refuse `--type chore` beside a drafted
|
|
101
|
+
// epic.md whose frontmatter names no type, telling the author to go and fix a value their file does
|
|
102
|
+
// not contain. So the raw keys decide whether there is anything to clash WITH, and `workItemType`
|
|
103
|
+
// still decides what it says: the OLD name (`kind:`) wins there, the same tie-break the stamper uses.
|
|
104
|
+
const mdPath = path.join(dir, 'epic.md');
|
|
105
|
+
const fm = fs.existsSync(mdPath) ? readFrontmatter(mdPath) : {};
|
|
106
|
+
if (fs.existsSync(mdPath)) {
|
|
107
|
+
const declares = typeof fm.kind === 'string' && fm.kind.trim()
|
|
108
|
+
|| typeof fm.type === 'string' && fm.type.trim();
|
|
109
|
+
if (declares) {
|
|
110
|
+
const authored = workItemType(fm);
|
|
111
|
+
if (type && type !== authored) {
|
|
112
|
+
return bail(`${epic}: epic.md says \`${authored}\`, --type says \`${type}\``,
|
|
113
|
+
'the type is authored in epic.md and copied into the ledger. Drop the flag to take the header\'s answer, or fix the header first — nothing here rewrites epic.md');
|
|
114
|
+
}
|
|
115
|
+
type = authored;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
type = type || 'feature';
|
|
119
|
+
|
|
120
|
+
if (!WORK_ITEM_TYPES.includes(type)) {
|
|
121
|
+
return bail(`unknown work-item type: ${type}`, `a work item is one of ${WORK_ITEM_TYPES.join(' · ')}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// THE PARENT AND WHAT IT CARRIES (E42) are authored in epic.md as well — `parent:` and `inherits:` —
|
|
125
|
+
// and the same rule as the type holds: the header's word wins over no flag, and a flag that
|
|
126
|
+
// contradicts it is refused. `yad thread` and the owner map read the header, so a ledger seeded from
|
|
127
|
+
// a flag the header disagrees with would describe a thread nobody can see.
|
|
128
|
+
// Normalised like the slug, so `--parent checkout` names the epic `yad next` printed as EP-checkout.
|
|
129
|
+
parent = parent ? epicIdFrom(parent) : null;
|
|
130
|
+
const declaredParent = typeof fm.parent === 'string' && fm.parent.trim() ? epicIdFrom(fm.parent) : null;
|
|
131
|
+
if (parent && declaredParent && parent !== declaredParent) {
|
|
132
|
+
return bail(`${epic}: epic.md says \`parent: ${declaredParent}\`, --parent says \`${parent}\``,
|
|
133
|
+
'drop the flag to take the header\'s answer, or fix the header first — nothing here rewrites epic.md');
|
|
134
|
+
}
|
|
135
|
+
parent = parent || declaredParent;
|
|
136
|
+
const flagInherits = inherits == null ? null : listOf(inherits);
|
|
137
|
+
const declaredInherits = 'inherits' in fm ? listOf(fm.inherits) : null;
|
|
138
|
+
// Every other reader — `yad thread`, the owner map, the next change threading off this one — takes the
|
|
139
|
+
// header through `epicLineage`, which reads a list only in brackets. `inherits: epic, ui-design` is one
|
|
140
|
+
// base named "epic, ui-design" to them, so seeding what this parse sees would describe a thread they
|
|
141
|
+
// cannot. Refused, with the spelling they all read.
|
|
142
|
+
if (declaredInherits && [...new Set(declaredInherits)].sort().join() !== [...new Set(epicLineage(root, epic).inherits)].sort().join()) {
|
|
143
|
+
return bail(`${epic}: epic.md writes \`inherits:\` in a form the other readers take as [${epicLineage(root, epic).inherits.join(' | ')}]`,
|
|
144
|
+
`write it as \`inherits: [${declaredInherits.join(', ')}]\` — in brackets — then run this again`);
|
|
145
|
+
}
|
|
146
|
+
if (flagInherits && declaredInherits
|
|
147
|
+
&& [...new Set(flagInherits)].sort().join() !== [...new Set(declaredInherits)].sort().join()) {
|
|
148
|
+
return bail(`${epic}: epic.md inherits [${declaredInherits.join(', ')}], --inherits says [${flagInherits.join(', ')}]`,
|
|
149
|
+
'drop the flag to take the header\'s answer, or fix the header first — nothing here rewrites epic.md');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Checked before the type rules, so `--stub --type defect --parent …` is told what is actually wrong
|
|
153
|
+
// rather than seeded as a threaded epic with the flag silently dropped.
|
|
154
|
+
if (stub && parent) {
|
|
155
|
+
return bail('a stub has no parent — it anchors a feature that shipped before it had an epic',
|
|
156
|
+
'drop --stub to thread a change off the parent, or drop --parent to mint the anchor');
|
|
157
|
+
}
|
|
158
|
+
if (isGenesisType(type) && parent) {
|
|
159
|
+
return bail(`a ${type} has no parent — it starts a thread`,
|
|
160
|
+
`a change on ${parent} is \`--type change\` (or defect / hotfix). Drop --parent to start a new ${type}`);
|
|
161
|
+
}
|
|
162
|
+
if (!isGenesisType(type) && !parent) {
|
|
163
|
+
return bail(`a ${type} epic cannot be seeded without its parent`,
|
|
164
|
+
`a ${type} threads off an epic that already exists and inherits what it does not change. Run the yad-change skill — it triages which steps are inherited and then runs \`yad epic new <slug> --type ${type} --parent EP-<parent> --inherits <bases>\``);
|
|
165
|
+
}
|
|
166
|
+
if (!parent && flagInherits) {
|
|
167
|
+
return bail('--inherits needs a parent', 'only a change, defect or hotfix carries steps by reference, from the epic named by --parent');
|
|
168
|
+
}
|
|
169
|
+
if (parent) {
|
|
170
|
+
return seedThreaded(root, {
|
|
171
|
+
epic, dir, files, mdPath, fm, type, parent, profile,
|
|
172
|
+
inherits: flagInherits ?? declaredInherits ?? [], headerLists: declaredInherits !== null, today, json, bail,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
profile = profile || 'classic';
|
|
176
|
+
|
|
177
|
+
// A STUB anchors a feature that was built before the Product existed, so a defect can thread off it
|
|
178
|
+
// today. Two restrictions, both from what a stub IS rather than from anything technical:
|
|
179
|
+
// * always `classic`. A stub's whole chain is blocked behind the `backfill-pending` sentinel and
|
|
180
|
+
// `yad-backfill promote` wakes it at `epic`, so a route that starts somewhere else has nothing
|
|
181
|
+
// to wake into.
|
|
182
|
+
// * always `feature`. A stub is an anchor for shipped behaviour; upkeep leaves nothing to backfill
|
|
183
|
+
// and nothing to thread a defect off, so a `chore` stub would be an anchor for no feature.
|
|
184
|
+
if (stub) {
|
|
185
|
+
if (profile !== 'classic') {
|
|
186
|
+
return bail(`a stub is always on the classic route, not '${profile}'`,
|
|
187
|
+
'`yad-backfill promote` wakes a stub at its `epic` step, so a route starting anywhere else has nothing to wake into. Drop --profile');
|
|
188
|
+
}
|
|
189
|
+
if (type !== 'feature') {
|
|
190
|
+
return bail(`a stub is always a feature, not a ${type}`,
|
|
191
|
+
'a stub anchors behaviour that already shipped so a defect can thread off it. Upkeep leaves nothing to backfill. Drop --type');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const known = lifecycleProfile(profile);
|
|
196
|
+
const seedable = seedableProfiles();
|
|
197
|
+
if (!seedable.includes(profile)) {
|
|
198
|
+
return bail(
|
|
199
|
+
known ? `the '${profile}' profile is not seeded from here` : `unknown lifecycle profile: ${profile}`,
|
|
200
|
+
known
|
|
201
|
+
// Today the only known-but-unseedable profiles are the two product routes, `discovery` and
|
|
202
|
+
// `foundation`, and the reason is specific enough to be worth naming rather than listing the
|
|
203
|
+
// alternatives again.
|
|
204
|
+
? `'${profile}' is the Product level, not an epic on the ladder: one per product, a fixed id, no epic.md and no work-item type. Run \`yad foundation new\` for it`
|
|
205
|
+
: `pick one of ${seedable.join(' · ')}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const state = seedState({ epic, profile, type, today, stub });
|
|
210
|
+
writeState(files.state, state);
|
|
211
|
+
// E19: the index lists the new work item — on the default branch of a local Product only; `quiet`
|
|
212
|
+
// keeps a --json stdout one document.
|
|
213
|
+
refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null), { quiet: json });
|
|
214
|
+
// The two ledgers the gate appends to, and the folder its markdown lands in. Empty is their correct
|
|
215
|
+
// starting value: an approval is written only by a real review, never by a seed.
|
|
216
|
+
for (const f of [files.approvals, files.comments]) if (!fs.existsSync(f)) writeJSON(f, []);
|
|
217
|
+
fs.mkdirSync(path.join(dir, 'reviews'), { recursive: true });
|
|
218
|
+
|
|
219
|
+
const first = state.steps[0];
|
|
220
|
+
// A stub has no runnable step — its whole chain is blocked behind the sentinel — so the skill it
|
|
221
|
+
// names is the one that wakes it, not the one that would have authored its first step.
|
|
222
|
+
//
|
|
223
|
+
// Everything else asks the PROJECT first (E6): a team that bound its own skill to `epic` must be
|
|
224
|
+
// told to run that one, or this command would hand a brand-new epic straight to a skill their
|
|
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));
|
|
228
|
+
const skill = skills[0] || null;
|
|
229
|
+
// `nextSkills` appears only for a bound chain, the same rule `yad next --json` follows — a key that
|
|
230
|
+
// showed up on every seed would be one more always-null field for every reader to ignore.
|
|
231
|
+
if (json) {
|
|
232
|
+
return emitJSON({
|
|
233
|
+
ok: true, epic, type, profile, stub, currentStep: state.currentStep,
|
|
234
|
+
steps: state.steps.map((s) => s.id), next: skill,
|
|
235
|
+
...(skills.length > 1 ? { nextSkills: skills } : {}),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
ok(`${epic} seeded — ${stub ? 'stub anchor' : typeNoun(type)} on the ${c.bold(profile)} route (${state.steps.length} steps)`);
|
|
239
|
+
info(`chain: ${state.steps.map((s) => (!stub && s.id === first.id ? c.bold(s.id) : s.id)).join(' → ')}`);
|
|
240
|
+
hand(stub
|
|
241
|
+
? `every step is blocked behind \`${state.currentStep}\` — document the code with the ${skill} skill, then \`yad-backfill promote\` to wake the chain. Defects can thread off it now`
|
|
242
|
+
: `${first.id} is open${skill ? ` — run the ${skills.join(' skill, then the ')} skill to author ${first.artifact}` : ''}`);
|
|
243
|
+
// Closed decision 7: every surface that prints a chain of more than one says what the extra runs
|
|
244
|
+
// cost. This one prints a chain too.
|
|
245
|
+
if (skills.length > 1) info(`${skills.length} skills run for this step, one after another — each one costs tokens`);
|
|
246
|
+
// Only worth saying when there is no header yet. When one exists it is where the type came FROM, so
|
|
247
|
+
// telling its author to go and write what they already wrote reads as the command not having looked.
|
|
248
|
+
if (!fs.existsSync(mdPath) && !stub) {
|
|
249
|
+
// BOTH keys, old name first. `kind:` is the one that is read — by `workItemType` here and by
|
|
250
|
+
// `lineage-check.sh` inside the user's own repo, which is refreshed by a different command
|
|
251
|
+
// (`yad update`) with no ordering against this one. A header carrying only `type:` reads to that
|
|
252
|
+
// gate as an epic with no type at all.
|
|
253
|
+
info(`epic.md is authored by ${skills.length > 1 ? 'those skills' : 'that skill'}, not by this command. Give it \`kind: ${type}\` and \`type: ${type}\` so the ledger and the header agree.`);
|
|
254
|
+
}
|
|
255
|
+
// Nothing was committed here, and on a verified Product the seed HAS to ride the first review PR:
|
|
256
|
+
// `ledger-guard` exempts a new epic's ledger only while it is absent from the base ref (creation,
|
|
257
|
+
// not mutation, #162). Left uncommitted until then, it lands by no path at all.
|
|
258
|
+
info('commit the seed on this epic\'s authoring branch — it reaches the default branch through the first review PR/MR.');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// `a,b`, `a b` and `[a, b]` all name the same list — the flag is typed, the header is YAML-ish.
|
|
262
|
+
const listOf = (v) => (Array.isArray(v) ? v : String(v ?? '').replace(/^\s*\[|\]\s*$/g, '').split(/[\s,]+/))
|
|
263
|
+
.map((x) => String(x).trim()).filter(Boolean);
|
|
264
|
+
|
|
265
|
+
// The threaded half of `yad epic new` (E42): a change, defect or hotfix off an epic that exists. The
|
|
266
|
+
// engine plans the chain from the parent (`planThreadedSeed`) and this writes it — the ledger, one
|
|
267
|
+
// provenance record per inherited gate, and the pointer-lock when the contract is carried.
|
|
268
|
+
function seedThreaded(root, { epic, dir, files, mdPath, fm, type, parent, profile, inherits, headerLists, today, json, bail }) {
|
|
269
|
+
const plan = planThreadedSeed(root, { epic, parent, inherits, type, today });
|
|
270
|
+
if (!plan.ok) return bail(plan.message, plan.hint);
|
|
271
|
+
// The route is the parent's. A flag naming the same one is harmless; a different one is refused
|
|
272
|
+
// rather than obeyed, because a child on another route would inherit steps its route does not have.
|
|
273
|
+
if (profile && profile !== plan.profile) {
|
|
274
|
+
return bail(`a change takes its parent's route — ${parent} is on '${plan.profile}', --profile says '${profile}'`,
|
|
275
|
+
'drop --profile. To change the route, start a new epic instead of threading one');
|
|
276
|
+
}
|
|
277
|
+
const declaredThread = typeof fm.thread === 'string' && fm.thread.trim() ? fm.thread.trim() : null;
|
|
278
|
+
// A header that EXISTS must carry the lineage too. `resolveThread` reads only epic.md, so a header with
|
|
279
|
+
// no `parent:` reads as a genesis of its own, and one with no `thread:` is a broken lineage the moment
|
|
280
|
+
// it is seeded — a ledger nobody's thread view can find.
|
|
281
|
+
if (fs.existsSync(mdPath) && !(typeof fm.parent === 'string' && fm.parent.trim())) {
|
|
282
|
+
return bail(`${epic}: epic.md has no \`parent:\``,
|
|
283
|
+
`add \`parent: ${parent}\` (and \`thread: ${plan.thread}\`) to epic.md — \`yad thread\` reads the header, not the flag`);
|
|
284
|
+
}
|
|
285
|
+
if (fs.existsSync(mdPath) && !declaredThread) {
|
|
286
|
+
return bail(`${epic}: epic.md has no \`thread:\` — add \`thread: ${plan.thread}\``,
|
|
287
|
+
'the thread is the genesis of the parent\'s line, and a change without the cache reads as a broken lineage');
|
|
288
|
+
}
|
|
289
|
+
if (declaredThread && declaredThread !== plan.thread) {
|
|
290
|
+
return bail(`${epic}: epic.md says \`thread: ${declaredThread}\`, but ${parent}'s thread starts at ${plan.thread}`,
|
|
291
|
+
`set \`thread: ${plan.thread}\` in epic.md — the thread is the genesis of the parent's line`);
|
|
292
|
+
}
|
|
293
|
+
// Refuse before writing anything. An existing lock is somebody's record, and nothing here overwrites one.
|
|
294
|
+
if (plan.lock && fs.existsSync(files.contractLock)) {
|
|
295
|
+
return bail(`${epic} already has a contract-lock.json`,
|
|
296
|
+
'nothing here overwrites a lock. Remove it only if it is left over from a mistake, then run this again');
|
|
297
|
+
}
|
|
298
|
+
// The same for an approvals ledger holding anything at all. An empty list is what a seed writes, so it
|
|
299
|
+
// is the one value that is safe to replace; a file that will not parse is not.
|
|
300
|
+
if (fs.existsSync(files.approvals)) {
|
|
301
|
+
const held = readJSON(files.approvals, null);
|
|
302
|
+
if (!Array.isArray(held) || held.length) {
|
|
303
|
+
return bail(`${epic} already has an approvals.json with records in it`,
|
|
304
|
+
'nothing here overwrites an approval. Remove it only if it is left over from a mistake, then run this again');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
writeState(files.state, plan.state);
|
|
309
|
+
refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null), { quiet: json }); // E19, as above
|
|
310
|
+
writeJSON(files.approvals, plan.approvals);
|
|
311
|
+
if (!fs.existsSync(files.comments)) writeJSON(files.comments, []);
|
|
312
|
+
fs.mkdirSync(path.join(dir, 'reviews'), { recursive: true });
|
|
313
|
+
if (plan.lock) writeJSON(files.contractLock, plan.lock);
|
|
314
|
+
|
|
315
|
+
const { state } = plan;
|
|
316
|
+
const first = state.steps.find((s) => s.id === state.currentStep);
|
|
317
|
+
const skills = stepSkills(first.id, loadSkillBindings(root));
|
|
318
|
+
const carried = state.steps.filter((s) => s.inherited);
|
|
319
|
+
if (json) {
|
|
320
|
+
return emitJSON({
|
|
321
|
+
ok: true, epic, type, profile: plan.profile, stub: false, parent, thread: plan.thread,
|
|
322
|
+
inherits, inheritedFrom: plan.owners, pointerLock: plan.lock ? plan.lock.ref : null, anchor: plan.anchor,
|
|
323
|
+
currentStep: state.currentStep, steps: state.steps.map((s) => s.id), next: skills[0] || null,
|
|
324
|
+
...(skills.length > 1 ? { nextSkills: skills } : {}),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
ok(`${epic} seeded — ${typeNoun(type)} threaded off ${parent}, on its ${c.bold(plan.profile)} route (${state.steps.length} steps, ${carried.length} carried by reference)`);
|
|
328
|
+
info(`chain: ${state.steps.map((s) => (s.inherited ? c.dim(`${s.id}←${s.inheritedFrom}`) : s.id === first.id ? c.bold(s.id) : s.id)).join(' → ')}`);
|
|
329
|
+
if (plan.lock) {
|
|
330
|
+
info(`contract-lock.json points at ${plan.lock.inheritedFrom}'s lock (${plan.lock.hash.slice(0, 19)}…) — there is no contract.md here, so the surface cannot drift`);
|
|
331
|
+
} else if (state.steps.some((s) => s.id === 'architecture' && !s.inherited)) {
|
|
332
|
+
info('architecture is authored here — the change re-locks the contract, and its review is escalated');
|
|
333
|
+
}
|
|
334
|
+
if (plan.anchor) warn('a base is carried from a brownfield anchor — no hash and no contract lock yet; protection starts when the anchor is promoted');
|
|
335
|
+
if (!carried.length) info('nothing is carried by reference — every step runs on this epic');
|
|
336
|
+
hand(`${first.id} is open${skills.length ? ` — run the ${skills.join(' skill, then the ')} skill to author ${first.artifact}` : ''}`);
|
|
337
|
+
if (!fs.existsSync(mdPath)) {
|
|
338
|
+
info(`epic.md is authored by the yad-change skill, not by this command. Give it \`kind: ${type}\`, \`type: ${type}\`, \`parent: ${parent}\`, \`thread: ${plan.thread}\` and \`inherits: [${inherits.join(', ')}]\` so the header and the ledger agree.`);
|
|
339
|
+
} else if (!headerLists && inherits.length) {
|
|
340
|
+
warn(`epic.md lists no \`inherits:\` — add \`inherits: [${inherits.join(', ')}]\`. \`yad thread\` reads the header, and without it this epic reads as the owner of what it carries`);
|
|
341
|
+
}
|
|
342
|
+
info('commit the seed on this epic\'s authoring branch — it reaches the default branch through the first review PR/MR.');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// `yad foundation new` — the ENGINE seeds the Product level (E75).
|
|
346
|
+
//
|
|
347
|
+
// The Foundation's ledger was the last product-level chain written by hand: `yad-discovery` carried a
|
|
348
|
+
// literal JSON block, the same kind of copy E17 retired from the feature skills. This writes it from
|
|
349
|
+
// the `foundation` route and the catalogue instead, into `foundation/.sdlc/`, with the same two empty
|
|
350
|
+
// ledgers and `reviews/` folder `yad epic new` writes.
|
|
351
|
+
//
|
|
352
|
+
// WHAT IT DOES NOT WRITE is the same as `yad epic new`: no section files, no branch, no commit. The
|
|
353
|
+
// sections are prose authored with the user by the skill the chain names next.
|
|
354
|
+
//
|
|
355
|
+
// TWO REFUSALS, both about there being ONE product level:
|
|
356
|
+
// * a Foundation that already exists — nothing here overwrites a ledger, and there is no flag for it;
|
|
357
|
+
// * a product still in its OLD spelling (`epics/EP-discovery/`). Seeding beside it would make two
|
|
358
|
+
// product levels, which the roadmap calls a bug in as many words. That project converts with
|
|
359
|
+
// `yad migrate` when its ledger is local; a verified one keeps using what it has, because CI owns
|
|
360
|
+
// that ledger and cannot be asked to move it.
|
|
361
|
+
export async function runFoundationNew(root, { today, json = false } = {}) {
|
|
362
|
+
const bail = (message, hint) => {
|
|
363
|
+
if (json) emitJSON({ ok: false, error: message, hint });
|
|
364
|
+
else { fail(message); if (hint) hand(hint); }
|
|
365
|
+
process.exitCode = 1;
|
|
366
|
+
};
|
|
367
|
+
const files = epicFiles(epicRoot(root, FOUNDATION_EPIC));
|
|
368
|
+
if (fs.existsSync(files.state)) {
|
|
369
|
+
return bail(`this product already has its Foundation — ${path.relative(root, files.state)} exists`,
|
|
370
|
+
`run \`yad next ${FOUNDATION_EPIC}\` to see where it is. Nothing here overwrites a ledger`);
|
|
371
|
+
}
|
|
372
|
+
const legacy = epicFiles(epicRoot(root, DISCOVERY_EPIC)).state;
|
|
373
|
+
if (fs.existsSync(legacy)) {
|
|
374
|
+
return bail(`this product already has a product level, in its old spelling — ${path.relative(root, legacy)}`,
|
|
375
|
+
`a product has ONE Foundation. On a local ledger, \`yad migrate --apply\` converts that one into ${epicRel(FOUNDATION_EPIC)}/; on a verified ledger keep using it — \`yad next ${DISCOVERY_EPIC}\``);
|
|
376
|
+
}
|
|
377
|
+
// On a verified Product the ledger is protected only by the checks committed in the repo. If they
|
|
378
|
+
// predate the Foundation, seeding one now would put its ledger where CI stops nobody from hand-editing
|
|
379
|
+
// it — so refuse, rather than seed and leave `yad doctor` to warn about it afterwards (rule 6).
|
|
380
|
+
const stale = isVerifiedLedger(readJSON(productConfigPath(root), null)) ? staleFoundationGuards(root) : [];
|
|
381
|
+
if (stale.length) {
|
|
382
|
+
return bail(`the wired checks predate the Foundation: ${stale.join(', ')} ${stale.length === 1 ? 'does' : 'do'} not know ${epicRel(FOUNDATION_EPIC)}/ — on this verified Product, CI would not protect its ledger`,
|
|
383
|
+
'run `yad update`, commit the refreshed checks, then run `yad foundation new` again');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const state = seedFoundationState({ today });
|
|
387
|
+
writeState(files.state, state);
|
|
388
|
+
refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null), { quiet: json }); // E19, as above
|
|
389
|
+
for (const f of [files.approvals, files.comments]) if (!fs.existsSync(f)) writeJSON(f, []);
|
|
390
|
+
fs.mkdirSync(path.join(epicRoot(root, FOUNDATION_EPIC), 'reviews'), { recursive: true });
|
|
391
|
+
|
|
392
|
+
const skills = stepSkills(state.steps[0].id, loadSkillBindings(root));
|
|
393
|
+
const skill = skills[0] || null;
|
|
394
|
+
const required = FOUNDATION_SECTIONS.filter((s) => !s.optional).map((s) => s.file);
|
|
395
|
+
const optional = FOUNDATION_SECTIONS.filter((s) => s.optional).map((s) => s.file);
|
|
396
|
+
if (json) {
|
|
397
|
+
return emitJSON({
|
|
398
|
+
ok: true, epic: FOUNDATION_EPIC, profile: state.profile, currentStep: state.currentStep,
|
|
399
|
+
steps: state.steps.map((s) => s.id), next: skill,
|
|
400
|
+
...(skills.length > 1 ? { nextSkills: skills } : {}),
|
|
401
|
+
sections: { required, optional },
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
ok(`${FOUNDATION_EPIC} seeded — the Product level, in ${epicRel(FOUNDATION_EPIC)}/ (${state.steps.length} steps)`);
|
|
405
|
+
info(`chain: ${state.steps.map((s, i) => (i === 0 ? c.bold(s.id) : s.id)).join(' → ')}`);
|
|
406
|
+
hand(`foundation is open${skill ? ` — run the ${skills.join(' skill, then the ')} skill to author ${epicRel(FOUNDATION_EPIC)}/` : ''}`);
|
|
407
|
+
info(`sections: ${required.join(', ')} ${c.dim(`(optional: ${optional.join(', ')})`)}`);
|
|
408
|
+
if (skills.length > 1) info(`${skills.length} skills run for this step, one after another — each one costs tokens`);
|
|
409
|
+
info('then `yad gate open EP-foundation foundation/`. Commit the seed on the Foundation\'s authoring branch — it reaches the default branch through the first review PR/MR.');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// `yad foundation status` — which roadmap features are started, READ from the epic ledgers (E76
|
|
413
|
+
// follow-up). Read-only: it never writes `roadmap.md`, whose table is part of what the Foundation's
|
|
414
|
+
// reviewers approved. See `roadmapFeatures` / `featureStatus` in epic-state.mjs for the rules.
|
|
415
|
+
//
|
|
416
|
+
// Both spellings of the product level are read (rule 2): a product still in `epics/EP-discovery/` has
|
|
417
|
+
// the same `roadmap.md`, with a `Requirements` column the reader steps over.
|
|
418
|
+
//
|
|
419
|
+
// A hand-written `Status` cell is shown only when it disagrees with the ledger, as a note — that column
|
|
420
|
+
// is no longer kept by hand, and a person reading an older Foundation needs to be told which answer is
|
|
421
|
+
// current. `epic-started` was the old word for a seeded epic, so it agrees with `in-shape` and `in-build`.
|
|
422
|
+
const WRITTEN_AGREES = { 'epic-started': ['in-shape', 'in-build'] };
|
|
423
|
+
const writtenDisagrees = (written, status) => {
|
|
424
|
+
if (!written || !status) return false;
|
|
425
|
+
const w = written.toLowerCase();
|
|
426
|
+
return w !== status && !(WRITTEN_AGREES[w] || []).includes(status);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
export async function runFoundationStatus(root, { json = false } = {}) {
|
|
430
|
+
const bail = (message, hint) => {
|
|
431
|
+
if (json) emitJSON({ ok: false, error: message, hint });
|
|
432
|
+
else { fail(message); if (hint) hand(hint); }
|
|
433
|
+
process.exitCode = 1;
|
|
434
|
+
};
|
|
435
|
+
const present = PRODUCT_EPICS.filter((id) => fs.existsSync(epicFiles(epicRoot(root, id)).state));
|
|
436
|
+
const productId = present[0];
|
|
437
|
+
// Two product levels is a fault `yad doctor` fails on. The Foundation is the one read — the same
|
|
438
|
+
// choice `yad next` makes — and the other is named, so the answer is never silently from one of two.
|
|
439
|
+
const warnings = present.length > 1
|
|
440
|
+
? [`two product levels: ${present.map((id) => `${epicRel(id)}/`).join(' and ')} — reading ${productId}; run \`yad doctor\``]
|
|
441
|
+
: [];
|
|
442
|
+
if (!productId) {
|
|
443
|
+
return bail('this product has no Foundation yet, so there is no roadmap to read',
|
|
444
|
+
'run `yad foundation new`, then the yad-discovery skill — its roadmap.md lists the features');
|
|
445
|
+
}
|
|
446
|
+
const dir = epicRoot(root, productId);
|
|
447
|
+
const file = path.join(dir, 'roadmap.md');
|
|
448
|
+
const rel = path.relative(root, file);
|
|
449
|
+
if (!fs.existsSync(file)) return bail(`${rel} does not exist yet`, 'write the roadmap with the yad-discovery skill');
|
|
450
|
+
|
|
451
|
+
let productState = null;
|
|
452
|
+
try { productState = loadLedger(dir).state; } catch { /* `yad doctor` reports an unreadable ledger */ }
|
|
453
|
+
const approved = PRODUCT_DONE.includes(productState?.currentStep);
|
|
454
|
+
const features = roadmapFeatures(fs.readFileSync(file, 'utf8')).map((row) => {
|
|
455
|
+
if (!isValidEpicId(row.epicId) || PRODUCT_EPICS.includes(row.epicId)) {
|
|
456
|
+
return { ...row, status: null, problem: 'not a valid feature epic id' };
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
const epicDir = epicRoot(root, row.epicId);
|
|
460
|
+
const status = featureStatus(loadLedger(epicDir), { stories: epicStories(epicDir) });
|
|
461
|
+
return { ...row, status, ...(writtenDisagrees(row.written, status) ? { disagrees: true } : {}) };
|
|
462
|
+
} catch {
|
|
463
|
+
return { ...row, status: null, problem: 'its ledger does not load — run `yad doctor`' };
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
// Feature epics no row proposes. `yad-epic` assigns the id, and it may not be the proposed one, so this
|
|
467
|
+
// is where a started feature would otherwise disappear. Change, defect and hotfix epics are work ON a
|
|
468
|
+
// feature, never a roadmap row, so they are not named.
|
|
469
|
+
//
|
|
470
|
+
// Only a folder with a ledger counts: an empty `epics/EP-x/` is not an epic. The type comes from
|
|
471
|
+
// `epic.md` when it exists (that is where the author wrote it) and from the ledger's own `type` when it
|
|
472
|
+
// does not — a `yad epic new --type chore` has no `epic.md` yet, and reading that absence as `feature`
|
|
473
|
+
// would list a chore. Anything that cannot be read is left to `yad doctor`, never allowed to throw here.
|
|
474
|
+
const listed = new Set(features.map((f) => f.epicId));
|
|
475
|
+
const unlisted = epicIds(root).filter((id) => {
|
|
476
|
+
if (PRODUCT_EPICS.includes(id) || listed.has(id)) return false;
|
|
477
|
+
try {
|
|
478
|
+
const state = loadLedger(epicRoot(root, id)).state;
|
|
479
|
+
if (!state) return false;
|
|
480
|
+
const type = fs.existsSync(path.join(epicRoot(root, id), 'epic.md')) ? epicLineage(root, id).type : state.type;
|
|
481
|
+
return type === 'feature';
|
|
482
|
+
} catch { return false; }
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
if (json) {
|
|
486
|
+
return emitJSON({ ok: true, epic: productId, roadmap: rel, approved, features, unlisted, ...(warnings.length ? { warnings } : {}) });
|
|
487
|
+
}
|
|
488
|
+
for (const w of warnings) warn(w);
|
|
489
|
+
log(`\n ${c.bold(`${productId} roadmap`)} ${c.dim(`${rel} — each status is read from the epic ledgers`)}`);
|
|
490
|
+
if (!approved) info(c.dim('the Foundation has not passed its review yet, so this roadmap is still a draft'));
|
|
491
|
+
if (!features.length) warn(`${rel} has no feature table — a table whose header has a "Proposed epic id" column`);
|
|
492
|
+
let phase;
|
|
493
|
+
for (const f of features) {
|
|
494
|
+
if (f.phase !== phase) { phase = f.phase; log(` ${c.bold(phase || '(no heading)')}`); }
|
|
495
|
+
const name = `${f.feature || '(no name)'} ${c.dim(f.epicId || '(no id)')}`;
|
|
496
|
+
if (f.problem) { log(` ${c.yellow('!')} ${name} ${c.yellow(f.problem)}`); continue; }
|
|
497
|
+
const mark = f.status === 'shipped' ? c.green('✓') : f.status === 'planned' ? c.dim('·') : c.cyan('•');
|
|
498
|
+
log(` ${mark} ${name} ${f.status}`);
|
|
499
|
+
if (f.disagrees) log(` ${c.dim(`the row says "${f.written}" — that column is no longer kept by hand; the ledger's answer is shown`)}`);
|
|
500
|
+
}
|
|
501
|
+
if (unlisted.length) {
|
|
502
|
+
info(`not on the roadmap: ${unlisted.join(', ')} ${c.dim('(feature epics no row proposes — an epic may have been given a different id)')}`);
|
|
503
|
+
}
|
|
504
|
+
const next = features.find((f) => f.status === 'planned');
|
|
505
|
+
if (next) hand(`next planned feature: ${next.feature || next.epicId} — seed it with the yad-epic skill (proposed id ${next.epicId})`);
|
|
506
|
+
}
|
package/cli/errors.mjs
CHANGED
|
@@ -21,12 +21,15 @@ export const CODES = {
|
|
|
21
21
|
'YAD-STATE-003': 'a registered repo path is missing or not a git repository',
|
|
22
22
|
'YAD-STATE-004': 'an epic step cannot be skipped / un-skipped in its current state',
|
|
23
23
|
'YAD-STATE-005': 'an authoring step is stranded behind its completed review gate',
|
|
24
|
-
'YAD-STATE-006': 'a
|
|
24
|
+
'YAD-STATE-006': 'a Build ledger is locked by another yad process that is writing it',
|
|
25
|
+
'YAD-STATE-007': 'an epic cannot be seeded — the profile, the type or the existing ledger refuses it',
|
|
25
26
|
'YAD-CFG-001': 'hub.json names an unknown platform (expected github, gitlab, or null)',
|
|
26
27
|
'YAD-CFG-002': 'design.json names an unknown design tool (expected one of config.yaml design.tools, or none)',
|
|
27
28
|
'YAD-CFG-003': 'testing.json names an unknown testing tool (expected one of config.yaml testing.tools, or none)',
|
|
28
29
|
'YAD-CFG-004': 'learning.json names an unknown learning tool (expected one of config.yaml learning.tools, or none)',
|
|
29
30
|
'YAD-CFG-005': 'hub.json sets a platform but is missing git_url (required to scope auth + open PRs)',
|
|
31
|
+
'YAD-CFG-006': 'skills.json binds a step to something that is not a skill name (expected a string, or a non-empty list of strings)',
|
|
32
|
+
'YAD-CLI-001': 'a --json run needed an answer that only a prompt could give',
|
|
30
33
|
};
|
|
31
34
|
|
|
32
35
|
export const err = (code, message, hint) => new YadError(code, message, hint);
|