yadflow 4.0.0-next.3 → 4.0.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +699 -0
  2. package/README.md +85 -22
  3. package/bin/commands.mjs +3 -1
  4. package/bin/yad.mjs +183 -29
  5. package/cli/capture.mjs +4 -2
  6. package/cli/checkpoint.mjs +10 -10
  7. package/cli/claims.mjs +4 -4
  8. package/cli/commit.mjs +48 -12
  9. package/cli/docs.mjs +15 -13
  10. package/cli/doctor.mjs +557 -141
  11. package/cli/epic-state.mjs +41 -18
  12. package/cli/errors.mjs +3 -2
  13. package/cli/find-product.mjs +197 -0
  14. package/cli/fold.mjs +4 -4
  15. package/cli/gate-local.mjs +13 -13
  16. package/cli/gate.mjs +111 -103
  17. package/cli/history.mjs +13 -13
  18. package/cli/hook.mjs +201 -30
  19. package/cli/index-command.mjs +6 -6
  20. package/cli/lib.mjs +91 -29
  21. package/cli/manifest.mjs +171 -104
  22. package/cli/migrate.mjs +208 -17
  23. package/cli/mode.mjs +22 -22
  24. package/cli/next.mjs +6 -6
  25. package/cli/openpr.mjs +32 -24
  26. package/cli/people.mjs +22 -10
  27. package/cli/plan.mjs +718 -45
  28. package/cli/platform.mjs +13 -7
  29. package/cli/product-index.mjs +4 -4
  30. package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
  31. package/cli/protection.mjs +19 -9
  32. package/cli/reconcile.mjs +98 -23
  33. package/cli/repo-publish.mjs +22 -16
  34. package/cli/repo.mjs +128 -33
  35. package/cli/report.mjs +4 -2
  36. package/cli/review.mjs +26 -19
  37. package/cli/setup.mjs +105 -36
  38. package/cli/ship.mjs +1 -1
  39. package/cli/skip.mjs +4 -4
  40. package/cli/tidy.mjs +7 -7
  41. package/cli/update-commit.mjs +22 -19
  42. package/cli/workspace.mjs +495 -0
  43. package/package.json +7 -4
  44. package/skills/sdlc/config.yaml +14 -13
  45. package/skills/sdlc/module-help.csv +8 -8
  46. package/skills/yad-analysis/SKILL.md +6 -4
  47. package/skills/yad-architecture/SKILL.md +4 -3
  48. package/skills/yad-backfill/SKILL.md +11 -5
  49. package/skills/yad-backfill/references/backfill.md +33 -3
  50. package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
  51. package/skills/yad-change/SKILL.md +1 -1
  52. package/skills/yad-checks/SKILL.md +48 -27
  53. package/skills/yad-checks/references/check-gates.md +213 -53
  54. package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
  55. package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
  56. package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
  57. package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
  58. package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
  59. package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
  60. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
  61. package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
  62. package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
  63. package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
  64. package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
  65. package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
  66. package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
  67. package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
  68. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
  69. package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
  70. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
  71. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
  72. package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
  73. package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
  74. package/skills/yad-commit/SKILL.md +18 -7
  75. package/skills/yad-connect-design/SKILL.md +1 -1
  76. package/skills/yad-connect-design/references/design-registry.md +1 -1
  77. package/skills/yad-connect-docs/SKILL.md +16 -11
  78. package/skills/yad-connect-docs/references/docs-registry.md +8 -8
  79. package/skills/yad-connect-learning/SKILL.md +1 -1
  80. package/skills/yad-connect-learning/references/learning-registry.md +1 -1
  81. package/skills/yad-connect-repos/SKILL.md +31 -8
  82. package/skills/yad-connect-repos/references/code-context.md +1 -1
  83. package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
  84. package/skills/yad-connect-repos/references/repos-registry.md +1 -1
  85. package/skills/yad-connect-testing/SKILL.md +1 -1
  86. package/skills/yad-connect-testing/references/testing-registry.md +1 -1
  87. package/skills/yad-discovery/SKILL.md +2 -2
  88. package/skills/yad-discovery/references/discovery-schema.md +1 -1
  89. package/skills/yad-docs/SKILL.md +1 -1
  90. package/skills/yad-docs/references/data-mapping.md +1 -1
  91. package/skills/yad-docs/templates/app/package-lock.json +8 -8
  92. package/skills/yad-docs/templates/app/package.json +1 -1
  93. package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
  94. package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
  95. package/skills/yad-engineer-review/SKILL.md +2 -2
  96. package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
  97. package/skills/yad-epic/SKILL.md +6 -4
  98. package/skills/yad-epic/references/state-schema.md +13 -12
  99. package/skills/yad-open-pr/SKILL.md +1 -1
  100. package/skills/yad-pair-review/SKILL.md +5 -5
  101. package/skills/yad-pair-review/references/session-state.md +2 -2
  102. package/skills/yad-pr-template/SKILL.md +14 -12
  103. package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
  104. package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
  105. package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
  106. package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
  107. package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
  108. package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
  109. package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
  110. package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
  111. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
  112. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
  113. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
  114. package/skills/yad-report/SKILL.md +1 -1
  115. package/skills/yad-review-companion/SKILL.md +3 -3
  116. package/skills/yad-review-gate/SKILL.md +22 -23
  117. package/skills/yad-review-gate/references/gating.md +13 -13
  118. package/skills/yad-run/SKILL.md +2 -2
  119. package/skills/yad-run/references/run-loop.md +2 -2
  120. package/skills/yad-spec/SKILL.md +2 -1
  121. package/skills/yad-spec/references/spec-handoff.md +19 -10
  122. package/skills/yad-stories/SKILL.md +2 -2
  123. package/skills/yad-sync-repos/SKILL.md +19 -3
  124. package/skills/yad-test-cases/SKILL.md +2 -2
  125. package/skills/yad-ui/SKILL.md +2 -2
  126. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
  127. package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
  128. package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
package/cli/migrate.mjs CHANGED
@@ -17,16 +17,17 @@
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
 
20
- import { c, exists, fail, hand, info, isPlainObject, log, ok, readJSON, warn, writeJSON, writeProductConfig, emitJSON, collectWarning } from './lib.mjs';
20
+ import { ask, c, exists, fail, hand, info, isPlainObject, log, ok, readJSON, warn, writeFileAtomic, writeJSON, writeProductConfig, emitJSON, collectWarning } from './lib.mjs';
21
+ import { err } from './errors.mjs';
21
22
  import {
22
23
  ADVANCE_FROM_AUTOMATION, BACKUP_SUFFIX, DRIVER_FROM_ASSISTANCE, epicFiles, isVerifiedLedger,
23
- MANAGED_LEDGER, MIRRORED_FILES, PROJECT_FILES, preferring, productConfigPath, SCHEMA_VERSION,
24
+ MANAGED_LEDGER, MIRRORED_FILES, PRODUCT_RENAME_GUIDE_URL, PROJECT_FILES, preferring, productConfigPath, SCHEMA_VERSION,
24
25
  VERSION,
25
26
  } from './manifest.mjs';
26
27
  import { backupPathFor } from './plan.mjs';
27
28
  import { refreshIndexAfterWrite } from './product-index.mjs';
28
29
  import {
29
- artifactHash, canonicalApprovals, canonicalComments, canonicalHubPrs, DISCOVERY_EPIC, epicIds, epicRel,
30
+ artifactHash, canonicalApprovals, canonicalComments, canonicalProductPrs, DISCOVERY_EPIC, epicIds, epicRel, productDriftPairs,
30
31
  epicRoot, FOUNDATION_DIR, FOUNDATION_EPIC, isGateStep, isPassed, stampProfile, stampStepStates, stampWorkItemType, writeState,
31
32
  } from './epic-state.mjs';
32
33
 
@@ -77,6 +78,7 @@ export const MIGRATIONS = [
77
78
  ? { ...obj, ledger: isVerifiedLedger(obj) ? 'verified' : 'local' }
78
79
  : obj),
79
80
  },
81
+ // hub-keep:start shape-3-step
80
82
  {
81
83
  from: 2,
82
84
  to: 3,
@@ -117,6 +119,7 @@ export const MIGRATIONS = [
117
119
  };
118
120
  },
119
121
  },
122
+ // hub-keep:end
120
123
  {
121
124
  from: 3,
122
125
  to: 4,
@@ -494,8 +497,8 @@ export function applyProductMove(root, move, { migrations = MIGRATIONS, copy = f
494
497
  };
495
498
  relabel(f.approvals, canonicalApprovals);
496
499
  relabel(f.comments, canonicalComments);
497
- relabel(f.productPrs, canonicalHubPrs);
498
- relabel(f.hubPrs, canonicalHubPrs);
500
+ relabel(f.productPrs, canonicalProductPrs);
501
+ relabel(f.productPrsLegacy, canonicalProductPrs);
499
502
  for (const rel of move.reshape || []) {
500
503
  const file = path.join(root, rel);
501
504
  const { obj, version } = applyMigrations(JSON.parse(fs.readFileSync(file, 'utf8')), migrations, { base: path.basename(file), rel: path.join(...rel.split('/')), root });
@@ -689,7 +692,7 @@ export function projectJsonFiles(root) {
689
692
  for (const epic of epicIds(root)) {
690
693
  const epicDir = epicRoot(root, epic);
691
694
  const f = epicFiles(epicDir);
692
- files.push(f.state, f.approvals, f.comments, preferring(f.productPrs, f.hubPrs), f.contractLock,
695
+ files.push(f.state, f.approvals, f.comments, preferring(f.productPrs, f.productPrsLegacy), f.contractLock,
693
696
  f.buildLog, f.trustLog, f.change, f.reconcileDebt);
694
697
  files.push(...shardFiles(f.buildLogDir), ...shardFiles(f.trustLogDir), ...shardFiles(f.buildStateDir));
695
698
  // The docs-build cache (cli/docs.mjs) lives in the same directory and is written by the engine,
@@ -717,8 +720,8 @@ export function projectJsonFiles(root) {
717
720
  // ANY reason (a hand re-indent, say), and `ci-owned`/`ahead`/`list` short-circuit before the byte
718
721
  // comparison happens at all.
719
722
  export function planMigration(root, { migrations = MIGRATIONS } = {}) {
720
- const hub = readJSON(productConfigPath(root), null);
721
- const verified = isVerifiedLedger(hub);
723
+ const productConfig = readJSON(productConfigPath(root), null);
724
+ const verified = isVerifiedLedger(productConfig);
722
725
  // On a verified Product the ledger guard refuses a human commit to these, so rewriting them locally
723
726
  // would produce a change that cannot be committed. Of the four the guard names, only state.json is
724
727
  // an object; the rest are arrays and would be skipped anyway.
@@ -733,8 +736,10 @@ export function planMigration(root, { migrations = MIGRATIONS } = {}) {
733
736
 
734
737
  const rows = [];
735
738
  for (const file of projectJsonFiles(root)) {
736
- const rel = path.relative(root, file);
737
- if (moving && rel.split(path.sep).join('/').startsWith(moving)) continue;
739
+ // Forward slashes on every OS: the mirrored-pair, state-file and drift-repair checks all compare
740
+ // project-relative paths spelled that way, and on Windows `path.relative` says `.sdlc\product.json`.
741
+ const rel = path.relative(root, file).split(path.sep).join('/');
742
+ if (moving && rel.startsWith(moving)) continue;
738
743
  const raw = readRaw(file);
739
744
  if (!raw.ok) {
740
745
  rows.push({ file: rel, from: null, to: null, action: 'unreadable', changes: false, stamped: false, detail: raw.error });
@@ -857,6 +862,147 @@ function printProductMove(p, { apply }) {
857
862
  log(` ${c.dim(`a full copy stays at ${p.backup}/`)}`);
858
863
  }
859
864
 
865
+ // ---- two names that say different things (E122) -----------------------------------------------
866
+ //
867
+ // `.sdlc/product.json` + `.sdlc/hub.json`, and each epic's `product-prs.json` + `hub-prs.json`, are one
868
+ // file under two names until v5. When they disagree every other command refuses (YAD-STATE-008), and
869
+ // this is the command that ends it. It runs whatever shape the project is on: the drift is not a shape
870
+ // step, it is a repair — like the product-level move above, work outside the per-file rows.
871
+ //
872
+ // Only a person knows which copy they meant, so nothing is chosen for them: the preview shows the
873
+ // difference, and `--apply` asks — or, with no terminal or under `--json` (where a command never asks,
874
+ // E1), takes `--keep product` / `--keep hub` and refuses without it. The copy kept is written over the
875
+ // other one byte for byte, after the other one is backed up beside itself as `<file>.yad-orig`.
876
+ export const KEEP_CHOICES = ['product', 'hub'];
877
+
878
+ // What differs, in words a person can check against the files: the top-level keys of an object, the
879
+ // positions of an array. Never the values — they can be long, and the files are right there.
880
+ export function describeDrift(aPath, bPath) {
881
+ let a; let b;
882
+ try { a = JSON.parse(fs.readFileSync(aPath, 'utf8')); } catch { return [`${path.basename(aPath)} does not parse`]; }
883
+ try { b = JSON.parse(fs.readFileSync(bPath, 'utf8')); } catch { return [`${path.basename(bPath)} does not parse`]; }
884
+ const same = (x, y) => JSON.stringify(x) === JSON.stringify(y);
885
+ if (isPlainObject(a) && isPlainObject(b)) {
886
+ const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
887
+ const out = keys.filter((k) => !same(a[k], b[k])).map((k) => (!(k in a) ? `\`${k}\` only in ${path.basename(bPath)}` : !(k in b) ? `\`${k}\` only in ${path.basename(aPath)}` : `\`${k}\` differs`));
888
+ // Same keys and values in another order, or only the spacing differs: still two different files.
889
+ return out.length ? out : ['the same settings, written differently (spacing or key order)'];
890
+ }
891
+ if (Array.isArray(a) && Array.isArray(b)) {
892
+ const n = Math.max(a.length, b.length);
893
+ const at = [];
894
+ for (let i = 0; i < n; i++) if (!same(a[i], b[i])) at.push(i + 1);
895
+ const count = a.length === b.length ? '' : `${path.basename(aPath)} has ${a.length} entries, ${path.basename(bPath)} ${b.length}; `;
896
+ return at.length ? [`${count}entries ${at.join(', ')} differ`] : ['the same entries, written differently (spacing or key order)'];
897
+ }
898
+ return ['the two files hold different kinds of value'];
899
+ }
900
+
901
+ const bytesOrNull = (abs) => { try { return fs.readFileSync(abs); } catch { return null; } };
902
+
903
+ // Every drifted pair under `root`, project-relative, with what differs.
904
+ export function driftedPairs(root) {
905
+ return productDriftPairs(root).map(({ canonical, legacy }) => {
906
+ const pair = { canonical, legacy, differences: describeDrift(path.join(root, canonical), path.join(root, legacy)) };
907
+ // What each name held when the difference was shown — not part of the report (not enumerable), but
908
+ // `checkDrift` refuses a pair that has changed since, because it is not the pair the person chose for.
909
+ Object.defineProperty(pair, 'seen', { value: [canonical, legacy].map((rel) => bytesOrNull(path.join(root, rel))) });
910
+ return pair;
911
+ });
912
+ }
913
+
914
+ // Settle each pair on the copy `keep` names, in two steps: `checkDrift` first, which writes nothing and
915
+ // refuses if any pair cannot be settled, so a refusal leaves every pair — and `.gitignore` — as it was;
916
+ // then `settleDrift`, which writes.
917
+ // - a name that is a symbolic link is refused. The kept copy's bytes are copied into a committed file,
918
+ // and a link would copy whatever file it points at on this machine (E115 refuses links in the same
919
+ // spirit). The file itself is checked, not the folders above it: a linked `.sdlc/` holds both names,
920
+ // so nothing from outside it can reach a committed file through it;
921
+ // - a kept copy that does not parse is refused: settling on it would leave two broken files, and every
922
+ // command refusing them for another reason;
923
+ // - a name that is not a plain readable file (a folder, a file yad cannot read) is refused;
924
+ // - a name that has gone, or changed, since the difference was found (while the question waited) is
925
+ // refused too: the pair is not the one the person chose for.
926
+ function settlePlan(root, pairs, keep) {
927
+ return pairs.map(({ canonical, legacy }) => {
928
+ const [from, to] = keep === 'product' ? [canonical, legacy] : [legacy, canonical];
929
+ return { from, to, src: path.join(root, from), dst: path.join(root, to) };
930
+ });
931
+ }
932
+
933
+ function checkDrift(root, pairs, keep) {
934
+ const plan = settlePlan(root, pairs, keep);
935
+ for (const [i, { from, to, src }] of plan.entries()) {
936
+ for (const rel of [from, to]) {
937
+ let st;
938
+ try { st = fs.lstatSync(path.join(root, rel)); } catch (e) {
939
+ if (e.code !== 'ENOENT') throw e;
940
+ throw err('YAD-STATE-008', `${rel} is gone since the difference was found — nothing was written`, 'run `yad migrate` again to see the pair as it is now');
941
+ }
942
+ if (st.isSymbolicLink()) {
943
+ throw err('YAD-STATE-008', `${rel} is a symbolic link — yad does not settle a pair through one, and nothing was written`, 'replace it with the file itself (or delete it and keep the other name), then run `yad migrate --apply` again');
944
+ }
945
+ if (!st.isFile()) throw err('YAD-STATE-008', `${rel} is not a file — nothing was written`, `make ${rel} a file again (restore it from git), then run \`yad migrate --apply\` again`);
946
+ }
947
+ const seen = pairs[i].seen;
948
+ const now = [pairs[i].canonical, pairs[i].legacy].map((rel) => bytesOrNull(path.join(root, rel)));
949
+ if (now.some((b) => b === null)) throw err('YAD-STATE-008', `${from} or ${to} cannot be read — nothing was written`, 'check the two files can be read, then run `yad migrate --apply` again');
950
+ if (seen && seen.some((b, j) => !b || !b.equals(now[j]))) {
951
+ throw err('YAD-STATE-008', `${pairs[i].canonical} or ${pairs[i].legacy} changed since the difference was shown — nothing was written`, 'run `yad migrate` again to see the pair as it is now');
952
+ }
953
+ try { JSON.parse(fs.readFileSync(src, 'utf8')); } catch {
954
+ throw err('YAD-STATE-001', `${from} does not parse, so it cannot be the copy kept — nothing was written`, `fix it or restore it from git, or keep the other copy (--keep ${keep === 'product' ? 'hub' : 'product'})`);
955
+ }
956
+ }
957
+ }
958
+
959
+ // Returns the paths written (the losing names). Only after `checkDrift` has passed.
960
+ function settleDrift(root, pairs, keep) {
961
+ const written = [];
962
+ for (const { to, src, dst } of settlePlan(root, pairs, keep)) {
963
+ writeFileAtomic(backupPathFor(dst), fs.readFileSync(dst));
964
+ // The kept copy's exact bytes — the gates compare with `cmp -s` — written through a temporary file
965
+ // and a rename, so a crash never leaves a half file.
966
+ writeFileAtomic(dst, fs.readFileSync(src));
967
+ written.push(to);
968
+ }
969
+ return written;
970
+ }
971
+
972
+ // Which copy wins: the flag, else the person at the terminal. `choose` stands in for the prompt in tests.
973
+ async function chooseKeep(pairs, { keep, json, choose }) {
974
+ if (keep) return keep;
975
+ const flagHint = 'pass `--keep product` to keep .sdlc/product.json (and each product-prs.json), or `--keep hub` to keep the old names — `yad migrate` shows the difference first';
976
+ if (json) throw err('YAD-CLI-001', 'a --json run cannot ask which copy to keep', flagHint);
977
+ if (!choose && (!process.stdin.isTTY || process.env.SDLC_NONINTERACTIVE)) throw err('YAD-STATE-008', 'there is no terminal to ask which copy to keep', flagHint);
978
+ const question = `${pairs.length === 1 ? 'Two copies disagree' : `${pairs.length} pairs disagree`} — keep which: product (the new name) or hub (the old name)? Enter to stop`;
979
+ const answer = String(await (choose ? choose(question) : ask(question, ''))).trim().toLowerCase();
980
+ if (!answer) throw err('YAD-STATE-008', 'stopped — nothing was written', flagHint);
981
+ if (!KEEP_CHOICES.includes(answer)) throw err('YAD-STATE-008', `"${answer}" is not product or hub — nothing was written`, flagHint);
982
+ return answer;
983
+ }
984
+
985
+ function printDrift(pairs) {
986
+ log('');
987
+ log(c.bold(' two names that disagree'));
988
+ for (const p of pairs) {
989
+ log(` ${p.canonical} ≠ ${p.legacy}`);
990
+ for (const d of p.differences) log(` ${c.dim(d)}`);
991
+ }
992
+ }
993
+
994
+ // What this command does NOT rename, said once, so nobody takes a finished migrate for a finished
995
+ // rename. Printed only while the old settings name is still on disk.
996
+ function printNotRenamedHere(root) {
997
+ if (!exists(path.join(root, PROJECT_FILES.productConfigLegacy))) return;
998
+ log('');
999
+ log(c.bold(' hub → Product'));
1000
+ log(` ${PROJECT_FILES.productConfig} is the name read; ${PROJECT_FILES.productConfigLegacy} is still written beside it for check gates an older yadflow installed, and v5 deletes it`);
1001
+ log(` ${c.dim('not done here:')}`);
1002
+ log(` ${c.dim('the names installed into your repos (the yad-hub-checks workflow → yad-product-checks, the yad-hub-bridge skill → yad-product-bridge, the GitLab yad-hub-* jobs → yad-product-*) — `yad update` renames those')}`);
1003
+ log(` ${c.dim('your own CI settings and scripts that name .sdlc/hub.json, hub-prs.json or SDLC_HUB_CONFIG — they work until v5; change them to .sdlc/product.json, product-prs.json and SDLC_PRODUCT_CONFIG by hand')}`);
1004
+ }
1005
+
860
1006
  // ---- the command ---------------------------------------------------------------------------
861
1007
  // `.sdlc/cli-version.json` is migrated as an ordinary row, like every other project file — it is in
862
1008
  // PROJECT_FILES, so it is previewed, backed up and stamped along with the rest. Nothing here writes it
@@ -864,7 +1010,10 @@ function printProductMove(p, { apply }) {
864
1010
  // which means no backup, no row in the report, and a preview that under-reports what an apply does. It
865
1011
  // would also be free to overwrite the one file the same run had just declared corrupt or newer than
866
1012
  // this engine. What shape a file is in is recorded in that file, which is the whole point of rule 1.
867
- export async function runMigrate(root, { apply = false, json = false } = {}, { migrations = MIGRATIONS, copy = fs.cpSync } = {}) {
1013
+ export async function runMigrate(root, { apply = false, json = false, keep = null } = {}, { migrations = MIGRATIONS, copy = fs.cpSync, choose = null } = {}) {
1014
+ if (keep != null && !KEEP_CHOICES.includes(keep)) {
1015
+ throw err('YAD-STATE-008', `--keep takes product or hub, not "${keep}"`, '`--keep product` keeps .sdlc/product.json (and each product-prs.json); `--keep hub` keeps the old names');
1016
+ }
868
1017
  if (!exists(path.join(root, PROJECT_FILES.version)) && !exists(productConfigPath(root))) {
869
1018
  const message = 'no yad project here (.sdlc/ not initialised)';
870
1019
  if (json) { emitJSON({ ok: false, error: message }); }
@@ -873,20 +1022,40 @@ export async function runMigrate(root, { apply = false, json = false } = {}, { m
873
1022
  return { ok: false };
874
1023
  }
875
1024
 
1025
+ // E122: settled first, so the plan below reads the copy that was kept.
1026
+ const drift = driftedPairs(root);
1027
+ let kept = null;
1028
+ let ignored = false;
1029
+ const settledFiles = [];
1030
+ if (drift.length && apply) {
1031
+ if (!json) printDrift(drift);
1032
+ kept = await chooseKeep(drift, { keep, json, choose });
1033
+ checkDrift(root, drift, kept);
1034
+ // Before the backup is made, as below — and after every check, so a refusal changes nothing.
1035
+ ignored = ensureBackupsIgnored(root);
1036
+ settledFiles.push(...settleDrift(root, drift, kept));
1037
+ }
1038
+
876
1039
  const plan = planMigration(root, { migrations });
1040
+ // On a verified Product an epic's PR ledger is CI's to write, and `ledger-guard` refuses a person's
1041
+ // commit of it. The repair is still made — the pair has to agree before any command runs — and the
1042
+ // way to commit it is named: a drift there came from a hand edit that got past the guard already.
1043
+ const ciOwnedSettled = plan.verified ? settledFiles.filter((f) => !f.startsWith('.sdlc/')) : [];
1044
+ if (ciOwnedSettled.length) {
1045
+ warn(`${ciOwnedSettled.join(', ')} ${ciOwnedSettled.length === 1 ? 'is' : 'are'} CI-owned on this verified Product: the ledger-guard check refuses a hand commit of ${ciOwnedSettled.length === 1 ? 'it' : 'them'} — commit the repair with \`yad commit --manual --reason "<why>" --type chore -m "<subject>"\`, which records the reason the check quotes`);
1046
+ }
877
1047
  const move = plan.product?.action === 'move' ? plan.product : null;
878
1048
  const pending = plan.rows.filter((r) => r.changes);
879
1049
  const blocked = plan.rows.filter((r) => r.action === 'ahead' || r.action === 'unreadable');
880
1050
  // An unreadable product-level ledger blocks like an unreadable file. The other refusals do not: the
881
1051
  // project stays on the old spelling, which this release reads correctly, so nothing is broken.
882
1052
  const productBlocked = plan.product?.action === 'unreadable';
883
- const written = [];
1053
+ const written = [...settledFiles];
884
1054
 
885
- let ignored = false;
886
1055
  if (apply) {
887
1056
  // Before the first backup exists, not after — otherwise a gate advance racing this run could stage
888
1057
  // one. A no-op when the line is already there.
889
- if (pending.length || move) ignored = ensureBackupsIgnored(root);
1058
+ if (pending.length || move) ignored = ensureBackupsIgnored(root) || ignored;
890
1059
  if (move) {
891
1060
  // Reported through `fail`, never as a stack trace, and before any per-file write: a move that did
892
1061
  // not happen leaves the project exactly as it was, so the rest of this run must not half-proceed.
@@ -906,7 +1075,9 @@ export async function runMigrate(root, { apply = false, json = false } = {}, { m
906
1075
  // Back up first, always. Unlike the wiring copies in plan.mjs — which skip the backup when the
907
1076
  // file's bytes are provably ours — a ledger has no provenance record, so there is nothing to
908
1077
  // prove and the copy is unconditional.
909
- fs.copyFileSync(file, backupPathFor(file));
1078
+ // A file the drift repair above already backed up keeps THAT backup: it is the copy the person did
1079
+ // not keep, and its only record. Its bytes now are the kept copy's, backed up under the other name.
1080
+ if (!settledFiles.includes(row.file)) fs.copyFileSync(file, backupPathFor(file));
910
1081
  // A mirrored row writes its partner too, so the partner needs a backup of its OWN pre-migration
911
1082
  // bytes — taken here, before either write. Backing up only the row's file leaves the other name
912
1083
  // rewritten with no way back, and on the NEXT shape bump its `.yad-orig` would be overwritten
@@ -914,7 +1085,7 @@ export async function runMigrate(root, { apply = false, json = false } = {}, { m
914
1085
  const partnerRel = isMirroredPath(row.file) ? mirrorPartner(row.file) : null;
915
1086
  if (partnerRel) {
916
1087
  const partnerFile = path.join(root, partnerRel);
917
- if (exists(partnerFile)) fs.copyFileSync(partnerFile, backupPathFor(partnerFile));
1088
+ if (exists(partnerFile) && !settledFiles.includes(partnerRel)) fs.copyFileSync(partnerFile, backupPathFor(partnerFile));
918
1089
  }
919
1090
  // The SAME ctx the preview used. Passing it in one place and not the other is how a preview
920
1091
  // promises one thing and an apply writes another — the single worst failure this command can
@@ -947,7 +1118,11 @@ export async function runMigrate(root, { apply = false, json = false } = {}, { m
947
1118
  // The preview names each row's mirror partner too (`creates` / `rewrites`), exactly as the text
948
1119
  // report does: the apply writes the product config under BOTH names, so leaving the partner out
949
1120
  // made this list shorter than the apply's `written` — a preview that under-reports.
1121
+ // A drifted pair names BOTH its names on a preview: which one the apply rewrites depends on the
1122
+ // copy the person keeps, and a preview may over-report, never under-report. Its `rows` are planned
1123
+ // from the copy read now (the new name); an apply that keeps the old copy plans from that one.
950
1124
  changed: apply ? written : [...new Set([
1125
+ ...drift.flatMap((d) => [d.canonical, d.legacy]),
951
1126
  ...(move ? productMoveFiles(move) : []),
952
1127
  ...pending.flatMap((r) => [r.file, ...(r.creates ?? []), ...(r.rewrites ?? [])]),
953
1128
  ])],
@@ -955,15 +1130,28 @@ export async function runMigrate(root, { apply = false, json = false } = {}, { m
955
1130
  rows: plan.rows,
956
1131
  // The product-level move (shape 8), or null when the project has no old-spelling product level.
957
1132
  product: plan.product,
1133
+ // E122: the pairs whose two names disagreed, and which copy was kept (null on a preview).
1134
+ drift,
1135
+ kept,
958
1136
  });
959
1137
  } else {
960
1138
  log(c.bold(`\nyad migrate ${c.dim(`shape ${plan.engine}`)}`));
961
1139
  log(c.dim(`target: ${root}\n`));
962
1140
  printRows(plan.rows);
963
1141
  if (plan.product) printProductMove(plan.product, { apply });
1142
+ if (drift.length) {
1143
+ if (apply) ok(`kept the ${kept === 'product' ? 'new' : 'old'} name's copy of ${drift.length === 1 ? 'the pair' : `${drift.length} pairs`} — the other is backed up beside itself as <file>${BACKUP_SUFFIX}`);
1144
+ else printDrift(drift);
1145
+ }
1146
+ printNotRenamedHere(root);
964
1147
  log('');
1148
+ if (drift.length && !apply) {
1149
+ info('every other yad command refuses until one copy is chosen');
1150
+ hand('run `yad migrate --apply` to choose which copy to keep (or `--apply --keep product` / `--keep hub`)');
1151
+ }
965
1152
  if (!pending.length && !move) {
966
- ok(`nothing to do — this project is already on shape ${plan.engine}`);
1153
+ if (!drift.length) ok(`nothing to do — this project is already on shape ${plan.engine}`);
1154
+ else if (apply) ok(`nothing else to do — this project is already on shape ${plan.engine}`);
967
1155
  } else if (apply) {
968
1156
  if (move) ok(`the product level moved to ${move.to}/ as ${FOUNDATION_EPIC} — the original is kept whole at ${move.backup}/`);
969
1157
  ok(`${written.length} file(s) updated — a copy of each is beside it as <file>${BACKUP_SUFFIX}`);
@@ -983,6 +1171,9 @@ export async function runMigrate(root, { apply = false, json = false } = {}, { m
983
1171
  if (r.action === 'unreadable') hand(`${r.file} does not parse — restore it from git before migrating`);
984
1172
  }
985
1173
  if (blocked.length) warn('some files were left untouched — see above');
1174
+ if (apply && written.length) hand('then run `yad update`, which refreshes the check gates installed in your repos');
1175
+ // E124: the last line, for as long as the old settings name is on disk — the changes no command makes.
1176
+ if (exists(path.join(root, PROJECT_FILES.productConfigLegacy))) info(`the hub → Product changes only you can make (your own CI settings, scripts, badges), and the order to run things in: ${PRODUCT_RENAME_GUIDE_URL}`);
986
1177
  }
987
1178
 
988
1179
  if (blocked.length || productBlocked) process.exitCode = 1;
package/cli/mode.mjs CHANGED
@@ -29,7 +29,7 @@ export const MODES = ['solo', 'team'];
29
29
  const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
30
30
 
31
31
  // The mode the gates act on today. Read from `solo` (and the older `review_gate.solo`), never from `mode`.
32
- export const modeOf = (hub) => (isSolo(hub) ? 'solo' : 'team');
32
+ export const modeOf = (productConfig) => (isSolo(productConfig) ? 'solo' : 'team');
33
33
 
34
34
  // PURE. The fields a write of mode `to` puts on the Product config, given what is there now. Shared by this
35
35
  // command and `yad setup`, so the two writers can never spell the switch differently (staged-rename trap 1).
@@ -37,8 +37,8 @@ export const modeOf = (hub) => (isSolo(hub) ? 'solo' : 'team');
37
37
  // review_gate `review_gate.solo: true` also switches solo on, so switching to team turns it off too;
38
38
  // otherwise the old flag would keep the gates waived under a file that says `team`.
39
39
  // mode_set only when the mode the gates act on actually changes: who, when, why, and from what.
40
- export function modeFields(hub, to, { by = null, date = null, reason = null } = {}) {
41
- const cur = isObj(hub) ? hub : {};
40
+ export function modeFields(productConfig, to, { by = null, date = null, reason = null } = {}) {
41
+ const cur = isObj(productConfig) ? productConfig : {};
42
42
  const from = modeOf(cur);
43
43
  const fields = { solo: to === 'solo', mode: to };
44
44
  if (to === 'team' && isObj(cur.review_gate) && cur.review_gate.solo === true) {
@@ -52,11 +52,11 @@ export function modeFields(hub, to, { by = null, date = null, reason = null } =
52
52
  // changed: false the file already says it both ways — nothing to write.
53
53
  // flipped: true the gates act differently from the next sync; `mode_set` records it.
54
54
  // flipped: false, changed the gates act the same, and the new name is added beside the old one.
55
- export function planMode(hub, { to, reason = null, by = null, date = null } = {}) {
55
+ export function planMode(productConfig, { to, reason = null, by = null, date = null } = {}) {
56
56
  if (!MODES.includes(to)) {
57
57
  return { ok: false, message: `unknown mode: ${to ?? '(none)'}`, hint: 'usage: yad mode [solo --reason "<why>" | team [--reason "<why>"]]' };
58
58
  }
59
- const cur = isObj(hub) ? hub : {};
59
+ const cur = isObj(productConfig) ? productConfig : {};
60
60
  const from = modeOf(cur);
61
61
  const why = typeof reason === 'string' ? reason.trim() : '';
62
62
  if (from !== to && to === 'solo' && !why) {
@@ -69,7 +69,7 @@ export function planMode(hub, { to, reason = null, by = null, date = null } = {}
69
69
  const fields = modeFields(cur, to, { by, date, reason: why || null });
70
70
  const next = { ...cur, ...fields };
71
71
  const changed = Object.keys(fields).some((k) => JSON.stringify(cur[k]) !== JSON.stringify(fields[k]));
72
- return { ok: true, from, to, flipped: from !== to, changed, hub: next };
72
+ return { ok: true, from, to, flipped: from !== to, changed, productConfig: next };
73
73
  }
74
74
 
75
75
  // Every review gate that is open right now, across the feature epics and the Foundation. These are the ones
@@ -113,22 +113,22 @@ export async function runMode(root, { to = null, reason = null, json = false, to
113
113
  const bail = makeBail(json);
114
114
  const file = productConfigPath(root);
115
115
  const rel = path.relative(root, file).split(path.sep).join('/');
116
- let hub;
116
+ let productConfig;
117
117
  try {
118
- hub = readJSONStrict(file, null);
118
+ productConfig = readJSONStrict(file, null);
119
119
  } catch {
120
120
  return bail(`${rel} does not parse — nothing is written over it`, 'fix the JSON or restore it from git, then run this again');
121
121
  }
122
- if (hub !== null && !isObj(hub)) return bail(`${rel} has the wrong shape — nothing is written over it`, 'expected a JSON object; fix it or re-run `yad setup`');
122
+ if (productConfig !== null && !isObj(productConfig)) return bail(`${rel} has the wrong shape — nothing is written over it`, 'expected a JSON object; fix it or re-run `yad setup`');
123
123
 
124
124
  if (to === null) {
125
- const mode = modeOf(hub);
126
- const name = isObj(hub) && hub.mode !== undefined ? hub.mode : null;
127
- const set = isObj(hub) && isObj(hub.mode_set) ? hub.mode_set : null;
128
- const hint = soloTeamHint(root, hub, { solo: mode === 'solo', headCount, today });
125
+ const mode = modeOf(productConfig);
126
+ const name = isObj(productConfig) && productConfig.mode !== undefined ? productConfig.mode : null;
127
+ const set = isObj(productConfig) && isObj(productConfig.mode_set) ? productConfig.mode_set : null;
128
+ const hint = soloTeamHint(root, productConfig, { solo: mode === 'solo', headCount, today });
129
129
  if (json) return emitJSON({ ok: true, mode, name, agrees: name === null || name === mode, set, suggest: hint });
130
130
  ok(`mode: ${mode} — ${MEANING[mode]}`);
131
- if (!hub) info('no Product config yet — team mode is the default; `yad setup` records it');
131
+ if (!productConfig) info('no Product config yet — team mode is the default; `yad setup` records it');
132
132
  if (set) info(setLine(set));
133
133
  if (name !== null && name !== mode) warn(`the file also says mode: ${JSON.stringify(name)}, but the old \`solo\` flag is the one read — run \`yad mode ${mode}\` to make them agree`);
134
134
  printTeamHint(hint, { unknown: true });
@@ -137,26 +137,26 @@ export async function runMode(root, { to = null, reason = null, json = false, to
137
137
 
138
138
  // The word is checked first, so a typo on a fresh directory is named as a typo, not as a missing setup.
139
139
  if (!MODES.includes(to)) return bail(`unknown mode: ${to}`, 'usage: yad mode [solo --reason "<why>" | team [--reason "<why>"]]');
140
- if (!hub) return bail(`no Product config at ${rel}`, 'run `yad setup` first — it records the mode with everything else');
141
- const plan = planMode(hub, { to, reason, by: recordActor(root), date: today });
140
+ if (!productConfig) return bail(`no Product config at ${rel}`, 'run `yad setup` first — it records the mode with everything else');
141
+ const plan = planMode(productConfig, { to, reason, by: recordActor(root), date: today });
142
142
  if (!plan.ok) return bail(plan.message, plan.hint);
143
- if (plan.changed) writeProductConfig(root, plan.hub);
143
+ if (plan.changed) writeProductConfig(root, plan.productConfig);
144
144
  // On a verified Product CI writes the ledger only at a merge, so an open review is never `in_review` in
145
145
  // state.json — the platform's open PRs are the list. Say so, rather than print nothing and read as "none".
146
- const verified = isVerifiedLedger(hub);
146
+ const verified = isVerifiedLedger(productConfig);
147
147
  const open = plan.flipped && !verified ? openReviews(root) : [];
148
148
  if (json) {
149
- return emitJSON({ ok: true, mode: to, changed: plan.changed, flipped: plan.flipped, set: plan.flipped ? plan.hub.mode_set : null, openReviews: open, openReviewsKnown: !verified });
149
+ return emitJSON({ ok: true, mode: to, changed: plan.changed, flipped: plan.flipped, set: plan.flipped ? plan.productConfig.mode_set : null, openReviews: open, openReviewsKnown: !verified });
150
150
  }
151
151
  if (!plan.changed) return ok(`already ${to} — nothing changed`);
152
152
  if (!plan.flipped) {
153
153
  // Reached two ways: `mode` was absent (a Product set up before E10), or a hand edit left it saying the
154
154
  // other mode — which is where `mode:disagree` sends people. Say which one happened.
155
- const what = hub.mode === undefined ? 'the new name `mode` was added beside `solo`' : '`mode` now matches `solo`';
155
+ const what = productConfig.mode === undefined ? 'the new name `mode` was added beside `solo`' : '`mode` now matches `solo`';
156
156
  ok(`already ${to} — ${what}; the gates act exactly as before`);
157
157
  } else {
158
158
  ok(`mode: ${to} — ${MEANING[to]}`);
159
- if (plan.hub.mode_set.reason) info(`reason: ${plan.hub.mode_set.reason}`);
159
+ if (plan.productConfig.mode_set.reason) info(`reason: ${plan.productConfig.mode_set.reason}`);
160
160
  if (verified) {
161
161
  info(`this Product's ledger is verified, so its open reviews live on the platform, not in state.json — every open review PR/MR follows the ${to} rule from its next CI run`);
162
162
  } else if (open.length) {
@@ -166,5 +166,5 @@ export async function runMode(root, { to = null, reason = null, json = false, to
166
166
  if (to === 'solo') info(c.dim('`yad doctor` warns if branch protection still requires an approval you cannot give yourself'));
167
167
  }
168
168
  const back = to === 'solo' ? 'yad mode team' : 'yad mode solo --reason "<why>"';
169
- hand(`commit ${PROJECT_FILES.productConfig} and ${PROJECT_FILES.hubConfig} so every machine and CI run sees it (reverse with \`${back}\`)`);
169
+ hand(`commit ${PROJECT_FILES.productConfig} and ${PROJECT_FILES.productConfigLegacy} so every machine and CI run sees it (reverse with \`${back}\`)`);
170
170
  }
package/cli/next.mjs CHANGED
@@ -19,7 +19,7 @@ import { dedupeConsecutive, epicIds, isGateStep, killSwitchOn, loadAutomation, e
19
19
 
20
20
  // Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
21
21
  // missing/old hub.json never breaks the driver.
22
- const soloOf = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
22
+ const soloOf = (productConfig) => !!(productConfig && (productConfig.solo === true || productConfig.review_gate?.solo === true));
23
23
  function isSolo(root) {
24
24
  return soloOf(readJSON(productConfigPath(root), null));
25
25
  }
@@ -354,22 +354,22 @@ function generalNext(root, { all, headCount = null } = {}) {
354
354
  return;
355
355
  }
356
356
  // The Product config, read ONCE for this view.
357
- const hub = readJSON(productConfigPath(root), null);
358
- const solo = soloOf(hub);
357
+ const productConfig = readJSON(productConfigPath(root), null);
358
+ const solo = soloOf(productConfig);
359
359
  // E74: in solo mode, suggest `yad mode team` when the count shows more than one person may work here.
360
360
  // Printed first, so it is not lost under a long list of epics. An unknown count is not said here —
361
361
  // `yad doctor` and `yad mode` say it (the user's choice, 2026-09-22). `headCount` is for tests.
362
- printTeamHint(soloTeamHint(root, hub, { solo, headCount }));
362
+ printTeamHint(soloTeamHint(root, productConfig, { solo, headCount }));
363
363
  // `profile` here is the SETUP profile `yad setup` records (codebase / repo_layout / team_size), not the
364
364
  // lifecycle profile an epic walks (`LIFECYCLE_PROFILES`, E5): two things share the word.
365
- const brownfield = hub?.profile?.codebase === 'brownfield';
365
+ const brownfield = productConfig?.profile?.codebase === 'brownfield';
366
366
  // The PRODUCT level (the Foundation, or a ledger still in its old `discovery` spelling) is not a
367
367
  // feature epic — split it out so it is surfaced on its own line and never mixed into the roll-up.
368
368
  const allEpics = listEpics(root);
369
369
  // One read of `.sdlc/skills.json` for the whole roll-up, not one per epic.
370
370
  const bindings = loadSkillBindings(root);
371
371
  const automation = loadAutomation(root);
372
- const verified = isVerifiedLedger(hub);
372
+ const verified = isVerifiedLedger(productConfig);
373
373
  // The Foundation wins when both exist. Two product levels is a fault `yad doctor` fails on; until it
374
374
  // is fixed the new spelling is the one worth acting on, and the old one is named so it is not lost.
375
375
  const productIds = PRODUCT_EPICS.filter((id) => allEpics.includes(id));