yadflow 3.18.1 → 4.0.0-next.1

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.
Files changed (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. package/skills/sdlc/install.sh +0 -68
package/cli/ledger.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Shard-then-fold storage for the two append-only back-half ledgers (trust-log, build-log).
1
+ // Shard-then-fold storage for the two append-only Build ledgers (trust-log, build-log).
2
2
  //
3
3
  // The problem: both ledgers were ONE file per epic, so two people driving different stories of the
4
4
  // SAME epic both appended to the same file → git merge conflict on push. The fix ("loose objects +
@@ -57,9 +57,30 @@ export function withLedgerLock(lockPath, fn, { retries = LOCK_RETRIES, waitMs =
57
57
  } catch (e) {
58
58
  if (e.code !== 'EEXIST') throw e;
59
59
  // A holder that died leaves its lock behind forever; reclaim one that is provably too old.
60
- let age;
61
- try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch { continue; } // vanished → retry
62
- if (age > LOCK_STALE_MS) { try { fs.rmdirSync(lockPath); } catch { /* someone else won */ } continue; }
60
+ //
61
+ // NEITHER BRANCH BELOW MAY `continue`. Both used to, which skipped the retry cap AND the sleep
62
+ // underneath them — an unbounded, unpaused, fully synchronous loop. The reclaim path is the one
63
+ // that bites: `fs.rmdirSync` throws ENOTEMPTY when anything at all is inside the lock directory
64
+ // (a stray file, an editor's dotfile), so a lock that is older than LOCK_STALE_MS and not empty
65
+ // can NEVER be removed, and the old code retried it forever at 100% CPU. Because the loop is
66
+ // synchronous, node cannot reach its event loop, so the process also ignores SIGTERM: it has to
67
+ // be killed with -9. Observed here for 2h27m before it was noticed (2026-09-21).
68
+ //
69
+ // Now every path falls through to the cap and the sleep. A stale lock nobody can remove is
70
+ // REPORTED as YAD-STATE-006 after the normal retry window — and that error's hint already says
71
+ // to delete the lock directory it names, which is exactly the fix for a non-empty one.
72
+ let age = null;
73
+ try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch { /* it vanished — just retry */ }
74
+ if (age !== null && age > LOCK_STALE_MS) {
75
+ let reclaimed = false;
76
+ try { fs.rmdirSync(lockPath); reclaimed = true; } catch { /* someone else won, or it is not empty */ }
77
+ // TAKE IT IMMEDIATELY. Without this, a reclaim on the LAST pass deleted the stale lock and then
78
+ // reported "another process is writing" about a lock that no longer existed — the user re-ran
79
+ // and it worked, with no idea why. Losing the race here is normal: fall through and retry.
80
+ if (reclaimed) {
81
+ try { fs.mkdirSync(lockPath); break; } catch { /* another writer took it first */ }
82
+ }
83
+ }
63
84
  if (i >= retries) {
64
85
  throw err('YAD-STATE-006', `another process is writing ${path.basename(lockPath, '.lock')} in ${path.basename(path.dirname(path.dirname(lockPath)))}`,
65
86
  'wait for the other yad command to finish and re-run; if nothing else is running, delete the stale .lock directory the message names');
@@ -93,7 +114,7 @@ export const buildShardName = (e) => `${safe(e.story)}-${safe(e.task)}-${safe(e.
93
114
  // `schemaVersion` is dropped here on purpose. It describes the shape of a FILE, and a shard file has
94
115
  // one — but `fold()` copies these objects into the folded log's `ships`/`runs` array, where they stop
95
116
  // 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
117
+ // per-record version into an append-only ledger for good: once the shape moves — as it has, to 2 — a
97
118
  // build-log.json stamped 2 would hold entries stamped 1 from shards folded today. The folded file
98
119
  // states its own shape; its rows do not have one.
99
120
  function readShardDir(dir) {
@@ -108,6 +129,35 @@ function readShardDir(dir) {
108
129
  return out;
109
130
  }
110
131
 
132
+ // The shards in `dir` that do NOT parse as a JSON object, by file name. `readShardDir` SKIPS such a
133
+ // shard on purpose — these ledgers are advisory evidence and one bad file must not abort a report. A
134
+ // caller that COUNTS PEOPLE cannot accept that: a skipped ship shard is an engineer-review approval
135
+ // that vanishes, and a person who vanishes makes the active count SMALLER, which lowers E72's cap and
136
+ // weakens every gate (E71). Such a caller asks this first and reports "unknown" rather than a number.
137
+ // Deliberately cheap: it parses, it does not validate a ship's fields.
138
+ // Returns { bad: [names], unreadable: null | reason }. It NEVER throws: `fs.existsSync` is true for a
139
+ // FILE as well as a directory, so a stray file named `build-log` under an epic's `.sdlc/` made
140
+ // `readdirSync` throw ENOTDIR — and since the caller counts people for `yad gate sync`, `gate status`
141
+ // and `gate open`, that exception escaped and turned three working commands into a crash. A directory
142
+ // the user cannot list (EACCES) did the same. Both are now "we could not read this", which is exactly
143
+ // what the counter needs to hear.
144
+ export function corruptShards(dir) {
145
+ let names;
146
+ try {
147
+ if (!fs.existsSync(dir)) return { bad: [], unreadable: null };
148
+ if (!fs.statSync(dir).isDirectory()) return { bad: [], unreadable: `${path.basename(dir)} is not a directory` };
149
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort();
150
+ } catch (e) {
151
+ return { bad: [], unreadable: `${path.basename(dir)} could not be listed: ${e.code || e.message}` };
152
+ }
153
+ const bad = [];
154
+ for (const name of names) {
155
+ const obj = readJSON(path.join(dir, name), null);
156
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) bad.push(name);
157
+ }
158
+ return { bad, unreadable: null };
159
+ }
160
+
111
161
  // The half-applied-tidy guard key: a shard is a genuine duplicate of a folded entry ONLY when its FULL
112
162
  // identity matches — NOT `uid` alone. `uid` is minted by an LLM skill, so two runs of DIFFERENT
113
163
  // (story,repo,step) could share a short token; keying on uid alone would drop the second as a false
@@ -117,7 +167,7 @@ const trustKey = (e) => `${e.story}|${e.repo}|${e.step}|${e.uid}`;
117
167
 
118
168
  // trust-log = the evidence base. EVERY entry is a distinct step run (re-runs of a step share
119
169
  // story/repo/step), so we CONCATENATE folded runs + shards and never dedup by (story,repo,step) —
120
- // that would drop re-run history the trust threshold counts. The only guard is a shard whose full
170
+ // that would drop re-run history the run record `yad dial` shows counts. The only guard is a shard whose full
121
171
  // identity (`trustKey`) already appears folded (a half-applied tidy). A corrupt folded file must throw
122
172
  // (readJSONStrict), never silently read as empty — under-reporting the evidence base is a safety bug.
123
173
  export function readTrustRuns(epicDir) {
@@ -147,7 +197,7 @@ export function readShips(epicDir) {
147
197
  return [...byKey.values()];
148
198
  }
149
199
 
150
- // Record a RETROACTIVE ship for a pre-tracking story — one merged & shipped before the back-half
200
+ // Record a RETROACTIVE ship for a pre-tracking story — one merged & shipped before the Build
151
201
  // ledger existed, so it has no build-log ship and `yad checkpoint` can't carry its `status:` flip
152
202
  // (issue #142). Writes ONE minimal ship shard, marked `retroactive: true`, so `readShips` now proves
153
203
  // the story shipped and checkpoint carries the human's already-made flip. It is NOT a fabricated real
package/cli/lib.mjs CHANGED
@@ -1,7 +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
+ import { MIRRORED_FILES, SCHEMA_VERSION, VERSION } from './manifest.mjs';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import * as readline from 'node:readline/promises';
7
7
  import { stdin as input, stdout as output } from 'node:process';
@@ -13,9 +13,76 @@ import path from 'node:path';
13
13
  // resolve from HERE, never from the user's cwd.
14
14
  export const PKG_ROOT = fileURLToPath(new URL('../', import.meta.url));
15
15
 
16
+ // ---- --json (E1) ---------------------------------------------------------
17
+ // Every command answers `--json` with ONE object on stdout, built here and nowhere else:
18
+ // { jsonVersion, version, command, ok, ...the command's own keys, warnings }
19
+ // and a refusal adds `error`, `code` (a `YAD-` code, or null) and `hint` (or null). `jsonVersion` is
20
+ // the contract's own number: adding a key keeps it, renaming or removing one — or changing what a key
21
+ // means — bumps it. `version` is the package that answered.
22
+ //
23
+ // While a `--json` run is on, `log` (and so every helper built on it) writes to STDERR, so stdout
24
+ // holds the one object and nothing else — a progress line printed before the answer would otherwise
25
+ // make the whole output unparseable. Each `warn` is also collected into `warnings`, as plain text.
26
+ // A command never calls JSON.stringify toward stdout itself; a test greps for it.
27
+ export const JSON_VERSION = 1;
28
+ export const ENVELOPE_KEYS = ['jsonVersion', 'version', 'command'];
29
+ let jsonRun = null;
30
+ export function beginJSON(command) { jsonRun = { command, emitted: false, warnings: [], failures: [] }; }
31
+ export const inJSON = () => jsonRun !== null;
32
+ // A warning said some other way than `warn` (straight to stderr, before a command's own output) still
33
+ // belongs in `warnings`: a --json reader must never read `warnings: []` while stderr warned (E1 review).
34
+ export const collectWarning = (s) => { if (jsonRun) jsonRun.warnings.push(stripAnsi(s)); };
35
+ export const jsonEmitted = () => jsonRun?.emitted === true;
36
+ // The refusal a command SAID in prose (`fail`, then the `hand` under it), for the run that ends with a
37
+ // non-zero exit and no answer: most refusals are written that way, and turning each by hand into a
38
+ // JSON one is how one arm gets fixed and its twin missed. `bin/yad.mjs` answers with the first.
39
+ // Every failure after the first goes into `warnings`, so a sweep that skipped several epics says all of
40
+ // them (E1 review 2) — `error` holds one line, and none is lost.
41
+ export const jsonFailure = () => {
42
+ const [f, ...more] = jsonRun?.failures ?? [];
43
+ if (!f) return null;
44
+ // With its hint: for a repo whose push failed, the hint is the only recovery step.
45
+ for (const m of more) jsonRun.warnings.push(m.hint ? `${m.error} — ${m.hint}` : m.error);
46
+ const code = /\b(YAD-[A-Z]+-\d{3})\b/.exec(f.error)?.[1] ?? null;
47
+ return { error: f.error, hint: f.hint, code };
48
+ };
49
+ // For a test that drives several commands in one process.
50
+ export function endJSON() { jsonRun = null; }
51
+ // eslint-disable-next-line no-control-regex -- removing the colour codes is the point
52
+ export const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
53
+
54
+ // Written with console.log, which is stdout: during a --json run `log` has moved to stderr, so the
55
+ // answer is the only thing left there. Called outside a run (a test calling a command with
56
+ // `json: true`), it still answers, with `command: null` and no collected warnings.
57
+ export function emitJSON(payload = {}) {
58
+ const command = jsonRun?.command ?? null;
59
+ // A second answer would make stdout two documents; a bug, and said as one.
60
+ if (jsonRun?.emitted) throw new Error(`yad ${command}: a second JSON answer`);
61
+ const clash = ENVELOPE_KEYS.filter((k) => Object.hasOwn(payload, k));
62
+ if (clash.length) throw new Error(`yad ${command}: the answer sets the envelope key(s) ${clash.join(', ')}`);
63
+ if (jsonRun) jsonRun.emitted = true;
64
+ const { ok = true, warnings, ...rest } = payload;
65
+ // A refusal always carries all three keys, so a reader never tests whether one exists.
66
+ const refusal = ok === false && typeof rest.error === 'string'
67
+ ? { error: stripAnsi(rest.error), code: rest.code ?? null, hint: rest.hint == null ? null : stripAnsi(rest.hint) } : {};
68
+ const all = [...(jsonRun?.warnings ?? []), ...(Array.isArray(warnings) ? warnings.map(stripAnsi) : [])];
69
+ console.log(JSON.stringify({ jsonVersion: JSON_VERSION, version: VERSION, command, ok, ...rest, ...refusal, warnings: all }, null, 2));
70
+ }
71
+
72
+ // A refusal: one object under --json, the red line and its hint otherwise. Exit code 1 either way.
73
+ // `json` answers in JSON outside a CLI run too (a command called with `json: true`); `extra` carries
74
+ // keys a command adds to its refusal (never an envelope key).
75
+ export function refuse(message, hint = null, { code = null, json = false, ...extra } = {}) {
76
+ process.exitCode = 1;
77
+ if (jsonRun || json) return emitJSON({ ok: false, error: stripAnsi(message), code, hint: hint === null || hint === undefined ? null : stripAnsi(hint), ...extra });
78
+ log(c.red(message));
79
+ if (hint) log(c.yellow(` → ${hint}`));
80
+ }
81
+
16
82
  // ---- output -------------------------------------------------------------
17
- const useColor = output.isTTY && !process.env.NO_COLOR;
18
- const paint = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
83
+ // Colour follows the stream the prose goes to: stderr during a --json run, stdout otherwise.
84
+ const useColor = () => (jsonRun ? process.stderr.isTTY : output.isTTY) && !process.env.NO_COLOR;
85
+ const paint = (code, s) => (useColor() ? `\x1b[${code}m${s}\x1b[0m` : s);
19
86
  export const c = {
20
87
  bold: (s) => paint('1', s),
21
88
  dim: (s) => paint('2', s),
@@ -24,13 +91,28 @@ export const c = {
24
91
  red: (s) => paint('31', s),
25
92
  cyan: (s) => paint('36', s),
26
93
  };
27
- export const log = (s = '') => console.log(s);
94
+ export const log = (s = '') => {
95
+ if (!jsonRun) return console.log(s);
96
+ jsonRun.afterFail = false;
97
+ console.error(s);
98
+ };
28
99
  export const step = (n, total, title) => log(`\n${c.cyan(`[${n}/${total}]`)} ${c.bold(title)}`);
29
100
  export const ok = (s) => log(` ${c.green('✓')} ${s}`);
30
101
  export const info = (s) => log(` ${c.dim('•')} ${s}`);
31
- export const warn = (s) => log(` ${c.yellow('!')} ${s}`);
32
- export const fail = (s) => log(` ${c.red('✗')} ${s}`);
33
- export const hand = (s) => log(` ${c.yellow('→')} ${s}`);
102
+ export const warn = (s) => {
103
+ if (jsonRun) jsonRun.warnings.push(stripAnsi(s));
104
+ log(` ${c.yellow('!')} ${s}`);
105
+ };
106
+ export const fail = (s) => {
107
+ log(` ${c.red('✗')} ${s}`);
108
+ if (jsonRun) { jsonRun.failures.push({ error: stripAnsi(s), hint: null }); jsonRun.afterFail = true; }
109
+ };
110
+ // A hint belongs to a failure only when it is the very next line under it — never a later `hand`.
111
+ export const hand = (s) => {
112
+ const attach = jsonRun?.afterFail ? jsonRun.failures.at(-1) : null;
113
+ log(` ${c.yellow('→')} ${s}`);
114
+ if (attach) attach.hint = stripAnsi(s);
115
+ };
34
116
  // Like `info`, but to STDERR — for diagnostics emitted by commands whose STDOUT must stay pure (e.g. a
35
117
  // JSON bundle a tool parses). Keeps the human hint visible without corrupting machine-readable output.
36
118
  export const note = (s) => console.error(` ${c.dim('•')} ${s}`);
@@ -45,14 +127,20 @@ export function closePrompts() {
45
127
  rl?.close();
46
128
  rl = undefined;
47
129
  }
130
+ // A --json run never prompts: its stdin is a program's, and a question on stderr would hang it.
131
+ const noPromptInJSON = (question) => {
132
+ if (jsonRun) throw err('YAD-CLI-001', `a --json run cannot ask: ${stripAnsi(question)}`, 'pass the answer as a flag where one exists (`yad --help` lists them), set SDLC_NONINTERACTIVE=1 to take every default, or run without --json');
133
+ };
48
134
  export async function ask(question, def = '') {
49
135
  if (process.env.SDLC_NONINTERACTIVE) return def;
136
+ noPromptInJSON(question);
50
137
  const suffix = def ? c.dim(` (${def})`) : '';
51
138
  const a = (await getRl().question(` ${question}${suffix}: `)).trim();
52
139
  return a || def;
53
140
  }
54
141
  export async function askYesNo(question, def = true) {
55
142
  if (process.env.SDLC_NONINTERACTIVE) return def;
143
+ noPromptInJSON(question);
56
144
  const hint = def ? 'Y/n' : 'y/N';
57
145
  const a = (await getRl().question(` ${question} ${c.dim(`(${hint})`)} `)).trim().toLowerCase();
58
146
  if (!a) return def;
@@ -124,7 +212,10 @@ export function dirMatches(src, dest) {
124
212
  // file in a project that merely happened to live somewhere under a directory called `.sdlc` — the
125
213
  // user's own .claude/settings.json included, which is exactly what this must never touch. A new kind
126
214
  // nested deeper than that has to be added here on purpose.
127
- const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
215
+ // Exported so the dial stampers (migrate.mjs, epic-state.mjs) and doctor all decide "is this a step
216
+ // object" the same way. Three private copies drifted once already: one guarded arrays, one did not,
217
+ // so an array-valued step came out as `["x"]` from one and `{"0":"x"}` from the other.
218
+ export const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
128
219
  const underSdlcDir = (p) => {
129
220
  const dir = path.dirname(path.resolve(p));
130
221
  return path.basename(dir) === '.sdlc' || path.basename(path.dirname(dir)) === '.sdlc';
@@ -132,16 +223,30 @@ const underSdlcDir = (p) => {
132
223
  const carriesShape = (p, v) => isPlainObject(v) && underSdlcDir(p);
133
224
 
134
225
  // 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);
226
+ // bytes without changing the meaning. An existing version is preserved, never forced back or forward,
227
+ // so a file already on another shape survives being read and written by this release.
228
+ //
229
+ // Reading and writing do NOT default the same way, and the difference is load-bearing:
230
+ //
231
+ // read — a file with no version IS shape 1 (rule 1). Defaulting it to the engine's current shape
232
+ // would be the engine telling itself an old file is already current. Everything downstream
233
+ // believes it: `yad doctor` stops reporting drift, and a plain read-modify-write stamps the
234
+ // engine's shape onto content that was never migrated — after which `yad migrate` skips the
235
+ // file forever, because its own claim says there is nothing to do. Silent, permanent, and
236
+ // invisible until someone asks why a field never got renamed.
237
+ // write — a NEW object, one that was never read from disk and so has no version of its own, is
238
+ // written by this engine and therefore has this engine's shape.
239
+ //
240
+ // This was harmless while SCHEMA_VERSION was 1, because the two defaults were the same number. It
241
+ // stops being harmless the moment the shape moves, which is why it is fixed in the commit that moves it.
242
+ const readShape = (p, v) => (carriesShape(p, v) ? { schemaVersion: v.schemaVersion ?? 1, ...v } : v);
243
+ const writeShape = (p, v) => (carriesShape(p, v) ? { schemaVersion: v.schemaVersion ?? SCHEMA_VERSION, ...v } : v);
139
244
 
140
245
  export function readJSON(p, def = null) {
141
246
  try {
142
247
  // "Read old, write new" (rule 2): an unstamped file reads back as shape 1, so a caller never has
143
248
  // to ask whether the file it just loaded predates the stamp.
144
- return withShape(p, JSON.parse(fs.readFileSync(p, 'utf8')));
249
+ return readShape(p, JSON.parse(fs.readFileSync(p, 'utf8')));
145
250
  } catch {
146
251
  // The caller's own default is returned untouched: it is not a file, and stamping it would invent
147
252
  // a shape for something that was never read from disk.
@@ -154,7 +259,7 @@ export function readJSON(p, def = null) {
154
259
  export function readJSONStrict(p, def = null) {
155
260
  if (!fs.existsSync(p)) return def;
156
261
  try {
157
- return withShape(p, JSON.parse(fs.readFileSync(p, 'utf8')));
262
+ return readShape(p, JSON.parse(fs.readFileSync(p, 'utf8')));
158
263
  } catch (e) {
159
264
  throw err('YAD-STATE-001', `corrupt JSON in ${p}: ${e.message}`, 'fix the file or restore it from git — never delete a ledger blindly');
160
265
  }
@@ -163,7 +268,7 @@ export function readJSONStrict(p, def = null) {
163
268
  // then rename over the target. A killed process can never leave a truncated ledger
164
269
  // file, and a failed rename never leaves a stray .tmp for `git add -A` to pick up.
165
270
  export function writeJSON(p, obj) {
166
- const data = JSON.stringify(withShape(p, obj), null, 2) + '\n';
271
+ const data = JSON.stringify(writeShape(p, obj), null, 2) + '\n';
167
272
  // Byte-identical content is not a write. The ledger writers are unconditional — they re-serialize
168
273
  // whether or not anything changed — so this keeps an unchanged sync from touching the file at all
169
274
  // (no mtime churn, nothing for a watcher or a `git add -A` to notice). A backstop, not the fix: the
@@ -185,7 +290,10 @@ export function writeJSON(p, obj) {
185
290
  // ---- subprocess ---------------------------------------------------------
186
291
  // Returns { ok, stdout, stderr, code }. Never throws on non-zero exit.
187
292
  export function run(cmd, args = [], opts = {}) {
188
- const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
293
+ // A subprocess that shares our stdout (`npm run build`) would print into a --json answer: during a
294
+ // --json run its stdout is our stderr instead (E1).
295
+ const stdio = jsonRun && opts.stdio === 'inherit' ? ['inherit', 2, 'inherit'] : opts.stdio;
296
+ const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts, ...(stdio === undefined ? {} : { stdio }) });
189
297
  return {
190
298
  ok: r.status === 0,
191
299
  code: r.status,
@@ -196,8 +304,8 @@ export function run(cmd, args = [], opts = {}) {
196
304
  }
197
305
  export const has = (cmd) => run(process.platform === 'win32' ? 'where' : 'which', [cmd]).ok;
198
306
 
199
- // Push HEAD to origin/<target>, rebasing onto it and retrying on rejection — both the front-half gate
200
- // sync and the back-half checkpoint push append-only ledgers to the default branch, so a concurrent
307
+ // Push HEAD to origin/<target>, rebasing onto it and retrying on rejection — both the Shape gate
308
+ // sync and the Build checkpoint push append-only ledgers to the default branch, so a concurrent
201
309
  // push is a normal race, not an error. Returns { ok } after up to `attempts` tries; logs each retry.
202
310
  // A failed `pull --rebase` is aborted so we never leave a wedged rebase for the next command.
203
311
  export function pushWithRebase(cwd, target, { attempts = 3 } = {}) {
@@ -211,3 +319,61 @@ export function pushWithRebase(cwd, target, { attempts = 3 } = {}) {
211
319
  }
212
320
  return { ok: false };
213
321
  }
322
+
323
+ // ---- the product config, which lives under two names for one major -----------------------------
324
+ //
325
+ // Read through `productConfigPath` (manifest.mjs), which every reader in cli/ now does. Write
326
+ // through here: BOTH names, every time.
327
+ //
328
+ // The duplicate is not sloppiness. `templates/checks/ledger-guard.sh` sits committed inside the
329
+ // user's own repository and opens `.sdlc/hub.json` by that literal path; it is refreshed by
330
+ // `yad update`, which is a separate act from `yad migrate`. So a migrated-but-not-updated project is
331
+ // a real state, and if only the new name existed its guard would find nothing, read no platform,
332
+ // call the ledger local, and stop rejecting human commits to it. An upgrade that silently disarms a
333
+ // safety gate is worse than a duplicated file.
334
+ //
335
+ // Order matters, and it follows which file is AUTHORITATIVE — the old name, this major
336
+ // (`productConfigPath`, cli/manifest.mjs). The new name is written first, so if the second write
337
+ // fails the readers are all still on the untouched old copy: nothing has half-changed underneath
338
+ // them. Writing the authoritative file first would leave every reader on new settings while the
339
+ // mirror still says something else, which is the harder failure to notice.
340
+ //
341
+ // `yad doctor` reports the two copies disagreeing either way, so a half-written pair is visible
342
+ // rather than silent.
343
+ // Write a file that lives under two names. Returns the paths it actually wrote — empty when there
344
+ // was nothing to do.
345
+ //
346
+ // The "nothing to do" case is load-bearing, not an optimisation. The ledger writers are
347
+ // UNCONDITIONAL: `yad gate` re-serializes and writes every time, and relies on `writeJSON` doing
348
+ // nothing when the bytes match, which is what keeps a read-only pre-merge run from touching the
349
+ // working tree at all. A naive mirror breaks that on day one — the new name does not exist yet, so
350
+ // every read-only run would create it and leave the tree dirty, and `gate ci` would try to push a
351
+ // file it never meant to write. So the comparison happens FIRST, against whichever name is currently
352
+ // readable; only a genuine change writes, and then it writes both.
353
+ export function writeMirrored(canonicalPath, legacyPath, obj) {
354
+ // Compare against the AUTHORITATIVE copy — the same one readers use (`preferring`,
355
+ // cli/manifest.mjs), which is the legacy name while it exists. Comparing against the other file
356
+ // would let the two rules disagree about whether anything changed.
357
+ const readable = fs.existsSync(legacyPath) ? legacyPath : canonicalPath;
358
+ const next = `${JSON.stringify(writeShape(canonicalPath, obj), null, 2)}\n`;
359
+ // "Unchanged" means BOTH names are already right. A half-made pair — one side missing — is work
360
+ // to do even when the readable copy matches, or the pair can never be repaired: the comparison
361
+ // says nothing changed, nothing is written, and `yad doctor` reports the missing file for ever.
362
+ const bothPresent = fs.existsSync(canonicalPath) && fs.existsSync(legacyPath);
363
+ try {
364
+ if (bothPresent && fs.readFileSync(readable, 'utf8') === next) return [];
365
+ } catch { /* unreadable — treat as a first write and fall through */ }
366
+ writeJSON(canonicalPath, obj);
367
+ writeJSON(legacyPath, obj);
368
+ return [canonicalPath, legacyPath];
369
+ }
370
+
371
+ export function writeProductConfig(root, obj) {
372
+ const written = [];
373
+ for (const { canonical, legacy } of MIRRORED_FILES) {
374
+ const wrote = writeMirrored(path.join(root, canonical), path.join(root, legacy), obj);
375
+ if (wrote.length) written.push(canonical, legacy);
376
+ }
377
+ return written;
378
+ }
379
+