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/manifest.mjs CHANGED
@@ -44,7 +44,7 @@ export const SKILLS = [
44
44
  'yad-implement',
45
45
  'yad-checks',
46
46
  'yad-pr-template',
47
- 'yad-hub-bridge',
47
+ 'yad-product-bridge',
48
48
  'yad-commit',
49
49
  'yad-open-pr',
50
50
  'yad-ship',
@@ -63,8 +63,11 @@ export const SKILLS = [
63
63
  'yad-stub',
64
64
  ];
65
65
 
66
- // Pre-2.0 skill names (the sdlc-* -> yad-* rename). `check`/`update` migrate any install
67
- // still carrying an old name: remove the old copy, install the renamed one.
66
+ // Old skill names. `check`/`update` migrate any install still carrying one: remove the old copy,
67
+ // install the renamed one. A value is one old name or a list of them — a skill renamed twice keeps
68
+ // every name it shipped under, since an install can skip releases. Read through `legacySkillPairs`.
69
+ // sdlc-* -> yad-* the 2.0 rename.
70
+ // yad-hub-bridge the 4.0 hub -> Product rename (E123).
68
71
  export const LEGACY_SKILLS = {
69
72
  'yad-analysis': 'sdlc-author-analysis',
70
73
  'yad-epic': 'sdlc-author-epic',
@@ -76,7 +79,7 @@ export const LEGACY_SKILLS = {
76
79
  'yad-implement': 'sdlc-implement',
77
80
  'yad-checks': 'sdlc-checks',
78
81
  'yad-pr-template': 'sdlc-pr-template',
79
- 'yad-hub-bridge': 'sdlc-hub-bridge',
82
+ 'yad-product-bridge': ['sdlc-hub-bridge', 'yad-hub-bridge'],
80
83
  // Step E ("ship") was renamed to yad-engineer-review (the yad-ship name now belongs to the new
81
84
  // commit+open-PR combined skill). Pre-2.0 installs carry sdlc-ship → migrate it to yad-engineer-review.
82
85
  // A 2.x install carrying yad-ship-as-Step-E is simply overwritten with the new combined content on
@@ -87,6 +90,9 @@ export const LEGACY_SKILLS = {
87
90
  'yad-review-gate': 'sdlc-review-gate',
88
91
  'yad-status': 'sdlc-status',
89
92
  };
93
+ // Every (current name, old name) pair in LEGACY_SKILLS, one pair per old name.
94
+ export const legacySkillPairs = () => Object.entries(LEGACY_SKILLS)
95
+ .flatMap(([skill, old]) => (Array.isArray(old) ? old : [old]).map((o) => [skill, o]));
90
96
 
91
97
  // Skills removed in a later release that must be PURGED from existing installs. Unlike
92
98
  // LEGACY_SKILLS (a rename: drop old name, install new), these have no replacement — a rerun of
@@ -96,22 +102,28 @@ export const LEGACY_SKILLS = {
96
102
  // yad-review-comments — removed in 2.x (was sdlc-review-comments pre-2.0).
97
103
  export const REMOVED_SKILLS = ['yad-review-comments', 'sdlc-review-comments'];
98
104
 
99
- // Pre-2.0 wired-file dests replaced by renamed ones (old dest -> new dest, per platform).
100
- // An old file is removed ONLY when its first line carries the old ownership marker —
101
- // a same-named file the user authored themselves is never touched.
105
+ // Wired-file dests replaced by renamed ones (old dest -> { to: new dest, marker }, per platform).
106
+ // An old file is removed ONLY when its first line starts with that entry's ownership marker — a
107
+ // same-named file the user authored themselves is never touched. Each entry carries its own marker
108
+ // because each rename happened under a different one: the pre-2.0 files start `# sdlc-managed`, the
109
+ // hub -> Product ones (E123) `# yad-managed: yad-checks` (GitHub) or `# yad-managed-include: yad-checks`
110
+ // (GitLab). A marker is not proof nobody edited the file: `legacyFileActions` (cli/plan.mjs) also
111
+ // asks the provenance record, as every other managed file does.
102
112
  export const LEGACY_MARKER = '# sdlc-managed';
103
113
  export const LEGACY_REPO_FILES = {
104
- github: { '.github/workflows/sdlc-checks.yml': '.github/workflows/yad-checks.yml' },
105
- gitlab: { '.gitlab/ci/sdlc-checks.yml': '.gitlab/ci/yad-checks.yml' },
114
+ github: { '.github/workflows/sdlc-checks.yml': { to: '.github/workflows/yad-checks.yml', marker: LEGACY_MARKER } },
115
+ gitlab: { '.gitlab/ci/sdlc-checks.yml': { to: '.gitlab/ci/yad-checks.yml', marker: LEGACY_MARKER } },
106
116
  };
107
117
  export const LEGACY_PRODUCT_FILES = {
108
118
  github: {
109
- '.github/workflows/sdlc-gate-sync.yml': '.github/workflows/yad-gate-sync.yml',
110
- '.github/workflows/sdlc-verified-commits.yml': '.github/workflows/yad-verified-commits.yml',
119
+ '.github/workflows/sdlc-gate-sync.yml': { to: '.github/workflows/yad-gate-sync.yml', marker: LEGACY_MARKER },
120
+ '.github/workflows/sdlc-verified-commits.yml': { to: '.github/workflows/yad-verified-commits.yml', marker: LEGACY_MARKER },
121
+ '.github/workflows/yad-hub-checks.yml': { to: '.github/workflows/yad-product-checks.yml', marker: '# yad-managed: yad-checks' },
111
122
  },
112
123
  gitlab: {
113
- '.gitlab/ci/sdlc-gate-sync.yml': '.gitlab/ci/yad-gate-sync.yml',
114
- '.gitlab/ci/sdlc-verified-commits.yml': '.gitlab/ci/yad-verified-commits.yml',
124
+ '.gitlab/ci/sdlc-gate-sync.yml': { to: '.gitlab/ci/yad-gate-sync.yml', marker: LEGACY_MARKER },
125
+ '.gitlab/ci/sdlc-verified-commits.yml': { to: '.gitlab/ci/yad-verified-commits.yml', marker: LEGACY_MARKER },
126
+ '.gitlab/ci/yad-hub-checks.yml': { to: '.gitlab/ci/yad-product-checks.yml', marker: '# yad-managed-include: yad-checks' },
115
127
  },
116
128
  };
117
129
 
@@ -254,7 +266,7 @@ export const PROJECT_FILES = {
254
266
  // The product's own settings. `product.json` is the name from shape 3 onward; `hub.json` is what
255
267
  // it was called before, and it is NOT dead — see MIRRORED_FILES below.
256
268
  productConfig: '.sdlc/product.json',
257
- hubConfig: '.sdlc/hub.json',
269
+ productConfigLegacy: '.sdlc/hub.json',
258
270
  designConfig: '.sdlc/design.json',
259
271
  testingConfig: '.sdlc/testing.json',
260
272
  learningConfig: '.sdlc/learning.json',
@@ -274,60 +286,74 @@ export const PROJECT_FILES = {
274
286
 
275
287
  // ---- files that changed NAME in shape 3 --------------------------------------------------------
276
288
  //
277
- // `hub` became `Product`, so `.sdlc/hub.json` became `.sdlc/product.json`. A field rename is easy;
278
- // a FILE rename is not, because a file is opened by name from outside this codebase:
289
+ // `hub` became `Product`, so `.sdlc/hub.json` became `.sdlc/product.json` (and each epic's
290
+ // `hub-prs.json` became `product-prs.json`). A field rename is easy; a FILE rename is not, because a
291
+ // file is opened by name from outside this codebase:
279
292
  //
280
- // - `templates/checks/ledger-guard.sh` is committed inside the USER's repo and opens
281
- // `.sdlc/hub.json` by that literal path. It is refreshed by `yad update`, which is a separate act
282
- // from `yad migrate` with no ordering between them.
283
- // - So a project WILL exist that has been migrated but not updated. Its guard would open a path
284
- // that no longer exists, read no platform, conclude the ledger is local, and stop rejecting human
293
+ // - the check gates (`templates/checks/*.sh`, `checks/verified-commits.sh`) are committed inside
294
+ // the USER's repo, and the ones installed before E122 open `.sdlc/hub.json` by that literal
295
+ // path. They are refreshed by `yad update`, which is a separate act from `yad migrate`.
296
+ // - So a project WILL exist whose gates are older than its CLI. If only the new name existed, such
297
+ // a `ledger-guard` would read no platform, conclude the ledger is local, and stop rejecting human
285
298
  // commits to it — the audit trail disarmed by an upgrade.
286
299
  //
287
- // Hence: for one whole major version BOTH files exist, and the OLD name is the one that is READ.
288
- // `product.json` is written on every save so that it is there, correct, and ready — but nothing
289
- // depends on it yet. `productConfigPath` picks the old name whenever it exists and falls back to the
290
- // new one, so a project holding either name alone still works.
300
+ // Hence BOTH names are written on every save (`writeMirrored`, cli/lib.mjs) until v5, and the gates
301
+ // installed from this release on read the new name first.
291
302
  //
292
- // Reading the new name first is the tempting version and it is wrong: the moment two names exist and
293
- // the new one wins, everything that writes the old one — the guard above, a script someone wrote,
294
- // a person editing the file they know — is silently ignored.
303
+ // The ladder (E122, 2026-09-28). It was three majors — add, switch, delete — and is now two, because
304
+ // 4.0.0 reached `@next` only and no ordinary user lived with the add-only stage:
305
+ // v3 the new name appeared beside the old one; the OLD one was read
306
+ // v4 (this) the NEW name is read; the old one is still written, for the gates above;
307
+ // `yad doctor` says v5 deletes it
308
+ // v5 the old name is deleted (a shape of its own, with a `MIGRATIONS` step)
295
309
  //
296
- // The ladder is add, then switch, then remove:
297
- // this major the new name appears and is maintained; the old one is still read
298
- // next major the new name becomes the one read, and `yad doctor` warns about the old
299
- // after that the old name is deleted
300
- //
301
- // This costs a duplicated file on disk for two releases. That is the price of not silently disarming
302
- // a safety gate in somebody else's repository, and it is worth paying.
310
+ // Two copies that DISAGREE are never settled by picking one. Whichever name won, somebody's edit —
311
+ // a person editing the name they know, a script, an older gate's world view — would be silently
312
+ // ignored. So every command refuses (`mirrorDrift`, checked once in bin/yad.mjs and again in
313
+ // `loadLedger`), names both files, and points at `yad migrate`, which asks which copy wins.
303
314
  export const MIRRORED_FILES = [
304
- { canonical: PROJECT_FILES.productConfig, legacy: PROJECT_FILES.hubConfig },
315
+ { canonical: PROJECT_FILES.productConfig, legacy: PROJECT_FILES.productConfigLegacy },
305
316
  ];
306
317
 
307
- // Which of the two names to READ.
308
- //
309
- // The OLD name wins while it exists, and that is deliberate. Renaming a file across an ecosystem
310
- // takes three releases, not one:
311
- //
312
- // this major the new name appears and is written on every save. The OLD name is still the one
313
- // that counts, so everything that already reads it keeps working — the ledger-guard
314
- // committed in the user's repo, a script somebody wrote, a person editing the file
315
- // they know. Nothing can be silently ignored, because the file everyone knows is
316
- // still authoritative.
317
- // next major the new name becomes authoritative and `yad doctor` warns about the old one.
318
- // the one after that the old name is deleted.
319
- //
320
- // Reading the NEW name first this early looks tidier and is a trap: the moment two names exist and
321
- // the new one wins, anyone who edits the old one — including our own fixtures, which is how this was
322
- // found — has their change silently ignored. `yad doctor` reports the two copies drifting apart, so
323
- // a project that gets into that state is told, rather than left to wonder.
324
- export const productConfigPath = (root) => {
325
- const legacy = path.join(root, PROJECT_FILES.hubConfig);
326
- return existsSync(legacy) ? legacy : path.join(root, PROJECT_FILES.productConfig);
318
+ // Which of the two names to READ: the new one when it exists, the old one otherwise, and the new one
319
+ // again when neither does (so "absent" is reported under the name people should use). A project
320
+ // holding either name alone still works. Pure on purpose — it is a probe used by every directory
321
+ // walk that asks "is this a Product?", so it must never throw; the refusal lives in `mirrorDrift`.
322
+ export const preferring = (canonical, legacy) => (existsSync(canonical) || !existsSync(legacy) ? canonical : legacy);
323
+
324
+ export const productConfigPath = (root) =>
325
+ preferring(path.join(root, PROJECT_FILES.productConfig), path.join(root, PROJECT_FILES.productConfigLegacy));
326
+
327
+ // Where a person edits the Product settings, for a hint (E122): the file that is READ, and — only when
328
+ // both names exist — the command that copies the edit over the other one. Naming product.json on a
329
+ // Product that has only hub.json would have a person create a one-key product.json, and `--keep product`
330
+ // would then copy it over every other setting.
331
+ export const settingsEditHint = (root) => {
332
+ const rel = path.relative(root, productConfigPath(root)).split(path.sep).join('/');
333
+ const both = existsSync(path.join(root, PROJECT_FILES.productConfig)) && existsSync(path.join(root, PROJECT_FILES.productConfigLegacy));
334
+ return both ? `${rel}, then \`yad migrate --apply --keep product\`` : rel;
335
+ };
336
+ // The same, where no Product root is at hand.
337
+ export const SETTINGS_EDIT_HINT = '.sdlc/product.json (.sdlc/hub.json on a Product that has only that name), then `yad migrate --apply --keep product` if both exist';
338
+
339
+ // Both names present and saying different things. Byte for byte, like `yad doctor` and the gates
340
+ // (`cmp -s`): the engine writes both from one string, so any difference at all came from outside it.
341
+ // A copy that cannot be read counts as different — it is not the same file.
342
+ export const mirrorDrift = (canonical, legacy) => {
343
+ if (!existsSync(canonical) || !existsSync(legacy)) return false;
344
+ try { return !readFileSync(canonical).equals(readFileSync(legacy)); } catch { return true; }
327
345
  };
328
346
 
329
- // Same rule for a renamed file inside an epic's ledger.
330
- export const preferring = (canonical, legacy) => (existsSync(legacy) ? legacy : canonical);
347
+ // The drifted pairs under a Product root, project-relative: the settings file and every epic's PR
348
+ // ledger. Empty when nothing disagrees. `epicDirs` is passed in (manifest.mjs lists no folders).
349
+ export const productDrift = (root, epicDirs = []) => {
350
+ const pairs = [...MIRRORED_FILES];
351
+ for (const e of epicDirs) {
352
+ const f = epicFiles(e);
353
+ pairs.push({ canonical: f.productPrs, legacy: f.productPrsLegacy });
354
+ }
355
+ return pairs.filter(({ canonical, legacy }) => mirrorDrift(path.join(root, canonical), path.join(root, legacy)));
356
+ };
331
357
 
332
358
  // Who writes the ledger. Two values, and the switch lives in `.sdlc/hub.json`:
333
359
  //
@@ -349,10 +375,10 @@ export const preferring = (canonical, legacy) => (existsSync(legacy) ? legacy :
349
375
  // SAME order in bash because the check gates are standalone by design; that copy is the only one,
350
376
  // its header says so, and cli/test-checks.mjs runs a table of hub.json variants through both and
351
377
  // asserts they agree on every row. Three keys is three ways for two readers to drift.
352
- export const isVerifiedLedger = (hub) => {
353
- if (!hub?.platform) return false;
354
- if (typeof hub.ledger === 'string') return hub.ledger === 'verified';
355
- return hub.bridge_enabled === true || hub.bridge === true;
378
+ export const isVerifiedLedger = (productConfig) => {
379
+ if (!productConfig?.platform) return false;
380
+ if (typeof productConfig.ledger === 'string') return productConfig.ledger === 'verified';
381
+ return productConfig.bridge_enabled === true || productConfig.bridge === true;
356
382
  };
357
383
 
358
384
  // ---- the two dials (E28) ------------------------------------------------------------------------
@@ -362,7 +388,8 @@ export const isVerifiedLedger = (hub) => {
362
388
  // assistance: none | review | heavy -> driver: human | pair | agent
363
389
  // automation: human_approve | machine_advance -> advance: human | auto
364
390
  //
365
- // Same rule as `.sdlc/hub.json` above: BOTH are written, and the OLD one wins while it exists.
391
+ // BOTH spellings are written, and the OLD one wins while it exists. (Not the rule the renamed files
392
+ // follow since E122 — those read the new name first. The dials keep old-first until their own switch.)
366
393
  // The old name is what the 29 skills that hand-write `state.json` still emit, and what an older CLI
367
394
  // still reads. A step whose dials disagree is a project mid-upgrade, not a decision — `yad doctor`
368
395
  // reports it and `yad migrate` closes it.
@@ -397,14 +424,19 @@ export const AI_COAUTHORS = {
397
424
  };
398
425
  // Atomic-commit guard: warn/refuse above this many staged files (build plan: ≤3 where possible).
399
426
  export const ATOMIC_FILE_LIMIT = 3;
400
- // Trailer order is fixed: Task -> Contract-Change -> Co-Authored-By (config.yaml build comment).
427
+ // Trailer order is fixed: Task -> Contract-Change -> Ledger-Override -> Co-Authored-By (config.yaml build
428
+ // comment; Ledger-Override is E49's `yad commit --manual --reason`).
401
429
  export const TASK_TRAILER = 'Task';
402
430
  export const CONTRACT_CHANGE_TRAILER = 'Contract-Change';
431
+ // E49: `yad commit --manual --reason` records why a person committed past the ledger hook. The CI
432
+ // `ledger-guard` check quotes it and still fails the commit — anyone can type a trailer.
433
+ export const LEDGER_OVERRIDE_TRAILER = 'Ledger-Override';
403
434
  export const COAUTHOR_TRAILER = 'Co-Authored-By';
404
- // A Task trailer id must be a real <story>-T<NN>. Mirrors the spec-link gate contract
405
- // (checks/spec-link.sh: `.+-T[0-9]+$`) so an explicit --task that would fail CI is
406
- // rejected locally at commit time instead of after a push + history rewrite.
407
- export const TASK_ID_RE = /.+-T\d+$/;
435
+ // A Task trailer id must be a real <story>-T<NN>, the story an `EP-<slug>-S<n>` with a lowercase slug.
436
+ // Mirrors the spec-link gate contract (checks/spec-link.sh: `-T[0-9]+$`, then STORY_RE on the story, E117)
437
+ // so an explicit --task that would fail CI is rejected locally at commit time instead of after a push +
438
+ // history rewrite.
439
+ export const TASK_ID_RE = /^EP-[a-z0-9-]+-S\d+-T\d+$/;
408
440
 
409
441
  // Per-epic ledger files under epics/<epic>/.sdlc/ (the file source of truth the gate reads/writes).
410
442
  export const epicFiles = (epicRoot) => ({
@@ -412,11 +444,11 @@ export const epicFiles = (epicRoot) => ({
412
444
  approvals: `${epicRoot}/.sdlc/approvals.json`,
413
445
  comments: `${epicRoot}/.sdlc/comments.json`,
414
446
  // The record of review PR/MRs opened on the product. `product-prs.json` is the name from shape 3
415
- // onward; `hub-prs.json` is what it was called before and is written alongside it for one major —
416
- // both `templates/checks/ledger-guard.sh` and `cli/hook.mjs` name it literally, and the guard in a
417
- // user's repo only learns the new name when they run `yad update`. See MIRRORED_FILES.
447
+ // onward and the one read from v4; `hub-prs.json` is what it was called before and is still written
448
+ // beside it until v5 — `templates/checks/ledger-guard.sh` guards both names, and a guard in a user's
449
+ // repo only learns anything new when they run `yad update`. See MIRRORED_FILES.
418
450
  productPrs: `${epicRoot}/.sdlc/product-prs.json`,
419
- hubPrs: `${epicRoot}/.sdlc/hub-prs.json`,
451
+ productPrsLegacy: `${epicRoot}/.sdlc/hub-prs.json`,
420
452
  contractLock: `${epicRoot}/.sdlc/contract-lock.json`,
421
453
  // The two append-only Build ledgers use shard-then-fold storage (cli/ledger.mjs): writers add
422
454
  // one loose shard per entry under the *Dir path (conflict-free concurrent writes); `yad tidy up`
@@ -443,6 +475,8 @@ export const REPO_WIRING = {
443
475
  { src: 'skills/yad-checks/templates/checks/lineage-check.sh', dest: 'checks/lineage-check.sh', exec: true },
444
476
  { src: 'skills/yad-checks/templates/checks/epic-open.sh', dest: 'checks/epic-open.sh', exec: true },
445
477
  { src: 'skills/yad-checks/templates/checks/reconcile-debt-check.sh', dest: 'checks/reconcile-debt-check.sh', exec: true },
478
+ // E120. Checks the Product out in CI where .sdlc/product-link.json says, before the four gates above read it.
479
+ { src: 'skills/yad-checks/templates/checks/product-checkout.sh', dest: 'checks/product-checkout.sh', exec: true },
446
480
  { src: 'skills/yad-checks/templates/checks/verified-commits.sh', dest: 'checks/verified-commits.sh', exec: true },
447
481
  { src: 'skills/yad-checks/templates/checks/commit-message.sh', dest: 'checks/commit-message.sh', exec: true },
448
482
  // E65. The CHECK is ours and wired; the map it reads (`.sdlc/risk-map`) is the team's and never is.
@@ -471,6 +505,13 @@ export const REPO_WIRING = {
471
505
  // else is a local edit, reported as `modified` and left alone (#164). Committed, so the record
472
506
  // travels with the repo instead of living in one person's clone.
473
507
  export const MANAGED_LEDGER = '.sdlc/managed.json';
508
+ // E120. Where a connected code repo's Product lives, as a fact of the repo — `git_url`, `path` (from the
509
+ // repo root: where CI checks the Product out) and `default_branch`. Written by `yad check --fix` /
510
+ // `yad update` (the Product knows both sides), merged on its own, and read from the BASE by the four
511
+ // Product-reading gates and checks/product-checkout.sh. NOT `.sdlc/product.json`: that is the Product's
512
+ // own settings file, and a code repo holding one would be taken for a Product.
513
+ export const PRODUCT_LINK = '.sdlc/product-link.json';
514
+ export const PRODUCT_LINK_DEFAULT_PATH = '.yad/product';
474
515
  // Suffix for the copy written beside a managed file before its content is replaced without that
475
516
  // proof — the local edit is always recoverable from the working tree, not only from git history.
476
517
  export const BACKUP_SUFFIX = '.yad-orig';
@@ -481,31 +522,33 @@ export const wiringFor = (platform) => [
481
522
  ];
482
523
 
483
524
  // Product wiring: CI installed on the PRODUCT itself (dest is the project root — the Product IS the
484
- // root). Installed only when hub.json has a platform and the ledger is verified. Carries the
485
- // event-driven gate sync (approvals/change requests/the merge trigger `yad gate ci`) and the
486
- // verified-commits gate (no commit without a platform-Verified signature reaches merge on the Product).
525
+ // root). Installed only when the Product settings (product.json, or hub.json until v5) name a
526
+ // platform and the ledger is verified. Carries the merge-time gate sync (a review PR's merge, and a
527
+ // 15-minute schedule that catches a merge the event missed, run `yad gate ci --merged`; nothing fires
528
+ // on an approval) and the verified-commits gate (no commit without a platform-Verified signature
529
+ // reaches merge on the Product).
487
530
  export const PRODUCT_WIRING = {
488
531
  common: [
489
532
  { src: 'skills/yad-checks/templates/checks/verified-commits.sh', dest: 'checks/verified-commits.sh', exec: true },
490
533
  // The ledger is CI-owned: block non-bot commits to gate-state files on the Product review PRs.
491
534
  { src: 'skills/yad-checks/templates/checks/ledger-guard.sh', dest: 'checks/ledger-guard.sh', exec: true },
492
- // Pattern gates run on the Product too (profile: hub) — commit subject + PR title + PR body.
535
+ // Pattern gates run on the Product too (profile: product) — commit subject + PR title + PR body.
493
536
  { src: 'skills/yad-checks/templates/checks/commit-message.sh', dest: 'checks/commit-message.sh', exec: true },
494
537
  { src: 'skills/yad-pr-template/templates/checks/pr-title.sh', dest: 'checks/pr-title.sh', exec: true },
495
538
  { src: 'skills/yad-pr-template/templates/checks/pr-template.sh', dest: 'checks/pr-template.sh', exec: true },
496
539
  ],
497
540
  github: [
498
- { src: 'skills/yad-hub-bridge/templates/github/yad-gate-sync.yml', dest: '.github/workflows/yad-gate-sync.yml' },
541
+ { src: 'skills/yad-product-bridge/templates/github/yad-gate-sync.yml', dest: '.github/workflows/yad-gate-sync.yml' },
499
542
  { src: 'skills/yad-checks/templates/github/yad-verified-commits.yml', dest: '.github/workflows/yad-verified-commits.yml' },
500
- { src: 'skills/yad-checks/templates/github/yad-hub-checks.yml', dest: '.github/workflows/yad-hub-checks.yml' },
543
+ { src: 'skills/yad-checks/templates/github/yad-product-checks.yml', dest: '.github/workflows/yad-product-checks.yml' },
501
544
  // Integrity gate for the Product's own direct-to-default pushes (`yad update --push`; the machine-
502
545
  // state `yad checkpoint`/`gate ci` commits carry [skip ci] and are intentionally not re-checked).
503
546
  { src: 'skills/yad-checks/templates/github/yad-update-guard.yml', dest: '.github/workflows/yad-update-guard.yml' },
504
547
  ],
505
548
  gitlab: [
506
- { src: 'skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml', dest: '.gitlab/ci/yad-gate-sync.yml' },
549
+ { src: 'skills/yad-product-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml', dest: '.gitlab/ci/yad-gate-sync.yml' },
507
550
  { src: 'skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml', dest: '.gitlab/ci/yad-verified-commits.yml' },
508
- { src: 'skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml', dest: '.gitlab/ci/yad-hub-checks.yml' },
551
+ { src: 'skills/yad-checks/templates/gitlab/yad-product-checks.gitlab-ci.yml', dest: '.gitlab/ci/yad-product-checks.yml' },
509
552
  { src: 'skills/yad-checks/templates/gitlab/yad-update-guard.gitlab-ci.yml', dest: '.gitlab/ci/yad-update-guard.yml' },
510
553
  ],
511
554
  };
@@ -513,10 +556,26 @@ export const PRODUCT_WIRING = {
513
556
  // Harness hooks: the LOCAL half of the ledger rule, installed on the Product beside the CI gates and
514
557
  // active under the same verified-ledger predicate (#171). Kept out of `PRODUCT_WIRING` because a hook is not a
515
558
  // CI gate — it is advisory, fails open, and its adapter (below) is per-harness, not per-platform.
559
+ //
560
+ // Node scripts, not shell scripts (E113): one file runs on Windows, macOS and Linux, and a harness entry
561
+ // runs it as `node <file>`, so no execute bit is needed — Windows cannot keep one on disk.
562
+ // The branch-protection setup guide (E48), linked from `yad doctor`'s `protection` section. A URL, not a
563
+ // path: the reader is in their own repo, where `docs/` is not yadflow's.
564
+ export const PROTECTION_GUIDE_URL = 'https://github.com/abdelrahmannasr/yadflow/blob/main/docs/branch-protection.md';
565
+ // The hub -> Product hand-change guide (E124), linked from `yad migrate`'s last line while `.sdlc/hub.json` is on
566
+ // disk. A URL for the same reason.
567
+ export const PRODUCT_RENAME_GUIDE_URL = 'https://github.com/abdelrahmannasr/yadflow/blob/main/docs/migrations/hub-to-product.md';
568
+
516
569
  export const HOOK_WIRING = [
517
- { src: 'skills/yad-checks/templates/hooks/ledger-guard.sh', dest: 'hooks/ledger-guard.sh', exec: true },
570
+ { src: 'skills/yad-checks/templates/hooks/ledger-guard.mjs', dest: 'hooks/ledger-guard.mjs' },
518
571
  ];
519
572
 
573
+ // The shell scripts every hook was before E113. `yad check --fix` rewrites an entry that still names one
574
+ // (each adapter's `legacyCommands`) and then deletes the script — but only a copy the provenance record
575
+ // proves yad wrote and nobody edited. An edited or unrecorded copy is the team's, and may be what their
576
+ // own hand-wired hook runs, so it is left where it is.
577
+ export const LEGACY_HOOK_SCRIPTS = Object.freeze(['hooks/ledger-guard.sh', 'hooks/ledger-guard-cursor.sh', 'hooks/yad-capture.sh']);
578
+
520
579
  // Per-harness adapter config: which IDE target gets a hook entry written, where, and in what shape.
521
580
  //
522
581
  // A harness qualifies for an entry here ONLY if it can run a command BEFORE a file is written and
@@ -526,10 +585,10 @@ export const HOOK_WIRING = [
526
585
  // Cursor — so "no entry here" means "not wired", never "checked and found wanting":
527
586
  //
528
587
  // .claude Claude Code — `PreToolUse` in `.claude/settings.json`, reading the EXIT CODE, so the
529
- // entry points straight at the shared `hooks/ledger-guard.sh`.
588
+ // entry points straight at the shared `hooks/ledger-guard.mjs`.
530
589
  // .cursor Cursor — `preToolUse` in `.cursor/hooks.json`, a PERMISSION hook that reads a JSON
531
590
  // verdict on STDOUT and treats an empty answer as a refusal. The shared script prints
532
- // nothing when it allows, so this entry points at `hooks/ledger-guard-cursor.sh`
591
+ // nothing when it allows, so this entry points at `hooks/ledger-guard-cursor.mjs`
533
592
  // instead (the `wiring` below), which speaks that protocol.
534
593
  //
535
594
  // Cursor's `afterFileEdit` is deliberately NOT used: it fires once the edit is already on disk, so it
@@ -554,7 +613,9 @@ export const HOOK_WIRING = [
554
613
  // guard that is off while every report says it is on.
555
614
  const CLAUDE_PROJECT_DIR_ENV = 'CLAUDE_PROJECT_DIR';
556
615
  const CURSOR_PROJECT_DIR_ENV = 'CURSOR_PROJECT_DIR';
557
- const guardCommand = (envVar) => `"$${envVar}/hooks/ledger-guard.sh"`;
616
+ // `node` then the script: the harness runs the command through a shell (Git Bash on Windows for Claude
617
+ // Code), and `node` is on PATH wherever yadflow runs at all.
618
+ const guardCommand = (envVar) => `node "$${envVar}/hooks/ledger-guard.mjs"`;
558
619
 
559
620
  export const HOOK_ADAPTERS = Object.freeze({
560
621
  '.claude': Object.freeze({
@@ -581,7 +642,10 @@ export const HOOK_ADAPTERS = Object.freeze({
581
642
  // is ours to normalise; anything else is the team's, even if it names a similar path. Never widen
582
643
  // this to a substring test — a team keeping its own wrapper at `.claude/hooks/ledger-guard.sh`
583
644
  // would have their hook silently rewritten to ours.
584
- legacyCommands: Object.freeze(['$CLAUDE_PROJECT_DIR/hooks/ledger-guard.sh']), // 3.16.x, pre-quoting
645
+ legacyCommands: Object.freeze([
646
+ '$CLAUDE_PROJECT_DIR/hooks/ledger-guard.sh', // 3.16.x, pre-quoting
647
+ '"$CLAUDE_PROJECT_DIR/hooks/ledger-guard.sh"', // the bash script, before E113
648
+ ]),
585
649
  }),
586
650
  '.cursor': Object.freeze({
587
651
  target: '.cursor',
@@ -600,36 +664,38 @@ export const HOOK_ADAPTERS = Object.freeze({
600
664
  // Cursor's `preToolUse` is a PERMISSION hook: it answers in JSON on stdout, and empty stdout is
601
665
  // invalid JSON, which BLOCKS. So the plain guard cannot be wired here — it prints nothing when it
602
666
  // allows, which would have blocked every file write in a verified project. This wrapper, installed
603
- // with the adapter below, always prints a permission answer. It takes no arguments on purpose:
604
- // Cursor holds one command STRING and does not document whether it is split by a shell.
667
+ // with the adapter below, always prints a permission answer. The script itself takes no arguments:
668
+ // the entry stays a program and one path, the one form Cursor's own docs show.
605
669
  wiring: Object.freeze([
606
- Object.freeze({ src: 'skills/yad-checks/templates/hooks/ledger-guard-cursor.sh', dest: 'hooks/ledger-guard-cursor.sh', exec: true }),
670
+ Object.freeze({ src: 'skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs', dest: 'hooks/ledger-guard-cursor.mjs' }),
607
671
  ]),
608
672
  // RELATIVE, and deliberately not `$CURSOR_PROJECT_DIR/…` — the opposite of the Claude entry above,
609
673
  // for a documented reason. Cursor's docs say a project hook RUNS FROM THE PROJECT ROOT, which is
610
674
  // the directory holding the `.cursor/hooks.json` this entry lives in, and the same directory
611
- // holding `hooks/ledger-guard.sh`. So the relative path always resolves, whatever the harness's
675
+ // holding `hooks/ledger-guard.mjs`. So the relative path always resolves, whatever the harness's
612
676
  // notion of cwd elsewhere.
613
677
  //
614
- // What they do NOT say is whether the command runs through a shell. That is exactly why there is
615
- // no variable and no quotes here: `$CURSOR_PROJECT_DIR` never expanded would be a command not
616
- // found, and quotes taken literally by a non-shell exec would be part of the filename. An
617
- // unquoted relative path with no variable and no space is the one spelling that works either way
618
- // — which matters, because every one of those failures is silent, fails OPEN, and leaves
619
- // `yad doctor` truthfully reporting the entry as wired while nothing is ever refused.
678
+ // What they do NOT say is WHICH shell runs the command — on Windows it is PowerShell. That is why
679
+ // there is no variable and no quotes here: `$CURSOR_PROJECT_DIR` is not an environment variable to
680
+ // PowerShell, and a command that begins with a quoted path is not a command to it either. Every one
681
+ // of those failures is silent, fails OPEN, and leaves `yad doctor` truthfully reporting the entry
682
+ // as wired while nothing is ever refused.
683
+ //
684
+ // E113: `node` and a relative script. Cursor's docs call the command a "shell string" and show
685
+ // `python3 .cursor/hooks/x.py`, so a program and one argument is a documented form — and on Windows
686
+ // it is the one that runs at all: PowerShell cannot run a `.sh` file.
620
687
  projectDirEnv: CURSOR_PROJECT_DIR_ENV,
621
- command: 'hooks/ledger-guard-cursor.sh',
688
+ command: 'node hooks/ledger-guard-cursor.mjs',
622
689
  // This harness answers with a JSON verdict and treats an empty or off-schema answer as a REFUSAL.
623
690
  // `yad doctor` uses the flag to catch a hand-wired command that would fail closed — see
624
691
  // `miswiredGuardCommand`. An adapter without it reads the exit code, where a silent command is
625
692
  // simply a guard that never fires.
626
693
  requiresJsonVerdict: true,
627
- // Nothing shipped before this release, so there is no past spelling of ours to normalise.
628
- legacyCommands: Object.freeze([]),
694
+ legacyCommands: Object.freeze(['hooks/ledger-guard-cursor.sh']), // the bash script, before E113
629
695
  }),
630
696
  });
631
697
 
632
- // Background capture (E43): the POST-edit half. After an agent writes a file, `hooks/yad-capture.sh` runs
698
+ // Background capture (E43): the POST-edit half. After an agent writes a file, `hooks/yad-capture.mjs` runs
633
699
  // `yad capture --hook`, which snapshots every changed artifact onto the person's private
634
700
  // `yad/wip/<name>/<epic>` branches. Unlike the guard it is wired in BOTH ledger modes — capture is about
635
701
  // never losing a draft, which matters whoever owns the ledger — and on any Product whose config does not
@@ -637,7 +703,7 @@ export const HOOK_ADAPTERS = Object.freeze({
637
703
  // check serve both; `observe: true` marks a hook that only watches (it cannot refuse), so its entry carries
638
704
  // no `failClosed`, and a null `matcher` is an event that takes none (Cursor's `afterFileEdit`).
639
705
  export const CAPTURE_WIRING = [
640
- { src: 'skills/yad-checks/templates/hooks/yad-capture.sh', dest: 'hooks/yad-capture.sh', exec: true },
706
+ { src: 'skills/yad-checks/templates/hooks/yad-capture.mjs', dest: 'hooks/yad-capture.mjs' },
641
707
  ];
642
708
  export const CAPTURE_ADAPTERS = Object.freeze({
643
709
  '.claude': Object.freeze({
@@ -651,10 +717,11 @@ export const CAPTURE_ADAPTERS = Object.freeze({
651
717
  matcher: 'Edit|Write|MultiEdit|NotebookEdit',
652
718
  projectDirEnv: CLAUDE_PROJECT_DIR_ENV,
653
719
  // Quoted and anchored on the project root, for the reason the guard's entry is (see above).
654
- command: `"$${CLAUDE_PROJECT_DIR_ENV}/hooks/yad-capture.sh"`,
720
+ // `--format claude` is said here, not guessed by the script: Cursor sets `CLAUDE_PROJECT_DIR` too.
721
+ command: `node "$${CLAUDE_PROJECT_DIR_ENV}/hooks/yad-capture.mjs" --format claude`,
655
722
  observe: true,
656
723
  wiring: Object.freeze([]),
657
- legacyCommands: Object.freeze([]),
724
+ legacyCommands: Object.freeze([`"$${CLAUDE_PROJECT_DIR_ENV}/hooks/yad-capture.sh"`]), // before E113
658
725
  }),
659
726
  '.cursor': Object.freeze({
660
727
  target: '.cursor',
@@ -668,10 +735,10 @@ export const CAPTURE_ADAPTERS = Object.freeze({
668
735
  matcher: null,
669
736
  projectDirEnv: CURSOR_PROJECT_DIR_ENV,
670
737
  // Relative and unquoted, for the reason the guard's Cursor entry is (see above).
671
- command: 'hooks/yad-capture.sh',
738
+ command: 'node hooks/yad-capture.mjs',
672
739
  observe: true,
673
740
  wiring: Object.freeze([]),
674
- legacyCommands: Object.freeze([]),
741
+ legacyCommands: Object.freeze(['hooks/yad-capture.sh']), // before E113
675
742
  }),
676
743
  });
677
744