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/ship.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `yad ship` — commit the staged atomic change AND open its task PR/MR, in one step (
|
|
1
|
+
// `yad ship` — commit the staged atomic change AND open its task PR/MR, in one step (Build).
|
|
2
2
|
// A thin orchestration over the two existing engines: `yad commit` then `yad open-pr`. It holds no
|
|
3
3
|
// commit/PR logic of its own — it reuses runCommit/runOpenPr so the conventions stay in one place.
|
|
4
4
|
// The PR step runs ONLY when the commit actually lands: a failed commit, a tripped atomic guard, or a
|
|
@@ -17,14 +17,14 @@ export async function runShip(root, opts = {}) {
|
|
|
17
17
|
contractChange: opts.contractChange, dryRun: opts.dryRun, force: opts.force,
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
if (opts.dryRun) { info('dry run — not committed, PR/MR not opened'); return committed; }
|
|
20
|
+
if (opts.dryRun) { info('dry run — not committed, PR/MR not opened'); return { ...committed, pr: null }; }
|
|
21
21
|
|
|
22
22
|
// runCommit signals failure by setting process.exitCode (not by throwing) — honour it and abort the
|
|
23
23
|
// PR step so we never open a PR for a branch whose commit did not land.
|
|
24
|
-
if (process.exitCode) { info('commit did not land — skipping open-pr'); return committed; }
|
|
24
|
+
if (process.exitCode) { info('commit did not land — skipping open-pr'); return committed && { ...committed, pr: null }; }
|
|
25
25
|
|
|
26
|
-
// Step 2 — open the task PR/MR from the committed template (pushes the branch,
|
|
27
|
-
//
|
|
26
|
+
// Step 2 — open the task PR/MR from the committed template (pushes the branch, assigns the committer,
|
|
27
|
+
// requests no reviewers — E62). Pass ONLY an explicit --title: when omitted, runOpenPr derives the title
|
|
28
28
|
// from the committed subject (the full `<type>: …` form), which the pr-title gate expects — passing
|
|
29
29
|
// the bare --message here would override that with a type-less title and fail the gate.
|
|
30
30
|
const opened = await runOpenPr(root, {
|
|
@@ -33,5 +33,6 @@ export async function runShip(root, opts = {}) {
|
|
|
33
33
|
risk: opts.risk, contractChange: opts.contractChange,
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// The commit's keys, and the PR open-pr opened (or null when it opened none).
|
|
37
|
+
return { ...committed, pr: opened ?? null };
|
|
37
38
|
}
|
package/cli/skill.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// `yad skill` — which skill runs which step (E6).
|
|
2
|
+
//
|
|
3
|
+
// The engine ships a default for every step (the `skill` column of the step catalogue in
|
|
4
|
+
// epic-state.mjs). A project that wants a different one records it in `.sdlc/skills.json`, and every
|
|
5
|
+
// surface that names a skill — `yad next`, `yad epic new` — reads the project's answer first.
|
|
6
|
+
//
|
|
7
|
+
// yad skill list [--json] every step, its bound skill(s), and where that came from
|
|
8
|
+
// yad skill bind <step> <skill> [<skill>…] bind one step; several skills run in the order given
|
|
9
|
+
// yad skill unbind <step> drop the binding and fall back to the engine's default
|
|
10
|
+
//
|
|
11
|
+
// WHY A COMMAND AND NOT JUST A FILE. The file stays hand-editable — it is read by
|
|
12
|
+
// `loadSkillBindings`, and `yad doctor` reports a line that binds nothing rather than correcting it.
|
|
13
|
+
// But a file the engine never writes is a file with no `schemaVersion` in it, and `yad doctor` would
|
|
14
|
+
// then tell the user to migrate the config file it had just asked them to write. Writing it through
|
|
15
|
+
// `writeJSON` stamps the shape like every other engine-written file, and gives the cost warning a
|
|
16
|
+
// place to appear at the moment somebody opts into a chain.
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { c, exists, fail, hand, info, log, ok, readJSONStrict, warn, writeJSON, emitJSON } from './lib.mjs';
|
|
19
|
+
import { loadSkillBindings, stepDef, stepSkills, STEPS } from './epic-state.mjs';
|
|
20
|
+
import { PROJECT_FILES, SCHEMA_VERSION } from './manifest.mjs';
|
|
21
|
+
|
|
22
|
+
const bail = (message, hint) => { fail(message); if (hint) hand(hint); process.exitCode = 1; };
|
|
23
|
+
|
|
24
|
+
// Every step the engine actually runs a skill for — the ones worth binding. Shape review gates are
|
|
25
|
+
// driven by `yad gate` and are deliberately not listed: offering to bind one would promise something
|
|
26
|
+
// that never runs.
|
|
27
|
+
const bindableSteps = () => STEPS.filter((s) => s.skill).map((s) => s.id);
|
|
28
|
+
|
|
29
|
+
const skillsFile = (root) => path.join(root, PROJECT_FILES.skillsConfig);
|
|
30
|
+
|
|
31
|
+
// Read the file as it is on disk, so a write preserves keys this release does not know about (E50 and
|
|
32
|
+
// E51 add some). `normalizeBindings` is for READING a binding; this is for editing the document.
|
|
33
|
+
//
|
|
34
|
+
// STRICT, unlike the resolver. `loadSkillBindings` treats a broken file as an empty one because
|
|
35
|
+
// `yad next` must still answer; this is a read-modify-write, and doing the same here would rebuild
|
|
36
|
+
// the document from nothing and delete every binding the file held — silently, with a green tick, on
|
|
37
|
+
// the file the docs tell people to hand-edit. Returns null when the bytes do not parse; the caller
|
|
38
|
+
// refuses rather than writing.
|
|
39
|
+
// Returns `{ doc }`, or `{ error }` naming which of the two failures it is. The two are reported with
|
|
40
|
+
// different codes by `yad doctor` on the same bytes, and saying "does not parse" about a file that
|
|
41
|
+
// parses perfectly and is simply a JSON array sends the reader looking for a missing comma.
|
|
42
|
+
const readRaw = (root) => {
|
|
43
|
+
const file = skillsFile(root);
|
|
44
|
+
if (!exists(file)) return { doc: {} };
|
|
45
|
+
let raw;
|
|
46
|
+
try { raw = readJSONStrict(file, null); } catch { return { error: 'does not parse [YAD-STATE-001]' }; }
|
|
47
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'has the wrong shape [YAD-STATE-002]' };
|
|
48
|
+
return { doc: raw };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// An existing document plus this edit, ready to write.
|
|
52
|
+
//
|
|
53
|
+
// `schemaVersion` is set OUTRIGHT rather than carried through. `readJSON` back-stamps an unstamped
|
|
54
|
+
// file as shape 1 (rule 1: a file with no version IS shape 1), and `writeJSON` then preserves what it
|
|
55
|
+
// was handed — so writing the document back unchanged would stamp a hand-authored file as shape 1 and
|
|
56
|
+
// `yad doctor` would immediately report it as behind. This command is the file's engine writer; like
|
|
57
|
+
// `writeState`, its write IS the file's migration.
|
|
58
|
+
const withSteps = (raw, steps) => ({ ...raw, schemaVersion: SCHEMA_VERSION, steps });
|
|
59
|
+
|
|
60
|
+
const brokenFile = (error) => bail(`${PROJECT_FILES.skillsConfig} ${error}`,
|
|
61
|
+
'fix the file (or restore it from git) first — writing over it would delete every binding it holds');
|
|
62
|
+
|
|
63
|
+
// A step id has the shape every catalogue id has. An id this release does not KNOW is allowed through
|
|
64
|
+
// with a warning (the file wins, rule 3), so this is not an allowlist — it is a shape guard, and the
|
|
65
|
+
// one thing it has to stop is `__proto__`: assigning that key to the document sets the object's
|
|
66
|
+
// prototype instead of adding a line, `JSON.stringify` then drops it, and the command would report a
|
|
67
|
+
// binding it did not write. `yad epic new` guards its slug the same way.
|
|
68
|
+
const STEP_ID = /^[a-z][a-z0-9-]*$/;
|
|
69
|
+
|
|
70
|
+
// One row per step the engine can run a skill for: what runs it now, and whether that is the project's
|
|
71
|
+
// choice or the engine's. `source` is the field worth having — "yad-stories" alone never says whether
|
|
72
|
+
// someone chose it.
|
|
73
|
+
// `written` is the set of step ids the FILE mentions, whether or not the value was usable. It is what
|
|
74
|
+
// separates "this project left the step alone" from "this project wrote a line here and the line does
|
|
75
|
+
// nothing" — the second is invisible otherwise, which is the failure `yad skill list` exists to end.
|
|
76
|
+
export function skillRows(root, bindings = loadSkillBindings(root), written = null) {
|
|
77
|
+
return bindableSteps().map((id) => {
|
|
78
|
+
const bound = Object.hasOwn(bindings.steps, id) ? bindings.steps[id] : null;
|
|
79
|
+
return {
|
|
80
|
+
step: id,
|
|
81
|
+
phase: stepDef(id).phase,
|
|
82
|
+
skills: stepSkills(id, bindings),
|
|
83
|
+
source: bound?.length ? 'project' : (written?.has(id) ? 'ignored' : 'engine'),
|
|
84
|
+
default: stepDef(id).skill,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function runSkillList(root, { json = false } = {}) {
|
|
90
|
+
const bindings = loadSkillBindings(root);
|
|
91
|
+
const { doc, error } = readRaw(root);
|
|
92
|
+
const written = new Set(error ? [] : Object.keys(doc.steps && typeof doc.steps === 'object' ? doc.steps : {}));
|
|
93
|
+
const rows = skillRows(root, bindings, written);
|
|
94
|
+
// Bindings on ids the catalogue does not know are listed too, and marked. They are the ones a person
|
|
95
|
+
// most needs to see: `yad next` never looks them up, so without this line they are invisible.
|
|
96
|
+
const extra = [...written]
|
|
97
|
+
.filter((id) => !stepDef(id))
|
|
98
|
+
.map((step) => ({
|
|
99
|
+
step, phase: null,
|
|
100
|
+
skills: stepSkills(step, bindings),
|
|
101
|
+
source: Object.hasOwn(bindings.steps, step) ? 'project' : 'ignored',
|
|
102
|
+
default: null,
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
if (json) return emitJSON({ ok: true, file: PROJECT_FILES.skillsConfig, steps: [...rows, ...extra] });
|
|
106
|
+
|
|
107
|
+
if (error) warn(`${PROJECT_FILES.skillsConfig} ${error} — showing the engine's defaults`);
|
|
108
|
+
log(`\n ${c.bold('step')} ${c.bold('skill(s)')}`);
|
|
109
|
+
for (const r of [...rows, ...extra]) {
|
|
110
|
+
const mark = r.source === 'project' ? c.cyan('•') : (r.source === 'ignored' ? c.red('!') : ' ');
|
|
111
|
+
const notes = [];
|
|
112
|
+
if (r.phase === null) notes.push('not a step this yadflow runs');
|
|
113
|
+
if (r.source === 'ignored') notes.push('the file has a line for this step that names no skill');
|
|
114
|
+
const note = notes.length ? c.dim(` (${notes.join('; ')})`) : '';
|
|
115
|
+
log(` ${mark} ${r.step.padEnd(18)} ${r.skills.join(c.dim(' → ')) || c.dim('(none)')}${note}`);
|
|
116
|
+
}
|
|
117
|
+
const all = [...rows, ...extra];
|
|
118
|
+
const chosen = all.filter((r) => r.source === 'project').length;
|
|
119
|
+
const ignored = all.filter((r) => r.source === 'ignored').length;
|
|
120
|
+
info(chosen
|
|
121
|
+
? `${c.cyan('•')} = bound by this project in ${PROJECT_FILES.skillsConfig}; the rest are the engine's defaults`
|
|
122
|
+
: `every step is on the engine's default — bind one with \`yad skill bind <step> <skill>\``);
|
|
123
|
+
if (ignored) info(`${c.red('!')} = a line in that file that binds nothing — \`yad doctor\` says which`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function runSkillBind(root, { step, skills = [] } = {}) {
|
|
127
|
+
const names = skills.map((s) => String(s || '').trim()).filter(Boolean);
|
|
128
|
+
if (!step || !names.length) {
|
|
129
|
+
return bail('usage: yad skill bind <step> <skill> [<skill> …]',
|
|
130
|
+
`bindable steps: ${bindableSteps().join(' · ')}`);
|
|
131
|
+
}
|
|
132
|
+
if (!STEP_ID.test(step)) {
|
|
133
|
+
return bail(`\`${step}\` is not a step id`, 'a step id is lower-case letters, digits and dashes — for example `architecture` or `ui-design`');
|
|
134
|
+
}
|
|
135
|
+
const def = stepDef(step);
|
|
136
|
+
// A review gate is refused rather than warned about: nothing would ever invoke the binding, so
|
|
137
|
+
// writing it would record a decision that silently never happens.
|
|
138
|
+
if (def && !def.skill) {
|
|
139
|
+
return bail(`\`${step}\` is a review gate — no skill runs it`,
|
|
140
|
+
`it is driven by \`yad gate open\` / \`yad gate sync\`. Bind the step it reviews instead${def.reviews ? `: \`${def.reviews}\`` : ''}`);
|
|
141
|
+
}
|
|
142
|
+
// An UNKNOWN id is allowed through with a warning, not refused. The file wins for this whole major
|
|
143
|
+
// (rule 3), and a project may legitimately hold a step from a newer release than the CLI in hand.
|
|
144
|
+
const { doc, error } = readRaw(root);
|
|
145
|
+
if (error) return brokenFile(error);
|
|
146
|
+
const steps = doc.steps && typeof doc.steps === 'object' && !Array.isArray(doc.steps) ? { ...doc.steps } : {};
|
|
147
|
+
steps[step] = names.length === 1 ? names[0] : names;
|
|
148
|
+
writeJSON(skillsFile(root), withSteps(doc, steps));
|
|
149
|
+
|
|
150
|
+
ok(`${step} → ${names.join(' → ')}`);
|
|
151
|
+
if (!def) {
|
|
152
|
+
info(`\`${step}\` is not a step this yadflow runs — the binding is recorded but nothing will invoke it`);
|
|
153
|
+
}
|
|
154
|
+
// Closed decision 7: extra skills are a chain, never a panel, and every extra one costs another run.
|
|
155
|
+
if (names.length > 1) {
|
|
156
|
+
info(`${names.length} skills run for this step, one after another — each one costs tokens`);
|
|
157
|
+
info('they chain: each sees what the one before it produced, and the last output is the artifact');
|
|
158
|
+
}
|
|
159
|
+
hand(`written to ${PROJECT_FILES.skillsConfig} — \`yad next\` names it from now on (undo with \`yad skill unbind ${step}\`)`);
|
|
160
|
+
return { step, skills: names, known: !!def, file: PROJECT_FILES.skillsConfig };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function runSkillUnbind(root, { step } = {}) {
|
|
164
|
+
if (!step) return bail('usage: yad skill unbind <step>');
|
|
165
|
+
const { doc, error } = readRaw(root);
|
|
166
|
+
if (error) return brokenFile(error);
|
|
167
|
+
const steps = doc.steps && typeof doc.steps === 'object' && !Array.isArray(doc.steps) ? { ...doc.steps } : {};
|
|
168
|
+
if (!(step in steps)) {
|
|
169
|
+
return bail(`${step} is not bound in ${PROJECT_FILES.skillsConfig}`, 'see `yad skill list` for what is bound');
|
|
170
|
+
}
|
|
171
|
+
delete steps[step];
|
|
172
|
+
// The file is left behind, holding an empty `steps`, rather than deleted. Deleting a file the user
|
|
173
|
+
// may have hand-authored — with comments-by-convention, or keys a later release reads — to undo one
|
|
174
|
+
// line would throw away more than was asked for.
|
|
175
|
+
writeJSON(skillsFile(root), withSteps(doc, steps));
|
|
176
|
+
// What runs it now: the engine's default, or nothing at all if this engine does not know the step.
|
|
177
|
+
const fallback = stepSkills(step, null);
|
|
178
|
+
ok(`${step} unbound${fallback.length ? ` — back to the engine's default (${fallback.join(' → ')})` : ' — this yadflow runs no skill for it'}`);
|
|
179
|
+
return { step, skills: fallback, file: PROJECT_FILES.skillsConfig };
|
|
180
|
+
}
|
package/cli/skip.mjs
CHANGED
|
@@ -1,45 +1,226 @@
|
|
|
1
|
-
// `yad skip <epic> <step> --reason "<why>"`
|
|
2
|
-
// epic
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
1
|
+
// `yad skip <epic> <step> --reason "<why>"` / `yad unskip <epic> <step>` (E35, E36) and
|
|
2
|
+
// `yad defer <epic> <step> --reason "<why>"` / `yad undefer <epic> <step>` (E37) — set an OPTIONAL Shape
|
|
3
|
+
// step aside for one epic, or put it back. A skip says the step does not apply; a deferral says it does,
|
|
4
|
+
// later. `yad skip … --undo` and `yad defer … --undo` are the same as the undo verbs. `yad defer --debt`
|
|
5
|
+
// (E41) marks the deferral owed back, and `yad undefer` re-opens a deferral even after later work finished.
|
|
6
|
+
// Which steps qualify is a fact about the epic's ROUTE, read from the lifecycle profile it is on (E35,
|
|
7
|
+
// `optionalStepsFor`), not a list this engine keeps: on `classic` and `analysis-first` that is
|
|
8
|
+
// `ui-design` and its gate. E40's short lanes mark NOTHING optional — they drop the steps they do not
|
|
9
|
+
// need from the chain instead — so both verbs are refused outright on one, and `notOptional` says which
|
|
10
|
+
// of the two reasons applies rather than reporting every empty answer as a broken chain.
|
|
11
|
+
// The too-late checks name no step (E36): a route that marks another step optional gets the same verbs
|
|
12
|
+
// with the same guards, measured against whatever steps follow the pair on that route.
|
|
13
|
+
// Putting a step back asks no route at all: restoring a step to the chain can never let a gate pass, and
|
|
14
|
+
// `yad unskip` is the remedy `yad doctor`'s `skip:not-optional` recommends on an epic whose route forbids
|
|
15
|
+
// the skip. Either way the step stays VISIBLE and auditable — marked with a recorded reason (and
|
|
16
|
+
// actor/date), short-circuited at the gate. All state logic is the pure `skipStep` / `unskipStep` /
|
|
17
|
+
// `deferStep` / `undeferStep` in epic-state.mjs; this is the thin file-load/save + attribution wrapper.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { ok, info, hand, fail, readJSON, readJSONStrict, warn, writeJSON } from './lib.mjs';
|
|
21
|
+
import { epicRel, epicRoot, epicStories, loadLedger, skipLane, skipStep, unskipLane, unskipStep, deferStep, undeferStep, unblockStep, writeState, isReopenedStep, stepStatus } from './epic-state.mjs';
|
|
22
|
+
import { epicFiles, isVerifiedLedger, productConfigPath } from './manifest.mjs';
|
|
23
|
+
import { refreshIndexAfterWrite } from './product-index.mjs';
|
|
24
|
+
import { readShips } from './ledger.mjs';
|
|
25
|
+
import { loadProduct } from './gate.mjs';
|
|
26
|
+
import { seededSlugs } from './hook.mjs';
|
|
27
|
+
import { actorName } from './platform.mjs';
|
|
28
|
+
|
|
29
|
+
// Best-effort auditable actor for a record's `by` — who WROTE the record: the platform login the CLI
|
|
30
|
+
// reports (`actorName`), else the raw git user.name, else null. A malformed/absent Product has no
|
|
31
|
+
// platform to ask and degrades to the raw name — attribution is a nicety on the audit trail, never a
|
|
32
|
+
// gate, so it must not block the verb.
|
|
33
|
+
export function recordActor(root) {
|
|
34
|
+
let platform = null;
|
|
35
|
+
try { platform = loadProduct(root)?.hub?.platform || null; } catch { /* no Product / malformed — attribute by raw git name */ }
|
|
36
|
+
return actorName(root, platform);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The `--json` answer (E1) of every verb here: the step as the file now holds it.
|
|
40
|
+
function stepAnswer(state, epic, step) {
|
|
41
|
+
const s = (Array.isArray(state.steps) ? state.steps : []).find((x) => x?.id === step) || null;
|
|
42
|
+
return { epic, step, state: s ? stepStatus(s) : null, debt: s?.debt === true, record: s?.record ?? null, currentStep: state.currentStep ?? null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// What differs between the two verbs, as a person reads it.
|
|
46
|
+
const VERBS = {
|
|
47
|
+
skip: {
|
|
48
|
+
set: skipStep, restore: unskipStep, undo: 'unskip', done: 'marked N/A', undone: 'un-skipped', state: 'skipped',
|
|
49
|
+
gate: 'its review gate is short-circuited',
|
|
50
|
+
reasonNote: '--reason is not used when un-skipping: the skip record is removed with the skip',
|
|
51
|
+
},
|
|
52
|
+
defer: {
|
|
53
|
+
set: deferStep, restore: undeferStep, undo: 'undefer', done: 'deferred', undone: 'un-deferred', state: 'deferred',
|
|
54
|
+
gate: 'the chain continues past it; its review is still owed',
|
|
55
|
+
reasonNote: '--reason is not used when un-deferring: the record is removed with the deferral',
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// On a verified Product, an epic's `state.json` is CI's alone once it is on the default branch (rule 9):
|
|
60
|
+
// `ledger-guard` rejects every other commit that changes it, and `gate ci` has no step for a skip, a
|
|
61
|
+
// deferral or an unblock. A write here could never land, and the ledger on this machine would drift from
|
|
62
|
+
// the one CI keeps — so these verbs refuse, before anything is written. The question is the one the
|
|
63
|
+
// `ledger-guard` hook asks (`seededSlugs`, cli/hook.mjs), so the hook and the verb never disagree:
|
|
64
|
+
// - an epic whose ledger is NOT on the base yet still writes. Its seed rides the first review PR (#162):
|
|
65
|
+
// the epic review on `classic`, the analysis review on `analysis-first`. A skip or deferral written
|
|
66
|
+
// then is one way once that PR merges, so the verb says so.
|
|
67
|
+
// - a base that cannot be read is unknown, and allowed with a warning, as the hook allows it. The CI gate
|
|
68
|
+
// is the one that fails closed.
|
|
69
|
+
// `hub.json` is read leniently, as the hook reads it. A broken `hub.json` or `repos.json` must not stop
|
|
70
|
+
// these verbs on a local ledger (`yad doctor` reports both), and the hook and the verb must give the same
|
|
71
|
+
// answer on the same file.
|
|
72
|
+
function ciOwnsLedger(root, { epic, command, undoCommand = null, runner }) {
|
|
73
|
+
const hub = readJSON(productConfigPath(root), null);
|
|
74
|
+
if (!isVerifiedLedger(hub)) return false;
|
|
75
|
+
const seeded = seededSlugs(root, hub, runner);
|
|
76
|
+
if (seeded === null) {
|
|
77
|
+
warn(`this Product's ledger is verified, and origin could not be read — cannot tell whether CI already owns ${epicRel(epic)}/.sdlc/state.json. If the epic's first review PR has merged, ledger-guard will reject this change`);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (!seeded.has(epic.toLowerCase())) {
|
|
81
|
+
if (undoCommand) {
|
|
82
|
+
info(`on this verified Product ${epicRel(epic)}/.sdlc/state.json is yours to write only until the epic's first review PR merges; after that \`${undoCommand}\` is refused until CI has a step for it (checked against origin as last fetched — run \`git fetch origin\` if that PR may have merged)`);
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
fail(`\`${command}\` is refused: ${epicRel(epic)}/.sdlc/state.json is on the default branch, and on a verified Product only CI writes it — nothing is written`);
|
|
87
|
+
hand(`ledger-guard rejects any commit to it that CI did not make, and CI has no step for \`${command}\` yet. An epic's ledger is still yours to write while the epic is new, before its first review PR merges`);
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
return true;
|
|
21
90
|
}
|
|
22
91
|
|
|
23
|
-
|
|
92
|
+
async function runSetAside(root, verb, { epic, step, reason, debt = false, undo = false, today, runner } = {}) {
|
|
93
|
+
const V = VERBS[verb];
|
|
24
94
|
const epicDir = epicRoot(root, epic);
|
|
25
95
|
const ledger = loadLedger(epicDir);
|
|
26
96
|
if (!ledger.state) { fail(`no epic state at ${epicDir} — seed the epic first with yad-epic`); process.exitCode = 1; return; }
|
|
27
|
-
if (!step) {
|
|
97
|
+
if (!step) {
|
|
98
|
+
fail(undo ? `usage: yad ${V.undo} <epic> <step>` : `usage: yad ${verb} <epic> <step> --reason "<why>" (undo it with: yad ${V.undo} <epic> <step>)`);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (ciOwnsLedger(root, { epic, command: `yad ${undo ? V.undo : verb}`, undoCommand: undo ? null : `yad ${V.undo} ${epic} ${step}`, runner })) return;
|
|
28
103
|
|
|
29
104
|
// Guard violations throw a YadError (YAD-STATE-004) with a hint — the top-level catch in bin/yad.mjs
|
|
30
105
|
// renders those. Here we only handle the happy path + the two plain-arg checks above.
|
|
31
106
|
if (undo) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
107
|
+
V.restore(ledger.state, step);
|
|
108
|
+
// Putting a step back deletes its record, so a reason given here would be recorded nowhere. Say so
|
|
109
|
+
// rather than accept it in silence.
|
|
110
|
+
if (reason != null && reason !== true) info(V.reasonNote);
|
|
111
|
+
// The same for `--debt`: putting a step back is how a debt is PAID, so the flag means nothing here (E41).
|
|
112
|
+
if (debt === true) info('--debt is not used when putting a step back: a debt is set with `yad defer --debt`, and putting the step back starts paying it');
|
|
113
|
+
writeState(ledger.files.state, ledger.state);
|
|
114
|
+
refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null)); // E19: the default branch of a local Product only
|
|
115
|
+
// A deferral put back after later work finished RE-OPENS beside that work (E41), and `currentStep`
|
|
116
|
+
// stays where the chain is — so "back in the chain" and a currentStep line would both mislead.
|
|
117
|
+
const answer = { ...stepAnswer(ledger.state, epic, step), changed: true, reopened: isReopenedStep(ledger.state, step) };
|
|
118
|
+
if (answer.reopened) {
|
|
119
|
+
ok(`${step} ${V.undone} — re-opened beside the work already finished after it, which stays done`);
|
|
120
|
+
hand(`currentStep stays ${ledger.state.currentStep}; see the re-opened lane with: yad next ${epic}`);
|
|
121
|
+
return answer;
|
|
122
|
+
}
|
|
123
|
+
ok(`${step} ${V.undone} — back in the chain`);
|
|
35
124
|
hand(`currentStep is now ${ledger.state.currentStep}`);
|
|
125
|
+
return answer;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const by = recordActor(root);
|
|
129
|
+
// A repeat on a step already set aside this way changes nothing but, with `--debt`, the flag — and keeps
|
|
130
|
+
// the ORIGINAL record. Printing this run's actor, date and reason would misstate who set it aside and why.
|
|
131
|
+
const steps = Array.isArray(ledger.state.steps) ? ledger.state.steps : [];
|
|
132
|
+
const before = steps.find((s) => s?.id === step);
|
|
133
|
+
const already = stepStatus(before) === V.state;
|
|
134
|
+
const owedBefore = before?.debt === true;
|
|
135
|
+
V.set(ledger.state, step, { reason, by, at: today, debt: debt === true });
|
|
136
|
+
writeState(ledger.files.state, ledger.state);
|
|
137
|
+
refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null)); // E19, as above
|
|
138
|
+
const after = ledger.state.steps.find((s) => s?.id === step);
|
|
139
|
+
const owed = after?.debt === true;
|
|
140
|
+
if (already) {
|
|
141
|
+
const r = after?.record || {};
|
|
142
|
+
ok(`${step} was already ${V.done}${r.by ? ` by ${r.by}` : ''}${r.date ? ` on ${r.date}` : ''}${owed && !owedBefore ? ' — now marked as debt' : ' — nothing changed'}`);
|
|
143
|
+
if (r.reason) info(`reason: ${r.reason}`);
|
|
144
|
+
return { ...stepAnswer(ledger.state, epic, step), changed: owed && !owedBefore };
|
|
145
|
+
}
|
|
146
|
+
ok(`${step} ${V.done}${owed ? ' as debt' : ''}${by ? ` by ${by}` : ''}${today ? ` on ${today}` : ''}`);
|
|
147
|
+
info(`reason: ${String(reason).trim()}`);
|
|
148
|
+
hand(`${V.gate}; currentStep is now ${ledger.state.currentStep} (reverse with \`yad ${V.undo} ${epic} ${step}\`)`);
|
|
149
|
+
return { ...stepAnswer(ledger.state, epic, step), changed: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// `yad skip <epic> <story> --repo <name> --reason "<why>"` / `yad unskip <epic> <story> --repo <name>`
|
|
153
|
+
// (E39) — set a whole Build LANE aside: this story needs no change in this repo. The rules are the pure
|
|
154
|
+
// `skipLane` / `unskipLane` in epic-state.mjs; this reads the story and the ships, and writes
|
|
155
|
+
// `build-state/<story>.json`, which `yad checkpoint --push` commits. There is no lane deferral: a lane that
|
|
156
|
+
// is owed later is simply not driven yet.
|
|
157
|
+
export async function runLaneSkip(root, { epic, story, repo, reason, undo = false, today } = {}) {
|
|
158
|
+
const epicDir = epicRoot(root, epic);
|
|
159
|
+
const ledger = loadLedger(epicDir);
|
|
160
|
+
if (!ledger.state) { fail(`no epic state at ${epicDir} — seed the epic first with yad-epic`); process.exitCode = 1; return; }
|
|
161
|
+
if (!repo || repo === true) {
|
|
162
|
+
fail(`usage: yad ${undo ? 'unskip' : 'skip'} ${epic} ${story} --repo <name>${undo ? '' : ' --reason "<why>"'}`);
|
|
163
|
+
process.exitCode = 1;
|
|
36
164
|
return;
|
|
37
165
|
}
|
|
166
|
+
const file = path.join(epicFiles(epicDir).buildStateDir, `${story}.json`);
|
|
167
|
+
const current = readJSONStrict(file, null);
|
|
168
|
+
|
|
169
|
+
// Putting a lane back asks nothing but that it was skipped — not even that the story file still exists,
|
|
170
|
+
// so a story renamed or removed after the skip can still have its skip undone (E39 review).
|
|
171
|
+
if (undo) {
|
|
172
|
+
const { buildState, empty } = unskipLane(current, { story, repo });
|
|
173
|
+
if (empty) fs.rmSync(file);
|
|
174
|
+
else writeJSON(file, buildState);
|
|
175
|
+
if (reason != null && reason !== true) info('--reason is not used when un-skipping: the skip record is removed with the skip');
|
|
176
|
+
ok(`${story} / ${repo} un-skipped — the lane is owed again`);
|
|
177
|
+
hand(`yad-run adds the lane the next time ${story} is driven in ${repo}; commit this with \`yad checkpoint --push\``);
|
|
178
|
+
return { epic, story, repo, skipped: false, changed: true, record: null, file: path.relative(root, file) };
|
|
179
|
+
}
|
|
38
180
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
181
|
+
const entry = epicStories(epicDir).find((st) => st.id === story);
|
|
182
|
+
if (!entry) { fail(`no story ${story} under ${epicRel(epic)}/stories/`); process.exitCode = 1; return; }
|
|
183
|
+
// An EARNED stories review only: `done` here, or `satisfied` (reviewed in the parent epic) — the rule E36
|
|
184
|
+
// gives for "Build can run". `isPassed` would also accept a hand-typed `skipped` or `deferred` review, and
|
|
185
|
+
// a lane skip over stories nobody approved is what this refusal exists to stop (E39 review).
|
|
186
|
+
const storiesReview = ledger.state.steps.find((st) => st?.id === 'stories-review');
|
|
187
|
+
const shippedRepos = readShips(epicDir).filter((sh) => sh.story === story).map((sh) => sh.repo);
|
|
188
|
+
const by = recordActor(root);
|
|
189
|
+
const { buildState, already } = skipLane(current, {
|
|
190
|
+
story, repo, reason, by, date: today, declared: entry.repos, shippedRepos, storiesPassed: ['done', 'satisfied'].includes(stepStatus(storiesReview)),
|
|
191
|
+
});
|
|
192
|
+
if (already) {
|
|
193
|
+
const r = current.repos[repo].record || {};
|
|
194
|
+
ok(`${story} / ${repo} was already skipped${r.by ? ` by ${r.by}` : ''}${r.date ? ` on ${r.date}` : ''} — nothing changed`);
|
|
195
|
+
if (r.reason) info(`reason: ${r.reason}`);
|
|
196
|
+
return { epic, story, repo, skipped: true, changed: false, record: current.repos[repo].record ?? null, file: path.relative(root, file) };
|
|
197
|
+
}
|
|
198
|
+
writeJSON(file, buildState);
|
|
199
|
+
ok(`${story} / ${repo} lane skipped — N/A${by ? ` by ${by}` : ''}${today ? ` on ${today}` : ''}`);
|
|
43
200
|
info(`reason: ${String(reason).trim()}`);
|
|
44
|
-
hand(`
|
|
201
|
+
hand(`the feature can ship without it; commit this with \`yad checkpoint --push\` (reverse with \`yad unskip ${epic} ${story} --repo ${repo}\`)`);
|
|
202
|
+
return { epic, story, repo, skipped: true, changed: true, record: buildState.repos?.[repo]?.record ?? null, file: path.relative(root, file) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const runSkip = (root, opts) => runSetAside(root, 'skip', opts);
|
|
206
|
+
export const runDefer = (root, opts) => runSetAside(root, 'defer', opts);
|
|
207
|
+
|
|
208
|
+
// `yad unblock <epic> <step>` (E37) — clear a recorded blocker once the wait is over. The state logic is
|
|
209
|
+
// the pure `unblockStep`; `build-state/<story>.json` belongs to the skills and is never touched here.
|
|
210
|
+
export async function runUnblock(root, { epic, step, runner } = {}) {
|
|
211
|
+
const epicDir = epicRoot(root, epic);
|
|
212
|
+
const ledger = loadLedger(epicDir);
|
|
213
|
+
if (!ledger.state) { fail(`no epic state at ${epicDir} — seed the epic first with yad-epic`); process.exitCode = 1; return; }
|
|
214
|
+
if (!step) { fail('usage: yad unblock <epic> <step>'); process.exitCode = 1; return; }
|
|
215
|
+
if (ciOwnsLedger(root, { epic, command: 'yad unblock', runner })) return;
|
|
216
|
+
// Read the reason BEFORE the write removes it, so the line can say what was cleared.
|
|
217
|
+
const steps = Array.isArray(ledger.state.steps) ? ledger.state.steps : [];
|
|
218
|
+
const was = steps.find((s) => s?.id === step)?.record?.reason || null;
|
|
219
|
+
unblockStep(ledger.state, step);
|
|
220
|
+
writeState(ledger.files.state, ledger.state);
|
|
221
|
+
refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null)); // E19, as above
|
|
222
|
+
ok(`${step} unblocked — now ${ledger.state.steps.find((s) => s?.id === step).status}`);
|
|
223
|
+
if (was) info(`cleared: ${was}`);
|
|
224
|
+
hand(`see what to do now: yad next ${epic}`);
|
|
225
|
+
return { ...stepAnswer(ledger.state, epic, step), changed: true, cleared: was };
|
|
45
226
|
}
|
package/cli/thread.mjs
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
// this only DISCOVERS, exactly as yad-docs-sync flags and the build gates block. Node built-ins only.
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
|
-
import { c, log, ok, info, warn, hand, readJSON, exists } from './lib.mjs';
|
|
7
|
+
import { c, log, ok, info, warn, hand, readJSON, exists, emitJSON, refuse } from './lib.mjs';
|
|
8
8
|
import { readShips } from './ledger.mjs';
|
|
9
9
|
import {
|
|
10
|
-
epicRoot, isValidEpicId, epicLineage, readFrontmatter, isStubEpic,
|
|
10
|
+
epicRoot, isValidEpicId, epicLineage, readFrontmatter, isStubEpic, typeNoun, currentPhase, isProductLevel,
|
|
11
11
|
resolveThread, threadEpics, resolveCurrentArtifacts, resolveCurrentStories, THREAD_ARTIFACT_BASES,
|
|
12
12
|
} from './epic-state.mjs';
|
|
13
13
|
|
|
@@ -24,7 +24,7 @@ export const loadBuildLog = (root, epic) => ({ epic, ships: readShips(epicRoot(r
|
|
|
24
24
|
|
|
25
25
|
// An epic is SEALED once every authored story is `shipped` (config.yaml change.seal_on). A sealed epic
|
|
26
26
|
// refuses new behaviour (epic-open.sh) — a further change must open a new threaded change-epic, which is
|
|
27
|
-
// what keeps the
|
|
27
|
+
// what keeps the Shape artifacts from going stale. An epic with no stories is NOT sealed (nothing built).
|
|
28
28
|
export function sealedEpic(root, epic) {
|
|
29
29
|
const dir = path.join(epicRoot(root, epic), 'stories');
|
|
30
30
|
if (!exists(dir)) return false;
|
|
@@ -54,8 +54,18 @@ export function threadSummary(root, threadOrEpic) {
|
|
|
54
54
|
const state = readJSON(path.join(epicRoot(root, id), '.sdlc', 'state.json'), null);
|
|
55
55
|
const change = loadChange(root, id);
|
|
56
56
|
return {
|
|
57
|
-
|
|
57
|
+
// `type` is the word from shape 5 on; `kind` is the same value under the name this key has
|
|
58
|
+
// always had. Both are emitted for one major so a script reading either keeps working.
|
|
59
|
+
id, type: lin.type, kind: lin.type, parent: lin.parent, inherits: lin.inherits,
|
|
60
|
+
// The free grouping tag from epic.md (E31), null when unset. This is the only machine-readable
|
|
61
|
+
// surface that carries it: `yad next --json` is frozen by the golden test and cannot gain a key.
|
|
62
|
+
theme: lin.theme,
|
|
58
63
|
currentStep: state?.currentStep || 'unseeded',
|
|
64
|
+
// Which of the six phases this epic is in — the same answer `yad next` prints, from the same
|
|
65
|
+
// function. Null is a real answer, not a gap: a stub and any step id this release does not
|
|
66
|
+
// recognise have no phase, and neither is guessed at. (The product level never reaches here —
|
|
67
|
+
// a thread is built from epics with an `epic.md` — but the same predicate is asked anyway.)
|
|
68
|
+
phase: currentPhase(state?.currentStep, { product: isProductLevel(state) }),
|
|
59
69
|
sealed: sealedEpic(root, id),
|
|
60
70
|
stub: isStubEpic(root, id),
|
|
61
71
|
depth: change?.depth || null,
|
|
@@ -73,16 +83,18 @@ export function threadSummary(root, threadOrEpic) {
|
|
|
73
83
|
};
|
|
74
84
|
}
|
|
75
85
|
|
|
76
|
-
// Colour a node's
|
|
77
|
-
// only layers the per-
|
|
78
|
-
const
|
|
79
|
-
|
|
86
|
+
// Colour a node's type noun for the tree render. The noun words live in one place (`typeNoun`); this
|
|
87
|
+
// only layers the per-type colour on top, so the two never drift. Unknown type → uncoloured noun.
|
|
88
|
+
const TYPE_COLOR = {
|
|
89
|
+
feature: c.green, change: c.cyan, defect: c.yellow, hotfix: c.red, chore: c.dim,
|
|
90
|
+
};
|
|
91
|
+
const typeTag = (t) => (TYPE_COLOR[t] || ((s) => s))(typeNoun(t));
|
|
80
92
|
|
|
81
93
|
export async function runThread(root, { epic, json = false } = {}) {
|
|
82
94
|
if (!epic) {
|
|
83
95
|
// List every distinct thread root in the project.
|
|
84
96
|
const dir = path.join(root, 'epics');
|
|
85
|
-
if (!exists(dir))
|
|
97
|
+
if (!exists(dir)) return refuse('no epics/ directory', null, { json });
|
|
86
98
|
const roots = new Set();
|
|
87
99
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
88
100
|
if (e.isDirectory() && isValidEpicId(e.name) && exists(path.join(dir, e.name, 'epic.md'))) {
|
|
@@ -90,27 +102,37 @@ export async function runThread(root, { epic, json = false } = {}) {
|
|
|
90
102
|
}
|
|
91
103
|
}
|
|
92
104
|
log(c.bold('\nFeature threads'));
|
|
105
|
+
// The --json answer (E1): one line per thread, as the list prints it.
|
|
106
|
+
const threads = [];
|
|
93
107
|
for (const r of [...roots].sort()) {
|
|
94
108
|
const s = threadSummary(root, r);
|
|
109
|
+
threads.push({ thread: r, theme: s.nodes[0]?.theme ?? null, epics: s.nodes.length, stub: !!s.nodes[0]?.stub, openDebt: s.openDebt.length });
|
|
95
110
|
const debt = s.openDebt.length ? c.red(` ⚠ ${s.openDebt.length} open reconcile-debt`) : '';
|
|
96
111
|
const stub = s.nodes[0]?.stub ? c.yellow(' [stub · backfill pending]') : '';
|
|
97
|
-
|
|
112
|
+
// The genesis epic's grouping theme (E31). This list is where a person looks to see which
|
|
113
|
+
// threads belong together, so it is the one place the tag earns its keep most.
|
|
114
|
+
const theme = s.nodes[0]?.theme ? c.dim(` #${s.nodes[0].theme}`) : '';
|
|
115
|
+
log(` ${c.bold(r)}${theme} ${c.dim(`${s.nodes.length} epic(s)`)}${stub}${debt}`);
|
|
98
116
|
}
|
|
99
117
|
log(c.dim('\n yad thread <epic> show one thread in full'));
|
|
100
|
-
return;
|
|
118
|
+
if (json) return emitJSON({ threads });
|
|
119
|
+
return { threads };
|
|
101
120
|
}
|
|
102
|
-
if (!isValidEpicId(epic))
|
|
121
|
+
if (!isValidEpicId(epic)) return refuse(`invalid epic id: ${epic}`, null, { json });
|
|
103
122
|
const s = threadSummary(root, epic);
|
|
104
|
-
if (json) {
|
|
123
|
+
if (json) { emitJSON(s); return; }
|
|
105
124
|
|
|
106
125
|
log(c.bold(`\nThread ${s.thread}`) + c.dim(' (genesis → tip)'));
|
|
107
126
|
if (s.broken) log(c.red(` ✗ broken lineage: ${s.broken}`));
|
|
108
127
|
for (const n of s.nodes) {
|
|
109
|
-
const tag =
|
|
128
|
+
const tag = typeTag(n.type);
|
|
110
129
|
const seal = n.sealed ? c.dim(' [sealed]') : '';
|
|
111
130
|
const stub = n.stub ? c.yellow(' [stub · backfill pending]') : '';
|
|
112
131
|
const dep = n.depth ? c.dim(` ${n.depth}`) : '';
|
|
113
|
-
|
|
132
|
+
// The grouping theme prints only when there is one. An epic with no theme is normal, and an
|
|
133
|
+
// empty `theme: —` on every line would be noise on a surface people read top to bottom.
|
|
134
|
+
const theme = n.theme ? c.dim(` #${n.theme}`) : '';
|
|
135
|
+
log(` • ${c.bold(n.id)} ${tag}${dep}${theme} ${c.dim('@ ' + n.currentStep)}${seal}${stub}`);
|
|
114
136
|
if (n.parent) log(c.dim(` parent: ${n.parent} inherits: [${n.inherits.join(', ') || '—'}]`));
|
|
115
137
|
if (n.defect) log(c.dim(` defect: ${n.defect.severity || '?'} · escaped@${n.defect.escape_stage || '?'} · ${n.defect.root_cause || ''}`));
|
|
116
138
|
if (n.brokenThread) log(c.red(` ✗ ${n.brokenThread}`));
|
|
@@ -150,16 +172,18 @@ function threadRoots(root) {
|
|
|
150
172
|
|
|
151
173
|
export async function runReconcile(root, { action = 'check', thread = null } = {}) {
|
|
152
174
|
const roots = thread ? [resolveThread(root, thread).rootId] : threadRoots(root);
|
|
153
|
-
if (!roots.length) { info('no feature threads found (no epics with epic.md yet)'); return; }
|
|
175
|
+
if (!roots.length) { info('no feature threads found (no epics with epic.md yet)'); return { action, flags: 0, threads: [] }; }
|
|
154
176
|
|
|
155
177
|
log(c.bold(`\nChange reconcile ${c.dim(action)}`));
|
|
156
178
|
let flags = 0;
|
|
179
|
+
const threads = []; // the --json answer (E1)
|
|
157
180
|
for (const r of roots) {
|
|
158
181
|
const s = threadSummary(root, r);
|
|
159
182
|
const issues = [];
|
|
160
183
|
if (s.broken) issues.push(`broken lineage: ${s.broken}`);
|
|
161
184
|
for (const n of s.nodes) if (n.brokenThread) issues.push(`${n.id}: ${n.brokenThread}`);
|
|
162
185
|
for (const d of s.openDebt) issues.push(`open reconcile debt on ${d.epicId} — next change blocked until paid`);
|
|
186
|
+
threads.push({ thread: r, clean: !issues.length, issues });
|
|
163
187
|
if (!issues.length) { ok(`${r} — clean`); continue; }
|
|
164
188
|
flags += issues.length;
|
|
165
189
|
warn(`${r}`);
|
|
@@ -168,7 +192,7 @@ export async function runReconcile(root, { action = 'check', thread = null } = {
|
|
|
168
192
|
|
|
169
193
|
if (action === 'refresh') {
|
|
170
194
|
log('');
|
|
171
|
-
info('refresh is advisory: open a reconcile change-epic with `yad-change` (
|
|
195
|
+
info('refresh is advisory: open a reconcile change-epic with `yad-change` (type change) threaded to');
|
|
172
196
|
info('the affected feature, then pay any open debt (update artifacts + add a regression test).');
|
|
173
197
|
info('for shipped brownfield code with NO epic at all, anchor it first with `yad-stub`, then thread');
|
|
174
198
|
info('the change/defect off that stub (and run `yad-backfill` to make the anchor real).');
|
|
@@ -182,4 +206,5 @@ export async function runReconcile(root, { action = 'check', thread = null } = {
|
|
|
182
206
|
log('');
|
|
183
207
|
if (flags) { warn(`${flags} item(s) need attention — reconcile is advisory; the gates block at merge`); }
|
|
184
208
|
else { ok('all threads reconciled — no drift, no open debt'); }
|
|
209
|
+
return { action, flags, threads };
|
|
185
210
|
}
|