yadflow 2.0.0 → 2.1.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,27 +1,15 @@
1
- # [2.0.0](https://github.com/abdelrahmannasr/yadflow/compare/v1.4.0...v2.0.0) (2026-06-12)
2
-
3
-
4
- * feat!: rename sdlc-* skills to yad-* and the CLI to yad; feature the report ([ea05f17](https://github.com/abdelrahmannasr/yadflow/commit/ea05f17085f992343fc9d1f25bde24c87815be1a))
1
+ # [2.1.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.0.1...v2.1.0) (2026-06-13)
5
2
 
6
3
 
7
4
  ### Bug Fixes
8
5
 
9
- * rewrite the root .gitlab-ci.yml include when migrating gitlab fragments ([75eeb3a](https://github.com/abdelrahmannasr/yadflow/commit/75eeb3acf4f2c77b43af4577fe5d1d3cc4285258))
6
+ * address CodeRabbit review on the hardening PR ([7dbe9e3](https://github.com/abdelrahmannasr/yadflow/commit/7dbe9e358e731d69ecead6ecac9faa8377c37023))
7
+ * drop useless backtick escapes in a single-quoted doctor hint (lint) ([c0cf1a2](https://github.com/abdelrahmannasr/yadflow/commit/c0cf1a26c15fccc91df15013d6bac83892f2af25))
10
8
 
11
9
 
12
10
  ### Features
13
11
 
14
- * migrate pre-2.0 sdlc-* installs in place via yad update ([f85433f](https://github.com/abdelrahmannasr/yadflow/commit/f85433ff8fb4f54ce0c455abb2d72974f82fd507))
15
-
16
-
17
- ### BREAKING CHANGES
18
-
19
- * the installed command is now `yad` (was `sdlc`) and the
20
- skills are invoked as /yad-* (were /sdlc-*). Repos wired before this release
21
- keep their old sdlc-* workflow files and markers; re-run `yad check --fix`
22
- to install the renamed ones.
23
-
24
- Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12
+ * yad doctor + structured YAD-* error codes with recovery hints ([94f9e9f](https://github.com/abdelrahmannasr/yadflow/commit/94f9e9f6ff6d6d3c83ed29f1cfcc97e32678615c))
25
13
 
26
14
  # [1.1.0](https://github.com/abdelrahmannasr/sdlc-workflow/compare/v1.0.3...v1.1.0) (2026-06-09)
27
15
 
package/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  [![npm version](https://img.shields.io/npm/v/yadflow?logo=npm)](https://www.npmjs.com/package/yadflow)
4
4
  [![CI](https://github.com/abdelrahmannasr/yadflow/actions/workflows/ci.yml/badge.svg)](https://github.com/abdelrahmannasr/yadflow/actions/workflows/ci.yml)
5
5
  [![provenance](https://img.shields.io/badge/npm-provenance-blue?logo=npm)](https://docs.npmjs.com/generating-provenance-statements)
6
+ [![node](https://img.shields.io/node/v/yadflow?logo=node.js)](https://github.com/abdelrahmannasr/yadflow/blob/main/package.json)
7
+ [![security policy](https://img.shields.io/badge/security-policy-brightgreen)](https://github.com/abdelrahmannasr/yadflow/blob/main/SECURITY.md)
6
8
  [![report](https://img.shields.io/badge/docs-Yadflow%20report-2471a3)](https://abdelrahmannasr.github.io/yadflow/)
7
9
 
8
10
  > 📖 **Start here: the [Yadflow Terminology & Workflow Structure Report](https://abdelrahmannasr.github.io/yadflow/)** —
@@ -26,67 +28,8 @@ is human-gated and runs once per epic in the product hub; the **build half** run
26
28
  per code repo; **automation** is opt-in and earned. `yad-status` reads it all; `yad-hub-bridge`
27
29
  mirrors front-half reviews to real PR/MRs.
28
30
 
29
- ```mermaid
30
- flowchart TD
31
- classDef gated fill:#fdebd0,stroke:#ca6f1e,color:#000
32
- classDef earns fill:#d6eaf8,stroke:#2471a3,color:#000
33
- classDef locked fill:#eaecee,stroke:#566573,color:#000,stroke-dasharray:5 3
34
- classDef artifact fill:#fcf3cf,stroke:#b7950b,color:#000
35
- classDef sentinel fill:#d5f5e3,stroke:#1e8449,color:#000
36
-
37
- subgraph SETUP["0 · One-time setup (per project)"]
38
- direction TB
39
- inst["install.sh<br/>copy yad-* skills into IDE dirs"]
40
- wire["wire each repo:<br/>yad-checks · yad-pr-template · yad-review-comments"]
41
- conn["yad-connect-repos<br/>repos.json + cached code-map"]
42
- phub["optional: hub on a platform<br/>detect-hub · roster"]
43
- inst --> wire --> conn --> phub
44
- end
45
-
46
- subgraph FRONT["A · Front half — product hub · human-gated · once per epic"]
47
- direction TB
48
- an["yad-analysis<br/>optional → analysis.md"]:::artifact
49
- ep["yad-epic<br/>epic.md · assigns EP-&lt;slug&gt;"]:::artifact
50
- ar["yad-architecture<br/>architecture.md + locked contract.md"]:::artifact
51
- ui["yad-ui<br/>ui-design.md + DESIGN.md"]:::artifact
52
- st["yad-stories<br/>repo-tagged stories/EP-&lt;slug&gt;-S0N.md"]:::artifact
53
- gAn{{"gate · analysis"}}:::gated
54
- gEp{{"gate · epic<br/>base: owner + reviewer"}}:::gated
55
- gAr{{"gate · architecture<br/>escalated: + repo domain owners"}}:::gated
56
- gUi{{"gate · UI · base"}}:::gated
57
- gSt{{"gate · stories<br/>per-repo domain owners"}}:::gated
58
- rfb(["currentStep: ready-for-build"]):::sentinel
59
- an --> gAn --> ep --> gEp --> ar --> gAr --> ui --> gUi --> st --> gSt --> rfb
60
- end
61
-
62
- subgraph BUILD["B · Build half — per story, per code repo"]
63
- direction TB
64
- sp["yad-spec<br/>Spec Kit ceremony → specs/&lt;story&gt;/"]
65
- im["yad-implement<br/>1 task = 1 branch = 1 commit"]:::earns
66
- ck["yad-checks<br/>spec-link · contract-check · build/test/lint"]:::earns
67
- prm["open PR/MR + yad-pr-template route"]
68
- shp["yad-ship<br/>AI review (advisory)"]
69
- eng{{"engineer review<br/>human · never automated"}}:::locked
70
- merged(["merge → build-log.json"]):::sentinel
71
- sp --> im --> ck --> prm --> shp --> eng --> merged
72
- end
73
-
74
- subgraph AUTO["C · Automation — earned & reversible"]
75
- direction TB
76
- run["yad-run<br/>reads automation dial + trust-log.json"]:::earns
77
- kill["kill switch → everything human_approve"]
78
- run --- kill
79
- end
80
-
81
- phub --> an
82
- rfb --> sp
83
- run -. drives earned back steps .-> im
84
- bridge["yad-hub-bridge<br/>review PR/MR ↔ file ledger"]:::gated
85
- bridge -. syncs approvals .-> gEp
86
- status["yad-status<br/>read-only view over all of it"]
87
- status -. observes .-> FRONT
88
- status -. observes .-> BUILD
89
- ```
31
+ <!-- Source: docs/diagrams/sdlc-overview.mmd — edit the .mmd and run `npm run diagrams` to regenerate -->
32
+ ![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)
90
33
 
91
34
  **Legend.** <span>🟨</span> **artifact** = an author step writes a file and stops; <span>🟧</span>
92
35
  **gate** = a human review that must pass (`open → comment → approve → advance`); <span>🟦</span>
@@ -129,12 +72,18 @@ The module ships a zero-dependency CLI, published to npm as
129
72
  [`yadflow`](https://www.npmjs.com/package/yadflow). Run it
130
73
  with `npx` from your **product hub** repo — no clone needed.
131
74
 
75
+ > **Platform support.** Linux and macOS are first-class — the test suite, the bash check gates, and
76
+ > the end-to-end harness all run on both in CI. The CLI shells out to `git` (and the bash gate
77
+ > scripts), so on **Windows use [WSL](https://learn.microsoft.com/windows/wsl/)**; native PowerShell
78
+ > is not yet supported. Requires **Node.js ≥ 18**.
79
+
132
80
  | Command | What it does |
133
81
  |---------|--------------|
134
82
  | `npx yadflow setup` | Guided first-run wizard (the steps below). |
135
83
  | `npx yadflow check` | Read-only report: what is **missing** / **outdated** (drifted) / **stale** (code-context) / **legacy** (pre-2.0 `sdlc-*` names) vs the bundled manifest. |
136
84
  | `npx yadflow check --fix` | Reconcile: fill what is missing **and** update what changed — touches nothing already correct. |
137
85
  | `npx yadflow update` | Apply drift only (alias for `check --fix --scope=changed`). Also migrates a pre-2.0 install in place: `sdlc-*` skill copies and marker-owned `sdlc-*.yml` CI files are replaced by their `yad-*` names (a same-named file *you* authored is never touched). |
86
+ | `npx yadflow doctor [--json]` | Environment + state health: tools on PATH and platform auth, config files parse and point at real repos, every epic ledger loads. Exit 1 on any failure; `--json` for CI and bug reports. |
138
87
  | `yad gate open <epic> <artifact>` | Open the front-half **review PR/MR** for an artifact and mark the step `in_review`. |
139
88
  | `yad gate sync <epic> [artifact]` | Pull the PR/MR's reviews + comment threads into the file ledger; **auto-advance** the step when approvals are satisfied, all threads are resolved, and the PR is merged. |
140
89
  | `yad gate comments <epic> [artifact]` | Fetch the unresolved review comments to address (then reply on the PR; reviewers resolve their threads). |
@@ -202,6 +151,25 @@ provenance). See [`RELEASING.md`](RELEASING.md).
202
151
  > ships the `CHANGELOG.md` in the tarball, and cuts a GitHub release. No manual `npm publish`. See
203
152
  > [`RELEASING.md`](RELEASING.md).
204
153
 
154
+ ### Troubleshooting (`yad doctor` + error codes)
155
+
156
+ When something is off, run `yad doctor` first — it checks the environment (git, gh/glab auth, node
157
+ version), the project state (`.sdlc/*.json` parse and point at real repos), and every epic ledger,
158
+ with a fix-it hint per finding. Failures carry stable, greppable codes, also printed by any failing
159
+ `yad` command:
160
+
161
+ | Code | Meaning | Fix |
162
+ |------|---------|-----|
163
+ | `YAD-ENV-001` | git is not installed or not on PATH | install git — every yad command needs it |
164
+ | `YAD-ENV-002` | platform CLI (gh/glab) missing or not authenticated | install it and authenticate — `gh auth login` (GitHub) or `glab auth login` (GitLab); the gate degrades to file-only without it |
165
+ | `YAD-ENV-003` | Node.js older than the supported range | install Node >= 18 |
166
+ | `YAD-STATE-001` | a ledger/config JSON file exists but does not parse | fix the file or restore from git — never delete a ledger blindly |
167
+ | `YAD-STATE-002` | a ledger/config file parses but has the wrong shape | fix the file or restore from git (the message names the field) |
168
+ | `YAD-STATE-003` | a registered repo path is missing or not a git repo | fix the path in `.sdlc/repos.json` or re-connect the repo |
169
+ | `YAD-CFG-001` | `hub.json` names an unknown platform | expected `github`, `gitlab`, or `null` — fix it or re-run `yad setup` |
170
+
171
+ Filing a bug? Attach `yad doctor --json` — it contains no secrets (names, paths, and check results only).
172
+
205
173
  ## Agent skills (all 17)
206
174
 
207
175
  The CLI **installs and wires** the module; the skills below are the **agents you invoke by name** in your
@@ -413,16 +381,8 @@ accumulate, and the step moves forward only when the rule is met. **File-only**
413
381
  `advance`; **PR-driven** (hub on a platform) ends when the approved, fully-resolved review PR is
414
382
  **merged**:
415
383
 
416
- ```mermaid
417
- flowchart LR
418
- a["author writes<br/>artifact"] --> o["open<br/>raise review PR/MR"]
419
- o --> c["comment<br/>reviewers leave notes"]
420
- c -->|owner addresses,<br/>edits in place| c
421
- c --> ap["approve<br/>+ resolve threads"]
422
- ap --> adv{"rule met,<br/>threads resolved,<br/>merged?"}
423
- adv -->|no — names who's missing| o
424
- adv -->|yes| nxt(["next step"])
425
- ```
384
+ <!-- Source: docs/diagrams/review-loop.mmd — edit the .mmd and run `npm run diagrams` to regenerate -->
385
+ ![Review gate loop — author, open, comment, approve, advance](https://raw.githubusercontent.com/abdelrahmannasr/yadflow/main/docs/diagrams/review-loop.svg)
426
386
 
427
387
  **File-only** — invoke **`yad-review-gate`** with `open` (present the artifact; reviewers comment in
428
388
  `reviews/<artifact>--<date>--comments.md`), `approve` (name + role → `.sdlc/approvals.json`), and
package/bin/yad.mjs CHANGED
@@ -9,6 +9,7 @@ import { isValidEpicId } from '../cli/epic-state.mjs';
9
9
  import { runCommit } from '../cli/commit.mjs';
10
10
  import { runOpenPr } from '../cli/openpr.mjs';
11
11
  import { runRepo } from '../cli/repo.mjs';
12
+ import { runDoctor } from '../cli/doctor.mjs';
12
13
 
13
14
  const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
14
15
 
@@ -18,6 +19,8 @@ ${c.bold('Setup & maintenance')}
18
19
  yad check --fix Reconcile: fill what is missing, update what changed
19
20
  yad update Apply drift only (alias for: check --fix --scope=changed);
20
21
  also migrates pre-2.0 sdlc-* installs to the yad-* names
22
+ yad doctor [--json] Environment + state health: tools/auth, config files,
23
+ repo paths, epic ledgers (exit 1 on any failure)
21
24
 
22
25
  ${c.bold('Review gate (front half)')}
23
26
  yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review
@@ -62,6 +65,7 @@ function parseArgs(argv) {
62
65
  else if (a === '--contract-change') o.contractChange = true;
63
66
  else if (a === '--no-push') o.noPush = true;
64
67
  else if (a === '--dry-run') o.dryRun = true;
68
+ else if (a === '--json') o.json = true;
65
69
  else if (a === '-h' || a === '--help') o.help = true;
66
70
  else if (a === '-v' || a === '--version') o.version = true;
67
71
  else if (a.startsWith('--scope=')) o.scope = a.slice('--scope='.length);
@@ -96,6 +100,9 @@ async function main() {
96
100
  case 'update':
97
101
  await reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, today });
98
102
  break;
103
+ case 'doctor':
104
+ await runDoctor(o.dir, { json: o.json });
105
+ break;
99
106
  case 'gate': {
100
107
  const [, action, epic, artifact] = o._;
101
108
  // `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
@@ -130,7 +137,10 @@ async function main() {
130
137
 
131
138
  main()
132
139
  .catch((err) => {
133
- log(c.red(`\nyad failed: ${err?.message || err}`));
140
+ const code = err?.code && /^YAD-/.test(err.code) ? ` [${err.code}]` : '';
141
+ log(c.red(`\nyad failed${code}: ${err?.message || err}`));
142
+ if (err?.hint) log(c.yellow(` → ${err.hint}`));
143
+ if (code) log(c.dim(' (see README "Troubleshooting" for this code, or run `yad doctor`)'));
134
144
  process.exitCode = 1;
135
145
  })
136
146
  .finally(closePrompts);
package/cli/doctor.mjs ADDED
@@ -0,0 +1,146 @@
1
+ // `yad doctor` — environment + state health, the complement of `yad check` (file drift).
2
+ // Three sections: environment (tools on PATH, auth), project state (config files parse and
3
+ // point at real repos), epics (each ledger loads). Pure reporting: exit 1 on any FAIL,
4
+ // 0 with warnings. `--json` emits the checks for CI / bug reports.
5
+ import path from 'node:path';
6
+ import fs from 'node:fs';
7
+ import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSONStrict } from './lib.mjs';
8
+ import { VERSION, PROJECT_FILES } from './manifest.mjs';
9
+ import { loadLedger, epicRoot } from './epic-state.mjs';
10
+ import { gitHead } from './setup.mjs';
11
+ import { cliFor } from './platform.mjs';
12
+
13
+ const MIN_NODE = 18;
14
+
15
+ // Each check: { id, section, status: 'ok'|'warn'|'fail', message, hint? }
16
+ function check(checks, id, section, status, message, hint = '') {
17
+ checks.push({ id, section, status, message, ...(hint ? { hint } : {}) });
18
+ }
19
+
20
+ export function envChecks(checks) {
21
+ const major = Number(process.versions.node.split('.')[0]);
22
+ if (major >= MIN_NODE) check(checks, 'node', 'environment', 'ok', `node ${process.versions.node}`);
23
+ else check(checks, 'node', 'environment', 'fail', `node ${process.versions.node} is below the supported range [YAD-ENV-003]`, `install Node.js >= ${MIN_NODE}`);
24
+
25
+ if (has('git')) check(checks, 'git', 'environment', 'ok', 'git present');
26
+ else check(checks, 'git', 'environment', 'fail', 'git not found on PATH [YAD-ENV-001]', 'install git — every yad command needs it');
27
+
28
+ for (const tool of ['npx', 'bash']) {
29
+ if (has(tool)) check(checks, tool, 'environment', 'ok', `${tool} present`);
30
+ else check(checks, tool, 'environment', 'warn', `${tool} not found on PATH`, tool === 'npx' ? 'repomix packing will be skipped' : 'the check gates are bash scripts');
31
+ }
32
+ }
33
+
34
+ export function projectChecks(checks, root) {
35
+ const hubPath = path.join(root, PROJECT_FILES.hubConfig);
36
+ const regPath = path.join(root, PROJECT_FILES.reposRegistry);
37
+ const verPath = path.join(root, PROJECT_FILES.version);
38
+ if (!exists(hubPath) && !exists(regPath) && !exists(verPath)) {
39
+ check(checks, 'project', 'project', 'warn', 'no yad project here (.sdlc/ not initialised)', 'run `yad setup` to start one — environment checks above still apply');
40
+ return null;
41
+ }
42
+
43
+ // version stamp
44
+ const ver = readJSON(verPath, null);
45
+ if (!ver) check(checks, 'cli-version', 'project', 'warn', `${PROJECT_FILES.version} missing or unreadable`, 'run `yad check --fix`');
46
+ else if (ver.version !== VERSION) check(checks, 'cli-version', 'project', 'warn', `project stamped v${ver.version}, CLI is v${VERSION}`, 'run `yad update` to reconcile');
47
+ else check(checks, 'cli-version', 'project', 'ok', `version stamp matches (v${VERSION})`);
48
+
49
+ // hub.json: parse + shape
50
+ let hub = null;
51
+ if (!exists(hubPath)) {
52
+ check(checks, 'hub', 'project', 'warn', `${PROJECT_FILES.hubConfig} absent — file-only gate`, 'run `yad setup` to configure a platform + roster');
53
+ } else {
54
+ let hubBroken = false;
55
+ try {
56
+ hub = readJSONStrict(hubPath, null);
57
+ } catch (e) {
58
+ hubBroken = true;
59
+ check(checks, 'hub', 'project', 'fail', `${PROJECT_FILES.hubConfig} does not parse [${e.code || 'YAD-STATE-001'}]`, e.hint || 'fix the JSON or restore it from git');
60
+ }
61
+ if (hubBroken) { /* reported above */ }
62
+ else if (typeof hub !== 'object' || Array.isArray(hub) || hub === null) check(checks, 'hub', 'project', 'fail', `${PROJECT_FILES.hubConfig} has the wrong shape [YAD-STATE-002]`, 'expected a JSON object');
63
+ else if (![null, undefined, 'github', 'gitlab'].includes(hub.platform)) check(checks, 'hub', 'project', 'fail', `${PROJECT_FILES.hubConfig}: unknown platform '${hub.platform}' [YAD-CFG-001]`, 'expected github, gitlab, or null');
64
+ // Mirror gate.mjs's roster shape check so doctor never reports "ok" on a hub the gate would reject.
65
+ else if (hub.roster !== undefined && !Array.isArray(hub.roster)) check(checks, 'hub', 'project', 'fail', `${PROJECT_FILES.hubConfig}: \`roster\` must be an array [YAD-STATE-002]`, 'fix the file or re-run `yad setup`');
66
+ else {
67
+ check(checks, 'hub', 'project', 'ok', `hub: ${hub.platform || 'file-only'}, ${(hub.roster || []).length} reviewer(s)`);
68
+ // platform CLI + auth (best-effort; auth probing is the user's own session)
69
+ const cli = cliFor(hub.platform);
70
+ if (cli) {
71
+ if (!has(cli)) check(checks, 'platform-cli', 'project', 'warn', `${cli} not found on PATH [YAD-ENV-002]`, `install ${cli} — the gate degrades to file-only without it`);
72
+ else if (!run(cli, ['auth', 'status']).ok) check(checks, 'platform-cli', 'project', 'warn', `${cli} present but not authenticated [YAD-ENV-002]`, `run \`${cli} auth login\``);
73
+ else check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
74
+ }
75
+ }
76
+ }
77
+
78
+ // repos.json: parse + every entry is a live git repo; staleness vs syncedHead
79
+ let registry = { repos: [] };
80
+ let regBroken = false;
81
+ try {
82
+ registry = readJSONStrict(regPath, { repos: [] });
83
+ } catch (e) {
84
+ regBroken = true;
85
+ check(checks, 'repos', 'project', 'fail', `${PROJECT_FILES.reposRegistry} does not parse [${e.code || 'YAD-STATE-001'}]`, e.hint || 'fix the JSON or restore it from git');
86
+ }
87
+ if (regBroken) { /* reported above */ }
88
+ else if (!Array.isArray(registry?.repos)) check(checks, 'repos', 'project', 'fail', `${PROJECT_FILES.reposRegistry} has the wrong shape [YAD-STATE-002]`, 'expected a `repos` array');
89
+ else {
90
+ for (const repo of registry.repos) {
91
+ // A missing/empty path must NOT fall back to the project root (which is itself a git repo and
92
+ // would read as "healthy") — an entry with no path is malformed.
93
+ 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; }
94
+ const repoRoot = path.resolve(root, repo.path);
95
+ if (!exists(repoRoot)) { 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'); continue; }
96
+ const head = gitHead(repoRoot);
97
+ 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; }
98
+ if (repo.syncedHead && head !== repo.syncedHead) check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: code-context is stale (HEAD moved since last pack)`, 'run `yad repo refresh ' + repo.name + '`');
99
+ else check(checks, `repo:${repo.name}`, 'project', 'ok', `${repo.name}: git repo, context fresh`);
100
+ }
101
+ if (!registry.repos.length) check(checks, 'repos', 'project', 'warn', 'no code repos registered', 'run `yad setup` to connect one');
102
+ }
103
+ return { hub, registry };
104
+ }
105
+
106
+ export function epicChecks(checks, root) {
107
+ const epicsDir = path.join(root, 'epics');
108
+ if (!exists(epicsDir)) return;
109
+ for (const e of fs.readdirSync(epicsDir).sort()) {
110
+ if (!fs.statSync(path.join(epicsDir, e)).isDirectory()) continue;
111
+ try {
112
+ const ledger = loadLedger(epicRoot(root, e));
113
+ 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');
114
+ else check(checks, `epic:${e}`, 'epics', 'ok', `${e}: currentStep ${ledger.state.currentStep}`);
115
+ } catch (err) {
116
+ check(checks, `epic:${e}`, 'epics', 'fail', `${e}: ${err.message} [${err.code || 'YAD-STATE-001'}]`, err.hint || 'fix the file or restore it from git');
117
+ }
118
+ }
119
+ }
120
+
121
+ export async function runDoctor(root, { json = false } = {}) {
122
+ const checks = [];
123
+ envChecks(checks);
124
+ projectChecks(checks, root);
125
+ epicChecks(checks, root);
126
+
127
+ const failed = checks.filter((x) => x.status === 'fail');
128
+ const warned = checks.filter((x) => x.status === 'warn');
129
+ if (json) {
130
+ log(JSON.stringify({ version: VERSION, ok: failed.length === 0, checks }, null, 2));
131
+ } else {
132
+ log(c.bold(`\nyad doctor ${c.dim('v' + VERSION)}`));
133
+ let section = '';
134
+ for (const x of checks) {
135
+ if (x.section !== section) { section = x.section; log(`\n ${c.bold(section)}`); }
136
+ ({ ok, warn, fail })[x.status](x.message);
137
+ if (x.hint && x.status !== 'ok') hand(x.hint);
138
+ }
139
+ log('');
140
+ if (failed.length) fail(`${failed.length} problem(s) found`);
141
+ else if (warned.length) info(`healthy with ${warned.length} warning(s)`);
142
+ else ok('all clear');
143
+ }
144
+ if (failed.length) process.exitCode = 1;
145
+ return { ok: failed.length === 0, failed: failed.length, warned: warned.length, checks };
146
+ }
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { createHash } from 'node:crypto';
6
6
  import fs from 'node:fs';
7
7
  import { readJSONStrict, writeJSON, fileSha } from './lib.mjs';
8
+ import { err } from './errors.mjs';
8
9
  import { epicFiles } from './manifest.mjs';
9
10
 
10
11
  const RISK_ESCALATORS = ['contract', 'auth', 'payments'];
@@ -98,7 +99,7 @@ export function artifactHash(epicDir, artifact) {
98
99
 
99
100
  // Shape checks for the ledger files. Fail fast with the exact file named — a wrong-shape ledger
100
101
  // silently treated as a default would be rewritten by the next sync, destroying the real data.
101
- const badShape = (file, what) => new Error(`${file}: ${what} fix the file or restore it from git`);
102
+ const badShape = (file, what) => err('YAD-STATE-002', `${file}: ${what}`, 'fix the file or restore it from git');
102
103
  function requireArray(v, file) {
103
104
  if (!Array.isArray(v)) throw badShape(file, 'expected a JSON array');
104
105
  return v;
package/cli/errors.mjs ADDED
@@ -0,0 +1,25 @@
1
+ // Structured error codes for the `yad` CLI. A YadError carries a stable code (greppable,
2
+ // documented in README "Troubleshooting") and a one-line recovery hint that the top-level
3
+ // catch in bin/yad.mjs prints after the message. Plain Errors still work everywhere; codes
4
+ // are reserved for the failures users actually hit and need to act on.
5
+ export class YadError extends Error {
6
+ constructor(code, message, hint = '') {
7
+ super(message);
8
+ this.name = 'YadError';
9
+ this.code = code;
10
+ this.hint = hint;
11
+ }
12
+ }
13
+
14
+ // The catalog — single source for doctor, the top-level catch, and the README table.
15
+ export const CODES = {
16
+ 'YAD-ENV-001': 'git is not installed or not on PATH',
17
+ 'YAD-ENV-002': 'the platform CLI (gh/glab) is missing or not authenticated',
18
+ 'YAD-ENV-003': 'Node.js is older than the supported range (>=18)',
19
+ 'YAD-STATE-001': 'a ledger/config JSON file exists but does not parse',
20
+ 'YAD-STATE-002': 'a ledger/config JSON file parses but has the wrong shape',
21
+ 'YAD-STATE-003': 'a registered repo path is missing or not a git repository',
22
+ 'YAD-CFG-001': 'hub.json names an unknown platform (expected github, gitlab, or null)',
23
+ };
24
+
25
+ export const err = (code, message, hint) => new YadError(code, message, hint);
package/cli/gate.mjs CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  artifactPaths, upsertHubPr,
15
15
  } from './epic-state.mjs';
16
16
  import { readPr, mapApprovers, createPr } from './platform.mjs';
17
+ import { err } from './errors.mjs';
17
18
 
18
19
  // ---- tiny frontmatter reader (key: value, and `repos: [a, b]`) ----------------------------------
19
20
  function frontmatter(file) {
@@ -64,18 +65,23 @@ function warnUnlockedContract(epicDir, artifact) {
64
65
  function loadHub(root) {
65
66
  const hubFile = path.join(root, PROJECT_FILES.hubConfig);
66
67
  const regFile = path.join(root, PROJECT_FILES.reposRegistry);
68
+ // Distinguish an ABSENT hub.json (null default → fine, file-only gate) from one that exists but
69
+ // holds literal `null` (malformed — must not silently downgrade to file-only).
67
70
  const hub = readJSONStrict(hubFile, null);
71
+ if (hub === null && fs.existsSync(hubFile)) {
72
+ throw err('YAD-STATE-002', `${hubFile}: contains \`null\` — expected a config object`, 'fix the file or re-run `yad setup`');
73
+ }
68
74
  if (hub !== null) {
69
- if (typeof hub !== 'object' || Array.isArray(hub)) throw new Error(`${hubFile}: expected a JSON object`);
75
+ if (typeof hub !== 'object' || Array.isArray(hub)) throw err('YAD-STATE-002', `${hubFile}: expected a JSON object`, 'fix the file or re-run `yad setup`');
70
76
  if (![null, undefined, 'github', 'gitlab'].includes(hub.platform)) {
71
- throw new Error(`${hubFile}: unknown platform '${hub.platform}' expected github, gitlab, or null`);
77
+ throw err('YAD-CFG-001', `${hubFile}: unknown platform '${hub.platform}'`, 'expected github, gitlab, or null — fix the file or re-run `yad setup`');
72
78
  }
73
79
  if (hub.roster !== undefined && !Array.isArray(hub.roster)) {
74
- throw new Error(`${hubFile}: expected \`roster\` to be an array`);
80
+ throw err('YAD-STATE-002', `${hubFile}: expected \`roster\` to be an array`, 'fix the file or re-run `yad setup`');
75
81
  }
76
82
  }
77
83
  const registry = readJSONStrict(regFile, { repos: [] });
78
- if (!Array.isArray(registry?.repos)) throw new Error(`${regFile}: expected a \`repos\` array`);
84
+ if (!Array.isArray(registry?.repos)) throw err('YAD-STATE-002', `${regFile}: expected a \`repos\` array`, 'fix the file or re-run `yad setup`');
79
85
  return { hub, repos: registry.repos };
80
86
  }
81
87
 
@@ -131,7 +137,7 @@ function writeComments(epicDir, base, today, blocking) {
131
137
  // Upsert machine-readable participation records into the comments ledger (the counterpart to the
132
138
  // markdown side file) so the ledger — not just reviews/*.md — reflects platform thread state. One
133
139
  // record per (step, commenter, round); `round` is the count of prior synced rounds for the step.
134
- function recordComments(comments, { artifact, stepId, today, roster, repos, blocking }) {
140
+ function recordComments(comments, { artifact, stepId, today, roster, blocking }) {
135
141
  if (!blocking.length) return comments;
136
142
  const byName = (login) => (roster.find((r) => r.login === login)?.name) || login || 'reviewer';
137
143
  const roleOf = (login) => (roster.find((r) => r.login === login)?.role) || 'reviewer';
@@ -386,8 +392,8 @@ export async function gateStatus(root, { epic } = {}) {
386
392
  }
387
393
  }
388
394
 
389
- export async function gateOpen(root, { epic, artifact, today } = {}) {
390
- const { hub, repos } = loadHub(root);
395
+ export async function gateOpen(root, { epic, artifact } = {}) {
396
+ const { hub } = loadHub(root);
391
397
  const epicDir = epicRoot(root, epic);
392
398
  const ledger = loadLedger(epicDir);
393
399
  if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
package/cli/lib.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Shared helpers for the `yad` CLI. Node >=18 built-ins only — no dependencies.
2
2
  import { createHash } from 'node:crypto';
3
+ import { err } from './errors.mjs';
3
4
  import { spawnSync } from 'node:child_process';
4
5
  import * as readline from 'node:readline/promises';
5
6
  import { stdin as input, stdout as output } from 'node:process';
@@ -108,7 +109,7 @@ export function readJSONStrict(p, def = null) {
108
109
  try {
109
110
  return JSON.parse(fs.readFileSync(p, 'utf8'));
110
111
  } catch (e) {
111
- throw new Error(`corrupt JSON in ${p}: ${e.message} fix or delete the file`);
112
+ throw err('YAD-STATE-001', `corrupt JSON in ${p}: ${e.message}`, 'fix the file or restore it from git — never delete a ledger blindly');
112
113
  }
113
114
  }
114
115
  // Atomic: serialize first, write a sibling tmp file (same dir = same filesystem),
package/cli/reconcile.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // manifest: missing setup, drifted files, stale code-context.
4
4
  import path from 'node:path';
5
5
  import {
6
- c, log, ok, info, warn, hand, fail, readJSON, writeJSON, exists,
6
+ c, log, ok, info, warn, hand, readJSON, writeJSON, exists,
7
7
  } from './lib.mjs';
8
8
  import { VERSION, PROJECT_FILES } from './manifest.mjs';
9
9
  import {
package/cli/setup.mjs CHANGED
@@ -3,9 +3,9 @@ import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
  import {
5
5
  c, log, step, ok, info, warn, hand, fail, ask, askYesNo, run, has,
6
- exists, asset, copyFile, readJSON, writeJSON,
6
+ exists, readJSON, writeJSON,
7
7
  } from './lib.mjs';
8
- import { VERSION, IDE_FOLDER_TARGETS, IDE_OPENCODE_DIR, PROJECT_FILES } from './manifest.mjs';
8
+ import { VERSION, IDE_FOLDER_TARGETS, PROJECT_FILES } from './manifest.mjs';
9
9
  import { moduleActions, repoActions, hubActions, authorsActions } from './plan.mjs';
10
10
 
11
11
  const ALL_IDES = [...IDE_FOLDER_TARGETS, '.opencode'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "2.0.0",
3
+ "version": "2.1.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, repo). A BMAD module + 17 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -20,6 +20,7 @@
20
20
  "bin/",
21
21
  "cli/",
22
22
  "!cli/test.mjs",
23
+ "!cli/test-checks.mjs",
23
24
  "skills/",
24
25
  "docs/index.html",
25
26
  "README.md",
@@ -35,7 +36,11 @@
35
36
  },
36
37
  "scripts": {
37
38
  "yad": "node bin/yad.mjs",
38
- "test": "node --test cli/test.mjs",
39
+ "lint": "eslint cli bin",
40
+ "test": "node --test cli/test.mjs cli/test-checks.mjs",
41
+ "test:e2e": "bash test/e2e/run.sh",
42
+ "coverage": "node --test --experimental-test-coverage --test-coverage-exclude='cli/test*.mjs' --test-coverage-lines=70 --test-coverage-branches=70 cli/test.mjs cli/test-checks.mjs",
43
+ "diagrams": "npx -y @mermaid-js/mermaid-cli -i docs/diagrams/sdlc-overview.mmd -o docs/diagrams/sdlc-overview.svg -b transparent && npx -y @mermaid-js/mermaid-cli -i docs/diagrams/review-loop.mmd -o docs/diagrams/review-loop.svg -b transparent",
39
44
  "prepublishOnly": "npm test"
40
45
  },
41
46
  "keywords": [
@@ -48,7 +53,9 @@
48
53
  "spec-driven-development"
49
54
  ],
50
55
  "devDependencies": {
56
+ "@eslint/js": "^9.39.4",
51
57
  "@semantic-release/changelog": "^6.0.3",
58
+ "eslint": "^9.39.4",
52
59
  "semantic-release": "^25.0.3"
53
60
  }
54
61
  }