yadflow 3.17.3 → 3.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,3 +1,13 @@
1
+ # [3.18.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.17.3...v3.18.0) (2026-09-05)
2
+
3
+
4
+ ### Features
5
+
6
+ * **cli:** add yad migrate with preview, backup and report ([39dde87](https://github.com/abdelrahmannasr/yadflow/commit/39dde87b9f38936b05b6cd6243a67b9a2aea7fb9))
7
+ * **doctor:** report shape drift against the engine ([f2dc377](https://github.com/abdelrahmannasr/yadflow/commit/f2dc377815a1135b4015cff9f46f3384dc08ee56))
8
+ * **release:** publish majors to a next channel and warn before upgrading ([70d2334](https://github.com/abdelrahmannasr/yadflow/commit/70d23347021a3635e8d71f6465d516bd6e72322d))
9
+ * **state:** stamp schemaVersion 1 on every engine-written file ([872dde6](https://github.com/abdelrahmannasr/yadflow/commit/872dde61975b942e95670c60f329621802256ab8)), closes [#163](https://github.com/abdelrahmannasr/yadflow/issues/163)
10
+
1
11
  ## [3.17.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.17.2...v3.17.3) (2026-09-03)
2
12
 
3
13
  ## [3.17.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.17.1...v3.17.2) (2026-09-03)
package/README.md CHANGED
@@ -181,5 +181,6 @@ workflow-hygiene flags — derived read-only, so an EM can see how the team actu
181
181
  end-to-end harness on both). On **Windows use [WSL](https://learn.microsoft.com/windows/wsl/)** — native
182
182
  PowerShell is not yet supported. Requires **Node.js ≥ 18**.
183
183
 
184
- **Releases** are automated via [semantic-release](https://semantic-release.gitbook.io/) on merge to
185
- `main` (Conventional Commits → npm, with provenance). See [RELEASING.md](RELEASING.md).
184
+ **Releases** are a human decision. Merging to `main` never publishes; a person fast-forwards the
185
+ `release` branch, and [semantic-release](https://semantic-release.gitbook.io/) publishes from there
186
+ (Conventional Commits → npm, with provenance). See [RELEASING.md](RELEASING.md).
package/bin/yad.mjs CHANGED
@@ -16,6 +16,7 @@ import { runRepo } from '../cli/repo.mjs';
16
16
  import { runRoster } from '../cli/roster.mjs';
17
17
  import { runDocs } from '../cli/docs.mjs';
18
18
  import { runDoctor } from '../cli/doctor.mjs';
19
+ import { runMigrate } from '../cli/migrate.mjs';
19
20
  import { runNext } from '../cli/next.mjs';
20
21
  import { runSkip } from '../cli/skip.mjs';
21
22
  import { syncStatuses } from '../cli/artifact-status.mjs';
@@ -47,6 +48,11 @@ ${c.bold('Setup & maintenance')}
47
48
  'yad check --fix --push'; --allow-branch permits a non-default branch
48
49
  yad doctor [--json] Environment + state health: tools/auth, config files,
49
50
  repo paths, epic ledgers (exit 1 on any failure)
51
+ yad migrate [--apply] [--json] Move this project's state files onto the shape
52
+ this yadflow expects. Prints what WOULD change and writes
53
+ nothing until --apply, which copies each file it rewrites to
54
+ <file>.yad-orig first. Safe to run twice — the second run
55
+ reports there is nothing to do
50
56
  yad sync-status [epic] Update artifact frontmatter status (draft/in-review/approved)
51
57
  from .sdlc/state.json — all epics if omitted (--dry-run to preview)
52
58
  yad report [-m <text>] File a bug in the yadflow repo with auto-scrubbed diagnostics
@@ -218,6 +224,10 @@ function parseArgs(argv) {
218
224
  else if (a === '--repos') o.repos = true;
219
225
  else if (a === '--wire') o.wire = true;
220
226
  else if (a === '--dry-run') o.dryRun = true;
227
+ else if (a === '--apply') o.apply = true;
228
+ // The roadmap and the release check spell the default `--preview`. Accepting it means a script can
229
+ // say what it means rather than relying on the absence of a flag.
230
+ else if (a === '--preview') o.apply = false;
221
231
  else if (a === '--json') o.json = true;
222
232
  else if (a === '-h' || a === '--help') o.help = true;
223
233
  else if (a === '-v' || a === '--version') o.version = true;
@@ -260,6 +270,9 @@ async function main() {
260
270
  case 'doctor':
261
271
  await runDoctor(o.dir, { json: o.json });
262
272
  break;
273
+ case 'migrate':
274
+ await runMigrate(o.dir, { apply: o.apply, json: o.json });
275
+ break;
263
276
  // Harness-invoked, not typed by a human: a tool-call payload arrives on stdin and the exit code
264
277
  // is the verdict (0 allow, 2 deny). See cli/hook.mjs for the contract.
265
278
  case 'hook': {
package/cli/doctor.mjs CHANGED
@@ -1,12 +1,14 @@
1
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.
2
+ // Five sections: environment (tools on PATH, auth), project state (config files parse and point at
3
+ // real repos), shape (what schemaVersion the files are on vs the engine), epics (each ledger loads),
4
+ // and threads (feature-thread lineage). Pure reporting: exit 1 on any FAIL, 0 with warnings.
5
+ // `--json` emits the checks for CI / bug reports.
5
6
  import path from 'node:path';
6
7
  import fs from 'node:fs';
7
8
  import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSONStrict } from './lib.mjs';
8
9
  import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS, HOOK_SETTINGS, HOOK_TOOL_MATCHER, isBridgeHub } from './manifest.mjs';
9
10
  import { mergeHookSettings, hookMatcherFires, ideTargetsFor } from './plan.mjs';
11
+ import { planMigration } from './migrate.mjs';
10
12
  import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants, contractSurfaceHash, artifactHash } from './epic-state.mjs';
11
13
  import { loadDebt } from './thread.mjs';
12
14
  import { gitHead, insideWorkspace } from './setup.mjs';
@@ -32,9 +34,11 @@ const underProjectRoot = (root, p) => {
32
34
  // corruption, and must not be reassured away as an expected sibling.
33
35
  const isRegistrableSibling = (root, rpath) => insideWorkspace(root, rpath);
34
36
 
35
- // Each check: { id, section, status: 'ok'|'warn'|'fail', message, hint? }
36
- function check(checks, id, section, status, message, hint = '') {
37
- checks.push({ id, section, status, message, ...(hint ? { hint } : {}) });
37
+ // Each check: { id, section, status: 'ok'|'warn'|'fail', message, hint?, …extra }
38
+ // `extra` carries structured detail for the `--json` consumer that would be unreadable in the prose
39
+ // line — e.g. the per-file shape table behind a one-sentence drift summary.
40
+ function check(checks, id, section, status, message, hint = '', extra = null) {
41
+ checks.push({ id, section, status, message, ...(hint ? { hint } : {}), ...(extra || {}) });
38
42
  }
39
43
 
40
44
  export function envChecks(checks) {
@@ -507,6 +511,111 @@ export function epicChecks(checks, root) {
507
511
  }
508
512
  }
509
513
 
514
+ // ---- file shape (schemaVersion) -------------------------------------------------------------
515
+ // What shape this project's files are in, against the shape this engine writes. The stamp itself is
516
+ // silent by design (cli/lib.mjs), and `yad migrate` only speaks when you run it — so without this
517
+ // section a project could sit a shape behind, or a shape ahead, with nothing ever saying so. Rule 6:
518
+ // the engine never goes quiet about what is unprotected.
519
+ //
520
+ // The reading comes from `planMigration`, the same function `yad migrate` previews with, so doctor and
521
+ // migrate can never disagree about what state a project is in or what would fix it.
522
+ //
523
+ // Three outcomes, and the middle one is the whole point:
524
+ // ok every file is on the engine's shape
525
+ // warn a file is BEHIND — `yad migrate` walks it forward, and the message says so
526
+ // fail a file is AHEAD — written by a newer yadflow than this one; migrating would downgrade it,
527
+ // so the fix is to upgrade the CLI, not to touch the file
528
+ const scopeOf = (rel) => {
529
+ const parts = rel.split(path.sep);
530
+ return parts[0] === 'epics' && parts.length > 1 ? parts[1] : null;
531
+ };
532
+
533
+ function shapeCheckFor(checks, id, label, rows, engine) {
534
+ // A file that does not parse has no shape to compare. It still gets said out loud here, because
535
+ // nothing else in doctor reads these files — a corrupt change.json or build-log shard would
536
+ // otherwise pass a clean health check while `yad migrate` refuses to touch the project over it.
537
+ const unreadable = rows.filter((r) => r.from === null);
538
+ if (unreadable.length) {
539
+ check(checks, `${id}:unreadable`, 'shape', 'fail',
540
+ `${label}: ${unreadable.length} state file(s) do not parse — ${unreadable.map((r) => r.file).join(', ')}`,
541
+ 'restore them from git — a broken state file blocks `yad migrate` and cannot be read by the gate');
542
+ }
543
+
544
+ const readable = rows.filter((r) => r.from !== null);
545
+ if (!readable.length) return;
546
+ const ahead = readable.filter((r) => r.action === 'ahead');
547
+ // A file behind the engine on a VERIFIED hub is real drift, but `yad migrate` deliberately refuses
548
+ // to touch it — CI is its only writer. Pointing at migrate there would send someone to a command
549
+ // that changes nothing while the warning never clears, so those are counted and named separately.
550
+ const behind = readable.filter((r) => r.from < engine && r.action !== 'ci-owned');
551
+ const behindCi = readable.filter((r) => r.from < engine && r.action === 'ci-owned');
552
+ // An object with no key yet is shape 1 by rule 1 — correct, not drifted. Read from the bytes
553
+ // (`stamped`), not from `action`: that fires on any byte difference, a re-indent included.
554
+ const unstamped = readable.filter((r) => !r.stamped).length;
555
+ const shapes = [...new Set(readable.map((r) => r.from))].sort((a, b) => a - b);
556
+ const on = shapes.length === 1 ? `shape ${shapes[0]}` : `shapes ${shapes.join(' and ')}`;
557
+ const detail = { shape: { engine, files: readable.map((r) => ({ file: r.file, shape: r.from, stamped: !!r.stamped })) } };
558
+
559
+ if (ahead.length) {
560
+ check(checks, id, 'shape', 'fail',
561
+ `${label} is on ${on}, the engine is on shape ${engine} — ${ahead.length} file(s) are newer than this yadflow`,
562
+ 'upgrade yadflow (`npm i -g yadflow@latest`) — migrating would move those files BACKWARD and lose what the newer version wrote',
563
+ detail);
564
+ return;
565
+ }
566
+ if (behind.length || behindCi.length) {
567
+ const parts = [];
568
+ if (behind.length) parts.push(`${behind.length} file(s) are behind`);
569
+ if (behindCi.length) parts.push(`${behindCi.length} are CI-owned and behind`);
570
+ check(checks, id, 'shape', 'warn',
571
+ `${label} is on ${on}, the engine is on shape ${engine} — ${parts.join(', ')}`,
572
+ behind.length
573
+ ? 'run `yad migrate` to see what would change, then `yad migrate --apply` (each file is backed up first)'
574
+ : 'nothing to run — in verified mode CI owns these files and moves them on its next gate sync',
575
+ detail);
576
+ return;
577
+ }
578
+ // The suggestion lives in the MESSAGE, not the hint: runDoctor prints hints only for warn/fail, so a
579
+ // hint on a passing check would reach `--json` and never the person reading the terminal.
580
+ check(checks, id, 'shape', 'ok',
581
+ `${label} is on shape ${engine}, the engine is on shape ${engine}`
582
+ + (unstamped ? ` (${unstamped} file(s) do not record it yet — counted as shape 1; \`yad migrate --apply\` writes it in)` : ''),
583
+ '',
584
+ detail);
585
+ }
586
+
587
+ // `plan` is injectable for the same reason `yad migrate` takes an injectable migration list: while the
588
+ // engine is on shape 1 nothing can be BEHIND it, so the warn branch — the one this section exists for —
589
+ // is unreachable from a real project until the first real shape change lands. Tests supply a plan that
590
+ // reaches it, which is how the drift report is proven before there is any drift to report.
591
+ export function shapeChecks(checks, root, { plan: injected = null } = {}) {
592
+ if (!injected && !exists(path.join(root, PROJECT_FILES.hubConfig)) && !exists(path.join(root, PROJECT_FILES.version))) return;
593
+ let plan = injected;
594
+ if (!plan) {
595
+ try {
596
+ plan = planMigration(root);
597
+ } catch (e) {
598
+ // Say so rather than returning quietly. The failure modes here do not overlap with the other
599
+ // sections — an unreadable shard DIRECTORY, say, throws while `loadLedger` never looks at it —
600
+ // so a silent return would drop this whole section and let doctor print "all clear" over it.
601
+ check(checks, 'shape', 'shape', 'warn',
602
+ `could not read this project's file shapes: ${e.message}`,
603
+ 'fix the path in the message, then re-run — until then neither doctor nor `yad migrate` can tell you what shape this project is on');
604
+ return;
605
+ }
606
+ }
607
+ const { rows, engine } = plan;
608
+ // A list ledger has no key to read and never will (rule 1's second half) — reporting it as a file
609
+ // that "does not record its shape" would be a permanent nag about something that is already correct.
610
+ const relevant = rows.filter((r) => r.action !== 'list');
611
+
612
+ shapeCheckFor(checks, 'shape', 'this project', relevant.filter((r) => scopeOf(r.file) === null), engine);
613
+ const epics = [...new Set(relevant.map((r) => scopeOf(r.file)).filter(Boolean))].sort();
614
+ for (const e of epics) {
615
+ shapeCheckFor(checks, `shape:${e}`, e, relevant.filter((r) => scopeOf(r.file) === e), engine);
616
+ }
617
+ }
618
+
510
619
  // Phase 6 — feature-thread integrity. A change-epic must thread to a real parent and its denormalized
511
620
  // `thread` cache must equal the computed root; an open hotfix reconcile-debt is a warn (the next change
512
621
  // on that thread is blocked at the gate until it is paid). Pure reporting, like the other sections.
@@ -535,13 +644,15 @@ export function threadChecks(checks, root) {
535
644
  }
536
645
  }
537
646
 
538
- // Run every check section and return the diagnostic object without printing. This is the shared
539
- // core of `runDoctor` — the reporter (cli/report.mjs) consumes it to derive a *scrubbed* safe
540
- // subset (never the raw checks, which carry names + paths). Same shape `--json` prints.
647
+ // Run every check section and return the diagnostic object without printing. The shared core of
648
+ // `runDoctor`, and the same shape `--json` prints. Checks carry names and paths, so anything that
649
+ // leaves the machine must scrub them — `yad report` does NOT consume this; it builds its own
650
+ // allowlisted subset (cli/report.mjs `sanitizeContext`).
541
651
  export function collectDoctor(root) {
542
652
  const checks = [];
543
653
  envChecks(checks);
544
654
  projectChecks(checks, root);
655
+ shapeChecks(checks, root);
545
656
  epicChecks(checks, root);
546
657
  threadChecks(checks, root);
547
658
  const failed = checks.filter((x) => x.status === 'fail');
package/cli/gate.mjs CHANGED
@@ -111,7 +111,7 @@ export function loadHub(root) {
111
111
  // Solo mode (a lone developer): waive the approval requirement — on GitHub you cannot approve your own
112
112
  // PR, so an approval gate would deadlock. The review PR/MR and its merge stay (CI runs on the PR; the
113
113
  // merge advances the step). Recorded per-project in hub.json by `yad setup`.
114
- const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
114
+ export const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
115
115
 
116
116
  // Bridge mode: CI is the sole ledger writer, so `gate open`/`sync` stay hands-off. The predicate is
117
117
  // defined once in manifest.mjs (`isBridgeHub`) and shared with plan.mjs's wiring and the ledger
@@ -121,7 +121,7 @@ const isBridge = isBridgeHub;
121
121
  // requireEngagement (config `hub.review.requireEngagement`): when on, the predicate counts only
122
122
  // approvals carrying a verified engagement signal. Soft-off by default — a bare approve still counts
123
123
  // but is recorded `engagement: none` and draws the friendly nudge.
124
- const requireEngagement = (hub) => !!(hub && (hub.review?.requireEngagement === true));
124
+ export const requireEngagement = (hub) => !!(hub && (hub.review?.requireEngagement === true));
125
125
 
126
126
  // Re-add this step's bridge approvals from the current platform state (drop+re-add => dismissals and
127
127
  // revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
package/cli/ledger.mjs CHANGED
@@ -89,12 +89,21 @@ export const buildShardName = (e) => `${safe(e.story)}-${safe(e.task)}-${safe(e.
89
89
 
90
90
  // Read every shard object under `dir` (each file = ONE entry object). Sorted for determinism; a
91
91
  // corrupt/non-object shard is skipped (these ledgers are advisory evidence, never fatal).
92
+ //
93
+ // `schemaVersion` is dropped here on purpose. It describes the shape of a FILE, and a shard file has
94
+ // one — but `fold()` copies these objects into the folded log's `ships`/`runs` array, where they stop
95
+ // being files and become records inside one. Carrying the stamp across that boundary would bake a
96
+ // per-record version into an append-only ledger for good: after SCHEMA_VERSION moves to 2, a
97
+ // build-log.json stamped 2 would hold entries stamped 1 from shards folded today. The folded file
98
+ // states its own shape; its rows do not have one.
92
99
  function readShardDir(dir) {
93
100
  if (!fs.existsSync(dir)) return [];
94
101
  const out = [];
95
102
  for (const name of fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
96
103
  const obj = readJSON(path.join(dir, name), null);
97
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) out.push({ name, obj });
104
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) continue;
105
+ delete obj.schemaVersion;
106
+ out.push({ name, obj });
98
107
  }
99
108
  return out;
100
109
  }
package/cli/lib.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // Shared helpers for the `yad` CLI. Node >=18 built-ins only — no dependencies.
2
2
  import { createHash } from 'node:crypto';
3
3
  import { err } from './errors.mjs';
4
+ import { SCHEMA_VERSION } from './manifest.mjs';
4
5
  import { spawnSync } from 'node:child_process';
5
6
  import * as readline from 'node:readline/promises';
6
7
  import { stdin as input, stdout as output } from 'node:process';
@@ -100,10 +101,50 @@ export function dirMatches(src, dest) {
100
101
  return files.every((rel) => sameContent(path.join(src, rel), path.join(dest, rel)));
101
102
  }
102
103
 
104
+ // ---- file shape (schemaVersion) -----------------------------------------
105
+ // Rule 1 of the change-safety rules (docs/roadmap-idea-1.md, Part 2): every file the engine writes
106
+ // states its shape, and a file with no version counts as 1.
107
+ //
108
+ // Both halves live here, in the one JSON reader/writer pair, rather than at the ~30 call sites. Two
109
+ // writers that disagreed about whether — or where — a file carries the stamp would flip its bytes back
110
+ // and forth on every sync, which is the ledger-churn failure issue #163 exists to prevent.
111
+ //
112
+ // Two conditions gate it, and both are load-bearing:
113
+ //
114
+ // * the path runs through a `.sdlc` directory. writeJSON is also how the CLI writes
115
+ // `.claude/settings.json` (cli/plan.mjs) and the per-user update cache (cli/update-notice.mjs).
116
+ // Stamping a file the engine does not own would be a bug, not a feature.
117
+ // * the value is a plain object. Four ledger kinds — approvals, comments, hub-prs and
118
+ // reconcile-debt — are top-level JSON arrays, which cannot carry a key. Rule 1's second half
119
+ // already covers them: no version means version 1. Wrapping them in an object would be a shape
120
+ // change, and shape changes wait for v4 (rule 7).
121
+ // Matched against the file's own directory and its parent, NOT any ancestor. Every kind the engine
122
+ // writes sits either directly in a `.sdlc/` (state.json, hub.json, …) or one level down in a shard
123
+ // folder (build-log/, trust-log/, build-state/). Matching any ancestor instead would stamp every JSON
124
+ // file in a project that merely happened to live somewhere under a directory called `.sdlc` — the
125
+ // user's own .claude/settings.json included, which is exactly what this must never touch. A new kind
126
+ // nested deeper than that has to be added here on purpose.
127
+ const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
128
+ const underSdlcDir = (p) => {
129
+ const dir = path.dirname(path.resolve(p));
130
+ return path.basename(dir) === '.sdlc' || path.basename(path.dirname(dir)) === '.sdlc';
131
+ };
132
+ const carriesShape = (p, v) => isPlainObject(v) && underSdlcDir(p);
133
+
134
+ // Always FIRST in the serialized object. A stamp that moved around between writers would change the
135
+ // bytes without changing the meaning. An existing version is preserved, never forced back to 1, so a
136
+ // file already on a newer shape survives being read and written by this release.
137
+ const withShape = (p, v) =>
138
+ (carriesShape(p, v) ? { schemaVersion: v.schemaVersion ?? SCHEMA_VERSION, ...v } : v);
139
+
103
140
  export function readJSON(p, def = null) {
104
141
  try {
105
- return JSON.parse(fs.readFileSync(p, 'utf8'));
142
+ // "Read old, write new" (rule 2): an unstamped file reads back as shape 1, so a caller never has
143
+ // to ask whether the file it just loaded predates the stamp.
144
+ return withShape(p, JSON.parse(fs.readFileSync(p, 'utf8')));
106
145
  } catch {
146
+ // The caller's own default is returned untouched: it is not a file, and stamping it would invent
147
+ // a shape for something that was never read from disk.
107
148
  return def;
108
149
  }
109
150
  }
@@ -113,7 +154,7 @@ export function readJSON(p, def = null) {
113
154
  export function readJSONStrict(p, def = null) {
114
155
  if (!fs.existsSync(p)) return def;
115
156
  try {
116
- return JSON.parse(fs.readFileSync(p, 'utf8'));
157
+ return withShape(p, JSON.parse(fs.readFileSync(p, 'utf8')));
117
158
  } catch (e) {
118
159
  throw err('YAD-STATE-001', `corrupt JSON in ${p}: ${e.message}`, 'fix the file or restore it from git — never delete a ledger blindly');
119
160
  }
@@ -122,7 +163,7 @@ export function readJSONStrict(p, def = null) {
122
163
  // then rename over the target. A killed process can never leave a truncated ledger
123
164
  // file, and a failed rename never leaves a stray .tmp for `git add -A` to pick up.
124
165
  export function writeJSON(p, obj) {
125
- const data = JSON.stringify(obj, null, 2) + '\n';
166
+ const data = JSON.stringify(withShape(p, obj), null, 2) + '\n';
126
167
  // Byte-identical content is not a write. The ledger writers are unconditional — they re-serialize
127
168
  // whether or not anything changed — so this keeps an unchanged sync from touching the file at all
128
169
  // (no mtime churn, nothing for a watcher or a `git add -A` to notice). A backstop, not the fix: the
package/cli/manifest.mjs CHANGED
@@ -147,6 +147,22 @@ export const TESTING_PRIMARY = 'playwright';
147
147
  export const LEARNING_TOOLS = ['deeptutor'];
148
148
  export const LEARNING_PRIMARY = 'deeptutor';
149
149
 
150
+ // The shape (schema version) every file the engine writes declares, as `"schemaVersion": 1`.
151
+ //
152
+ // Rule 1 of the change-safety rules (docs/roadmap-idea-1.md, Part 2): every file states its shape,
153
+ // and a file with no version counts as 1.
154
+ //
155
+ // Today the stamp is written and read back, and nothing yet acts on it: raising this number would move
156
+ // what new files say without upgrading existing ones. The two halves that make it usable are the next
157
+ // tasks on the roadmap — `yad migrate` (E14), which moves a project from one shape to the next, and a
158
+ // `yad doctor` report (E16) for a project whose files disagree with the engine. Do NOT raise this
159
+ // number before both exist.
160
+ //
161
+ // Deliberately NOT the same thing as `VERSION` above. That is which release of the CLI you are
162
+ // running and moves on every publish; this is what the files on disk look like and moves only when
163
+ // their shape actually changes.
164
+ export const SCHEMA_VERSION = 1;
165
+
150
166
  // Project-level files setup produces (used by `check` to spot missing setup).
151
167
  export const PROJECT_FILES = {
152
168
  reposRegistry: '.sdlc/repos.json',
@@ -0,0 +1,317 @@
1
+ // `yad migrate` — the one command that moves a project's files from one shape to the next.
2
+ //
3
+ // Rule 4 of the change-safety rules (docs/roadmap-idea-1.md, Part 2): one real upgrade command —
4
+ // preview, back up, rewrite, report, safe to run twice.
5
+ //
6
+ // The shape of a file is its `schemaVersion` (cli/lib.mjs stamps it; manifest.mjs holds the engine's
7
+ // current number). This command exists so that raising that number is survivable: every project can be
8
+ // walked forward, in the open, with a copy of anything it rewrites left beside the original.
9
+ //
10
+ // Two things it deliberately is NOT:
11
+ //
12
+ // * not automatic. Nothing here runs as a side effect of another command. An upgrade that happens
13
+ // without being asked for is how a tool loses the trust this one is selling.
14
+ // * not destructive by default. Running `yad migrate` prints what WOULD change and touches nothing.
15
+ // Only `--apply` writes, and only after copying each file it rewrites to `<file>.yad-orig` — the
16
+ // same backup mechanism `yad check --fix` already uses.
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ import { c, exists, fail, hand, info, log, ok, readJSON, warn, writeJSON } from './lib.mjs';
21
+ import { BACKUP_SUFFIX, epicFiles, isBridgeHub, MANAGED_LEDGER, PROJECT_FILES, SCHEMA_VERSION, VERSION } from './manifest.mjs';
22
+ import { backupPathFor } from './plan.mjs';
23
+ import { isValidEpicId } from './epic-state.mjs';
24
+
25
+ // ---- the migration list --------------------------------------------------------------------
26
+ // Ordered steps, each moving a file from one shape to the next. A step is applied to a file only when
27
+ // the file's current shape equals its `from`; the list is walked ONCE per file, in order, so a step
28
+ // can never be applied twice and a same-version step cannot spin.
29
+ //
30
+ // The first entry is 1 → 1 on purpose. It changes no field. It exists so the machinery — preview,
31
+ // backup, rewrite, report, re-run — is exercised and tested by real use before there is any real shape
32
+ // change to trust it with. When a genuine 1 → 2 lands, it appends here and this one stays.
33
+ export const MIGRATIONS = [
34
+ {
35
+ from: 1,
36
+ to: 1,
37
+ title: 'baseline — every file states its shape',
38
+ apply: (obj) => obj,
39
+ },
40
+ ];
41
+
42
+ // ---- reading a file's shape ---------------------------------------------------------------
43
+ // Deliberately NOT readJSON: that reports an unstamped file as shape 1 (rule 2, "read old"), which is
44
+ // the right answer for every other caller and the wrong one here. Migrate is the one place that has to
45
+ // see the bytes as they are, so it can tell a file that already carries the key from one that does not.
46
+ function readRaw(file) {
47
+ try {
48
+ return { ok: true, value: JSON.parse(fs.readFileSync(file, 'utf8')) };
49
+ } catch (e) {
50
+ return { ok: false, error: e.message };
51
+ }
52
+ }
53
+
54
+ const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
55
+
56
+ // A file's shape as recorded ON DISK. An object with no key is shape 1 by rule 1; so is an array,
57
+ // which cannot carry a key at all.
58
+ const shapeOf = (v) => (isPlainObject(v) && Number.isInteger(v.schemaVersion) ? v.schemaVersion : 1);
59
+
60
+ // Walk the migration list once. Returns the migrated object, the shape it ended on, and which steps ran.
61
+ function applyMigrations(obj, migrations) {
62
+ let out = obj;
63
+ let version = shapeOf(obj);
64
+ const applied = [];
65
+ for (const m of migrations) {
66
+ if (version !== m.from) continue;
67
+ out = m.apply({ ...out });
68
+ version = m.to;
69
+ applied.push(m.title);
70
+ }
71
+ return { obj: out, version, applied };
72
+ }
73
+
74
+ // ---- what the engine would write -----------------------------------------------------------
75
+ // The single source of truth for "does this file need writing" is the bytes writeJSON would produce.
76
+ // Comparing those against the bytes on disk means preview can never disagree with apply: whatever the
77
+ // preview says would change is exactly what a write would change.
78
+ const serialize = (obj) => JSON.stringify(obj, null, 2) + '\n';
79
+
80
+ // The version the migration ENDED on wins, so the old key is removed before the new one is written.
81
+ // Spreading the object over the stamp instead would let a file's existing `schemaVersion` shadow it:
82
+ // the content would migrate while the number stayed put, so the file would be migrated again on every
83
+ // later run — each one overwriting its own .yad-orig with already-migrated bytes until the original
84
+ // was gone. (This is the one place that must NOT behave like lib.mjs's writeJSON, which preserves a
85
+ // file's existing version on purpose. Here, changing it is the entire point.)
86
+ const stamped = (obj, version) => {
87
+ const rest = { ...obj };
88
+ delete rest.schemaVersion;
89
+ return { schemaVersion: version, ...rest };
90
+ };
91
+
92
+ // ---- keeping the backups out of the commit ---------------------------------------------------
93
+ // The `.yad-orig` copies land beside the files they back up, which for a ledger means inside the
94
+ // tracked `epics/<epic>/.sdlc/` tree. Two commands stage that whole directory with `git add -A` —
95
+ // `gate sync`'s merge-phase advance and `yad tidy up` — so telling the user "do not commit these"
96
+ // would be advice they cannot act on: the next gate advance would sweep them into a `chore(gate)`
97
+ // commit and push it to the default branch on its own.
98
+ //
99
+ // So ignore them instead, idempotently, the way `yad setup` already ignores the repomix packs. Only on
100
+ // `--apply`, and only when a backup is actually about to be written — a preview still touches nothing.
101
+ export const BACKUP_IGNORE_GLOB = `*${BACKUP_SUFFIX}`;
102
+ export const BACKUP_IGNORE_BLOCK = [
103
+ '# Pre-migration copies written by `yad migrate --apply`. They are your safety net on disk,',
104
+ '# not history — the migrated file is what gets committed.',
105
+ BACKUP_IGNORE_GLOB,
106
+ ];
107
+
108
+ export function ensureBackupsIgnored(root) {
109
+ const gi = path.join(root, '.gitignore');
110
+ const lines = exists(gi) ? fs.readFileSync(gi, 'utf8').split('\n') : [];
111
+ if (lines.some((l) => l.trim() === BACKUP_IGNORE_GLOB)) return false;
112
+ const body = lines.join('\n').replace(/\n*$/, '');
113
+ const prefix = body ? `${body}\n\n` : '';
114
+ fs.writeFileSync(gi, `${prefix}${BACKUP_IGNORE_BLOCK.join('\n')}\n`);
115
+ return true;
116
+ }
117
+
118
+ // ---- the file set --------------------------------------------------------------------------
119
+ // Every JSON file this project's engine owns: the product-level files (including the hub's own
120
+ // provenance ledger and each epic's docs-build cache, neither of which is in PROJECT_FILES/epicFiles),
121
+ // then each epic's ledger and its three shard folders.
122
+ //
123
+ // A CONNECTED REPO's own `.sdlc/managed.json` is deliberately not here. It belongs to that repo, is
124
+ // rewritten wholesale by that repo's `yad check --fix`, and migrating it from the hub would reach
125
+ // across a boundary the rest of the CLI respects. The hub's copy is a different file and is included.
126
+ function shardFiles(dir) {
127
+ if (!exists(dir)) return [];
128
+ return fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort().map((n) => path.join(dir, n));
129
+ }
130
+
131
+ export function projectJsonFiles(root) {
132
+ const files = [];
133
+ for (const rel of Object.values(PROJECT_FILES)) files.push(path.join(root, rel));
134
+ // The hub's own provenance record (cli/plan.mjs) — a stamped object under .sdlc/ like any other.
135
+ files.push(path.join(root, MANAGED_LEDGER));
136
+
137
+ const epicsDir = path.join(root, 'epics');
138
+ if (exists(epicsDir)) {
139
+ for (const epic of fs.readdirSync(epicsDir).sort()) {
140
+ if (!isValidEpicId(epic)) continue;
141
+ const epicDir = path.join(epicsDir, epic);
142
+ if (!fs.statSync(epicDir).isDirectory()) continue;
143
+ const f = epicFiles(epicDir);
144
+ files.push(f.state, f.approvals, f.comments, f.hubPrs, f.contractLock,
145
+ f.buildLog, f.trustLog, f.change, f.reconcileDebt);
146
+ files.push(...shardFiles(f.buildLogDir), ...shardFiles(f.trustLogDir), ...shardFiles(f.buildStateDir));
147
+ // The docs-build cache (cli/docs.mjs) lives in the same directory and is written by the engine,
148
+ // so it moves shape with everything else rather than being quietly left behind.
149
+ files.push(path.join(epicDir, '.sdlc', 'docs-build.json'));
150
+ }
151
+ }
152
+ return files.filter((f) => exists(f));
153
+ }
154
+
155
+ // ---- the plan ------------------------------------------------------------------------------
156
+ // One row per file. `action` is what would happen, and it is the same value whether this runs as a
157
+ // preview or as an apply:
158
+ //
159
+ // stamp an object with no schemaVersion — gets one, no field changes
160
+ // migrate a real shape change, one or more steps applied
161
+ // unchanged already on the engine's shape, bytes identical
162
+ // list a top-level JSON array: shape 1 by rule 1, and it cannot carry a key
163
+ // ahead the file's shape is NEWER than this engine — never touched, always reported
164
+ // ci-owned a verified (bridge) hub's ledger file: CI is its only writer
165
+ // unreadable does not parse — reported, never rewritten
166
+ //
167
+ // Each row also carries `stamped`: whether the file literally holds a `schemaVersion` key. That is a
168
+ // fact about the bytes, read the same way for every branch, so a caller never has to infer it from
169
+ // `action` — which would be wrong twice over: `stamp` fires whenever the serialized bytes differ for
170
+ // ANY reason (a hand re-indent, say), and `ci-owned`/`ahead`/`list` short-circuit before the byte
171
+ // comparison happens at all.
172
+ export function planMigration(root, { migrations = MIGRATIONS } = {}) {
173
+ const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), null);
174
+ const bridge = isBridgeHub(hub);
175
+ // On a verified hub the ledger guard refuses a human commit to these, so rewriting them locally
176
+ // would produce a change that cannot be committed. Of the four the guard names, only state.json is
177
+ // an object; the rest are arrays and would be skipped anyway.
178
+ const ciOwned = new Set(['state.json', 'approvals.json', 'comments.json', 'hub-prs.json']);
179
+
180
+ const rows = [];
181
+ for (const file of projectJsonFiles(root)) {
182
+ const rel = path.relative(root, file);
183
+ const raw = readRaw(file);
184
+ if (!raw.ok) {
185
+ rows.push({ file: rel, from: null, to: null, action: 'unreadable', changes: false, stamped: false, detail: raw.error });
186
+ continue;
187
+ }
188
+ const from = shapeOf(raw.value);
189
+ // Read from the bytes, before any branch: a list can never carry the key, and every other kind
190
+ // either does or does not, whoever owns the file.
191
+ const isStamped = isPlainObject(raw.value) && Number.isInteger(raw.value.schemaVersion);
192
+ if (!isPlainObject(raw.value)) {
193
+ rows.push({ file: rel, from, to: from, action: 'list', changes: false, stamped: false });
194
+ continue;
195
+ }
196
+ if (from > SCHEMA_VERSION) {
197
+ rows.push({ file: rel, from, to: from, action: 'ahead', changes: false, stamped: isStamped });
198
+ continue;
199
+ }
200
+ if (bridge && ciOwned.has(path.basename(file))) {
201
+ rows.push({ file: rel, from, to: from, action: 'ci-owned', changes: false, stamped: isStamped });
202
+ continue;
203
+ }
204
+ const { obj, version, applied } = applyMigrations(raw.value, migrations);
205
+ const next = serialize(stamped(obj, version));
206
+ const current = fs.readFileSync(file, 'utf8');
207
+ const changes = next !== current;
208
+ const action = version !== from ? 'migrate' : (changes ? 'stamp' : 'unchanged');
209
+ // `steps` lists the migrations that actually moved the file's shape. The baseline 1 → 1 runs on
210
+ // every file by design and moves nothing, so naming it on every row would be noise reported as work.
211
+ rows.push({ file: rel, from, to: version, action, changes, stamped: isStamped, ...(version !== from ? { steps: applied } : {}) });
212
+ }
213
+ return { engine: SCHEMA_VERSION, bridge, rows };
214
+ }
215
+
216
+ // ---- the report ----------------------------------------------------------------------------
217
+ const ACTION_NOTE = {
218
+ stamp: 'record its shape',
219
+ migrate: 'move to a new shape',
220
+ unchanged: 'already current',
221
+ list: 'a list — counts as shape 1',
222
+ ahead: 'newer than this engine',
223
+ 'ci-owned': 'CI writes it — will be stamped by the next gate sync',
224
+ unreadable: 'does not parse — fix it by hand',
225
+ };
226
+
227
+ function printRows(rows) {
228
+ const width = Math.max(...rows.map((r) => r.file.length), 4);
229
+ for (const r of rows) {
230
+ const shape = r.from === null ? '?' : (r.from === r.to ? `shape ${r.from}` : `shape ${r.from} → ${r.to}`);
231
+ const line = ` ${r.file.padEnd(width)} ${shape.padEnd(14)} ${c.dim(ACTION_NOTE[r.action] ?? r.action)}`;
232
+ if (r.action === 'unreadable' || r.action === 'ahead') fail(line.trim());
233
+ else log(line);
234
+ }
235
+ }
236
+
237
+ // ---- the command ---------------------------------------------------------------------------
238
+ // `.sdlc/cli-version.json` is migrated as an ordinary row, like every other project file — it is in
239
+ // PROJECT_FILES, so it is previewed, backed up and stamped along with the rest. Nothing here writes it
240
+ // a second time to record that a migration happened: a separate side-write would sit outside the plan,
241
+ // which means no backup, no row in the report, and a preview that under-reports what an apply does. It
242
+ // would also be free to overwrite the one file the same run had just declared corrupt or newer than
243
+ // this engine. What shape a file is in is recorded in that file, which is the whole point of rule 1.
244
+ export async function runMigrate(root, { apply = false, json = false } = {}, { migrations = MIGRATIONS } = {}) {
245
+ if (!exists(path.join(root, PROJECT_FILES.version)) && !exists(path.join(root, PROJECT_FILES.hubConfig))) {
246
+ const message = 'no yad project here (.sdlc/ not initialised)';
247
+ if (json) { log(JSON.stringify({ version: VERSION, ok: false, error: message }, null, 2)); }
248
+ else { fail(message); hand('run `yad setup` to start one'); }
249
+ process.exitCode = 1;
250
+ return { ok: false };
251
+ }
252
+
253
+ const plan = planMigration(root, { migrations });
254
+ const pending = plan.rows.filter((r) => r.changes);
255
+ const blocked = plan.rows.filter((r) => r.action === 'ahead' || r.action === 'unreadable');
256
+ const written = [];
257
+
258
+ let ignored = false;
259
+ if (apply) {
260
+ // Before the first backup exists, not after — otherwise a gate advance racing this run could stage
261
+ // one. A no-op when the line is already there.
262
+ if (pending.length) ignored = ensureBackupsIgnored(root);
263
+ for (const row of pending) {
264
+ const file = path.join(root, row.file);
265
+ const raw = readRaw(file);
266
+ if (!raw.ok) continue; // re-read defensively; an unreadable file is never in `pending` anyway
267
+ // Back up first, always. Unlike the wiring copies in plan.mjs — which skip the backup when the
268
+ // file's bytes are provably ours — a ledger has no provenance record, so there is nothing to
269
+ // prove and the copy is unconditional.
270
+ fs.copyFileSync(file, backupPathFor(file));
271
+ const { obj, version } = applyMigrations(raw.value, migrations);
272
+ writeJSON(file, stamped(obj, version));
273
+ written.push(row.file);
274
+ }
275
+ }
276
+
277
+ if (json) {
278
+ log(JSON.stringify({
279
+ version: VERSION,
280
+ ok: blocked.length === 0,
281
+ engine: plan.engine,
282
+ applied: apply,
283
+ bridge: plan.bridge,
284
+ changed: apply ? written : pending.map((r) => r.file),
285
+ ...(ignored ? { gitignored: BACKUP_IGNORE_GLOB } : {}),
286
+ rows: plan.rows,
287
+ }, null, 2));
288
+ } else {
289
+ log(c.bold(`\nyad migrate ${c.dim(`shape ${plan.engine}`)}`));
290
+ log(c.dim(`target: ${root}\n`));
291
+ printRows(plan.rows);
292
+ log('');
293
+ if (!pending.length) {
294
+ ok(`nothing to do — this project is already on shape ${plan.engine}`);
295
+ } else if (apply) {
296
+ ok(`${written.length} file(s) updated — a copy of each is beside it as <file>${BACKUP_SUFFIX}`);
297
+ info('re-run `yad migrate` to confirm there is nothing left to do');
298
+ // These land inside the tracked .sdlc/ tree, so a `git add -A` would sweep them into the commit
299
+ // alongside the migration itself. Say so rather than editing a .gitignore the project owns.
300
+ info(`the ${BACKUP_SUFFIX} copies are yours to keep or delete${ignored ? ` — .gitignore now excludes ${BACKUP_IGNORE_GLOB}, so they stay out of the ledger commit` : ''}`);
301
+ } else {
302
+ info(`${pending.length} file(s) would change — nothing has been written`);
303
+ hand('run `yad migrate --apply` to make the change (each file is backed up first)');
304
+ }
305
+ if (plan.bridge && plan.rows.some((r) => r.action === 'ci-owned')) {
306
+ info('this project is in verified mode: CI owns some ledger files and stamps them on its next gate sync');
307
+ }
308
+ for (const r of blocked) {
309
+ if (r.action === 'ahead') hand(`${r.file} was written by a newer yadflow — upgrade with \`npm i -g ${'yadflow'}\` rather than migrating`);
310
+ if (r.action === 'unreadable') hand(`${r.file} does not parse — restore it from git before migrating`);
311
+ }
312
+ if (blocked.length) warn('some files were left untouched — see above');
313
+ }
314
+
315
+ if (blocked.length) process.exitCode = 1;
316
+ return { ok: blocked.length === 0, rows: plan.rows, written };
317
+ }
@@ -124,18 +124,48 @@ export function shouldSuppress({ env = process.env, pkgRoot = PKG_ROOT } = {}) {
124
124
  // ---- banner -------------------------------------------------------------
125
125
  // `yad update` is the necessary second half: upgrading the global CLI leaves this project's installed
126
126
  // yad-* skills stamped at the old version in .sdlc/cli-version.json, which `yad doctor` then flags.
127
+ // A MAJOR jump is the only upgrade that may change the shape of the files in a project (rule 7:
128
+ // file-shape changes wait for a major, and ship with their migration guide). Everything else is
129
+ // additive by policy, so the plain banner is right for it.
130
+ //
131
+ // This is the last moment the engine can speak before someone types the upgrade command, so it says
132
+ // the one thing that makes a major safe: look at what it would change to YOUR project first.
133
+ //
134
+ // It has to be `npx yadflow@<new> migrate`, NOT the installed `yad migrate`. A migration list ships
135
+ // inside the engine that introduces it (cli/migrate.mjs), so the copy already installed knows only its
136
+ // own steps — on 3.x that is the 1 -> 1 baseline, which reports "nothing would change" for every
137
+ // project. Advising the installed binary would hand the reader false reassurance about exactly the
138
+ // upgrade this warning exists for. `npx` runs the NEW engine against the current project without
139
+ // installing anything, and a preview writes nothing either way.
140
+ const crossesMajor = (current, latest) => {
141
+ const cur = parseVersion(current);
142
+ const l = parseVersion(latest);
143
+ return !!cur && !!l && l.major > cur.major;
144
+ };
145
+
127
146
  export function formatBanner(current, latest) {
128
147
  // Normalize so a `v`-prefixed input can never produce `.../releases/tag/vv3.11.0`. Callers only
129
148
  // reach here after isNewer(), so parseVersion has already accepted both — the ?? is belt and braces.
130
149
  const v = normalizeVersion(latest) ?? latest;
131
150
  const url = `https://github.com/${UPSTREAM_REPO}/releases/tag/v${v}`;
132
- return [
151
+ const lines = [
133
152
  '',
134
153
  ` ${c.yellow('!')} ${c.bold(`${PKG_NAME} update available`)} — ${c.dim(current)} → ${c.green(v)}`,
135
154
  ` ${c.dim('Changelog:')} ${url}`,
155
+ ];
156
+ if (crossesMajor(current, v)) {
157
+ lines.push(
158
+ ` ${c.yellow('Major:')} ${c.bold('this may change the shape of your project files.')}`,
159
+ ` ${c.dim('Preview it first, with the new engine — this writes nothing:')}`,
160
+ ` ${c.cyan(`npx ${PKG_NAME}@${v} migrate`)}`,
161
+ ` ${c.dim('Then upgrade, and run')} ${c.cyan('yad migrate --apply')} ${c.dim('(backs up every file it rewrites)')}`,
162
+ );
163
+ }
164
+ lines.push(
136
165
  ` ${c.dim('Update:')} ${c.cyan(`npm install ${PKG_NAME} -g`)}`,
137
166
  ` ${c.dim('Then:')} ${c.cyan('yad update')} ${c.dim("(re-sync this project's yad-* skills)")}`,
138
- ].join('\n');
167
+ );
168
+ return lines.join('\n');
139
169
  }
140
170
 
141
171
  // ---- orchestrator -------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.17.3",
3
+ "version": "3.18.0",
4
4
  "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -22,6 +22,9 @@
22
22
  "!cli/test.mjs",
23
23
  "!cli/test-checks.mjs",
24
24
  "!cli/test-threads.mjs",
25
+ "!cli/test-golden.mjs",
26
+ "!cli/test-migrate.mjs",
27
+ "!cli/fixtures",
25
28
  "skills/",
26
29
  "README.md",
27
30
  "LICENSE",
@@ -37,9 +40,10 @@
37
40
  "scripts": {
38
41
  "yad": "node bin/yad.mjs",
39
42
  "lint": "eslint cli bin",
40
- "test": "node --test cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs",
43
+ "test": "node --test cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs",
41
44
  "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 cli/test-threads.mjs",
45
+ "release-check": "bash scripts/release-check.sh",
46
+ "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 cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs",
43
47
  "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",
44
48
  "prepublishOnly": "npm test"
45
49
  },
@@ -108,6 +108,7 @@ authoring step **locked**. Use this exact shape (see `references/state-schema.md
108
108
 
109
109
  ```json
110
110
  {
111
+ "schemaVersion": 1,
111
112
  "epicId": "EP-<slug>",
112
113
  "createdAt": "<YYYY-MM-DD>",
113
114
  "currentStep": "analysis-review",
@@ -28,6 +28,7 @@ re-authors stories+test-cases:
28
28
 
29
29
  ```json
30
30
  {
31
+ "schemaVersion": 1,
31
32
  "epicId": "EP-<slug>", "createdAt": "<today>", "currentStep": "stories",
32
33
  "steps": [
33
34
  { "id": "epic", "type": "author", "artifact": "epic.md", "assistance": "review", "automation": "human_approve", "locked": true, "status": "done", "inherited": true, "inheritedFrom": "EP-<genesis>", "boundHash": "sha256:…", "risk_tags": [] },
@@ -128,6 +128,7 @@ steps **locked**. Use this exact shape (see `references/state-schema.md`):
128
128
 
129
129
  ```json
130
130
  {
131
+ "schemaVersion": 1,
131
132
  "epicId": "EP-<slug>",
132
133
  "createdAt": "<YYYY-MM-DD>",
133
134
  "currentStep": "epic-review",
@@ -3,6 +3,39 @@
3
3
  All SDLC state lives in plain files under `epics/EP-<slug>/.sdlc/` (build plan §1: "All state lives
4
4
  in files on disk. Nothing hidden."). No database, no browser storage.
5
5
 
6
+ ## Every file states its shape — `schemaVersion`
7
+
8
+ Each JSON **object** the CLI writes under a `.sdlc/` directory carries `"schemaVersion": 1` as its
9
+ first key. It says what shape the file is in, so a future release can recognise an older file and
10
+ upgrade it instead of guessing.
11
+
12
+ **When you author one of these files by hand, include the key**, exactly as the examples below show.
13
+ Several kinds here — `state.json` on the seeding path, `change.json`, `contract-lock.json`,
14
+ `build-state/<story-id>.json`, `design-links.json`, `test-links.json` — are written by skills, not by
15
+ the CLI. `state.json` is the one both write, so omitting the key there makes the file flip between two
16
+ byte forms: the skill writes it without, the next `yad gate sync` adds it back. Write it and there is
17
+ nothing to flip.
18
+
19
+ ```json
20
+ {
21
+ "schemaVersion": 1,
22
+ "epicId": "EP-checkout"
23
+ }
24
+ ```
25
+
26
+ Three rules go with it, and they are permanent (`docs/roadmap-idea-1.md`, Part 2):
27
+
28
+ 1. **A file with no version counts as version 1.** Nothing has to be rewritten to be readable. Files
29
+ written before the stamp existed are read as shape 1, and get the key the next time the engine
30
+ writes them.
31
+ 2. **The four list files never carry it.** `approvals.json`, `comments.json`, `hub-prs.json` and
32
+ `reconcile-debt.json` are JSON arrays at the top level, and an array cannot hold a key. Rule 1
33
+ covers them: no version means version 1.
34
+ 3. **`schemaVersion` is not the CLI version.** `.sdlc/cli-version.json` records which release of the
35
+ `yad` CLI set the project up, and changes on every release. `schemaVersion` describes the file's
36
+ shape and changes only when that shape really changes — which is rare, and always paired with a
37
+ `yad migrate` step that moves existing projects onto it.
38
+
6
39
  ## `state.json`
7
40
  The per-epic state machine.
8
41
 
@@ -93,6 +93,7 @@ into normal authoring with zero re-seeding.
93
93
 
94
94
  ```json
95
95
  {
96
+ "schemaVersion": 1,
96
97
  "epicId": "EP-<slug>",
97
98
  "createdAt": "<YYYY-MM-DD>",
98
99
  "kind": "stub",