yadflow 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/bin/yad.mjs +6 -3
- package/cli/doctor.mjs +30 -3
- package/cli/epic-state.mjs +74 -0
- package/cli/errors.mjs +1 -0
- package/cli/gate.mjs +66 -1
- package/cli/next.mjs +14 -5
- package/cli/setup.mjs +25 -10
- package/cli/thread.mjs +6 -3
- package/package.json +1 -1
- package/skills/yad-connect-repos/SKILL.md +18 -0
- package/skills/yad-epic/SKILL.md +5 -0
- package/skills/yad-hub-bridge/references/bridge.md +7 -0
- package/skills/yad-review-gate/references/gating.md +3 -1
- package/skills/yad-status/SKILL.md +5 -2
- package/skills/yad-stories/SKILL.md +4 -0
- package/skills/yad-timeline/SKILL.md +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
# [3.12.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.11.1...v3.12.0) (2026-07-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* render an epic's kind as its noun in next/thread/status ([42e80e1](https://github.com/abdelrahmannasr/yadflow/commit/42e80e19e20e129a2a3941c85db6777a66ab00cc))
|
|
7
|
+
|
|
8
|
+
## [3.11.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.11.0...v3.11.1) (2026-07-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **gate:** close the authoring step when its review gate advances ([8baaed9](https://github.com/abdelrahmannasr/yadflow/commit/8baaed9416a063013b5e6acf1ae36c5a1b3c920b))
|
|
14
|
+
* **setup:** contain repo paths to the workspace so sibling repos connect ([265a7ae](https://github.com/abdelrahmannasr/yadflow/commit/265a7ae543fccf8697ff89726ecb1a6aadde62ce))
|
|
15
|
+
|
|
1
16
|
# [3.11.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.1...v3.11.0) (2026-07-09)
|
|
2
17
|
|
|
3
18
|
|
package/bin/yad.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { VERSION } from '../cli/manifest.mjs';
|
|
|
4
4
|
import { c, log, closePrompts, askYesNo } from '../cli/lib.mjs';
|
|
5
5
|
import { runSetup } from '../cli/setup.mjs';
|
|
6
6
|
import { reconcile } from '../cli/reconcile.mjs';
|
|
7
|
-
import { gateOpen, gateSync, gateComments, gateStatus, gateCi, gateReview, gateTrailer, gateWalkthrough } from '../cli/gate.mjs';
|
|
7
|
+
import { gateOpen, gateSync, gateComments, gateStatus, gateCi, gateReview, gateTrailer, gateWalkthrough, gateRepair } from '../cli/gate.mjs';
|
|
8
8
|
import { isValidEpicId } from '../cli/epic-state.mjs';
|
|
9
9
|
import { runCommit } from '../cli/commit.mjs';
|
|
10
10
|
import { runOpenPr } from '../cli/openpr.mjs';
|
|
@@ -78,6 +78,8 @@ ${c.bold('Review gate (front half)')}
|
|
|
78
78
|
yad gate sync <epic> [artifact] Pull PR state -> ledger; advance on approved+resolved+merged
|
|
79
79
|
yad gate comments <epic> [artifact] Fetch unresolved review comments to address
|
|
80
80
|
yad gate status <epic> Show each review step + approvals
|
|
81
|
+
yad gate repair <epic> [--push] Close an author step stranded behind a passed review gate
|
|
82
|
+
(YAD-STATE-005); --push commits state.json to the default branch
|
|
81
83
|
yad gate review <epic> [artifact] Print the grounding bundle for the review companion
|
|
82
84
|
(artifact + risk + contract + PR + code-maps) — fun, easy review
|
|
83
85
|
yad gate walkthrough <epic> [artifact] Grounding bundle + ordered risk-tagged stops for the
|
|
@@ -259,7 +261,7 @@ async function main() {
|
|
|
259
261
|
const [, action, epic, artifact] = o._;
|
|
260
262
|
// `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
|
|
261
263
|
if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, merged: o.merged, push: !o.noPush, today }); break; }
|
|
262
|
-
if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|review|walkthrough|trailer|ci> <epic> [artifact]')); process.exitCode = 1; break; }
|
|
264
|
+
if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|repair|review|walkthrough|trailer|ci> <epic> [artifact]')); process.exitCode = 1; break; }
|
|
263
265
|
// The epic id becomes a path segment under epics/ — reject anything but EP-<slug> outright.
|
|
264
266
|
if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
|
|
265
267
|
// In bridge mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
|
|
@@ -269,10 +271,11 @@ async function main() {
|
|
|
269
271
|
else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
|
|
270
272
|
else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
|
|
271
273
|
else if (action === 'status') await gateStatus(o.dir, { epic });
|
|
274
|
+
else if (action === 'repair') await gateRepair(o.dir, { epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
|
|
272
275
|
else if (action === 'review') await gateReview(o.dir, { epic, artifact });
|
|
273
276
|
else if (action === 'walkthrough') await gateWalkthrough(o.dir, { epic, artifact });
|
|
274
277
|
else if (action === 'trailer') await gateTrailer(o.dir, { epic, artifact, body: o.body || o.message, number: o.pr });
|
|
275
|
-
else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|review|walkthrough|trailer|ci)`)); process.exitCode = 1; }
|
|
278
|
+
else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|repair|review|walkthrough|trailer|ci)`)); process.exitCode = 1; }
|
|
276
279
|
break;
|
|
277
280
|
}
|
|
278
281
|
case 'review': {
|
package/cli/doctor.mjs
CHANGED
|
@@ -6,9 +6,9 @@ import path from 'node:path';
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSONStrict } from './lib.mjs';
|
|
8
8
|
import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS } from './manifest.mjs';
|
|
9
|
-
import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread } from './epic-state.mjs';
|
|
9
|
+
import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants } from './epic-state.mjs';
|
|
10
10
|
import { loadDebt } from './thread.mjs';
|
|
11
|
-
import { gitHead } from './setup.mjs';
|
|
11
|
+
import { gitHead, insideWorkspace } from './setup.mjs';
|
|
12
12
|
import { cliFor, validateLogin, hostFromGitUrl } from './platform.mjs';
|
|
13
13
|
|
|
14
14
|
const MIN_NODE = 18;
|
|
@@ -18,6 +18,18 @@ const MIN_NODE = 18;
|
|
|
18
18
|
const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
|
|
19
19
|
// owner/repo slug from a git url (https or ssh), for the branch-protection probe.
|
|
20
20
|
const repoSlug = (url) => ((url || '').match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/) || [])[1] || null;
|
|
21
|
+
// Is an already-resolved path nested under the project root? Repo paths are contained to the WORKSPACE
|
|
22
|
+
// (the root's parent, see setup.insideWorkspace), so a registered sibling resolves outside the root —
|
|
23
|
+
// which is what distinguishes "absent because it lives elsewhere" from "absent because it is broken".
|
|
24
|
+
// The path.sep suffix keeps /proj-evil from reading as inside /proj.
|
|
25
|
+
const underProjectRoot = (root, p) => {
|
|
26
|
+
const projectRoot = path.resolve(root);
|
|
27
|
+
return p === projectRoot || p.startsWith(projectRoot + path.sep);
|
|
28
|
+
};
|
|
29
|
+
// An absent path is only excused as "a sibling that lives elsewhere" when it is one the connect step
|
|
30
|
+
// would actually accept. A hand-edited registry pointing outside the workspace entirely (../../x) is
|
|
31
|
+
// corruption, and must not be reassured away as an expected sibling.
|
|
32
|
+
const isRegistrableSibling = (root, rpath) => insideWorkspace(root, rpath);
|
|
21
33
|
|
|
22
34
|
// Each check: { id, section, status: 'ok'|'warn'|'fail', message, hint? }
|
|
23
35
|
function check(checks, id, section, status, message, hint = '') {
|
|
@@ -213,7 +225,15 @@ export function projectChecks(checks, root) {
|
|
|
213
225
|
// would read as "healthy") — an entry with no path is malformed.
|
|
214
226
|
if (!repo.path) { check(checks, `repo:${repo.name || '(unnamed)'}`, 'project', 'fail', `${repo.name || '(unnamed)'}: no \`path\` in repos.json [YAD-STATE-003]`, 're-connect the repo (`yad setup`)'); continue; }
|
|
215
227
|
const repoRoot = path.resolve(root, repo.path);
|
|
216
|
-
|
|
228
|
+
// A registered repo may be a SIBLING of the hub (`../backend`, the standard multi-repo layout).
|
|
229
|
+
// Such a checkout is legitimately absent wherever only the hub is checked out — hub CI, a fresh
|
|
230
|
+
// clone — so its absence is a warn, not corruption. A missing path INSIDE the project root is
|
|
231
|
+
// still a hard fail: nothing but damage explains it.
|
|
232
|
+
if (!exists(repoRoot)) {
|
|
233
|
+
if (underProjectRoot(root, repoRoot) || !isRegistrableSibling(root, repo.path)) check(checks, `repo:${repo.name}`, 'project', 'fail', `${repo.name}: path ${repo.path} does not exist [YAD-STATE-003]`, 'fix the path in repos.json or re-connect the repo');
|
|
234
|
+
else check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: ${repo.path} is not present in this checkout (sibling repo, outside the hub)`, 'expected when only the hub is checked out; clone it alongside the hub to work on it here');
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
217
237
|
const head = gitHead(repoRoot);
|
|
218
238
|
if (!head) { check(checks, `repo:${repo.name}`, 'project', 'fail', `${repo.name}: ${repo.path} is not a git repository (or has no commits) [YAD-STATE-003]`, 'init/clone the repo, then re-connect it'); continue; }
|
|
219
239
|
if (!repo.syncedHead) check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: registered without a code-context pack (greenfield)`, 'run `yad repo refresh ' + repo.name + '` once it has code');
|
|
@@ -270,6 +290,13 @@ export function epicChecks(checks, root) {
|
|
|
270
290
|
if (!ledger.state) check(checks, `epic:${e}`, 'epics', 'warn', `${e}: no state.json — epic not seeded`, 'author it via yad-epic, or remove the directory');
|
|
271
291
|
else {
|
|
272
292
|
check(checks, `epic:${e}`, 'epics', 'ok', `${e}: currentStep ${ledger.state.currentStep}`);
|
|
293
|
+
// Chain consistency: a passed review gate whose author step was never closed. currentStep alone
|
|
294
|
+
// cannot see this, yet it blocks every later step (including the parallel test-cases track).
|
|
295
|
+
for (const v of stateInvariants(ledger.state)) {
|
|
296
|
+
check(checks, `epic:${e}:${v.authorStep}`, 'epics', 'fail',
|
|
297
|
+
`${e}: ${v.message} [${v.code}]`,
|
|
298
|
+
`run \`yad gate repair ${e}\` to close it`);
|
|
299
|
+
}
|
|
273
300
|
// Migration guard (pre-3.0 model): under the current model CI records the ledger on the
|
|
274
301
|
// default branch only at merge (when the step is already done), and writes nothing during
|
|
275
302
|
// review — so an OPEN (non-done) review PR recorded here means it was opened under an older
|
package/cli/epic-state.mjs
CHANGED
|
@@ -184,6 +184,29 @@ export function findReviewStep(state, artifact) {
|
|
|
184
184
|
export const isEscalated = (step) =>
|
|
185
185
|
(step?.risk_tags || []).some((t) => RISK_ESCALATORS.includes(t)) || step?.id === 'stories-review';
|
|
186
186
|
|
|
187
|
+
// The authoring step paired with a review gate: `stories-review` -> `stories`. Resolved BY ID (the
|
|
188
|
+
// same `-review` suffix convention `isSkippableStep` uses), never positionally — a chain may legally
|
|
189
|
+
// omit the author step (a change-epic that carries only the gate), and `steps[i-1]` would then point
|
|
190
|
+
// at an unrelated step. Returns null when the chain has no such step.
|
|
191
|
+
export function authorStepFor(state, reviewStep) {
|
|
192
|
+
const id = String(reviewStep?.id || '');
|
|
193
|
+
if (!id.endsWith('-review')) return null;
|
|
194
|
+
return state?.steps?.find((s) => s.id === id.replace(/-review$/, '')) || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Closing a review gate implies its artifact was authored — so the CLI, not the authoring skill, is
|
|
198
|
+
// what makes `<step>.status = done` true. Without this an author step left at `in_progress` strands
|
|
199
|
+
// forever: `preconditionsMet` requires every PRIOR step done, so the parallel `test-cases` track (and
|
|
200
|
+
// every later step) stays blocked behind a review that already passed. Idempotent; a no-op on an
|
|
201
|
+
// absent step and on a `skipped` one (already `done`, carrying its skip provenance).
|
|
202
|
+
// Returns the id it closed, or null.
|
|
203
|
+
function closeAuthorStep(state, reviewStep) {
|
|
204
|
+
const author = authorStepFor(state, reviewStep);
|
|
205
|
+
if (!author || author.status === 'done') return null;
|
|
206
|
+
author.status = 'done';
|
|
207
|
+
return author.id;
|
|
208
|
+
}
|
|
209
|
+
|
|
187
210
|
const uniqueBy = (arr, key) => {
|
|
188
211
|
const seen = new Set();
|
|
189
212
|
return arr.filter((x) => (seen.has(x[key]) ? false : seen.add(x[key])));
|
|
@@ -293,6 +316,10 @@ export function gatePredicate({
|
|
|
293
316
|
export function advanceState(state, step) {
|
|
294
317
|
const i = state.steps.findIndex((s) => s.id === step.id);
|
|
295
318
|
state.steps[i] = { ...state.steps[i], status: 'done' };
|
|
319
|
+
// Defensive: `markInReview` normally closed the author step when the gate opened, but the CI bridge
|
|
320
|
+
// advances on a merge event without ever running it locally. Close it here too, so a passed gate can
|
|
321
|
+
// never leave its author step behind (issue #131).
|
|
322
|
+
closeAuthorStep(state, step);
|
|
296
323
|
if (step.id === 'stories-review') {
|
|
297
324
|
const tc = state.steps.find((s) => s.id === 'test-cases');
|
|
298
325
|
if (tc && tc.status === 'blocked') tc.status = 'in_progress';
|
|
@@ -442,6 +469,9 @@ export function unskipStep(state, stepId) {
|
|
|
442
469
|
export function markInReview(state, step) {
|
|
443
470
|
const i = state.steps.findIndex((s) => s.id === step.id);
|
|
444
471
|
if (state.steps[i].status !== 'done') state.steps[i].status = 'in_review';
|
|
472
|
+
// Opening a review gate means the artifact was authored — close the paired author step rather than
|
|
473
|
+
// trusting the authoring skill to have hand-edited state.json (issue #131).
|
|
474
|
+
closeAuthorStep(state, step);
|
|
445
475
|
if (state.currentStep !== 'ready-for-build') state.currentStep = step.id;
|
|
446
476
|
return state;
|
|
447
477
|
}
|
|
@@ -567,6 +597,43 @@ export function preconditionsMet(state, stepId) {
|
|
|
567
597
|
return { ok: true, blockedBy: null, reason: 'ready' };
|
|
568
598
|
}
|
|
569
599
|
|
|
600
|
+
// PURE. Consistency invariants over a chain, for `doctor` (report) and `gate repair` (heal). Today one
|
|
601
|
+
// rule: a `review+approve` step that is `done` must have its paired author step `done` too — a gate
|
|
602
|
+
// cannot have passed on an unauthored artifact. Violations are epics damaged by a pre-fix `gate sync`
|
|
603
|
+
// (issue #131); they read as healthy to a `currentStep`-only check while silently blocking every later
|
|
604
|
+
// step through `preconditionsMet`.
|
|
605
|
+
//
|
|
606
|
+
// Deliberately NOT the broader "no non-done step precedes a done one": the parallel `test-cases` track
|
|
607
|
+
// legitimately sits `in_progress` after `stories-review` advanced the epic to ready-for-build.
|
|
608
|
+
// No FS / network. Returns [] on a missing or malformed chain (loadLedger already reports that).
|
|
609
|
+
export function stateInvariants(state) {
|
|
610
|
+
if (!state || !Array.isArray(state.steps)) return [];
|
|
611
|
+
const violations = [];
|
|
612
|
+
for (const step of state.steps) {
|
|
613
|
+
if (step.type !== 'review+approve' || step.status !== 'done') continue;
|
|
614
|
+
const author = authorStepFor(state, step);
|
|
615
|
+
if (!author || author.status === 'done') continue;
|
|
616
|
+
violations.push({
|
|
617
|
+
code: 'YAD-STATE-005',
|
|
618
|
+
reviewStep: step.id,
|
|
619
|
+
authorStep: author.id,
|
|
620
|
+
message: `${author.id} is '${author.status}' behind a completed ${step.id}`,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
return violations;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Apply the repair `stateInvariants` describes: close every author step stranded behind a done review
|
|
627
|
+
// gate. Mutates `state` and returns the ids it closed (empty when already consistent — idempotent).
|
|
628
|
+
export function repairState(state) {
|
|
629
|
+
const closed = [];
|
|
630
|
+
for (const v of stateInvariants(state)) {
|
|
631
|
+
const author = state.steps.find((s) => s.id === v.authorStep);
|
|
632
|
+
if (author && author.status !== 'done') { author.status = 'done'; closed.push(author.id); }
|
|
633
|
+
}
|
|
634
|
+
return closed;
|
|
635
|
+
}
|
|
636
|
+
|
|
570
637
|
// PURE next-action resolver for ONE epic's ledger — what `yad next <epic>` prints. Reads state + the
|
|
571
638
|
// recorded review PRs only. kind:
|
|
572
639
|
// 'new' — no epic state yet (seed one with yad-epic)
|
|
@@ -688,6 +755,13 @@ export function readFrontmatter(file) {
|
|
|
688
755
|
|
|
689
756
|
const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []);
|
|
690
757
|
|
|
758
|
+
// The human-facing noun for a lineage kind. Presentation only — the artifact is always an epic
|
|
759
|
+
// (`EP-<slug>`); this just renders WHAT KIND of work it is so `yad next`/`yad thread`/`yad status`
|
|
760
|
+
// read as "Defect EP-…" / "Change request EP-…" instead of a generic "Epic". `feature` (and any
|
|
761
|
+
// unknown/absent kind) falls back to "Epic". A bug is a defect (kind:defect) — no separate noun.
|
|
762
|
+
export const KIND_NOUN = { feature: 'Epic', change: 'Change request', defect: 'Defect', hotfix: 'Hotfix' };
|
|
763
|
+
export const kindNoun = (kind) => KIND_NOUN[kind] || 'Epic';
|
|
764
|
+
|
|
691
765
|
// The lineage of an epic from epic.md frontmatter. `kind` defaults to `feature` (genesis) when absent,
|
|
692
766
|
// so an un-migrated genesis epic behaves as the thread root. Greenfield/missing-safe.
|
|
693
767
|
export function epicLineage(root, epic) {
|
package/cli/errors.mjs
CHANGED
|
@@ -20,6 +20,7 @@ export const CODES = {
|
|
|
20
20
|
'YAD-STATE-002': 'a ledger/config JSON file parses but has the wrong shape',
|
|
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
|
+
'YAD-STATE-005': 'an authoring step is stranded behind its completed review gate',
|
|
23
24
|
'YAD-CFG-001': 'hub.json names an unknown platform (expected github, gitlab, or null)',
|
|
24
25
|
'YAD-CFG-002': 'design.json names an unknown design tool (expected one of config.yaml design.tools, or none)',
|
|
25
26
|
'YAD-CFG-003': 'testing.json names an unknown testing tool (expected one of config.yaml testing.tools, or none)',
|
package/cli/gate.mjs
CHANGED
|
@@ -11,8 +11,9 @@ import { PROJECT_FILES } from './manifest.mjs';
|
|
|
11
11
|
import {
|
|
12
12
|
epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
|
|
13
13
|
advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
|
|
14
|
-
upsertHubPr, DISCOVERY_FILES,
|
|
14
|
+
upsertHubPr, stateInvariants, repairState, DISCOVERY_FILES,
|
|
15
15
|
} from './epic-state.mjs';
|
|
16
|
+
import { hubGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
|
|
16
17
|
import {
|
|
17
18
|
readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin,
|
|
18
19
|
getPrBody, editPrBody, postComment,
|
|
@@ -486,6 +487,70 @@ export async function gateStatus(root, { epic } = {}) {
|
|
|
486
487
|
}
|
|
487
488
|
}
|
|
488
489
|
|
|
490
|
+
// PURE — the audit-trail commit message for a state repair (mirrors buildCheckpointMessage). The
|
|
491
|
+
// subject passes the hub commit-message gate (valid type `chore`, scope `gate`, no trailing period)
|
|
492
|
+
// and carries [skip ci]: the repair lands on the default branch, where a re-triggered gate workflow
|
|
493
|
+
// would have nothing to do. No Task trailer and no Co-Authored-By footer — this is machine state a
|
|
494
|
+
// human corrected, not an authored code change.
|
|
495
|
+
export function buildRepairMessage({ epic, steps }) {
|
|
496
|
+
const subject = 'chore(gate): repair epic state — close stranded author step(s) [skip ci]';
|
|
497
|
+
return `${subject}\n\nEpic: ${epic}\nClosed: ${steps.join(', ')}\nReason: YAD-STATE-005 — author step(s) left behind a completed review gate`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// `yad gate repair <epic>` — heal the chain inconsistency `doctor` reports as YAD-STATE-005: an author
|
|
501
|
+
// step stranded at in_progress behind a review gate that already advanced (issue #131). A pre-fix
|
|
502
|
+
// `gate sync` could leave this, and it never self-heals — sync skips a step that is already `done` — so
|
|
503
|
+
// the damage needs an explicit, auditable correction.
|
|
504
|
+
//
|
|
505
|
+
// Only state.json is touched, and `--push` commits ONLY that file: a `[skip ci]` chore commit must never
|
|
506
|
+
// sweep up an unrelated edit. It lands on the DEFAULT branch, where `ledger-guard` (which polices the
|
|
507
|
+
// machine-written ledger on review PRs) does not apply — so this stays compatible with "CI is the sole
|
|
508
|
+
// writer of the ledger" during review.
|
|
509
|
+
export async function gateRepair(root, { epic, push = false, allowBranch = false, dryRun = false } = {}) {
|
|
510
|
+
const epicDir = epicRoot(root, epic);
|
|
511
|
+
const ledger = loadLedger(epicDir);
|
|
512
|
+
if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { closed: [] }; }
|
|
513
|
+
|
|
514
|
+
log(c.bold(`\nyad gate repair ${c.dim(epic)}`));
|
|
515
|
+
const violations = stateInvariants(ledger.state);
|
|
516
|
+
if (!violations.length) { ok('epic state is consistent — nothing to repair'); return { closed: [] }; }
|
|
517
|
+
for (const v of violations) warn(`${v.message} [${v.code}]`);
|
|
518
|
+
|
|
519
|
+
const closed = repairState(ledger.state);
|
|
520
|
+
if (dryRun) { info('dry run — nothing written'); return { closed }; }
|
|
521
|
+
writeJSON(ledger.files.state, ledger.state);
|
|
522
|
+
ok(`closed ${closed.length} stranded author step(s): ${c.dim(closed.join(', '))}`);
|
|
523
|
+
if (!push) { hand('re-run `yad doctor` to confirm, then commit epics/*/.sdlc/state.json (or re-run with --push)'); return { closed }; }
|
|
524
|
+
|
|
525
|
+
// --- publish: narrow, default-branch-only commit of the one repaired file ---
|
|
526
|
+
preflightGuardReadiness(root);
|
|
527
|
+
const git = hubGit(root);
|
|
528
|
+
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
529
|
+
const defaultBranch = resolveDefaultBranch(git, loadHub(root).hub);
|
|
530
|
+
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad gate repair' })) return { closed };
|
|
531
|
+
|
|
532
|
+
const spec = path.relative(root, ledger.files.state);
|
|
533
|
+
if (!git('add', '--', spec).ok) { fail(`git add failed for ${spec}`); process.exitCode = 1; return { closed }; }
|
|
534
|
+
if (git('diff', '--cached', '--quiet', '--', spec).ok) { info('state.json unchanged on disk — nothing to commit'); return { closed }; }
|
|
535
|
+
|
|
536
|
+
const message = buildRepairMessage({ epic, steps: closed });
|
|
537
|
+
const cm = git('commit', '-m', message, '--', spec);
|
|
538
|
+
if (!cm.ok) {
|
|
539
|
+
git('reset', '-q', '--', spec); // never leave it staged for an unrelated commit to sweep up
|
|
540
|
+
fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
|
|
541
|
+
process.exitCode = 1;
|
|
542
|
+
return { closed };
|
|
543
|
+
}
|
|
544
|
+
ok(`committed the repair: ${c.dim(message.split('\n')[0])}`);
|
|
545
|
+
// Push HEAD to its OWN branch — with --allow-branch we are not on the default branch, and pushing
|
|
546
|
+
// HEAD:defaultBranch would publish a WIP branch straight to it.
|
|
547
|
+
if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return { closed }; }
|
|
548
|
+
fail(`could not push to origin/${branch} — a protected branch, or an unresolvable rebase conflict`);
|
|
549
|
+
hand('run `git pull --rebase` and re-run `yad gate repair <epic> --push`');
|
|
550
|
+
process.exitCode = 1;
|
|
551
|
+
return { closed };
|
|
552
|
+
}
|
|
553
|
+
|
|
489
554
|
// `head` overrides the review branch the PR is opened against — `open-pr` delegates here after pushing
|
|
490
555
|
// the user's checked-out branch, which for a per-story review (review/EP-*/stories-S01) does NOT equal
|
|
491
556
|
// the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
|
package/cli/next.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import fs from 'node:fs';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { c, log, ok, info, warn, hand, fail, readJSON, exists } from './lib.mjs';
|
|
15
15
|
import { PROJECT_FILES } from './manifest.mjs';
|
|
16
|
-
import { epicRoot, loadLedger, nextAction, preconditionsMet, isValidEpicId, DISCOVERY_EPIC } from './epic-state.mjs';
|
|
16
|
+
import { epicRoot, loadLedger, nextAction, preconditionsMet, isValidEpicId, epicLineage, kindNoun, DISCOVERY_EPIC } from './epic-state.mjs';
|
|
17
17
|
|
|
18
18
|
// Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
|
|
19
19
|
// missing/old hub.json never breaks the driver.
|
|
@@ -102,7 +102,10 @@ function actionLine(a, { solo } = {}) {
|
|
|
102
102
|
|
|
103
103
|
// Full, friendly printout for a single epic.
|
|
104
104
|
function printAction(a, { solo } = {}) {
|
|
105
|
-
|
|
105
|
+
// Prefix the id with the kind noun (Defect / Change request / Hotfix / Epic) so a glance says what
|
|
106
|
+
// kind of work this is. The discovery front-zero is not a feature epic — leave it un-prefixed.
|
|
107
|
+
const noun = a.lineageKind && a.epicId !== DISCOVERY_EPIC ? `${kindNoun(a.lineageKind)} ` : '';
|
|
108
|
+
log(`\n ${c.bold(`${noun}${a.epicId || '(epic)'}`)} ${c.dim(`— ${a.why}`)}`);
|
|
106
109
|
// In the build half with live lanes, print each story/repo's next sub-step + remaining chain instead
|
|
107
110
|
// of the single static hint; otherwise the one actionable line.
|
|
108
111
|
if (a.kind === 'build' && a.builds?.length) printBuildLanes(a.builds);
|
|
@@ -139,7 +142,10 @@ function generalNext(root, { all } = {}) {
|
|
|
139
142
|
return;
|
|
140
143
|
}
|
|
141
144
|
|
|
142
|
-
const actions = featureEpics.map((id) =>
|
|
145
|
+
const actions = featureEpics.map((id) => ({
|
|
146
|
+
...nextAction(loadLedger(epicRoot(root, id)), { epic: id }),
|
|
147
|
+
lineageKind: epicLineage(root, id).kind,
|
|
148
|
+
}));
|
|
143
149
|
if (discoveryOpen) printAction(discoveryAction, { solo }); // an unfinished discovery comes first
|
|
144
150
|
|
|
145
151
|
if (featureEpics.length === 1 || all) {
|
|
@@ -148,7 +154,7 @@ function generalNext(root, { all } = {}) {
|
|
|
148
154
|
}
|
|
149
155
|
// Several epics — list each with a one-liner, then point at the per-epic / --all views.
|
|
150
156
|
log(`\n ${c.bold(`${featureEpics.length} epics`)} ${c.dim('— next action each:')}`);
|
|
151
|
-
for (const a of actions) log(` ${c.cyan(a.epicId)} ${actionLine(a, { solo })}`);
|
|
157
|
+
for (const a of actions) log(` ${c.cyan(`${kindNoun(a.lineageKind)} ${a.epicId}`)} ${actionLine(a, { solo })}`);
|
|
152
158
|
info(c.dim(`detail: ${c.bold('yad next <epic>')} • all at once: ${c.bold('yad next --all')}`));
|
|
153
159
|
}
|
|
154
160
|
|
|
@@ -183,5 +189,8 @@ export async function runNext(root, { epic, check, all } = {}) {
|
|
|
183
189
|
process.exitCode = 1;
|
|
184
190
|
return;
|
|
185
191
|
}
|
|
186
|
-
printAction(
|
|
192
|
+
printAction(
|
|
193
|
+
{ ...nextAction(loadLedger(epicDir), { epic }), lineageKind: epicLineage(root, epic).kind },
|
|
194
|
+
{ solo: isSolo(root) },
|
|
195
|
+
);
|
|
187
196
|
}
|
package/cli/setup.mjs
CHANGED
|
@@ -43,14 +43,28 @@ export function detectPlatform(remoteUrl = '') {
|
|
|
43
43
|
}
|
|
44
44
|
export const gitHead = (cwd) => run('git', ['rev-parse', 'HEAD'], { cwd }).stdout || null;
|
|
45
45
|
|
|
46
|
-
// Containment:
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
46
|
+
// Containment: a repo path must live inside the WORKSPACE — the hub root's parent. The standard
|
|
47
|
+
// multi-repo layout puts the code repos BESIDE the hub, not under it (project/{product,backend,frontend}),
|
|
48
|
+
// so `../backend` has to register; containing to the hub root instead forced separate git repos to nest
|
|
49
|
+
// inside the hub's own repo (issue #129). A nested path (demo-repos/api) still works.
|
|
50
|
+
//
|
|
51
|
+
// The bound stays real: the registry path is later joined and executed against (repomix cwd,
|
|
52
|
+
// .coderabbit.yaml + CI wiring), and even the read-only remote probe must not run against an arbitrary
|
|
53
|
+
// outside path — so `../../elsewhere` and absolute-outside paths are still rejected. The path.sep-suffixed
|
|
54
|
+
// compare avoids the /project vs /project-evil prefix trap, now one level up at the workspace.
|
|
55
|
+
// A sibling of the hub (../product-evil) is, correctly, indistinguishable from ../backend: both are
|
|
56
|
+
// ordinary workspace members. The workspace DIRECTORY ITSELF (`..`) is not: it contains the hub, so
|
|
57
|
+
// registering it as a code repo would point repomix and the CI writes at the whole tree. Only the hub
|
|
58
|
+
// root itself (a monorepo, `.`) and strict descendants of the workspace pass.
|
|
59
|
+
//
|
|
60
|
+
// Place the hub one level below the workspace root (project/product), not directly in $HOME — the
|
|
61
|
+
// workspace is the trust boundary, and a shallow hub makes every sibling of it registerable.
|
|
62
|
+
export function insideWorkspace(root, rpath) {
|
|
51
63
|
const projectRoot = path.resolve(root);
|
|
64
|
+
const parent = path.dirname(projectRoot);
|
|
65
|
+
const workspace = parent === projectRoot ? projectRoot : parent; // degenerate: root is the fs root
|
|
52
66
|
const resolved = path.resolve(projectRoot, rpath);
|
|
53
|
-
return resolved === projectRoot || resolved.startsWith(
|
|
67
|
+
return resolved === projectRoot || resolved.startsWith(workspace + path.sep);
|
|
54
68
|
}
|
|
55
69
|
|
|
56
70
|
// Validate + record one code repo into the registry (the testable half of the connect loop).
|
|
@@ -192,8 +206,8 @@ export function reconcileRepoRoles(root, name, repo, current = [], want = []) {
|
|
|
192
206
|
}
|
|
193
207
|
|
|
194
208
|
export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null, pack = true }) {
|
|
195
|
-
if (!
|
|
196
|
-
warn(`${rpath} resolves outside the project root — skipped`);
|
|
209
|
+
if (!insideWorkspace(root, rpath)) {
|
|
210
|
+
warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`);
|
|
197
211
|
return null;
|
|
198
212
|
}
|
|
199
213
|
const repoRoot = path.resolve(root, rpath);
|
|
@@ -633,8 +647,9 @@ export async function runSetup(root, opts = {}) {
|
|
|
633
647
|
const name = await ask(' repo name (blank to finish)', '');
|
|
634
648
|
if (!name) break;
|
|
635
649
|
if (known.has(name)) { warn(`${name} already registered — skipping`); continue; }
|
|
636
|
-
|
|
637
|
-
|
|
650
|
+
// Siblings of the hub are the common layout (project/{product,backend}) — `../backend` is valid.
|
|
651
|
+
const rpath = await ask(' path (relative to project root, e.g. ../backend)', `demo-repos/${name}`);
|
|
652
|
+
if (!insideWorkspace(root, rpath)) { warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`); continue; }
|
|
638
653
|
const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
|
|
639
654
|
const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
|
|
640
655
|
// Domain owners route the per-repo review. Solo (no roster) and monorepo (one repo = one owner)
|
package/cli/thread.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import { c, log, ok, info, warn, hand, readJSON, exists } 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, kindNoun,
|
|
11
11
|
resolveThread, threadEpics, resolveCurrentArtifacts, resolveCurrentStories, THREAD_ARTIFACT_BASES,
|
|
12
12
|
} from './epic-state.mjs';
|
|
13
13
|
|
|
@@ -73,7 +73,10 @@ export function threadSummary(root, threadOrEpic) {
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
// Colour a node's kind noun for the tree render. The noun words live in one place (`kindNoun`); this
|
|
77
|
+
// only layers the per-kind colour on top, so the two never drift. Unknown kind → uncoloured noun.
|
|
78
|
+
const KIND_COLOR = { feature: c.green, change: c.cyan, defect: c.yellow, hotfix: c.red };
|
|
79
|
+
const kindTag = (kind) => (KIND_COLOR[kind] || ((s) => s))(kindNoun(kind));
|
|
77
80
|
|
|
78
81
|
export async function runThread(root, { epic, json = false } = {}) {
|
|
79
82
|
if (!epic) {
|
|
@@ -103,7 +106,7 @@ export async function runThread(root, { epic, json = false } = {}) {
|
|
|
103
106
|
log(c.bold(`\nThread ${s.thread}`) + c.dim(' (genesis → tip)'));
|
|
104
107
|
if (s.broken) log(c.red(` ✗ broken lineage: ${s.broken}`));
|
|
105
108
|
for (const n of s.nodes) {
|
|
106
|
-
const tag =
|
|
109
|
+
const tag = kindTag(n.kind);
|
|
107
110
|
const seal = n.sealed ? c.dim(' [sealed]') : '';
|
|
108
111
|
const stub = n.stub ? c.yellow(' [stub · backfill pending]') : '';
|
|
109
112
|
const dep = n.depth ? c.dim(` ${n.depth}`) : '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
4
4
|
"description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
@@ -34,6 +34,24 @@ epic's approvals. It only writes the project-wide registry and the per-repo cont
|
|
|
34
34
|
`roles` map, e.g. `roles: hub=owner,reviewer backend=domain-owner`). Validate the login against the
|
|
35
35
|
hub (`gh api users/<login>` / `glab api users?username=`); a miss is flagged `unverified` (warn-only).
|
|
36
36
|
- `path` — local path to the code repo (relative to `{project-root}` or absolute). For local repos.
|
|
37
|
+
It must resolve inside the **workspace** — the project root's parent — so the standard layout, where
|
|
38
|
+
the code repos sit **beside** the hub rather than under it, registers as `../backend`:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
project/ <- the workspace (containment boundary)
|
|
42
|
+
product/ <- the hub repo; `yad setup` runs here
|
|
43
|
+
backend/ <- ../backend
|
|
44
|
+
frontend/ <- ../frontend
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A nested path (`demo-repos/api`, the demo layout) also works. A path that escapes the workspace
|
|
48
|
+
(`../../elsewhere`, or an absolute path outside it) is rejected and skipped — the registry path is
|
|
49
|
+
later used as a working directory (repomix) and written into (`.coderabbit.yaml`, CI wiring).
|
|
50
|
+
It is stored **exactly as typed**; every consumer re-resolves it against the project root.
|
|
51
|
+
|
|
52
|
+
The workspace is the trust boundary, so **put the hub one level below it** (`project/product`), not
|
|
53
|
+
directly in `$HOME` — with the hub at `~/product` the workspace becomes `~` and every home-dir
|
|
54
|
+
sibling turns into a registrable repo. The workspace directory itself (`..`) is never registrable.
|
|
37
55
|
- `git_url` — optional remote (SSH or HTTPS; GitHub or GitLab). Used when the repo is not yet on disk.
|
|
38
56
|
- `domain_owners` — the engineer(s) who own this repo's domain (a repo may have several; drives per-repo
|
|
39
57
|
review routing). Each name is also written into that person's `roles[<repo>]` map in `hub.json`.
|
package/skills/yad-epic/SKILL.md
CHANGED
|
@@ -169,6 +169,11 @@ In `state.json`: set `epic.status: "done"`, set `epic-review.status: "in_review"
|
|
|
169
169
|
`currentStep: "epic-review"`. Write `state.json`. Do **not** re-seed and do **not** touch
|
|
170
170
|
`approvals.json` — only real reviewers approve, through the gate.
|
|
171
171
|
|
|
172
|
+
> Since 3.11 the CLI closes the authoring step itself whenever its review gate opens or advances
|
|
173
|
+
> (`yad gate open` / `sync`), so this edit is a no-op when the gate has already run. Keep making it —
|
|
174
|
+
> it keeps `state.json` truthful before the gate opens — but it is no longer load-bearing: an epic
|
|
175
|
+
> whose author step is left `in_progress` used to strand forever (`YAD-STATE-005`).
|
|
176
|
+
|
|
172
177
|
### Step 6 — Stop at the gate (do NOT advance)
|
|
173
178
|
Report: epic ID, the path to `epic.md`, and that the next action is **review** via
|
|
174
179
|
`yad-review-gate`. **Never mark the epic-review step approved here** — only real reviewers do that
|
|
@@ -169,6 +169,13 @@ opens the PR only; local `yad gate sync` is advisory in bridge mode (writes noth
|
|
|
169
169
|
everyone `git checkout <default> && git pull`. (Without the bridge, humans own the ledger locally and
|
|
170
170
|
these guards are no-ops.)
|
|
171
171
|
|
|
172
|
+
**The one sanctioned human ledger write: `yad gate repair`.** It heals a `YAD-STATE-005` chain (an
|
|
173
|
+
authoring step stranded behind a review gate that already advanced) by writing `state.json` alone. This
|
|
174
|
+
is not a `ledger-guard` gap: the repair commits to the **default branch**, where `ledger-guard` — which
|
|
175
|
+
only inspects review PRs — never runs, and where the `yad-update-guard` (platform-Verified signature +
|
|
176
|
+
roster-allowlisted author) vets it instead, exactly as it does for `yad checkpoint` and `yad update`.
|
|
177
|
+
The command refuses to commit off the default branch unless `--allow-branch` is passed.
|
|
178
|
+
|
|
172
179
|
**Loop prevention & races.** The only ledger commit lands on the **default branch** at merge, which
|
|
173
180
|
fires no PR trigger; it carries `[skip ci]` to guard sibling workflows. Because CI never pushes the
|
|
174
181
|
review branch, there is no `synchronize` / MR-pipeline loop to prevent — and it is now **safe to enable**
|
|
@@ -55,7 +55,9 @@ re-approve. (Hash recipe: `yad-architecture/references/contract-format.md`.)
|
|
|
55
55
|
4. `action: approve` approver *bob* role *reviewer* → ledger entry added. Predicate:
|
|
56
56
|
`|owners|=1, |reviewers|=1` → **base pass**.
|
|
57
57
|
5. `action: advance` → `epic-review.status=done`, `architecture.status=in_progress`,
|
|
58
|
-
`currentStep=architecture`. Gate reports the advance.
|
|
58
|
+
`currentStep=architecture`. Gate reports the advance. The paired authoring step (`epic`) is closed
|
|
59
|
+
too, if it was not already — a gate cannot have passed on an unauthored artifact. `doctor` reports
|
|
60
|
+
any surviving violation as `YAD-STATE-005`; `yad gate repair <epic>` heals it.
|
|
59
61
|
|
|
60
62
|
## Participation record (comments.json)
|
|
61
63
|
`approvals.json` answers "who approved"; `.sdlc/comments.json` answers "who reviewed/commented". The
|
|
@@ -35,8 +35,11 @@ Do not modify any of them.
|
|
|
35
35
|
### Step 3 — Report
|
|
36
36
|
Print, in this order:
|
|
37
37
|
|
|
38
|
-
1. **
|
|
39
|
-
|
|
38
|
+
1. **Header:** render the kind noun from `epic.md` frontmatter `kind` — **Change request** (`change`),
|
|
39
|
+
**Defect** (`defect`), **Hotfix** (`hotfix`), or **Epic** (`feature`, and the default when `kind` is
|
|
40
|
+
absent) — followed by `epicId`, then `status` from `epic.md` frontmatter, `currentStep`, and `repos`
|
|
41
|
+
(the touched domains). Example: `Defect EP-istifta-queue-filter — draft @ stories`. A bug is a defect
|
|
42
|
+
(`kind: defect`) — there is no separate noun. This is presentation only; the artifact is still an epic.
|
|
40
43
|
2. **Steps table** — for every front step in `steps[]` order (10, or 12 when the optional analysis step
|
|
41
44
|
was run): `id`, `type`, `status`, `assistance`, `automation`, `locked`, and `risk_tags`. Mark the
|
|
42
45
|
`currentStep` with `→`. The gating chain is `[analysis → analysis-review →] epic → epic-review →
|
|
@@ -104,6 +104,10 @@ As a <role>, I want <capability>, so that <outcome>.
|
|
|
104
104
|
In `state.json`: set `stories.status: "done"`, set `stories-review.status: "in_review"`, and set
|
|
105
105
|
`currentStep: "stories-review"`. Write `state.json`. Do **not** touch `approvals.json`.
|
|
106
106
|
|
|
107
|
+
> Since 3.11 the CLI also closes the authoring step when its review gate opens or advances, so this
|
|
108
|
+
> edit is a no-op once `yad gate open` has run. A `stories` step left `in_progress` behind a passed
|
|
109
|
+
> `stories-review` used to block the parallel `test-cases` track (`YAD-STATE-005`).
|
|
110
|
+
|
|
107
111
|
### Step 7 — Stop at the gate (do NOT advance)
|
|
108
112
|
Report: the story IDs created, the repos each touches, and that the next action is **review** via
|
|
109
113
|
`yad-review-gate`. Note that this review routes **per-repo reviewers**: owner + 1 reviewer **plus**, for
|
|
@@ -48,7 +48,9 @@ deterministically; theme from the design system). The thread maps onto the shell
|
|
|
48
48
|
= what it re-authored, its side-effects = the ships it produced + any contract re-lock.
|
|
49
49
|
- **System components** = the artifacts (epic/architecture/contract/ui/stories/test-cases), each labelled
|
|
50
50
|
with the epic that currently **owns** it (from the resolved map).
|
|
51
|
-
-
|
|
51
|
+
- Label and colour nodes by `kind` — render each node's kind noun (**Change request** / **Defect** /
|
|
52
|
+
**Hotfix** / **Epic** for feature) alongside its id, not the generic word "epic"; mark sealed epics and
|
|
53
|
+
open debt. (A bug is a defect — `kind: defect`. Presentation only; every node is still an epic.)
|
|
52
54
|
|
|
53
55
|
### Step 4 — Emit `thread-resolved.md` (the current-truth map — derived, non-authoritative)
|
|
54
56
|
Write `epics/<thread>/thread-resolved.md`: for each artifact base, the **owning epic** (the latest in the
|
|
@@ -58,7 +60,7 @@ is the file the next `yad-change` / `yad-epic` reads as "the feature's current t
|
|
|
58
60
|
|
|
59
61
|
### Step 5 — Emit `TIMELINE.md` + (optional) deploy
|
|
60
62
|
Write a short `epics/<thread>/TIMELINE.md` (the chain, what each node changed, ships, open debt) for a
|
|
61
|
-
plain-text read. On `action: deploy`, `yad docs deploy` the site (build-only when no target).
|
|
63
|
+
plain-text read — head each node with its kind noun (Change request / Defect / Hotfix / Epic) + id. On `action: deploy`, `yad docs deploy` the site (build-only when no target).
|
|
62
64
|
|
|
63
65
|
## Hard rules
|
|
64
66
|
|