yadflow 2.16.1 → 2.18.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 CHANGED
@@ -1,9 +1,9 @@
1
- ## [2.16.1](https://github.com/abdelrahmannasr/yadflow/compare/v2.16.0...v2.16.1) (2026-06-25)
1
+ # [2.18.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.17.0...v2.18.0) (2026-06-26)
2
2
 
3
3
 
4
- ### Bug Fixes
4
+ ### Features
5
5
 
6
- * **open-pr:** make build helpers stage-aware on the hub (closes [#80](https://github.com/abdelrahmannasr/yadflow/issues/80)) ([#81](https://github.com/abdelrahmannasr/yadflow/issues/81)) ([8d74e3d](https://github.com/abdelrahmannasr/yadflow/commit/8d74e3d267d4c056992d3bc6f5a2a7a15a66b431))
6
+ * **change:** post-lock change management via feature threads (Phase 6) ([#83](https://github.com/abdelrahmannasr/yadflow/issues/83)) ([f8024d5](https://github.com/abdelrahmannasr/yadflow/commit/f8024d5808070656d1c3039905ada39096fe7d3b)), closes [#1](https://github.com/abdelrahmannasr/yadflow/issues/1)
7
7
 
8
8
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
9
9
 
package/README.md CHANGED
@@ -23,10 +23,11 @@ a scaffolded module that installs cleanly, and a working **team review gate** yo
23
23
 
24
24
  ## The workflow at a glance
25
25
 
26
- The whole lifecycle, from an empty project to shipped code. Setup is one-time; the **front half**
27
- is human-gated and runs once per epic in the product hub; the **build half** runs once per story
28
- per code repo; **automation** is opt-in and earned. `yad-status` reads it all; `yad-hub-bridge`
29
- mirrors front-half reviews to real PR/MRs.
26
+ The whole lifecycle, from an empty project to shipped code. Setup is one-time; the optional
27
+ **front-zero** (`yad-discovery`) frames the whole project once market, feasibility, and a phased
28
+ roadmap; the **front half** is human-gated and runs once per epic in the product hub; the **build
29
+ half** runs once per story per code repo; **automation** is opt-in and earned. `yad-status` reads it
30
+ all; `yad-hub-bridge` mirrors front-half reviews to real PR/MRs.
30
31
 
31
32
  <!-- Source: docs/diagrams/sdlc-overview.mmd — edit the .mmd and run `npm run diagrams` to regenerate -->
32
33
  ![Yadflow SDLC overview — setup, human-gated front half, per-story build half, earned automation](https://raw.githubusercontent.com/abdelrahmannasr/yadflow/main/docs/diagrams/sdlc-overview.svg)
@@ -44,6 +45,7 @@ human**. Detailed walkthroughs for each phase follow below.
44
45
  | `RESEARCH-NOTES.md` | Verified Phase 0 facts about BMAD, Spec Kit, Repomix, Impeccable + deviations. |
45
46
  | `skills/sdlc/` | Module source of truth (`config.yaml`, `module-help.csv`, `install.sh`). Survives BMAD updates. |
46
47
  | `bin/`, `cli/` | The `yad` setup/update CLI (published to npm as `yadflow`). |
48
+ | `skills/yad-discovery/` | Optional front-zero (once per project, greenfield + brownfield): market research, competitor study, feasibility, current-state, requirements (functional + non-functional) and a phased roadmap (MVP+) under the reserved `EP-discovery`. `roadmap.md` is the menu of features each epic reads. |
47
49
  | `skills/yad-analysis/` | Optional front state 1: pressure-test the idea with the analyst into `analysis.md` (skippable). |
48
50
  | `skills/yad-epic/` | Front state 1: author an epic with AI assist, assign its `EP-<slug>` ID, seed state. |
49
51
  | `skills/yad-architecture/` | Front state 3: author `architecture.md` + the locked `contract.md`; hash-lock the contract surface. |
@@ -67,9 +69,13 @@ human**. Detailed walkthroughs for each phase follow below.
67
69
  | `skills/yad-backfill/` | Generate a human-verified spec for already-built code (Repomix), gated per touched feature. |
68
70
  | `skills/yad-run/` | Phase 4 orchestrator: drive a story's back half on the `automation` dial; kill switch. |
69
71
  | `skills/yad-status/` | Read-only view: front chain, build-half dials, trust record, fleet roll-up. |
70
- | `epics/EP-istifta-inquiries/` | A worked demo epic run **end to end** (front half + build half + automation). |
72
+ | `skills/yad-change/` | Phase 6: post-lock change/defect/hotfix **intake + triage** — seed a new epic threaded to its parent (inherit by reference, re-author only what changes). |
73
+ | `skills/yad-timeline/` | Phase 6: render a feature **thread** as an evolution view + resolve its current truth (`thread-resolved.md`). |
74
+ | `skills/yad-defects/` | Phase 6: per-epic/per-thread **quality-gap report** by `escape_stage` + `root_cause`. |
75
+ | `skills/yad-reconcile/` | Phase 6: read-only **drift/orphan/debt sweep** across threads (mirrors `yad-docs-sync`; never a gate). |
76
+ | `epics/EP-istifta-inquiries/` | A worked demo epic run **end to end** (front half + build half + automation + a Phase 6 change thread). |
71
77
  | `demo-repos/` | Throwaway code repos for the build half (separate git repos; regenerable — see `demo-repos/README.md`). |
72
- | `docs/` | The phased build plans (`phase-2`…`phase-5`) and the original workflow design. |
78
+ | `docs/` | The phased build plans (`phase-2`…`phase-6`) and the original workflow design. |
73
79
  | [`CONTRIBUTING.md`](CONTRIBUTING.md) | Commit & PR/MR title convention (Conventional Commits, lowercase after the type). |
74
80
 
75
81
  ## The `yad` CLI (install, update, reconcile)
@@ -102,6 +108,8 @@ with `npx` from your **product hub** repo — no clone needed.
102
108
  | `yad ship --type <t> -m <subject>` | Commit **and** open the task PR/MR in one step (`yad commit` then `yad open-pr`) — stage-aware, same as `open-pr`. |
103
109
  | `yad repo list` / `yad repo refresh [name]` | List connected repos as **fresh / stale**, and re-pack a stale one — staleness is now an explicit human decision, never an automatic skill side-effect. |
104
110
  | `yad repo sync [name]` | Switch every connected repo to its **default branch** and fast-forward it from origin (one or all). Dirty repos are skipped, never overwritten; fast-forward only. |
111
+ | `yad thread [<epic>]` | **Feature threads (Phase 6).** No arg: list every thread. With an epic: show its thread (genesis → changes → defects), the **resolved current-truth** map (which epic owns each artifact now), and any open hotfix debt. `--json` for tooling. Read-only. |
112
+ | `yad reconcile [check\|refresh\|wire]` | Sweep threads for **drift / orphans / open hotfix debt** and report which thread drifted and why (mirrors `yad docs sync`; advisory — the CI gates block at merge). |
105
113
  | `npx yadflow --version` | Print the installed CLI version. |
106
114
 
107
115
  Flags: `--dir <path>` targets a project other than the cwd; `--force` re-copies unchanged files (or
@@ -150,7 +158,7 @@ does / why / what to enter / what skipping means), and the step count adapts.
150
158
  0. **Profile** — the three questions above, plus "configure optional tools now?". Pre-answer for
151
159
  CI/scripts with `--solo`/`--team <n>`, `--greenfield`/`--brownfield`, `--monorepo`/`--separate`, `--tools`.
152
160
  1. **Preflight** — confirm the hub is a git repo (offers `git init`); check `git`/`node`/`npx`.
153
- 2. **Install the module** — copy all 30 `yad-*` skills into the IDE skill dirs you pick
161
+ 2. **Install the module** — copy all 31 `yad-*` skills into the IDE skill dirs you pick
154
162
  (`.claude/`, `.agents/`, `.zencoder/`, `.opencode/`) and register `_bmad/sdlc/`.
155
163
  3. **Hub platform & roster** — detect GitHub/GitLab from the remote; record reviewers → `.sdlc/hub.json`.
156
164
  **Solo skips the roster** (you review by merging your own PR). Edit the roster any time with `yad roster`.
@@ -204,7 +212,7 @@ with a fix-it hint per finding. Failures carry stable, greppable codes, also pri
204
212
 
205
213
  Filing a bug? Attach `yad doctor --json` — it contains no secrets (names, paths, and check results only).
206
214
 
207
- ## Agent skills (all 30)
215
+ ## Agent skills (all 35)
208
216
 
209
217
  The CLI **installs and wires** the module; the skills below are the **agents you invoke by name** in your
210
218
  AI IDE (e.g. *“run `yad-epic`”*) to actually do the work. State lives in files you can also edit
@@ -274,6 +282,17 @@ directly. Each skill stops at a gate and never auto-advances unless a step has *
274
282
  never touches epic state, approvals, or the contract lock. *AI builds, the hand decides* — and now the
275
283
  hand can also learn, on demand, what it is deciding about.
276
284
 
285
+ ### Front-zero — frame the whole project (once per project, optional, human-gated)
286
+
287
+ - **`yad-discovery`** — *Optional* front-zero, for **greenfield and brownfield**. With the analyst
288
+ and pm, run market research, a **competitor study** (both modes), a feasibility study, and — in
289
+ brownfield — a code-aware current-state study, then distil a **functional + non-functional
290
+ requirements** list and a **phased roadmap** (an explicit **MVP** phase, then later phases) under the
291
+ reserved `EP-discovery` ("epic zero"). It is gated by the same review gate (base rule: owner + 1
292
+ reviewer); on approval it terminates at `discovery-done` (no build half). Its `roadmap.md` is the menu
293
+ of features — each `yad-epic` reads it for project context (reference-only; discovery never
294
+ auto-seeds epics).
295
+
277
296
  ### Front half — author the "thinking" (once per epic, human-gated)
278
297
 
279
298
  - **`yad-analysis`** — *Optional* front state 1. With the analyst, pressure-test a feature idea
@@ -356,6 +375,33 @@ directly. Each skill stops at a gate and never auto-advances unless a step has *
356
375
  automation) and status, which approvals are still required, per-story back-half trust records, the
357
376
  kill-switch state, and a fleet roll-up across epics.
358
377
 
378
+ ### Post-lock change management — feature threads (Phase 6)
379
+
380
+ After the contract locks and code ships, a change must **not** mutate a locked artifact — it becomes a
381
+ **new epic threaded to its parent**. A feature is a *thread* of linked epics (genesis → change → defect →
382
+ …); a change-epic **inherits** the front artifacts it does not change (by reference) and **re-authors**
383
+ only what it does. So artifacts never go stale — they are *superseded*; the feature's current truth is the
384
+ head of the thread. This is what keeps the SDLC a trusted source of truth for AI on the next change.
385
+
386
+ - **`yad-change`** — the intake + triage. Classifies the change *depth* (defect-fix /
387
+ behavioral-no-surface / contract-surface / new-capability), seeds a new `EP-<slug>` threaded to its
388
+ parent (lineage frontmatter, an inherited-step `state.json`, a pointer-lock `contract-lock.json`,
389
+ `change.json`), and for hotfixes opens `reconcile-debt.json`. Never auto-advances — hands off to the
390
+ normal authoring skills + the review gate.
391
+ - **`yad-timeline`** — render the thread as an evolution view (yad-docs shell + `TIMELINE.md`) and emit
392
+ `thread-resolved.md`, the composed **current-truth map** (which epic owns each artifact now).
393
+ - **`yad-defects`** — a per-epic/per-thread quality-gap report aggregating defects by **`escape_stage`**
394
+ (the gate that should have caught it) + `root_cause` — *where the SDLC leaks*, so the team hardens the
395
+ originating stage.
396
+ - **`yad-reconcile`** — a read-only drift/orphan/debt sweep across threads (mirrors `yad-docs-sync`; never
397
+ a gate). The hard block is the CI gates.
398
+
399
+ Three CI gates (in `yad-checks`) enforce it: **lineage-check** (a change links a real threaded epic),
400
+ **epic-open** (a *sealed* epic — all stories shipped — refuses new behaviour, forcing a change-epic so the
401
+ front artifacts can never go stale), and **reconcile-debt** (a thread with open hotfix debt is frozen
402
+ until paid). Two read-only CLIs surface it: `yad thread <epic>` (the thread + resolved truth + open debt)
403
+ and `yad reconcile` (the drift sweep).
404
+
359
405
  ## The two dials (per step, build plan §2)
360
406
 
361
407
  - **assistance:** `none` | `review` | `heavy` — how much AI helps.
@@ -423,6 +469,10 @@ drive it deterministically with the **`yad gate`** CLI (`open → sync → …
423
469
  the per-step PR/MR and the step **auto-advances on merge** once approvals are satisfied and all comment
424
470
  threads are resolved. Details: **“Run the full front half by hand”** below.
425
471
 
472
+ 0. *(optional, once per project)* `yad-discovery` → the discovery set (`market-research.md`,
473
+ `competitor-analysis.md`, `current-state.md`, `feasibility.md`, `requirements.md`, `roadmap.md`)
474
+ under the reserved `EP-discovery` → review (base rule) → `currentStep: discovery-done`. The whole
475
+ set is required to review; its `roadmap.md` then frames each epic below (read once it is approved).
426
476
  6. `yad-epic` → `epic.md` (assigns `EP-<slug>`, seeds state) → review (base rule).
427
477
  7. `yad-architecture` → `architecture.md` + locked `contract.md` → review (**escalated**: contract).
428
478
  8. `yad-ui` → `ui-design.md` + `DESIGN.md` → review (base rule).
@@ -468,7 +518,10 @@ Details: **“Run the back half on the dial”** below.
468
518
 
469
519
  ## Run the full front half by hand
470
520
 
471
- The front half walks **epic review architecture+contract review UI design → review → stories
521
+ Optionally preceded once per project by the **front-zero** **`yad-discovery` → review →
522
+ `discovery-done`** — which frames the whole product (market, competitor, feasibility, requirements,
523
+ roadmap) under the reserved `EP-discovery`; its approved `roadmap.md` then feeds each epic. The front
524
+ half itself walks **epic → review → architecture+contract → review → UI design → review → stories
472
525
  → review → `ready-for-build`**, then **test cases → review** runs as a **parallel, non-blocking track**
473
526
  alongside the build half. It is all files under `epics/EP-<slug>/`. The skills below guide you, but you
474
527
  can also edit the files directly — that's the point.
package/bin/yad.mjs CHANGED
@@ -15,6 +15,7 @@ import { runDocs } from '../cli/docs.mjs';
15
15
  import { runDoctor } from '../cli/doctor.mjs';
16
16
  import { runNext } from '../cli/next.mjs';
17
17
  import { syncStatuses } from '../cli/artifact-status.mjs';
18
+ import { runThread, runReconcile } from '../cli/thread.mjs';
18
19
 
19
20
  const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
20
21
 
@@ -63,6 +64,12 @@ ${c.bold('Build helpers')}
63
64
  yad repo list Show connected repos (fresh / stale)
64
65
  yad repo refresh [name] Re-pack a stale repo (a human decision)
65
66
 
67
+ ${c.bold('Feature threads (post-lock change management)')}
68
+ yad thread List every feature thread (genesis → changes → defects)
69
+ yad thread <epic> [--json] Show one thread: its epics, the resolved current truth, open debt
70
+ yad reconcile [check|refresh|wire] Flag orphan drift + open hotfix debt across threads (advisory,
71
+ never a gate — the gates block at merge)
72
+
66
73
  ${c.bold('Interactive docs (generated sites)')}
67
74
  yad docs list Show the docs target + per-site freshness
68
75
  yad docs build [--epic <id>|--overview] npm-build a generated doc site
@@ -216,6 +223,23 @@ async function main() {
216
223
  await runDocs(o.dir, { action: action || 'list', epic: o.epic, overview: o.overview, sync, today });
217
224
  break;
218
225
  }
226
+ case 'thread': {
227
+ const [, epic] = o._;
228
+ if (epic && !isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
229
+ await runThread(o.dir, { epic, json: o.json });
230
+ break;
231
+ }
232
+ case 'reconcile': {
233
+ const [, action] = o._;
234
+ const thread = o.epic || o.thread || null;
235
+ if (thread && !isValidEpicId(thread)) { log(c.red(`invalid epic id: ${thread} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
236
+ const act = action || (o.wire ? 'wire' : o.refresh ? 'refresh' : 'check');
237
+ if (!['check', 'refresh', 'wire'].includes(act)) {
238
+ log(c.red(`unknown reconcile action: ${act} (check|refresh|wire)`)); process.exitCode = 1; break;
239
+ }
240
+ await runReconcile(o.dir, { action: act, thread });
241
+ break;
242
+ }
219
243
  default:
220
244
  log(c.red(`unknown command: ${cmd}`));
221
245
  log(HELP);
@@ -6,7 +6,7 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { c, log, ok, info, readJSONStrict } from './lib.mjs';
9
- import { epicRoot, artifactBase, artifactFromBase, findReviewStep } from './epic-state.mjs';
9
+ import { epicRoot, artifactBase, artifactFromBase, findReviewStep, DISCOVERY_FILES } from './epic-state.mjs';
10
10
  import { epicFiles } from './manifest.mjs';
11
11
 
12
12
  // The front-gate lifecycle this command manages. Forward-only: a status is only ever moved UP this
@@ -75,6 +75,11 @@ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
75
75
  files.push({ base: 'stories', file: path.join(storiesDir, f) });
76
76
  }
77
77
  }
78
+ // Discovery ("epic zero") set: the project-discovery files all key to the single discovery /
79
+ // discovery-review step pair, so they reconcile together (mirrors the stories/ set above).
80
+ for (const f of DISCOVERY_FILES) {
81
+ if (fs.existsSync(path.join(dir, f))) files.push({ base: 'discovery', file: path.join(dir, f) });
82
+ }
78
83
 
79
84
  for (const { base, file } of files) {
80
85
  if (!fs.existsSync(file)) continue;
package/cli/doctor.mjs CHANGED
@@ -6,7 +6,8 @@ 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 } from './epic-state.mjs';
9
+ import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread } from './epic-state.mjs';
10
+ import { loadDebt } from './thread.mjs';
10
11
  import { gitHead } from './setup.mjs';
11
12
  import { cliFor, validateLogin, hostFromGitUrl } from './platform.mjs';
12
13
 
@@ -264,11 +265,40 @@ export function epicChecks(checks, root) {
264
265
  }
265
266
  }
266
267
 
268
+ // Phase 6 — feature-thread integrity. A change-epic must thread to a real parent and its denormalized
269
+ // `thread` cache must equal the computed root; an open hotfix reconcile-debt is a warn (the next change
270
+ // on that thread is blocked at the gate until it is paid). Pure reporting, like the other sections.
271
+ export function threadChecks(checks, root) {
272
+ const epicsDir = path.join(root, 'epics');
273
+ if (!exists(epicsDir)) return;
274
+ for (const e of fs.readdirSync(epicsDir).sort()) {
275
+ if (!fs.statSync(path.join(epicsDir, e)).isDirectory() || !isValidEpicId(e)) continue;
276
+ if (!exists(path.join(epicsDir, e, 'epic.md'))) continue;
277
+ const lin = epicLineage(root, e);
278
+ if (lin.kind === 'feature' && !lin.parent) continue; // genesis with no lineage — nothing to check
279
+ const { broken } = resolveThread(root, e);
280
+ if (broken) {
281
+ check(checks, `thread:${e}`, 'threads', 'fail', `${e}: ${broken}`,
282
+ 'a change-epic must thread to a real parent; fix `parent:`/`thread:` in epic.md frontmatter');
283
+ } else {
284
+ check(checks, `thread:${e}`, 'threads', 'ok', `${e}: ${lin.kind} threaded to ${lin.thread || lin.parent}`);
285
+ }
286
+ for (const d of loadDebt(root, e)) {
287
+ if (d.status === 'open') {
288
+ check(checks, `thread:${e}:debt`, 'threads', 'warn',
289
+ `${e}: open reconcile debt (${d.reason || 'hotfix shipped first'})`,
290
+ 'pay it — update the artifacts + add a regression test; the next change on this thread is blocked until then');
291
+ }
292
+ }
293
+ }
294
+ }
295
+
267
296
  export async function runDoctor(root, { json = false } = {}) {
268
297
  const checks = [];
269
298
  envChecks(checks);
270
299
  projectChecks(checks, root);
271
300
  epicChecks(checks, root);
301
+ threadChecks(checks, root);
272
302
 
273
303
  const failed = checks.filter((x) => x.status === 'fail');
274
304
  const warned = checks.filter((x) => x.status === 'warn');
@@ -38,6 +38,7 @@ export function parseReviewBranch(branch = '') {
38
38
  // (storiesHash fingerprints the directory), so any story branch syncs the same review step.
39
39
  export function artifactFromBase(base) {
40
40
  if (base === 'stories' || /^stories-S\d+$/i.test(base)) return 'stories/';
41
+ if (base === 'discovery') return 'discovery/';
41
42
  return `${base}.md`;
42
43
  }
43
44
 
@@ -47,6 +48,7 @@ export function artifactFromBase(base) {
47
48
  export function artifactPaths(base) {
48
49
  if (base === 'architecture') return ['architecture.md', 'contract.md', '.sdlc/contract-lock.json'];
49
50
  if (base === 'stories') return ['stories'];
51
+ if (base === 'discovery') return [...DISCOVERY_FILES];
50
52
  return [`${base}.md`];
51
53
  }
52
54
 
@@ -87,13 +89,41 @@ export function storiesHash(epicDir) {
87
89
  return 'sha256:' + createHash('sha256').update(parts.join('\n')).digest('hex');
88
90
  }
89
91
 
92
+ // The reserved id of the project front-zero ("epic zero"). yad-discovery seeds it; yad-epic /
93
+ // yad-analysis must never pick this slug for a feature.
94
+ export const DISCOVERY_EPIC = 'EP-discovery';
95
+
96
+ // The project-discovery artifact set (EP-discovery / "epic zero"). The `discovery-review` step binds
97
+ // to the whole set, mirroring how stories-review binds to the stories/ directory — editing any file
98
+ // revokes prior approvals. A fixed list (not a dir scan) because the files live in the epic root.
99
+ export const DISCOVERY_FILES = [
100
+ 'market-research.md',
101
+ 'competitor-analysis.md',
102
+ 'current-state.md',
103
+ 'feasibility.md',
104
+ 'requirements.md',
105
+ 'roadmap.md',
106
+ ];
107
+
108
+ // Deterministic fingerprint of the discovery set: hash every file in the fixed DISCOVERY_FILES order,
109
+ // combine. The WHOLE set is the reviewable unit — if any required artifact is missing the discovery is
110
+ // incomplete and NON-REVIEWABLE, so this returns null (no hash to bind an approval to), the same
111
+ // "nothing to lock" signal storiesHash/contractSurfaceHash give for an absent/malformed surface. Once
112
+ // the full set exists, an edit (or deletion) of any file changes the hash and revokes prior approvals.
113
+ export function discoveryHash(epicDir) {
114
+ if (!DISCOVERY_FILES.every((f) => fs.existsSync(path.join(epicDir, f)))) return null;
115
+ const parts = DISCOVERY_FILES.map((f) => `${f}:${fileSha(path.join(epicDir, f))}`);
116
+ return 'sha256:' + createHash('sha256').update(parts.join('\n')).digest('hex');
117
+ }
118
+
90
119
  // The content fingerprint an approval is bound to. For architecture the fingerprint is the locked
91
- // contract surface (a re-lock => stale); for stories it is the whole stories/ set; for every other
92
- // artifact it is the file's bytes.
120
+ // contract surface (a re-lock => stale); for stories it is the whole stories/ set; for discovery it is
121
+ // the whole discovery file set; for every other artifact it is the file's bytes.
93
122
  export function artifactHash(epicDir, artifact) {
94
123
  const b = artifactBase(artifact);
95
124
  if (b === 'architecture') return contractSurfaceHash(epicDir);
96
125
  if (b === 'stories') return storiesHash(epicDir);
126
+ if (b === 'discovery') return discoveryHash(epicDir);
97
127
  return fileSha(path.join(epicDir, artifact.replace(/\/$/, '')));
98
128
  }
99
129
 
@@ -160,6 +190,21 @@ export function gatePredicate({
160
190
  merged = true,
161
191
  solo = false,
162
192
  }) {
193
+ // Phase 6: an INHERITED step (a change-epic carrying a parent artifact by reference) is satisfied
194
+ // without re-review — its approval lives upstream in the thread, recorded as an `inherited` provenance
195
+ // entry. It is pre-marked `done` in state.json, so the gate is normally never invoked on it; this
196
+ // short-circuit makes a direct call safe and surfaces a corrupted boundHash (a referenced artifact
197
+ // cannot change under the child, so a mismatch is corruption — re-thread, do not silently pass).
198
+ if (step?.inherited) {
199
+ const drift = step.boundHash && currentHash && step.boundHash !== currentHash;
200
+ return {
201
+ approvalsSatisfied: true, threadsResolved: true, merged: true, staleDropped: 0,
202
+ passed: !drift,
203
+ missing: drift ? [`inherited artifact drifted from ${step.inheritedFrom || 'parent'} — re-thread`] : [],
204
+ rule: 'inherited',
205
+ };
206
+ }
207
+
163
208
  const forStep = approvals.filter((a) => a.step === step.id && a.status === 'approved');
164
209
  // Revoke-on-change: an approval bound to a stale content hash no longer counts.
165
210
  const stale = forStep.filter((a) => a.artifactHash && currentHash && a.artifactHash !== currentHash);
@@ -221,6 +266,13 @@ export function advanceState(state, step) {
221
266
  state.currentStep = 'ready-for-build';
222
267
  return state;
223
268
  }
269
+ // Discovery is the project front-zero ("epic zero"): it has no build half, so its review terminates
270
+ // at a `discovery-done` sentinel rather than `ready-for-build` (which would make `yad next` claim the
271
+ // build half can run). The roadmap it approved is the input the real feature epics read.
272
+ if (step.id === 'discovery-review') {
273
+ state.currentStep = 'discovery-done';
274
+ return state;
275
+ }
224
276
  const next = state.steps[i + 1];
225
277
  if (next) {
226
278
  next.status = next.type === 'review+approve' ? 'in_review' : 'in_progress';
@@ -244,6 +296,7 @@ export function markInReview(state, step) {
244
296
  // The front authoring step a `yad next` action maps to — the skill the user invokes for that step.
245
297
  // Review (review+approve) steps are driven by the `yad gate` CLI, not a skill, so they are not here.
246
298
  export const STEP_SKILL = {
299
+ discovery: 'yad-discovery',
247
300
  analysis: 'yad-analysis',
248
301
  epic: 'yad-epic',
249
302
  architecture: 'yad-architecture',
@@ -258,8 +311,8 @@ export const STEP_SKILL = {
258
311
  // (the Phase B rail) and by the driver. No FS / network.
259
312
  export function preconditionsMet(state, stepId) {
260
313
  if (!state || !Array.isArray(state.steps)) {
261
- const ok = stepId === 'epic' || stepId === 'analysis';
262
- return { ok, blockedBy: null, reason: ok ? 'entry step (no epic seeded yet)' : `start with yad-epic — no epic state for '${stepId}'` };
314
+ const ok = stepId === 'epic' || stepId === 'analysis' || stepId === 'discovery';
315
+ return { ok, blockedBy: null, reason: ok ? 'entry step (no state seeded yet)' : `start with yad-epic — no epic state for '${stepId}'` };
263
316
  }
264
317
  const i = state.steps.findIndex((s) => s.id === stepId);
265
318
  if (i === -1) return { ok: false, blockedBy: null, reason: `unknown step '${stepId}'` };
@@ -281,6 +334,30 @@ export function nextAction(ledger, { epic } = {}) {
281
334
  const epicId = epic || state?.epicId || null;
282
335
  if (!state) return { epicId, kind: 'new', skill: 'yad-epic', why: 'no epic state yet — seed it with yad-epic' };
283
336
 
337
+ // EP-discovery ("epic zero") is the project front-zero: a 2-step author→review chain with no build
338
+ // half and no parallel track. Resolve its action in isolation so the feature-epic logic below never
339
+ // applies to it.
340
+ if (state.kind === 'discovery') {
341
+ if (state.currentStep === 'discovery-done') {
342
+ return { epicId, kind: 'discovery-done', step: 'discovery-done', status: 'done',
343
+ why: 'discovery approved — seed feature epics with yad-epic (each reads roadmap.md)' };
344
+ }
345
+ const dstep = state.steps.find((s) => s.id === state.currentStep)
346
+ || state.steps.find((s) => s.status !== 'done');
347
+ if (!dstep) return { epicId, kind: 'discovery-done', step: 'discovery-done', why: 'discovery is done' };
348
+ if (dstep.type === 'author') {
349
+ return { epicId, kind: 'author', step: dstep.id, status: dstep.status,
350
+ skill: STEP_SKILL[dstep.id] || null, artifact: dstep.artifact,
351
+ why: `${dstep.id} is ${dstep.status} — author ${dstep.artifact}` };
352
+ }
353
+ const dpr = (ledger.hubPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(dstep.artifact));
354
+ const dverb = dpr ? 'sync' : 'open';
355
+ return { epicId, kind: dpr ? 'review-sync' : 'review-open', step: dstep.id, status: dstep.status,
356
+ artifact: dstep.artifact, pr: dpr ? dpr.number : null,
357
+ command: `yad gate ${dverb} ${epicId} ${dstep.artifact}`,
358
+ why: dpr ? `review PR #${dpr.number} is open — sync its state to advance` : `${dstep.id} is open — create the review PR/MR` };
359
+ }
360
+
284
361
  // The parallel test-cases track stays workable even once the epic is ready-for-build.
285
362
  const tc = state.steps.find((s) => s.id === 'test-cases');
286
363
  const tcOpen = !!tc && tc.status !== 'done' && tc.status !== 'blocked';
@@ -310,4 +387,141 @@ export function nextAction(ledger, { epic } = {}) {
310
387
  why: pr ? `review PR #${pr.number} is open — sync its state to advance` : `${step.id} is open — create the review PR/MR` };
311
388
  }
312
389
 
390
+ // ---- Phase 6: feature threads (lineage frontmatter on epic.md) -----------------------------------
391
+
392
+ // Minimal frontmatter reader (key: value, and `inherits: [a, b]` arrays). Mirrors gate.mjs's reader so
393
+ // the thread helpers and the gate agree on the same parse; shared here as the lineage source.
394
+ export function readFrontmatter(file) {
395
+ if (!fs.existsSync(file)) return {};
396
+ const m = fs.readFileSync(file, 'utf8').match(/^---\n([\s\S]*?)\n---/);
397
+ if (!m) return {};
398
+ const out = {};
399
+ for (const line of m[1].split('\n')) {
400
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
401
+ if (!kv) continue;
402
+ const [, k, v] = kv;
403
+ out[k] = /^\[.*\]$/.test(v) ? v.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean) : v.trim();
404
+ }
405
+ return out;
406
+ }
407
+
408
+ const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []);
409
+
410
+ // The lineage of an epic from epic.md frontmatter. `kind` defaults to `feature` (genesis) when absent,
411
+ // so an un-migrated genesis epic behaves as the thread root. Greenfield/missing-safe.
412
+ export function epicLineage(root, epic) {
413
+ const fm = readFrontmatter(path.join(epicRoot(root, epic), 'epic.md'));
414
+ return {
415
+ kind: fm.kind || 'feature',
416
+ parent: fm.parent || null,
417
+ thread: fm.thread || null,
418
+ inherits: asList(fm.inherits),
419
+ supersedes: asList(fm.supersedes),
420
+ };
421
+ }
422
+
423
+ // Walk `parent` to the thread root. Cycle- and missing-safe. Returns the genesis-first `chain`, the
424
+ // computed `rootId`, and a `broken` reason (missing parent dir, a cycle, or a denormalized `thread`
425
+ // cache that disagrees with the computed root) — the signal yad doctor / yad next --check report.
426
+ export function resolveThread(root, epicId) {
427
+ const chain = [];
428
+ const seen = new Set();
429
+ let cur = epicId;
430
+ let broken = null;
431
+ while (cur) {
432
+ if (seen.has(cur)) { broken = `cycle at ${cur}`; break; }
433
+ seen.add(cur);
434
+ if (!fs.existsSync(epicRoot(root, cur))) {
435
+ broken = cur === epicId ? `missing epic ${cur}` : `missing parent epic ${cur}`;
436
+ break;
437
+ }
438
+ chain.unshift(cur); // genesis ends up first
439
+ const { parent } = epicLineage(root, cur);
440
+ if (!parent) break; // reached genesis
441
+ cur = parent;
442
+ }
443
+ const rootId = chain[0] || epicId;
444
+ const tip = epicLineage(root, epicId);
445
+ // A non-genesis epic (has a parent) MUST carry a `thread:` cache that equals the computed root.
446
+ // A missing cache is corruption too — without it the bash gates' parent-walk is the only safety net,
447
+ // and a tool reading the field would mis-scope the thread.
448
+ if (!broken && tip.parent && !tip.thread) {
449
+ broken = `missing thread cache on ${epicId} (kind:${tip.kind}, parent:${tip.parent}) — should be '${rootId}'`;
450
+ }
451
+ if (!broken && tip.thread && tip.thread !== rootId) {
452
+ broken = `thread cache '${tip.thread}' != computed root '${rootId}'`;
453
+ }
454
+ return { rootId, chain, broken };
455
+ }
456
+
457
+ // Every epic that belongs to a thread (resolved root == this thread's root), ordered genesis-first by
458
+ // chain depth. Derived by scanning epics/ — no duplicated thread registry. Used by yad-timeline/yad-defects.
459
+ export function threadEpics(root, threadOrEpicId) {
460
+ const { rootId } = resolveThread(root, threadOrEpicId);
461
+ const dir = path.join(root, 'epics');
462
+ if (!fs.existsSync(dir)) return [rootId];
463
+ const depth = new Map();
464
+ const members = [];
465
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
466
+ if (!e.isDirectory() || !isValidEpicId(e.name) || !fs.existsSync(path.join(dir, e.name, 'epic.md'))) continue;
467
+ const rt = resolveThread(root, e.name); // one walk per member (not per comparison)
468
+ if (rt.rootId !== rootId) continue;
469
+ members.push(e.name);
470
+ depth.set(e.name, rt.chain.length);
471
+ }
472
+ // Genesis-first by depth, then a STABLE, machine-independent tie-break (id order) so the resolver is
473
+ // deterministic across filesystems even when two epics sit at the same depth (a branch).
474
+ return members.sort((a, b) => (depth.get(a) - depth.get(b)) || a.localeCompare(b));
475
+ }
476
+
477
+ // Compose the CURRENT authoritative source per artifact base across a thread: the LATEST epic in the
478
+ // chain that actually RE-AUTHORED it (did NOT list it in `inherits`). Genesis owns everything; a later
479
+ // change-epic shadows only what it re-authored. Returns { <base>: <owning epic id> } — the source-of-
480
+ // truth map AI/humans read for the next change (rendered by yad-timeline as thread-resolved.md).
481
+ export const THREAD_ARTIFACT_BASES = ['epic', 'architecture', 'contract', 'ui-design', 'stories', 'test-cases'];
482
+ // REPLACE bases — a re-author supersedes the prior version wholesale, so the LATEST re-author owns it
483
+ // (a contract-surface change re-locks and replaces; a re-authored architecture supersedes the old one).
484
+ const REPLACE_BASES = ['epic', 'architecture', 'contract', 'ui-design'];
485
+ // ADDITIVE bases — each re-authoring epic CONTRIBUTES (stories add files; a change adds its test-cases
486
+ // file), so the current truth is the UNION of contributors, never a single owner. Collapsing these to
487
+ // one epic would drop the parent's inherited stories/cases.
488
+ const ADDITIVE_BASES = ['stories', 'test-cases'];
489
+
490
+ // The owning epic per artifact base across a thread. REPLACE bases resolve to a single epic id (the
491
+ // latest re-author); ADDITIVE bases resolve to the ordered LIST of every epic that re-authored them
492
+ // (genesis-first) — use resolveCurrentStories for story-id-level ownership of the composed set.
493
+ export function resolveCurrentArtifacts(root, threadOrEpicId) {
494
+ const members = threadEpics(root, threadOrEpicId); // genesis-first
495
+ const out = {};
496
+ for (const b of REPLACE_BASES) out[b] = null;
497
+ for (const b of ADDITIVE_BASES) out[b] = [];
498
+ for (const id of members) {
499
+ const { inherits } = epicLineage(root, id);
500
+ for (const b of REPLACE_BASES) if (!inherits.includes(b)) out[b] = id;
501
+ for (const b of ADDITIVE_BASES) if (!inherits.includes(b)) out[b].push(id);
502
+ }
503
+ return out;
504
+ }
505
+
506
+ // Compose the current STORY SET at story-id granularity across the thread: each re-authoring epic's
507
+ // stories/ files are overlaid (a later same-id supersedes; a `supersedes:` entry retires a parent
508
+ // story). Returns { <story-id>: <owning epic id> } — the real current truth for stories, because a
509
+ // change-epic re-authors only the stories it changes and inherits the rest by reference. Without this,
510
+ // a defect-fix that adds one regression story would appear to drop every unchanged parent story.
511
+ export function resolveCurrentStories(root, threadOrEpicId) {
512
+ const members = threadEpics(root, threadOrEpicId); // genesis-first
513
+ const owner = {};
514
+ for (const id of members) {
515
+ const lin = epicLineage(root, id);
516
+ for (const sid of lin.supersedes) delete owner[sid]; // explicitly retired parent stories
517
+ if (lin.inherits.includes('stories')) continue; // inherited wholesale -> contributes nothing new
518
+ const sdir = path.join(epicRoot(root, id), 'stories');
519
+ if (!fs.existsSync(sdir)) continue;
520
+ for (const f of fs.readdirSync(sdir).filter((x) => /\.md$/.test(x))) {
521
+ owner[f.replace(/\.md$/, '')] = id; // contribute / override same-id
522
+ }
523
+ }
524
+ return owner;
525
+ }
526
+
313
527
  export { writeJSON };
package/cli/gate.mjs CHANGED
@@ -11,7 +11,7 @@ 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,
14
+ upsertHubPr, DISCOVERY_FILES,
15
15
  } from './epic-state.mjs';
16
16
  import { readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
17
17
  import { syncStatuses } from './artifact-status.mjs';
@@ -48,7 +48,12 @@ export function touchedDomains(epicDir, step) {
48
48
  return frontmatter(path.join(epicDir, 'epic.md')).repos || [];
49
49
  }
50
50
 
51
- const ownerOf = (epicDir) => frontmatter(path.join(epicDir, 'epic.md')).owner || '<owner>';
51
+ // The artifact owner shown in the review PR/MR body. Feature epics carry it in epic.md; the discovery
52
+ // front-zero (EP-discovery) has no epic.md, so fall back to roadmap.md's frontmatter owner.
53
+ const ownerOf = (epicDir) =>
54
+ frontmatter(path.join(epicDir, 'epic.md')).owner
55
+ || frontmatter(path.join(epicDir, 'roadmap.md')).owner
56
+ || '<owner>';
52
57
 
53
58
  // A null architecture hash with a BEGIN marker present means the surface block is malformed
54
59
  // (no END, or empty) — approvals would not be hash-bound, so make that visible.
@@ -61,6 +66,16 @@ function warnUnlockedContract(epicDir, artifact) {
61
66
  }
62
67
  }
63
68
 
69
+ // A null discovery hash means the discovery set is incomplete (a required artifact is missing), so the
70
+ // review is not yet reviewable and an approval would not be hash-bound. Name the missing files so the
71
+ // owner can complete the set before the gate is opened/advanced (mirrors warnUnlockedContract).
72
+ function warnIncompleteDiscovery(epicDir, artifact) {
73
+ if (artifactBase(artifact) !== 'discovery') return;
74
+ if (artifactHash(epicDir, artifact) !== null) return;
75
+ const missing = DISCOVERY_FILES.filter((f) => !fs.existsSync(path.join(epicDir, f)));
76
+ warn(`discovery set incomplete — missing ${missing.join(', ')}; review is not yet reviewable (approvals will not be hash-bound until the full set exists)`);
77
+ }
78
+
64
79
  // Fail fast on a corrupt or wrong-shape hub config: a silently-defaulted hub.json would degrade
65
80
  // every gate to file-only without anyone noticing, and a typo'd platform would read as "no bridge".
66
81
  function loadHub(root) {
@@ -202,6 +217,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
202
217
 
203
218
  const curHash = artifactHash(epicDir, pr.artifact);
204
219
  warnUnlockedContract(epicDir, pr.artifact);
220
+ warnIncompleteDiscovery(epicDir, pr.artifact);
205
221
  const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains, headOid: pull.headOid });
206
222
  approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today });
207
223
 
@@ -465,6 +481,7 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr
465
481
  const branch = head || `review/${epic}/${b}`;
466
482
  const domains = touchedDomains(epicDir, step);
467
483
  warnUnlockedContract(epicDir, artifact);
484
+ warnIncompleteDiscovery(epicDir, artifact);
468
485
 
469
486
  const bridge = isBridge(hub);
470
487
  // Outside bridge mode (file-only, OR a platform with no gate-sync CI) there is no CI to write the