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 +3 -3
- package/README.md +62 -9
- package/bin/yad.mjs +24 -0
- package/cli/artifact-status.mjs +6 -1
- package/cli/doctor.mjs +31 -1
- package/cli/epic-state.mjs +218 -4
- package/cli/gate.mjs +19 -2
- package/cli/manifest.mjs +8 -1
- package/cli/next.mjs +26 -11
- package/cli/thread.mjs +174 -0
- package/package.json +5 -4
- package/skills/sdlc/config.yaml +64 -5
- package/skills/sdlc/install.sh +1 -1
- package/skills/sdlc/module-help.csv +5 -0
- package/skills/yad-analysis/SKILL.md +12 -1
- package/skills/yad-change/SKILL.md +174 -0
- package/skills/yad-change/references/triage.md +102 -0
- package/skills/yad-checks/SKILL.md +13 -1
- package/skills/yad-checks/references/check-gates.md +27 -0
- package/skills/yad-checks/templates/checks/epic-open.sh +98 -0
- package/skills/yad-checks/templates/checks/lineage-check.sh +97 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +105 -0
- package/skills/yad-checks/templates/github/yad-checks.yml +25 -0
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +20 -0
- package/skills/yad-defects/SKILL.md +79 -0
- package/skills/yad-discovery/SKILL.md +132 -0
- package/skills/yad-discovery/references/discovery-schema.md +106 -0
- package/skills/yad-docs-overview/references/pipeline-model.md +14 -2
- package/skills/yad-epic/SKILL.md +14 -1
- package/skills/yad-epic/references/state-schema.md +106 -0
- package/skills/yad-reconcile/SKILL.md +75 -0
- package/skills/yad-timeline/SKILL.md +78 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
# [2.18.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.17.0...v2.18.0) (2026-06-26)
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
###
|
|
4
|
+
### Features
|
|
5
5
|
|
|
6
|
-
* **
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|

|
|
@@ -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
|
-
| `
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|
package/cli/artifact-status.mjs
CHANGED
|
@@ -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');
|
package/cli/epic-state.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|