yadflow 3.17.2 → 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 +12 -0
- package/README.md +3 -2
- package/bin/yad.mjs +13 -0
- package/cli/doctor.mjs +120 -9
- package/cli/gate.mjs +2 -2
- package/cli/ledger.mjs +10 -1
- package/cli/lib.mjs +44 -3
- package/cli/manifest.mjs +16 -0
- package/cli/migrate.mjs +317 -0
- package/cli/update-notice.mjs +32 -2
- package/package.json +7 -3
- package/skills/yad-analysis/SKILL.md +1 -0
- package/skills/yad-change/references/triage.md +1 -0
- package/skills/yad-epic/SKILL.md +1 -0
- package/skills/yad-epic/references/state-schema.md +33 -0
- package/skills/yad-stub/SKILL.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
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
|
+
|
|
11
|
+
## [3.17.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.17.2...v3.17.3) (2026-09-03)
|
|
12
|
+
|
|
1
13
|
## [3.17.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.17.1...v3.17.2) (2026-09-03)
|
|
2
14
|
|
|
3
15
|
## [3.17.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.17.0...v3.17.1) (2026-09-02)
|
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
|
|
185
|
-
`
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
37
|
-
|
|
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.
|
|
539
|
-
//
|
|
540
|
-
//
|
|
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
|
|
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
|
-
|
|
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',
|
package/cli/migrate.mjs
ADDED
|
@@ -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
|
+
}
|
package/cli/update-notice.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
},
|
|
@@ -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": [] },
|
package/skills/yad-epic/SKILL.md
CHANGED
|
@@ -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
|
|